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/WingmanFlight-lesson-2/scene.json:
bjs download scene WingmanFlight-lesson-2 --all
Copy that scene.json over your src/scenes/wingman.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.
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:
src/components/Asteroid/Asteroid.core.ts
export const MAX_RIPPLES = 8;
export const RIPPLE_STRIDE = 5;
export function makeRippleBuffer(): Float32Array {
return new Float32Array(MAX_RIPPLES * RIPPLE_STRIDE);
}
export function pushRipple(
ripples: Float32Array,
count: number,
nx: number,
ny: number,
nz: number,
amplitude: number,
): number {
let slot: number;
if (count < MAX_RIPPLES) {
slot = count;
count += 1;
} else {
slot = 0;
let oldestAge = ripples[3];
for (let i = 1; i < MAX_RIPPLES; i++) {
const age = ripples[i * RIPPLE_STRIDE + 3];
if (age > oldestAge) {
oldestAge = age;
slot = i;
}
}
}
const off = slot * RIPPLE_STRIDE;
ripples[off] = nx;
ripples[off + 1] = ny;
ripples[off + 2] = nz;
ripples[off + 3] = 0;
ripples[off + 4] = amplitude;
return count;
}
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:
const buf = makeRippleBuffer();
let count = pushRipple(buf, 0, 0, 0, 1, 0.18);
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.
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:
src/components/Asteroid/Asteroid.ts
interface AsteroidRipples {
buf: Float32Array;
count: number;
}
export class AsteroidCollisionSystem extends System {
protected query = { required: [Transform6DOFComponent, PartsComponent] };
private readonly shipCollisionRadius = 3.2;
private readonly sweepSamples = 5;
private ripples = new WeakMap<AsteroidComponent, AsteroidRipples>();
private _r: Vec3 = [1, 0, 0];
private _u: Vec3 = [0, 1, 0];
private _f: Vec3 = [0, 0, 1];
constructor(eventBus: EventBus) {
super(eventBus);
this.priority = 55;
}
protected onInitialize(): void {
if (this.world) registerDebrisPool(this.world);
}
private getRipples(comp: AsteroidComponent): AsteroidRipples {
let rt = this.ripples.get(comp);
if (!rt) {
rt = { buf: makeRippleBuffer(), count: 0 };
this.ripples.set(comp, rt);
}
return rt;
}
Two details stand out at the top. priority = 55 runs this system just above BulletImpactSystem (52). So if a bullet must cross a rock to reach an enemy behind it, the rock eats the bullet first. And onInitialize registers the debris pool. It does this safely even if it runs twice. A rammed ship breaks into shootable chunks through the shared kill path. That pool has to exist even if no gun ever registered it.
src/components/Asteroid/Asteroid.ts
protected onUpdate(dt: number): void {
if (!this.world || dt <= 0) return;
const asteroids = this.collectAsteroids();
if (asteroids.length === 0) return;
const toRemove: Entity[] = [];
const bullets = this.collectBullets();
for (const bullet of bullets) {
if (toRemove.indexOf(bullet) >= 0) continue;
const b = bullet.get(BulletComponent)!;
const bt = bullet.get(Transform6DOFComponent);
if (!bt) continue;
const bv = bullet.get(Velocity6DOFComponent);
const curX = bt.x, curY = bt.y, curZ = bt.z;
const prevX = bt.x - (bv?.vx ?? 0) * dt;
const prevY = bt.y - (bv?.vy ?? 0) * dt;
const prevZ = bt.z - (bv?.vz ?? 0) * dt;
for (const a of asteroids) {
if (a.id === b.ownerEntityId) continue;
if (toRemove.indexOf(a) >= 0) continue;
const apos = a.get(Transform6DOFComponent);
const ac = a.get(AsteroidComponent);
if (!apos || !ac) continue;
const midX = (curX + prevX) * 0.5 - apos.x;
const midY = (curY + prevY) * 0.5 - apos.y;
const midZ = (curZ + prevZ) * 0.5 - apos.z;
const longest = Math.max(ac.hx, ac.hy, ac.hz) + 5;
if (midX * midX + midY * midY + midZ * midZ > longest * longest) continue;
quatRight(apos.q, this._r);
quatUp(apos.q, this._u);
quatForward(apos.q, this._f);
const rxw = this._r[0], ryw = this._r[1], rzw = this._r[2];
const ux = this._u[0], uy = this._u[1], uz = this._u[2];
const fx = this._f[0], fy = this._f[1], fz = this._f[2];
let hitX = 0, hitY = 0, hitZ = 0;
let hit = false;
for (let s = 0; s <= this.sweepSamples; s++) {
const u = s / this.sweepSamples;
const sxw = prevX + (curX - prevX) * u;
const syw = prevY + (curY - prevY) * u;
const szw = prevZ + (curZ - prevZ) * u;
const dlx = sxw - apos.x, dly = syw - apos.y, dlz = szw - apos.z;
const lx = dlx * rxw + dly * ryw + dlz * rzw;
const ly = dlx * ux + dly * uy + dlz * uz;
const lz = dlx * fx + dly * fy + dlz * fz;
if (lx >= -ac.hx && lx <= ac.hx &&
ly >= -ac.hy && ly <= ac.hy &&
lz >= -ac.hz && lz <= ac.hz) {
hitX = sxw; hitY = syw; hitZ = szw;
hit = true;
break;
}
}
if (!hit) continue;
toRemove.push(bullet);
const bvx = bv?.vx ?? 0, bvy = bv?.vy ?? 0, bvz = bv?.vz ?? 0;
this.eventBus.emit(BulletEvents.HIT, {
shooterId: b.ownerEntityId, targetId: a.id,
part: 'asteroid', partHp: 9999,
x: hitX, y: hitY, z: hitZ,
dirX: bvx, dirY: bvy, dirZ: bvz,
});
let hnx = hitX - apos.x;
let hny = hitY - apos.y;
let hnz = hitZ - apos.z;
const hlen = Math.hypot(hnx, hny, hnz) || 1;
hnx /= hlen; hny /= hlen; hnz /= hlen;
const rt = this.getRipples(ac);
rt.count = pushRipple(rt.buf, rt.count, hnx, hny, hnz, 0.18);
const av = a.get(Velocity6DOFComponent);
if (av) {
const bspd = Math.hypot(bvx, bvy, bvz) || 1;
const impulse = 6 / Math.max(20, ac.originalRadius);
av.vx += (bvx / bspd) * impulse;
av.vy += (bvy / bspd) * impulse;
av.vz += (bvz / bspd) * impulse;
const txr = hny * bvz - hnz * bvy;
const tyr = hnz * bvx - hnx * bvz;
const tzr = hnx * bvy - hny * bvx;
const tscale = 0.0008 * impulse;
av.wx += txr * tscale;
av.wy += tyr * tscale;
av.wz += tzr * tscale;
}
const shrinkAmt = ac.originalRadius * 0.015;
ac.radius = Math.max(0, ac.radius - shrinkAmt);
const scale = ac.radius / ac.originalRadius;
ac.hx = ac.originalHx * scale;
ac.hy = ac.originalHy * scale;
ac.hz = ac.originalHz * scale;
if (ac.radius < ac.originalRadius * 0.40) toRemove.push(a);
break;
}
}
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, and the rock never reaches HealthSystem. 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.
src/components/Asteroid/Asteroid.ts
for (const ship of this.entities) {
const st = ship.get(Transform6DOFComponent);
if (!st) continue;
for (const a of asteroids) {
if (toRemove.indexOf(a) >= 0) continue;
const ap = a.get(Transform6DOFComponent);
const ac = a.get(AsteroidComponent);
if (!ap || !ac) continue;
const longest = Math.max(ac.hx, ac.hy, ac.hz) + this.shipCollisionRadius;
const dx = st.x - ap.x;
const dy = st.y - ap.y;
const dz = st.z - ap.z;
if (dx * dx + dy * dy + dz * dz > longest * longest) continue;
quatRight(ap.q, this._r);
quatUp(ap.q, this._u);
quatForward(ap.q, this._f);
const lx = dx * this._r[0] + dy * this._r[1] + dz * this._r[2];
const ly = dx * this._u[0] + dy * this._u[1] + dz * this._u[2];
const lz = dx * this._f[0] + dy * this._f[1] + dz * this._f[2];
const phx = ac.hx + this.shipCollisionRadius;
const phy = ac.hy + this.shipCollisionRadius;
const phz = ac.hz + this.shipCollisionRadius;
if (lx >= -phx && lx <= phx &&
ly >= -phy && ly <= phy &&
lz >= -phz && lz <= phz) {
let hnx = st.x - ap.x;
let hny = st.y - ap.y;
let hnz = st.z - ap.z;
const hlen = Math.hypot(hnx, hny, hnz) || 1;
hnx /= hlen; hny /= hlen; hnz /= hlen;
const rt = this.getRipples(ac);
rt.count = pushRipple(rt.buf, rt.count, hnx, hny, hnz, 0.6);
destroyShip(this.world, this.eventBus, ship);
break;
}
}
}
for (const e of toRemove) this.world.removeEntity(e);
}
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. That is the same death a gun kill triggers. The rock flies on, unharmed. The queued removals run at the end.
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:
if (!world.getSystem(AsteroidCollisionSystem))
world.addSystem(new AsteroidCollisionSystem(world.getEventBus()));
There is no new pool here. The system reads the same bullet pool that MachineGunSystem registered back in Course 2.
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.