5 minutes
Catching up?
Dropping in at this lesson? One command reinstalls the library components the build uses so far and writes the scene as it stood at the end of the previous lesson to public/scenes/SpaceshooterFlight-lesson-2/scene.json:

Copy that scene.json over your src/scenes/spaceshooter.json and you are caught up to the start of this lesson.
Last lesson you filled the field with rocks. But those rocks are fake. You line up a boulder and hold the trigger, and your shots pass right through it. You aim your nose at a tall spire and fly straight in. You come out the other side without a scratch. The rocks are just scenery, not something you can hit.
A real rock needs to do two things. Ram it at full speed and you should die. Shoot it and it should chip away. Both cases are really the same problem. You need to test a fast-moving thing against a spinning box. That thing might be a bullet or a ship at full speed. And it must not slip through the box between two frames.
The boxes are already in your scene. Lesson 1 stored each rock's half-extents in its Asteroid component. Asteroid1 holds { radius: 58, hx: 59, hy: 36, hz: 80 }. That is a hitbox shaped to fit the lumpy rock. A fat sphere would be worse, since it would kill you out in open space. This lesson writes the system that reads those boxes.
The system owns the ripple buffer, not the component
Every hit sends a ring rippling out across the rock's surface. That ring is just for looks. It should never be saved. Serialize an asteroid mid-fight, write it to JSON, and reload, and the ripple should not come back. So the pure component holds none of it. Instead, the system owns the ring buffer, a plain Float32Array. Picking a slot in it takes one small helper:
The buffer has eight slots of five floats each. Give pushRipple a fresh buffer and a unit direction, and it hands back the new count:
An empty slot fills first. Once all eight slots are full, the next hit overwrites the oldest one. The oldest is the slot with the largest age. So a rock under fire always shows its newest hits and never runs out of room. None of this touches the serialized component.
Storing the buffers in a WeakMap
The system keeps those buffers in a WeakMap, keyed by the component. This keeps the runtime state off the saved data, so the save-and-reload round-trip stays clean:
One detail stands out at the top. priority = 55 runs this system just above SpaceShooterBulletSystem (52). So if a bullet must cross a rock to reach an enemy behind it, the rock eats the bullet first. And notice what is not here: no onInitialize, no pool-setup code. The system never registers a thing.
The debris pool is declared, not registered
A rammed ship doesn't just vanish. It breaks into shootable chunks through Course 2's shared destroyShip, and that path pulls each chunk from a debris pool with world.acquire('debris'). So this system leans on that pool existing. In the old imperative build, every impact component guaranteed it by calling a registerDebrisPool(world) in its onInitialize — idempotent, so the duplicate calls across guns and rocks did no harm. That function is gone. The pool is declared once, in the scene, as a blueprint: a Debris entity carrying a Pool marker beside the components each chunk gets.
You wrote that blueprint back in Course 2, the lesson that blew a ship apart. At load, PoolSystem reads the Pool marker, pre-builds 64 parked copies of the blueprint's other components, then consumes the Debris entity. Because SpaceShooterHealth, Lifetime, and the rest are ordinary top-level scene keys on the blueprint, their Systems register through the normal path — no onInitialize hook, no empty-marker stub. That is why AsteroidCollisionSystem carries no pool-setup code at all. It just trusts the slots to be parked in the scene, and destroyShip calls world.acquire('debris') the moment a rock kills you.
Pass one: a shot chips the rock
Read the code by what it does to one bullet. First it rebuilds the bullet's path. It takes this frame's position and steps back one dt of velocity to find where the bullet sat last frame. Then it samples that line at a few points. Each point gets transformed into the rock's local frame and tested against the half-extents with an AABB check. This sweep matters. A fast bullet can cover more ground in one frame than the rock is wide. Without the sweep, it would skip right over the rock. On the first sample inside the box, the code uses up the bullet and emits BulletEvents.HIT (bullet.hit) tagged part: 'asteroid'. That event is only a signal for hit-sparks and sound. It is not a damage route — the rock carries no HP and never runs through the ship-damage path. Then the code adds a ripple. It nudges the rock with a push and spin scaled inversely by size, so a boulder barely twitches and a small chunk drifts. It shrinks the rock by 1.5% of its original radius. Once the rock drops under 40% of its build size, the code queues it to despawn.
Pass two: a ram destroys your ship
Testing a ship against a box has a shortcut. It is the same as testing a single point against a bigger box. You just grow the box by the ship's radius. So the code adds the 3.2-unit hull radius to each half-extent. Then it checks the ship's centre in the rock's frame. A contact adds a big ripple and sends the rammer through Course 2's shared destroyShip, which sheds the wreckage by acquiring debris slots from that scene pool. That is the same death a gun kill triggers. The rock flies on, unharmed. The queued removals run at the end.
Wire it into the cabinet
The scene loader only auto-adds a system when it finds a ${Name}System. This one exports as AsteroidCollisionSystem, so the loader skips it. You add it yourself, once, after loadScene:
There is no new pool here. The system leans on two pools already declared as scene blueprints back in Course 2 — the bullet pool it sweeps for hits, and the debris pool the ram path fills. Both are parked in the scene before this system ever ticks.
Run it
Fire at a rock and it chips. A ring blooms at the hit, the rock shrinks a bit, and after enough hits it pops out below 40% size. Open the EventBus panel (≈F4) and hold the trigger. Watch bullet.hit tick under the rock's entity, with part: 'asteroid' in the row dump. Now aim your nose at the spire and fly in. Your ship dies through the same chain a gun kill fires in Course 2. The death effect blooms, and the rock sails on without a mark. The field can hurt you now. Next up: a rock that shoots back.
Continue reading
Unlock the Full Course
Every lesson, the runnable examples, and the finished build — yours to keep.