9 minutes
Composing Gameplay
TL;DR — You snap
KeyboardMover,Movement,Score,Health, andHealthBaronto one hero and a ring of coins. These arcadeThe BabylonJS Market component/game framework that ships a library of ready-made gameplay components. components never import each other. Together they make a playable coin collector. You glue them with one smallPickupSystemthat only emits and listens on the event bus. New here: separate components work together using only events, while your one system holds the game's rules. This is the composition pattern the rest of the course builds on.
Last lesson ended with a still scene. JSON declared a hero sphere, a ground, and an orbit camera. ArcadeGame booted it, and nothing moved. The scene format could name gameplay components, but it could not make them work together. In this lesson you wire them up. Snap KeyboardMover, Movement, Score, Health, and HealthBar onto one hero and ring it with coins. Now you have a playable coin collector. Seven of these components ship finished. Not one imports another's system, and none of them shares gameplay logic. They share primitives like MeshPrimitive and ArcCamera, never each other's rules. KeyboardMover has never heard of Score. Health does not import HealthBar. Their only shared dependencyAnother piece of code a file imports and relies on to do its job. is the event bus. The single system you write sits outside all of them and talks only through events. Each one announces what happened on the bus, and the others listen. That is how parts built alone can still work together.
The last five lessons taught the basics: write a system, read and emit on the bus, declare a world in JSON, and boot it with ArcadeGame. Now all of that pays off at once. Most of this game is JSON. The one system you write is under a hundred lines and imports nobody.
| Component pair | Owns | Talks via |
|---|---|---|
KeyboardMover | WASD/arrow input to camera-relativeMovement measured against where the camera looks: W pushes the hero away from the camera, not toward a fixed world direction, so movement matches what the player sees. X/Z motion | writes the mesh position |
Movement | gravityA constant downward acceleration applied each frame so an entity falls realistically., jump, ground-snapClamping a falling entity to the floor height so it lands cleanly instead of sinking through. - the Y axis | reads/writes the mesh position; listens for JUMP_REQUESTED |
Score | point buckets keyed by owner | score.add in, score.changed out |
Scoreboard | DOM HUD rows | subscribes to score.changed |
Health | HP, i-framesInvulnerability frames - a short window after taking damage during which further hits are ignored., death | health.damage in, health.damaged / health.died out |
HealthBar | billboarded world-spacePositioned inside the 3D scene itself, so it moves and scales with the world, unlike a fixed DOM/screen overlay drawn on top of the canvas. HP bar | reads the hero's Health + mesh |
Flash | self-deleting hit spark | a lifetime timer; its system removes the host |
In the "Talks via" column, in means events the component listens for, and out means events it emits. Names like score.add and health.damaged are event names on the bus, not files. (DOM here means the browser's HTML page, where key presses arrive.)

Declaring the arena in scene JSON
Scenes are data, so the arena is content, not code. The hero stacks four behavior components plus a mesh and a shadow. Each one has a job:
Each of the eight coins is the same: an emissive cylinder and a tagA plain string label attached to an entity that systems can query by, like 'coin' or 'player'.. The hazard is one red box that hurts on contact:
The coins carry no behavior components. They have a mesh and a tag, nothing more. The tag is how your system finds them, and the mesh is what the player sees. When the only thing you need to know is "this is a coin," a tag holds all of it. (Writing Coin1 through Coin8 by hand is the clunky part. The next lesson's entity pools make coins spawned at runtime cheap, and this hand-placement goes away.)
Moving the hero: KeyboardMover and Movement
The hero stacks KeyboardMover and Movement together. They never clash because they split the work by axis. In 3D, Y points up, and the floor is the X/Z plane. So moving sideways uses two axes, not one.
KeyboardMover | Movement | |
|---|---|---|
| Axis | X and Z (the ground plane) | Y (up/down) |
| Drives | camera-relative WASD/arrow input | gravity, jump impulse, terminal fall, ground-snap |
| Each frame | writes hero X/Z onto the mesh | reads the mesh's current Y back in, integrates, writes Y out |
| Listens for | DOM keydown/keyup | MovementInputEvents.JUMP_REQUESTED |
Here is the real KeyboardMoverSystem. You don't need to read every line of these source slices. The text below each one tells you what it does. Look at what it imports: System, the mesh component, the camera component, and an input-events constant. Now look at what it does not import. There is no MovementComponent and no @babylonjs. It reads which keys are held down. Then it asks the renderer for the camera's forward and right vectors. (A vectorA direction-plus-distance, like an arrow pointing where to move and how far. is just an arrow pointing in some direction.) It flattens those arrows onto the ground, keeping only the X/Z part and dropping up and down. Each frame it moves the mesh a little in that direction. Camera-relative means W pushes the hero away from the camera, not toward a fixed world direction. So the movement matches what the player sees.
A few TypeScript marks show up across these snippets. => is a short function. ! after a value means "trust me, this exists." ?. reads a field but skips it if the thing is missing. : Type labels what kind of value something holds. And await waits for a slow step to finish before moving on.
Splitting the work onto X and Z is the whole idea. (Normalizing those vectors keeps movement the same speed in every direction, even diagonals. The two engines disagree on coordinate-system handedness, which is which way "right" points, and the adapterThe piece that lets the same component run on either Babylon or Three by hiding each engine's differences behind one interface. hides that.) KeyboardMover writes mesh.position[0] and mesh.position[2] and never touches [1]. Movement owns [1]. Each frame MovementSystem reads the mesh's current Y back in, applies gravity, clamps to the ground, and writes Y out. Neither imports the other. They both share one mesh position, but they write different slots of it. So both can read and edit it in the same frame without getting in each other's way.

Movement also listens on the bus. Emit MovementInputEvents.JUMP_REQUESTED and the hero jumps. This is how an AI or a UI button makes the hero jump, using only the hero entity's id. (bus here is the EventBusThe central messaging channel where systems emit and listen for events instead of calling each other directly., which you can reach anywhere as game.world.getEventBus().)
Both components carry a faceMotion flag, because each one can run on its own. When you stack them, you want exactly one of them to set the yawRotation around the vertical axis - which compass direction the character faces., which is the compass direction the hero faces. So the scene sets faceMotion: false on Movement and true on KeyboardMover. ArcCamera creates the orbit camera, one that circles a point in space. CameraFollow re-aims it at whatever entity id you name (smoothing: 6 feels natural, 12 snaps tight). The look point is what the camera aims at. We leave ArcCamera's target unset so CameraFollow is the only thing that writes it, the same one-writer rule as the yaw.
Scoring with the Score and Scoreboard components
Score is data only. It holds buckets of points keyed by owner, and it works through events. It does no per-frame work. Every change arrives as an event and leaves as one. Here is the real system, and the empty body of onUpdate shows it:
handleAdd is the whole loop. A score.add comes in carrying an ownerEntity. The bucket for that owner gains points. Then a score.changed goes out carrying the new total. The owner keyThe entity id a score bucket is keyed by, matching the ownerEntity field carried in score events. is the id that ties a score event to the right bucket. Every score event names the entity whose bucket changed. Score also listens to goal.scored, so any scene built around the Goal mechanic feeds the scoreboard with no extra code. Import these names from @babylonjsmarket/arcade (ScoreInputEvents.ADD, ScoreEvents.CHANGED) instead of typing the strings. A misspelled event name does not throw an error. It just never fires.
Scoreboard is a DOM overlayRegular HTML drawn on top of the 3D canvas, used here for the score HUD and win/lose screens.. It subscribes to ScoreEvents.CHANGED and redraws one row per player. Both live on a HUD entity:
The roster's entityId matches the ownerEntity in score events. It is the same owner key on both ends.
Taking damage: the Health, HealthBar, and Flash components
Health follows the same shape. It is pure state, event-driven, with no per-frame work. Anything that hurts the hero emits HealthInputEvents.DAMAGE. The system ignores hits during the invulnerability window. It emits HealthEvents.DAMAGED when HP actually changes, and it emits HealthEvents.DIED the frame HP hits zero:
The if (now < h.invulnUntil) return line does real design work. The hazard check (coming up next) fires every frame the hero overlaps the red block. The update loop runs about sixty times a second, so that would be sixty damage events a second. This one comparison ignores all but the first hit. Then it sets invulnUntil one second into the future. That cuts a flood of overlap events down to one hit per second. You never wrote that limit into the hazard code. You set invulnSeconds: 1 in the scene JSON, and Health enforces it for any source of damage.
HealthBar is the world-space readout. It is a billboarded bar that lives in the 3D scene above the mesh. It drains as HP drops and hides at full health. Unlike the bus-driven components, it does not subscribe to anything. Each frame it reads the host's Health.hp and maxHp directly and resizes itself. Not every component listens to events. Some just check the value each frame. Flash is the smallest mechanic in the library: one lifetime field whose system deletes the host entity when the timer runs out. Pair it with a bright sphere mesh and you get a hit spark that removes itself.
The one system you write: PickupSystem
Everything so far was declared in JSON. Nothing yet knows that touching a coin should score, or that the hazard should hurt. That rule is your game, and it lives in your system. The system reads positions and emits events, and it imports none of the seven components' systems:
const [px, , pz] = playerMesh.position pulls the X and Z out of the position and skips Y. The empty slot between the commas is the height, and we ignore it, because dist2 measures distance on the floor plane only. A coin counts as collected when the hero is over it, no matter the heights. The queryThe filter a system declares so the framework hands it only the entities it cares about. asks for both a component and a tag, so this.entities holds only the coins. The framework filters them for you, and you never write if (isCoin). The player and hazard come from getEntitiesByTag, which still returns inactive entities, so those lookups filter on e.active. Removing the current coin inside the loop is safe here. Deleting the element you are standing on during a for...of won't skip the next one. Still, the library's own Flash plays it safer: it collects ids and removes them after the loop. (Setting coin.active = false instead of removing it would soft-remove the coin. It gets pulled from every query and its mesh is hidden, but nothing is destroyed. The next lesson shows that flag is the mechanism behind entity pooling.) The flash spawns from the HealthEvents.DAMAGED handler, so it fires once per real hit, never on the absorbed i-frame ones.
Booting it is the Scenes as Data recipe plus exactly one line:
BabylonAdapter is the adapter that runs the world on Babylon. canvas is the HTML element it draws into. That addSystem call is the only behavior the JSON didn't carry. Trace one coin pickup below. Every arrow is an event on the bus, never a direct call between systems:
| Emitter | Event | Who reacts |
|---|---|---|
PickupSystem (yours) | score.add | ScoreSystem |
ScoreSystem | score.changed | ScoreboardSystem, your DOM win check |
PickupSystem (yours) | health.damage | HealthSystem |
HealthSystem | health.damaged | PickupSystem (spawns the spark) |
HealthSystem | health.died | your DOM game-over overlay |
PickupSystem sits on both sides. It emits health.damage and reacts to the health.damaged that comes back. It never calls HealthSystem directly. It lets the bus carry the message both ways.

Win and lose screens from plain DOM
Win and lose conditions don't have to live inside the world. world.getEventBus() returns the same bus every system uses. So a plain DOM overlay follows the same rules a system does: subscribe, emit, follow the contract:
From the world's point of view, that game-over screen looks like any other system. The win check is one more if inside a handler, and the retry button is one more emitter. (Note the bus.on here, not this.listen. listen is the System-only wrapper that cleans up on its own. Outside a system you use bus.on and handle the unsubscribe yourself.)
You used seven library components you didn't write and an event vocabulary you didn't invent. You wrote one small system that carries only your game's rules. The library gives you the parts, and you build the game.
Two rough edges remain, and the next lesson covers both: those eight hand-placed coins, and a hit-flash that creates and destroys an entity on every hit. This is fine when hits are rare. It becomes a problem when it means two hundred bullets a second.
Next: Pools and Spawners — stop paying the create and destroy cost on everything you spawn at runtime, using the World's built-in entity pools and the spawner components that feed them.