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
Shakecomponent, 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
Healthcomponent 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
Gameclass.- 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/mathbecause 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
Worldarray 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_triggerwhich 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_collidefirst 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_rendercan reduce the number ofuseProgramcalls 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_selecttranslates 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_playerto set navigation and shooting targets.sys_lifespanremoves 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_playerhandles the player's input.sys_control_aimakes decisions for all NPCs.sys_control_projectilemoves bullets forward and destroys them when they hit something.
- Pre-Transform: Animation and Movement
sys_navigateguides entities on the path-finding grid, from cell to cell, by setting theirMove.Direction.sys_aimrotates entities withHas.Shootso that they face towards theirShoot.Target.sys_shakemodifiesTranslationin local space to emulate shaking.sys_animateplays animation clips.sys_movemodifiesTranslationbased onMove.Directionset 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. TheWorldmatrix 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_colliderecalculates 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_transformagain aftersys_collide.sys_triggerdispatches actions if certain entities walk into colliders withHas.Triggercomponents.sys_shootspawns projectiles ifShoot.Targetis set. It runs aftersys_transformto ensure that the spawn point for projectiles, which is attached to the character in front of their chest, has an up-to-dateTransform.Worldmatrix. Walking and aiming change its position and orientation in the world.
- Rendering:
sys_culltoggles components depending on the entities visibility in the camera frustum.sys_audioplays sound effects and music.sys_cameraupdates the active camera's view matrix.sys_renderrenders 3D entities.sys_drawdraws 2D elements in 3D space.sys_uihandles 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.Cameraandgame.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
HealCampfireaction, 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
Cullcomponent defines which other components will be toggled when the entity enters and leaves the screen. Thecullmixin 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_cullonly changes the integer storing the current component mask for the entity. - Nested transforms are relative to the parent. The
sys_transformsystem 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.Worldarray. Game.Addis 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 enumsto 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
Colorenum are inlined in the compiled code, and the interface disappears completely.
- Our most stark example is
com_audio_sourcewhich uses multipleinterfacesandconst enumsto 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.