diff --git a/docs/05-ENEMIES-AND-BOSSES.md b/docs/05-ENEMIES-AND-BOSSES.md
index 4530c68..3c3fb68 100644
--- a/docs/05-ENEMIES-AND-BOSSES.md
+++ b/docs/05-ENEMIES-AND-BOSSES.md
@@ -19,6 +19,41 @@ Every enemy is a real **autonomous** machine from THD – things that already dr
All of them: contact damage 1 cell, white hit flash, 3–4 frame knockback, and they stop dead and leak black fluid when defeated.
+### Rover controller plan (#45)
+
+Use only `kind: rover` spawns and their translated `patrol` endpoints.
+The standing `enemy-rover:idle-00` alpha bounds are 31 × 25 px, feet at
+the atlas pivot. The approved charge poses lower the mast (18–21 px
+visible height); keep their native pixels and the standing collision box.
+Patrol tuning is half the documented walking speed (75.25 px/s); charge
+uses the documented running speed (258 px/s). Lidar checks one walking
+second ahead (150.5 px), within the hero's 76 px height band. These are
+initial controller tuning, with the fixed 0.5 s tell and 3 HP above.
+The tell holds red-lidar `charge-00`, then reared `charge-01`; moving
+charge alternates poses 02/03. A normal hit knocks half a rover width
+(15.5 px) over three authored 12 fps frames. A boundary ends a charge
+with another 0.5 s recovery; it never homes on a jumping hero.
+The red lidar points left and dust trails right in the approved charge;
+mirror these poses for rightward motion.
+
+```mermaid
+flowchart LR
+ IN["Authored rover spawn + patrol range
enemy-rover atlas, 31 × 25 body"] --> PATROL["Patrol within endpoints
lidar tests forward hero band"]
+ PATROL --> TELL["Red lidar + rear-up
0.5 s; lock charge direction"]
+ TELL --> CHARGE["Straight charge at 258 px/s
stop at patrol boundary"]
+ CHARGE --> RECOVER["Recover 0.5 s, turn inward"]
+ RECOVER --> PATROL
+ HIT["Chair register target
one cell of contact damage to hero"] --> DAMAGE["3 HP; white flash
3-frame knockback or fatal wreck"]
+ DAMAGE --> WRECK["5 approved wreck poses
hit, flip, stopped machine"]
+ WRECK --> EVENT["enemy-defeated once
black-fluid consumer in #48"]
+ IN --> CHECK["Reference baseline + real patrol tests
sweep playthrough; pogo #40 / guard #41"]
+```
+
+The development `rover-test` URL starts three hero body widths behind the
+real tutorial spawn, on its deck, without changing the annotation.
+Guard, pogo and the shared fluid effect are verified when #41, #40 and
+#48 reach this stack; keep their integration criteria open until then.
+
## Boss – Jenny, the self-driving race car (Region 1)
Jenny is **Fast Forest**'s car – THD's Formula Student team, founded 2008. Every car they build is called Jenny; the current one is **Jenny 16GDX** (rolled out 22 May 2026), carbon-black with lime-green, teal and blue stripes. Fast Forest has worked on a **driverless** Jenny since 2017: a camera finds the track's cones and the car steers itself (sources: fast-forest.de, PNP 12 Oct 2023). In the game the cockpit is empty, the blue autonomous-status light on the roll hoop flashes, and the anomaly is doing the driving. 30 chair hits, three phases. The pattern comes straight from the brief: she tries to run you over, crashes into things, sheds parts you have to dodge, slows down, you hit her, she charges up again, and it repeats.
diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts
index 2bf5a9c..c57ce2f 100644
--- a/game/src/core/scaffold.ts
+++ b/game/src/core/scaffold.ts
@@ -8,6 +8,8 @@ import { Player } from '../player/player';
import { cameraFollow } from '../stage/camera';
import { ChairCombat } from '../combat/chair';
import { CoffeePoints } from '../combat/coffee';
+import { Rovers } from '../enemies/rovers';
+import { BODY_WIDTH } from '../player/body';
// Bootstrap-only scenes live in lane B until lane A provides story/UI scenes.
export class Boot extends Phaser.Scene {
@@ -36,6 +38,7 @@ export class Stage extends Phaser.Scene {
private follow?: ReturnType;
private combat?: ChairCombat;
private coffee?: CoffeePoints;
+ private rovers?: Rovers;
get playerActor() { return this.player; }
get chair() { return this.combat; }
constructor() { super('Stage'); }
@@ -44,14 +47,18 @@ export class Stage extends Phaser.Scene {
collisionDebug(this, data.collisions);
const machine = import.meta.env.DEV && new URLSearchParams(location.search).has('coffee-test')
? data.collisions.flatMap(chunk => chunk.world.interact).find(box => box.kind === 'coffee-machine') : undefined;
+ const rover = import.meta.env.DEV && new URLSearchParams(location.search).has('rover-test')
+ ? data.collisions.flatMap(chunk => chunk.world.spawn).find(spawn => spawn.kind === 'rover') : undefined;
this.player = new Player(this, data.collisions, data.manifest.region, this.defeatedBosses,
- machine ? { x: machine.x + machine.w / 2, y: machine.y + machine.h } : undefined);
+ machine ? { x: machine.x + machine.w / 2, y: machine.y + machine.h }
+ : rover ? { x: rover.x - BODY_WIDTH * 3, y: rover.y } : undefined);
this.player.onHealthChanged = health => {
const hud = this.scene.get('HUD') as Phaser.Scene & { model?: { setHealth(health: number, at: number): void } };
hud.model?.setHealth(health, this.time.now);
};
this.combat = new ChairCombat(this, this.player, data.manifest.region);
this.coffee = new CoffeePoints(this, this.player, data.collisions);
+ this.rovers = new Rovers(this, this.player, this.combat, data.collisions);
this.follow = import.meta.env.DEV && new URLSearchParams(location.search).has('inspect')
? undefined : cameraFollow(this, data.manifest);
this.scene.launch('UI');
@@ -70,6 +77,7 @@ export class Stage extends Phaser.Scene {
if (active <= 0) return;
this.coffee?.update();
this.player?.update(active);
+ this.rovers?.update(active);
this.combat?.update(active);
if (this.player) this.follow?.(this.player.body, active);
}
diff --git a/game/src/enemies/rover.test.ts b/game/src/enemies/rover.test.ts
new file mode 100644
index 0000000..822b91d
--- /dev/null
+++ b/game/src/enemies/rover.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest';
+import { Rover, CHARGE_SPEED, PATROL_SPEED } from './rover';
+import { parseCollision, placeCollision } from '../stage/collision';
+import type { Damageable } from '../combat/sweeps';
+import { Sweeps, intersects } from '../combat/sweeps';
+import raw from '../../../art/stages/rusty-creek/rc-0-0.anomaly.collision.json';
+
+const tutorial = () => {
+ const spawn = placeCollision(parseCollision(raw, 'rc-0-0', 'anomaly'), [0, 0]).world.spawn.find(point => point.kind === 'rover')!;
+ return new Rover({ x: spawn.x, y: spawn.y }, spawn.patrol as [number, number]);
+};
+describe('autonomous rover', () => {
+ it('patrols the real tutorial range at half walking speed without leaving its deck', () => {
+ const rover = tutorial(); expect(rover.patrol).toEqual([640, 880]);
+ expect(rover.bounds).toEqual({ x: 744.5, y: 483, w: 31, h: 25 });
+ expect(PATROL_SPEED).toBe(75.25); rover.step(1); expect(rover.x).toBeCloseTo(835.25);
+ for (let n = 0; n < 30; n++) { rover.step(0.1); expect(rover.x).toBeGreaterThanOrEqual(640); expect(rover.x).toBeLessThanOrEqual(880); }
+ });
+ it('shows red lidar and rear-up for exactly half a second before a locked straight charge', () => {
+ const rover = tutorial(); rover.step(1 / 120, { x: 840, y: 508 });
+ expect(rover.phase).toBe('tell'); expect(rover.pose).toBe('charge-00');
+ rover.step(0.25); expect(rover.pose).toBe('charge-01'); expect(rover.x).toBe(760);
+ rover.step(0.25); expect(rover.phase).toBe('charge'); expect(CHARGE_SPEED).toBe(258);
+ rover.step(0.1, { x: 700, y: 400 }); expect(rover.x).toBeCloseTo(785.8); expect(rover.direction).toBe(1);
+ rover.step(1); expect(rover.x).toBeLessThanOrEqual(880);
+ });
+ it('spots only forward within the documented tuning band', () => {
+ for (const hero of [{ x: 700, y: 508 }, { x: 920, y: 508 }, { x: 800, y: 400 }]) {
+ const rover = tutorial(); rover.step(1 / 120, hero); expect(rover.phase).toBe('patrol');
+ }
+ });
+ it('accepts three separate sweeps, plays the five wreck poses, and signals defeat once', () => {
+ const rover = tutorial(), sweep = new Sweeps(), hero = { x: 730, y: 508 };
+ const target: Damageable = { id: 'rover-1', bounds: () => rover.bounds, hit: hit => rover.hit(hit.damage, hit.direction) };
+ for (let n = 0; n < 3; n++) {
+ hero.x = rover.x - 30;
+ sweep.press(1); sweep.step((n % 2 ? 3 : 4) / 12);
+ expect(sweep.hit(hero, [target])).toHaveLength(1);
+ expect(sweep.hit(hero, [target])).toHaveLength(0);
+ expect(rover.health).toBe(2 - n);
+ if (n < 2) rover.step(3 / 12);
+ sweep.step(1);
+ }
+ expect(rover.phase).toBe('wreck'); expect(rover.pose).toBe('wreck-00');
+ expect(rover.step(4 / 12)).toBe(false); expect(rover.pose).toBe('wreck-04');
+ expect(rover.step(1 / 12)).toBe(true); expect(rover.phase).toBe('dead');
+ expect(rover.step(2)).toBe(false); expect(rover.hit(1, 1)).toBe(false);
+ rover.reset(); expect(rover.health).toBe(3); expect(rover.x).toBe(760);
+ });
+ it('uses three-frame hit recovery and lets the hero jump clear of the body', () => {
+ const rover = tutorial(); expect(rover.hit(1, -1)).toBe(true); expect(rover.hit(1, -1)).toBe(false);
+ rover.step(3 / 12); expect(rover.x).toBeCloseTo(744.5); expect(rover.phase).toBe('patrol');
+ expect(intersects(rover.bounds, { x: 730, y: 360, w: 28, h: 76 })).toBe(false);
+ expect(intersects(rover.bounds, { x: 730, y: 432, w: 28, h: 76 })).toBe(true);
+ });
+});
diff --git a/game/src/enemies/rover.ts b/game/src/enemies/rover.ts
new file mode 100644
index 0000000..6d2089c
--- /dev/null
+++ b/game/src/enemies/rover.ts
@@ -0,0 +1,69 @@
+import { WALK_SPEED, RUN_SPEED } from '../player/movement';
+import { BODY_HEIGHT } from '../player/body';
+import type { Position } from '../core/events';
+
+// docs/05: measured idle-00 alpha bounds and controller tuning.
+export const ROVER_WIDTH = 31, ROVER_HEIGHT = 25;
+export const TELL_SECONDS = 0.5, PATROL_SPEED = WALK_SPEED / 2, CHARGE_SPEED = RUN_SPEED;
+export type RoverPhase = 'patrol' | 'tell' | 'charge' | 'recover' | 'hit' | 'wreck' | 'dead';
+export class Rover {
+ x: number;
+ readonly y: number;
+ health = 3;
+ phase: RoverPhase = 'patrol';
+ direction: -1 | 1 = 1;
+ elapsed = 0;
+ private hitDirection = 1;
+ constructor(readonly spawn: Position, readonly patrol: [number, number]) {
+ this.x = spawn.x; this.y = spawn.y;
+ }
+ private enter(phase: RoverPhase) { this.phase = phase; this.elapsed = 0; }
+ reset() { this.x = this.spawn.x; this.health = 3; this.direction = 1; this.enter('patrol'); }
+ get bounds() { return { x: this.x - ROVER_WIDTH / 2, y: this.y - ROVER_HEIGHT, w: ROVER_WIDTH, h: ROVER_HEIGHT }; }
+ hit(damage: number, direction: number) {
+ if (!Number.isInteger(damage) || damage <= 0) throw new Error('Damage must be a positive integer');
+ if (this.health <= 0 || this.phase === 'hit') return false;
+ this.health = Math.max(0, this.health - damage); this.hitDirection = direction;
+ this.enter(this.health ? 'hit' : 'wreck'); return true;
+ }
+ /** Returns true exactly when the last wreck frame ends. Stage hit-stop pauses this clock. */
+ step(seconds: number, hero?: Position) {
+ let defeated = false, remaining = seconds;
+ while (remaining > 1e-9) {
+ const dt = Math.min(remaining, 1 / 120); remaining -= dt; this.elapsed += dt;
+ switch (this.phase) {
+ case 'patrol':
+ if (hero && (hero.x - this.x) * this.direction > 0 && Math.abs(hero.x - this.x) <= WALK_SPEED
+ && Math.abs(hero.y - this.y) <= BODY_HEIGHT) { this.enter('tell'); break; }
+ this.x += this.direction * PATROL_SPEED * dt;
+ if (this.x <= this.patrol[0] || this.x >= this.patrol[1]) {
+ this.x = Math.max(this.patrol[0], Math.min(this.patrol[1], this.x)); this.direction = this.x <= this.patrol[0] ? 1 : -1;
+ }
+ break;
+ case 'tell': if (this.elapsed + 1e-9 >= TELL_SECONDS) this.enter('charge'); break;
+ case 'charge':
+ this.x += this.direction * CHARGE_SPEED * dt;
+ if (this.x <= this.patrol[0] || this.x >= this.patrol[1]) {
+ this.x = Math.max(this.patrol[0], Math.min(this.patrol[1], this.x)); this.direction = this.x <= this.patrol[0] ? 1 : -1;
+ this.enter('recover');
+ }
+ break;
+ case 'recover': if (this.elapsed + 1e-9 >= TELL_SECONDS) this.enter('patrol'); break;
+ case 'hit':
+ this.x = Math.max(this.patrol[0], Math.min(this.patrol[1], this.x + this.hitDirection * ROVER_WIDTH / 2 / (3 / 12) * dt));
+ if (this.elapsed + 1e-9 >= 3 / 12) this.enter('patrol');
+ break;
+ case 'wreck': if (this.elapsed + 1e-9 >= 5 / 12) { this.enter('dead'); defeated = true; } break;
+ }
+ }
+ return defeated;
+ }
+ get pose() {
+ const frame = Math.floor((this.elapsed + 1e-9) * 12);
+ if (this.phase === 'wreck' || this.phase === 'dead') return `wreck-${String(this.phase === 'dead' ? 4 : Math.min(4, frame)).padStart(2, '0')}`;
+ if (this.phase === 'hit') return 'idle-00'; // The renderer flashes the living body white.
+ if (this.phase === 'tell') return `charge-0${this.elapsed + 1e-9 < TELL_SECONDS / 2 ? 0 : 1}`;
+ if (this.phase === 'charge') return `charge-0${2 + frame % 2}`;
+ return this.phase === 'patrol' ? `drive-${String(frame % 6).padStart(2, '0')}` : `idle-${String(frame % 4).padStart(2, '0')}`;
+ }
+}
diff --git a/game/src/enemies/rovers.ts b/game/src/enemies/rovers.ts
new file mode 100644
index 0000000..175e612
--- /dev/null
+++ b/game/src/enemies/rovers.ts
@@ -0,0 +1,50 @@
+import type Phaser from 'phaser';
+import type { CollisionChunk } from '../stage/collision';
+import { BODY_HEIGHT, BODY_WIDTH } from '../player/body';
+import type { Player } from '../player/player';
+import type { ChairCombat } from '../combat/chair';
+import { intersects } from '../combat/sweeps';
+import { gameEvents } from '../core/events';
+import { Rover, ROVER_HEIGHT } from './rover';
+
+export class Rovers {
+ private rows;
+ private debug: Phaser.GameObjects.Graphics;
+ private visible = false;
+ constructor(private scene: Phaser.Scene, private player: Player, chair: ChairCombat, chunks: CollisionChunk[]) {
+ const cleanup: (() => void)[] = [];
+ this.debug = scene.add.graphics().setDepth(10003);
+ this.rows = chunks.flatMap(chunk => chunk.world.spawn).filter(spawn => spawn.kind === 'rover').map(spawn => {
+ const range = spawn.patrol;
+ if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isFinite) || range[0] >= range[1]) {
+ throw new Error(`Invalid rover patrol: ${spawn.id}`);
+ }
+ const model = new Rover({ x: spawn.x, y: spawn.y }, range as [number, number]);
+ const sprite = scene.add.sprite(model.x, model.y, 'enemy-rover', 'idle-00').setOrigin(0.5, 1).setDepth(101);
+ const label = scene.add.text(model.x, model.y - ROVER_HEIGHT - BODY_WIDTH / 2, '',
+ { fontFamily: 'monospace', fontSize: '14px', backgroundColor: '#17242d' }).setDepth(102).setVisible(false);
+ cleanup.push(chair.register({ id: spawn.id!, bounds: () => model.bounds, hit: hit => model.hit(hit.damage, hit.direction) }));
+ cleanup.push(player.life.register(spawn.id!, () => model.reset()));
+ return { id: spawn.id!, model, sprite, label };
+ });
+ const toggle = () => { this.visible = !this.visible; for (const row of this.rows) row.label.setVisible(this.visible); };
+ scene.input.keyboard?.on('keydown-F2', toggle);
+ scene.events.once('shutdown', () => { scene.input.keyboard?.off('keydown-F2', toggle); cleanup.forEach(off => off()); });
+ }
+ update(milliseconds: number) {
+ this.debug.clear();
+ for (const { id, model, sprite, label } of this.rows) {
+ const defeated = model.step(milliseconds / 1000, this.player.life.dead === 0 ? this.player.body : undefined);
+ if (defeated) gameEvents.emit('enemy-defeated', { id, kind: 'rover', position: { x: model.x, y: model.y } });
+ sprite.setPosition(Math.round(model.x), model.y).setFrame(model.pose)
+ .setFlipX(model.direction > 0); // Approved charge has lidar left / trailing dust right.
+ if (model.phase === 'hit') sprite.setTintFill(0xffffff); else sprite.clearTint();
+ const box = model.bounds, body = this.player.body;
+ if (model.health > 0 && intersects(box, { x: body.x - BODY_WIDTH / 2, y: body.y - BODY_HEIGHT, w: BODY_WIDTH, h: BODY_HEIGHT })) {
+ this.player.damage(1, model.x);
+ }
+ label.setPosition(model.x, model.y - ROVER_HEIGHT - BODY_WIDTH / 2).setText(`${id} ${model.health}/3 ${model.phase}`);
+ if (this.visible && model.health > 0) this.debug.lineStyle(1, 0xff7835).strokeRect(box.x, box.y, box.w, box.h);
+ }
+ }
+}
diff --git a/game/verification/45-rover-pose-compare.png b/game/verification/45-rover-pose-compare.png
new file mode 100644
index 0000000..412f260
Binary files /dev/null and b/game/verification/45-rover-pose-compare.png differ