Axiom: A Javascript Shoot'em Up Game
Can working on a game engine also be an opportunity to improve my programming and design skills? (Of course.)
The more time I spent working on my simple JavaScript RTS game, the more I was confronted with issues in my system's design. Working on that game was enlightening and educational, but also messier than I anticipated.
Making code improvements
Instantiations, not Singletons
My last game engine turned out to be one where the most important components were singletons in the global scope. You could only have one copy of the audio emitter or the viewport. Even the quadtree was restricted to a single copy.
Creating singletons without a purpose produced inflexible code. Small features, such as changing the current level, could only be achieved by updating the one available entity.
There are times when a singleton makes sense. They can, for example, model a system's state or provide quick global access to a resource. But without a constraint that supports their use case they become an unnecessary limitation.
One of the major improvements in this project was to refactor the singletons and replace the references to them with instances of those objects.
Directory structure
The last engine also had a poorly organized file structure. There were five different files which had eighteen classes crammed inside. What's worse is that unrelated concepts (like the quadtree and the audio emitter) sometimes shared a file.
Muddling the concerns made maintaining the code a chore. I was forced to memorize which classes were in each file, and as they got bigger that task became harder.
One of my new project's goals was to limit each file to exactly one class. Now the filenames and the contents could be aligned.
No Gods or Kings
At least one class could be described as a God Object. The "Entity" class contained code for rendering, movement, collision detection, impact resolution, and user input handling. That might not seem so bad until you start noticing that not every entity relies on all that code.
Entities that didn't need to be driven by the user wouldn't need the input handling. Entities that weren't solid wouldn't need collision detection. Still, other entities that were stationary had no need for any of the movement functions.
So what? What's wrong with having functionality you don't need? Well, it makes the class challenging to maintain and makes it more difficult to express the limitations & functionality in different types of that object.
If we instead make modular classes we can then compose the desired functionality as needed.
We can start with a basic entity that has only the most common functionality. We can then, optionally, extend that to be a "MovableEntity" which provides movement logic. We can extend it further with a "DrivableEntity" that provides user input handling.
A space station might just be a stationary entity. A projectile might be movable. Our main character might be movable, drivable, and damageable.
Leveraging ES6
Recent changes to JavaScript have provided some exciting new tools.
Using let
and const
gives more fine-grained control over the scope of variables, and lets us define if they are immutable. This seems small, but allows us to better control variables that might be used only within loops, or which we want to guarantee won't be accidentally replaced.
Also, ES6 brings the object shorthand notation, which changes:
let foo = {
bar: bar,
baz: baz,
}
with:
let foo = {
bar,
baz,
}
(The key is inferred from the variable name.) Another small change that overall helps the readability of the source code.
Finally, using the improved Array prototype give us access to methods like .map
and .filter
which both serve to speed up development and improve readability for future maintenance.
New Features
Gamepad support
HTML5 added support for the Gamepad API which allows us to use access inputs from game controllers in our web applications. Given that this JavaScript engine is on a webpage, we can use it here.
I've only tested it with my Logitech gamepad thus far, but have had success.
The most difficult part of getting the gamepad to work was updating my engine to query the gamepad during each cycle since the gamepad doesn't fire events for us to handle.
Parallax layers
The last engine had one flat surface for the game to play out in. That was mostly sufficient for a 2D game, but we could do better.
Once the game world was improved from being a singleton to an instantiation, there wasn't much preventing us from rendering multiple "layers" of worlds on top of each other.
With some small tweaks to ensure we didn't need to run physics checks on each layer, and to update how they rendered their offset in the viewport, it was possible to make a faux 3D (2.5D really) parallax effect.
Particle effects
Using the lessons learned in my 2D particle effect project, I added some basic particle effects when shooting or destroying an entity.
Looping background music
A game without music is a bit melancholy. I had music working in my last engine, but this time I added logic to allow specific tracks to be looped (even in a 'playlist') to give the world an ambiance.
Easing functions
Easing functions allow us to specify the way that something moves from point A to point B.
Is it a linear movement with a constant velocity? Does it get faster as it accelerates and then slows down before reaching the final point? Does it overshoot and slide back a little?
We can use easing functions to define this behavior.
Debug mode
The last project would require tweaks to classes on the fly to add bounding boxes or radii for debugging help.
This project adds a debug flag which can be toggled to different thresholds, which triggers a consistent set of rendering aids.
Difficulties
I had to learn a lot while working on this project. And I learned that physics simulations are a challenging beast to tackle.
It seemed straightforward to write code that detects if two entities in 2D space overlapped (that's even a common interview question). It was a little harder to do that when they can rotate, but not egregiously so.
What was harder was handling those collisions. Do they just push apart? How much? What if separating them introduces a new collision with another object? How do you resolve oscillations? Does a collision introduce torque? If so, how much? How do you ensure collisions are handled for high-velocity objects that might move past other entities between frames?
Getting the physics working just-right was, and is, an ongoing project.