Architecture
Good Luck
- The game is based on Good Luck, a WebGL game template we started working on a few months before the compo.
- Good Luck is not an engine. It’s a repository template with a collection of files which are intended to be modified or removed according to the project’s needs.
- The core principles are:
- Write only the code you need for this game.
- Don’t generalize. Generic APIs and parameterization cost bytes.
- Avoid abstractions. Write simple procedural code.
- Don't DRY your code prematurely. WET (Write Everything Twice) if needed.
- Optimize for Zip rather than for number of characters used in the source code.
- See https://github.com/piesku/goodluck for more information.
- One of the main features of Good Luck is the focus on composability, achieved through:
- the ECS architecture, and
- the hierarchical scene graph.
Entity-Component-System (ECS)
- We use a strict ECS architecture. It’s proven to be very flexible and very performant.
- ECS enforces a strict separation between the data (components) and the logic (systems). The data is stored as plain JS objects whose shapes are described by TypeScript interfaces. The logic is usually a simple function called in a for loop on affected entities.
- Composition over inheritance. Behaviors and logic are added to entities with mixins, configurable functions which amend the entity’s component mask and create component data for it.
- Example: the particle emitter in the campfire model randomizes the position of the flames through a
Shake
component, the same as the one which shakes the camera when the player is shooting.
- Example: the particle emitter in the campfire model randomizes the position of the flames through a
- Composition works wonderfully well. It’s very easy to build new features and to add existing functionality to entities.
- For example, we ran a quick experiment with destructible terrain. All it took was adding the
Health
component to the mine walls which made their colliders take damage upon bullet impact. It was a one-line change in the mine wall’s blueprint function. - When building new features it was helpful to keep reminding ourselves that the system responsible for the feature should be as small as possible and that it should do just one thing and one thing only. Atomic systems are easy to understand, test, debug, and mix with other systems.
- For example, we ran a quick experiment with destructible terrain. All it took was adding the
- ECS helps organize the code. Whenever a question of “where should I put this code?” emerged, the answer was simple:
- If it’s per-entity data, then store it in a component.
- If it’s game logic running per entity, put it in a system.
- If it’s game logic running per scene, compute it every frame in the relevant system and store it in a local variable.
- Example: all active colliders, all active lights, the active camera.
- If it’s persistent game-wide data, define an interface for it and store it in the
Game
class.- Example: materials, models, UI state, the WebGL context etc.
- If it’s helper/utils logic, put it as an export in the relevant component’s file, or in a new file if no single component really stands out as relevant.
- Example: the ray casting code is in
src/math
because it was initially used both for mouse picking and shooting. In the final version of the game shooting was rewritten to use collision detection between the projectile and targets, but we kept ray casting in the old file.
- Example: the ray casting code is in
- Entities are simply indices into the
World
array storing numbers. Each number represents a bitmask of the entity’s components.- JS uses 64 bit floats to represent numbers but as soon as you try to perform a bitwise operation on a number it’s first converted to a 32 bit integer. This gives us 32 components to encode as bitmasks, which is more than enough for a small jam-scoped game.
-
Backcountry has 23 components and 24 systems.
- A few component examples:
Transform
,Render
,Shake
,Animate
,Health
. - An example of a system:
sys_trigger
which runs on entities with the union ofTransform | Collide | Trigger
. Triggers are colliders which activate some logic when the player walks into them.
- A few component examples:
- Every frame each system iterates over all entities in the world and bitwise-compares each entity against the system’s component mask.
- There are over 2,000 entities in the town and in the mine, and over 4,000 on the desert. That gives ca. 50,000 and 100,000 entity checks every frame, respectively.
- All of this takes less than 1 millisecond on modern CPUs.
- Plenty of room for actual game logic and rendering :)
- With the logic grouped into systems, some computations can be greatly optimized by performing them only once per frame for all entities.
sys_collide
first iterates through all entities with the Collide component, collects all active colliders in the scene and groups them into two buckets, dynamic and static; then it iterates over the dynamic colliders and checks for collisions against all static and other dynamic colliders.sys_render
can reduce the number ofuseProgram
calls by only issuing it once per sequence of consecutive entities rendered with a particular shader.- Some uniforms need to be updated only once per frame or once per shader.
- The main challenge that we encountered with ECS was ordering the systems correctly. It’s an inherent design problem in ECS and if done wrong, can lead to hard to track bugs.
- The order of systems is the most important design decision in ECS.
- Systems run in a precise deterministic order, and handle entities in batches.
- Sometimes, game logic runs over multiple frames. It may also happen that a system needs to run more than once in a single frame.
- We established the following pattern for ordering our systems: setup, control (input), pre-transform, transform, post-transform, and render (output).
- Setup systems run first and write data required by control systems.
sys_select
translates the current mouse position into a world position and uses ray casting to pick a collider under the cursor. This information is then used insys_control_player
to set navigation and shooting targets.sys_lifespan
removes entities past their expiration time. No need to run any more logic on them in other systems.
- Control systems are the brains of the game. Given the player's input or scripted conditions, they control entities by setting up data for other systems.
sys_control_player
handles the player's input.sys_control_ai
makes decisions for all NPCs.sys_control_projectile
moves bullets forward and destroys them when they hit something.
- Pre-Transform: Animation and Movement
sys_navigate
guides entities on the path-finding grid, from cell to cell, by setting theirMove.Direction
.sys_aim
rotates entities withHas.Shoot
so that they face towards theirShoot.Target
.sys_shake
modifiesTranslation
in local space to emulate shaking.sys_animate
plays animation clips.sys_move
modifiesTranslation
based onMove.Direction
set by other systems.
-
sys_transform
- This is one of the most important systems in the game. It commits changes to translation and rotation into a translation matrix stored in
Transform.World
. The changes may originate from player's input, AI decisions, or animations. In case of nested transforms, it also takes into account the changes to parent transforms. TheWorld
matrix is then sent to the GPU for rendering.
- This is one of the most important systems in the game. It commits changes to translation and rotation into a translation matrix stored in
- Post-Transform:
sys_collide
recalculates AABBs based on updated transforms, and detects intersections between them. Our collision detection is only used for triggers and shooting, and it doesn't require any collision response. If it was the case, i.e. if entities had rigid bodies and needed to be moved back in response to collisions, we'd need to runsys_transform
again aftersys_collide
.sys_trigger
dispatches actions if certain entities walk into colliders withHas.Trigger
components.sys_shoot
spawns projectiles ifShoot.Target
is set. It runs aftersys_transform
to ensure that the spawn point for projectiles, which is attached to the character in front of their chest, has an up-to-dateTransform.World
matrix. Walking and aiming change its position and orientation in the world.
- Rendering:
sys_cull
toggles components depending on the entities visibility in the camera frustum.sys_audio
plays sound effects and music.sys_camera
updates the active camera's view matrix.sys_render
renders 3D entities.sys_draw
draws 2D elements in 3D space.sys_ui
handles UI updates.
- Setup systems run first and write data required by control systems.
- Avoid caching data between frames! It’s tempting to store reference to entities someplace where they’re readily available from other code. We did this for
game.Camera
andgame.Player
, and it led to a hard to track down bug in the final build of the game. It’s vital to remember to reset these cached values when changing scenes.- A better approach might be to tag these interesting entities with a component, and then search for them every time they’re needed. Iteration is fast and you can return early when the entity is found.
- We had actually done this initially in the case of the bug mentioned above but then changed to caching in order to save a few bytes. Lesson learned the hard way.
- A better approach might be to tag these interesting entities with a component, and then search for them every time they’re needed. Iteration is fast and you can return early when the entity is found.
Hierarchical Scene Graph
- Transforms can have child transforms attached to them. We use this to group entities into larger wholes (e.g. a character is a hierarchy of body parts, the hat and the gun).
- We also use nested transforms for rotation pivots and as the anchor point for the camera.
- Nested transforms are used to isolate and compose logic, too. Consider the following blueprint for the campfire. Note that the trigger collider is a child of the root node, and then the particle emitter and the light source are children of the collider. The light source is additionally raised a few units above the ground level. When the player triggers the
HealCampfire
action, the entire collider's subtree is destroyed, which effectively puts out the fire!export function get_campfire_blueprint(game: Game) { return <Blueprint>{ Translation: [0, 1.5, 0], Using: [render_vox(game.Models[Models.CAMPFIRE]), cull(Has.Render)], Children: [ { Using: [ collide(false, [15, 15, 15]), trigger(Action.HealCampfire), cull(Has.Collide | Has.Trigger), ], Children: [ { Using: [ shake(Infinity), emit_particles(2, 0.1), render_particles([1, 0, 0], 15), cull(Has.Shake | Has.EmitParticles | Has.Render), ], }, { Translation: [0, 3, 0], Using: [light([1, 0.5, 0], 3), cull(Has.Light)], }, ], }, ], }; }
- Note how in the above example, the
Cull
component defines which other components will be toggled when the entity enters and leaves the screen. Thecull
mixin takes a component mask as the argument and then adds or removes it from the entity. The data related to the toggled components is left intact.sys_cull
only changes the integer storing the current component mask for the entity. - Nested transforms are relative to the parent. The
sys_transform
system is responsible for computing the absolute world-space transform of each entity. This requires that parent entities be processed before their children.- There are many algorithmic solutions to effectively sorting entities on the go to ensure the above requirement is met.
- However, a simple solution is to always create parent entities before any child entities are created. The system processes entities in order in which they appear in the
game.World
array. Game.Add
is the method we use for instantiating blueprints in the world. It ensures that the parent is created first.
TypeScript
- We wrote the game in TypeScript. Excellent editor support in VS Code with auto completion, suggestions and definition peeking.
- It allowed us to conveniently use simple functions with positional arguments: the TS language server would step in to help figure out the meaning, correct types, and the order of parameters.
- Heavy use of interfaces and
const enums
to describe data shapes. Both have the advantage of just disappearing during the compilation to JS, adding no overhead to the build size.- Consider the following example from the TypeScript Playground. The values of the
Color
enum are inlined in the compiled code, and the interface disappears completely. - Our most stark example is
com_audio_source
which uses multipleinterfaces
andconst enums
to describe the audio tracks and the synthesized instruments. It's over a 100 lines of TypeScript which compile down to 8 lines of JavaScript.
- Consider the following example from the TypeScript Playground. The values of the
- The JS produced by the TS compiler is good quality and it minifies well.
- With source maps, debugging right inside the editor is a breeze.
- Thanks to TypeScript we were more willing to do sweeping refactors even late in the project cycle. The “Rename Symbol” feature of VS Code and other code actions made refactors quick and safe.
- It also allowed us to try out ideas for code layout and architecture easily. Important for winning at the zipping game.