Architecture
Goodluck
- ROAR is based on Goodluck, a hackable template for creating small browser games.
- Goodluck implements a simple ECS architecture.
- Game object data is stored in arrays of components, each responsible for a different concern. (
Transform
,Render
,Collide
,RigidBody
etc.) - Entities are indices into these arrays, as well as into a special
Signature
array which stores a bit mask of components currently enabled for the entity. - Systems iterate over all entities in the scene and execute logic on those that match the system's query mask.
- Game object data is stored in arrays of components, each responsible for a different concern. (
- ROAR has 20 components and 25 systems.
- The maximum number of components in Goodluck is 32 and is limited by the fact that bitwise operations in JavaScript are only defined on 32-bit integers.
- 32 components has proven to be enough for small-scoped games like the ones developed for js13kGames.
- Backcountry has 21 components and 24 systems.
- Oh No!… has 12 components and 15 systems.
- It looks like I usually run out of time and bytes first, rather than components.
- Goodluck focuses on simplicity, code size and performance.
- It's able to excel on all three as long as the scenes are not too big.
- Usually, fewer than 5,000 entities should be fine on modern hardware.
- Conveniently, most games created for js13kGames are rather small in scope and wouldn't need more entities.
- A typical scene in ROAR is made of ~700 entities. With 25 systems, each of which iterates over all entities in the world, we're at 17,500 iterations per frame.
- It's blazing fast.
- CPU-bound systems take less than 1 ms in a frame.
- The game is GPU-bound.
- One of the design principles of Goodluck is to write simple, unsurprising code.
- I sometimes describe it as primitive and boring, too.
- I use TypeScript interfaces to describe the shape of component data, stored as POJOs.
- Systems are just functions with a
for
loop which iterates over all entities in the scene. - The code is not designed for extension in the future. My goal is to ship a game rather than build a generic engine.
- I try to limit the use of advanced programming idioms, and instead favor
if
,switch
andfor
.- This is sometimes less convenient, but I'm OK with it.
- I prioritize making the code easy to debug and to understand in the future.
- This "simple" JavaScript has the benefit of being very fast in terms of the execution speed.
- JavaScript engines are in general very very fast.
- Avoiding higher-level language features, like callbacks and iterators, can still make a difference in performance-sensitive scenarios, e.g. games.
Composition
- The ECS architecture is all about composition over inheritance.
- Components are added to entities upon instantiation, and they can be removed and re-added dynamically during the entity's lifetime.
- Systems always iterate over all entities in the world and check their signature against the system's query mask.
- There's no caching. Each frame, systems look for the current set of entities to update.
- In this model, the data always flows in one direction. It's stored in component arrays and goes through the systems in a deterministic order.
- This unidirectional flow of data is in principle similar to the paradigm of reactive programming.
- You can mix and match different behaviors without worrying about pulling in too much logic from a superclass.
- As the project grows, the behaviors continue to be well isolated from each other.
- The ease of adding new behaviors to existing entities makes experimenting with new ideas a breeze.
- The freedom of experimentation, together with the speed of iteration, are both important for the creative process.
- Components and systems in ROAR tend to be simple on their own, but can together create useful and complex behaviors.
- One of my favorite example is the explosion blueprint.
export function blueprint_explosion(game: Game): Blueprint { return { Using: [ audio_source(true, snd_explosion()), lifespan(1), light_point([1, 1, 1], 3), toggle(Has.Light, 0.1, true), ], Children: [ { Rotation: from_euler([0, 0, 0, 0], -90, 0, 0), Using: [ shake(Infinity, 0.5), emit_particles(1, 0, 0.2, true), render_particles(game.Textures["fire"], [1, 1, 1, 0.5], 100, [1, 1, 1, 0], 10), light_point([1, 0.5, 0], 3), toggle(Has.Light, 0.3, true), ], }, ], }; }
- The
Lifespan
system destroys the entity after a given amount of time.- Police cars, helicopters and missiles all have
Lifespans
which prevents the scene getting crowded with them. - Explosions are entities with a
Lifespan
of 1 second, a particle emitter and lights. - Building cubes have
Lifespans
as well, as a proxy for their health. They start with theLifespan
component disabled, but when the player touches them, they wake up and live for ca. 30 seconds.- One reason to do it this way was the size limit: I didn't have the room for a proper
Health
system. - Another reason was to nudge the player into the endgame, and help them find the Baby-zilla after a few minutes of gameplay.
- One reason to do it this way was the size limit: I didn't have the room for a proper
- Police cars, helicopters and missiles all have
- The
Shake
system randomly translates the entity relative to its parent.- Most particle emitters have
Shake
too, so that the stream of particles is not too straight and regular. - The missile spawner leverages
Shake
to randomize the exact position from which a missile is spawned.
- Most particle emitters have
- The
Toggle
system turns other components on and off on an interval.- The police car blueprint has two child entities with the
Light
component, one red and one blue. TheToggle
system alternates between switching them on and off, such that only one light is on at a time. - Explosions have two
Lights
as children, too, one white and one yellow, and they useToggle
to blink them rapidly during the lifetime of the explosion entity.
- The police car blueprint has two child entities with the
- The
ControlMove
system translates the entity each frame in a specified direction and rotates it on a specified axis.- This here is the MVP system of ROAR.
- It moves police cars, helicopters, and missiles forward.
- It rotates the police car, the helicopter and the missile spawners.
- It rotates the Moon's light around the city.
- It rotates the camera around the city in the non-VR view.
- It rotates the missile along its Z axis, to make it look more dynamic.
- The
Aim
system rotates the entity towards the position of another entity.- And this system, combined with
ControlMove
to push entities forward, is the AI of ROAR. - The missiles aim directly at the player's headset.
- The helicopters aim at an invisible entity attached 30 cm in front of the headset, so that there's a higher chance the player will see them.
- The police cars aim at the feet of the player, so that it always moves on the horizontal (XZ) plane.
- And this system, combined with
- Another great example is the helicopter blueprint.
- Note how the rotor child entity has its own
control_move
mixin, which controls its rotation.
export function blueprint_helicopter(game: Game): Blueprint { return { Scale: [0.03, 0.03, 0.03], Using: [ control_move([0, 0, 1], null), aim(find_first(game.World, Name.Head)), move(float(2, 4), float(1, 3)), collide(true, Layer.Vehicle, Layer.None, [0.5, 0.5, 0.5]), rigid_body(RigidKind.Static), audio_source(true, snd_helicopter), lifespan(8), ], Children: [ { // Searchlight. Translation: [0, -3, 3], Using: [light_point([1, 1, 1], 2)], }, { // Body. Scale: [2, 3, 4], Using: [ render_textured_diffuse( game.MaterialTexturedDiffuse, game.MeshCube, game.Textures["police"] ), ], }, { // Rotor. Translation: [0, 2, 0], Scale: [8, 0.1, 1], Using: [ control_move(null, [0, 1, 0, 0]), move(0, 20), render_textured_diffuse( game.MaterialTexturedDiffuse, game.MeshCube, game.Textures["noise"], [0.1, 0.1, 0.1, 1] ), ], }, { // Tail. Translation: [0, 0, -4], Rotation: from_euler([0, 0, 0, 0], 10, 0, 0), Scale: [1, 1.5, 4], Using: [ render_textured_diffuse( game.MaterialTexturedDiffuse, game.MeshCube, game.Textures["police"] ), ], }, ], }; }
- Note how the rotor child entity has its own
- One of my favorite example is the explosion blueprint.
Scene Graph
- Interesting behaviors can also be created through scene graphs.
- In other words, by anchoring entities as children of other entities.
- It's another kind of a composition technique.
- Sometimes I want entities to do different things but move together as a group.
- E.g. the particle emitter responsible for the fire breath is anchored as a child of the headset entity.
- The lights of the police car are anchored at its roof, etc.
- Some systems rely on the fact that the processed entity has a parent.
- E.g.
Shake
modifies the local position of the entity assuming that the original position was[0, 0, 0]
. - By making an entity with
Shake
a child of another entity, I can meet this requirement, and position the parent entity as desired. - E.g. buildings have child particle emitters (with
Shake
) which are activated when the building is set on fire.- For simplicity, I don't rotate the emitters to face always up. When you grab and rotate a building cube with your hand, the fire will burn in the direction of the roof, which might be downwards :)
- E.g.
- In Goodluck, a single entity can only have a single instance of a component.
- If more than one instance is needed, multiple child entities can be used instead.
- E.g. for the police car, I need a light alternating between red and blue. Rather than write extra logic to change the color of a single light, I created two child entities as siblings, each with its light color, and a
Toggle
to alternate between them.