diff --git a/docs/02-GAME-DESIGN.md b/docs/02-GAME-DESIGN.md
index 381bb9d..d03dff7 100644
--- a/docs/02-GAME-DESIGN.md
+++ b/docs/02-GAME-DESIGN.md
@@ -71,6 +71,36 @@ Every weapon is a real object we photographed and posed with on 23 Oct.
- Enemies show a 0.4–0.6 s **tell** (flash, rear-up, lidar turns red) before attacking.
- **Guard:** blocks frontal damage and pushes you back a little. A heavy hit (Jenny) breaks the guard.
+### Chair sweep implementation – measured contact (#39)
+
+The `chair-side` atlas uses 86 × 81 px cells with the feet at (43, 81).
+The approved contact poses are A04 and B03; B is mirrored into the strike
+direction. A04 reaches 31 px forward and B03 reaches 33 px. Their wooden
+seat pixels start 8 px in front of the pivot; the highest contact wood is
+58 px above the feet. The collision envelope therefore extends **8–33 px
+forward, 23–58 px above the feet**. Its lower edge includes two pixels of
+overlap with a 25 px rover (the 2 px grain in [09](09-ART-PIPELINE.md)),
+so the short sweep can catch the small ground enemy without extending its
+horizontal reach beyond the approved pose. This envelope is gameplay
+collision, not an alpha mask of the coat or legs.
+
+```mermaid
+flowchart LR
+ IN["chair-side atlas + animation table
86 × 81 cells, feet 43,81"] --> POSE["8 poses A / B at authored 12 fps
release Act; buffer one tap"]
+ POSE --> MASK["A04 / B03 contact window only
front 8–33; above feet 23–58 px"]
+ MASK --> HIT["Overlap target bounds
one attempt per target per swing"]
+ HIT --> FX["Accepted hit: 60 ms stop
spark, sound, target knockback"]
+ FX --> OUT["Finish sweep + 2 recoil poses
consume buffered return swing"]
+ MASK --> CHECK["compare.py baseline comparison
exact bounds + both directions in tests"]
+```
+
+The development rover fixture is 25 px tall ([05](05-ENEMIES-AND-BOSSES.md))
+and 31 px wide (the approved `enemy-rover:idle-00` alpha bounds).
+It starts 38.5 px ahead: 33 px reach + half the rover width − its 8 px test
+knockback − 2 px overlap. Both alternating sweeps can then connect while
+the hero stands still. The 8 px displacement is fixture tuning; real enemy
+knockback belongs to the enemy controller.
+
## The glitch meter (after Region 1)
Each defeated anomaly charges a 3-segment **glitch meter**. This was Lex's idea, merged with Alexander's Dexter version. **It is not a magic system** (A14): spells would make the game too easy.
diff --git a/game/README.md b/game/README.md
index 81665f4..9179b63 100644
--- a/game/README.md
+++ b/game/README.md
@@ -71,3 +71,9 @@ Actors register reset callbacks with Player.life; defeated boss ids persist.
Orange hazards damage and return to safe ground; black no-go resets are free.
Development F5 tests a normal hit, Shift+F5 a two-cell hit, Ctrl+F5 a hazard reset,
and Alt+F5 a free no-go reset. These keys are absent from production builds.
+
+Tap J or gamepad X/Square for alternating chair sweeps, each followed by the
+two-frame recoil. Only contact poses deal damage; a hit freezes stage actors for
+60ms and creates a small spark. F2 shows the active hitbox. Development
+`?region=rusty-creek&state=anomaly&combat-test` adds a stationary rover target.
+Story integration can gate chair use through `Stage.chair.enabled`.
diff --git a/game/src/combat/chair.ts b/game/src/combat/chair.ts
new file mode 100644
index 0000000..12da5aa
--- /dev/null
+++ b/game/src/combat/chair.ts
@@ -0,0 +1,92 @@
+import Phaser from 'phaser';
+import type { Player } from '../player/player';
+import { sound } from '../fx/sound/Audio';
+import { HitStop, Sweeps, SWEEP_CONTACT, type Damageable, type Rect } from './sweeps';
+
+const ROVER_HEIGHT = 25; // docs/05 and approved enemy-rover:idle-00 alpha height.
+const ROVER_WIDTH = 31; // Approved enemy-rover:idle-00 alpha width; compare.py measures 31×25.
+const FIXTURE_KNOCK = 8; // docs/02: development fixture displacement, not real enemy tuning.
+const GRAIN = 2; // docs/09: two-pixel grain; retain this overlap after the fixture moves.
+
+export class ChairCombat {
+ readonly sweeps = new Sweeps();
+ readonly pause = new HitStop();
+ private targets = new Map();
+ private key: Phaser.Input.Keyboard.Key;
+ private wasDown = false;
+ private debug: Phaser.GameObjects.Graphics;
+ private label: Phaser.GameObjects.Text;
+ private visible = false;
+ enabled: boolean;
+ constructor(private scene: Phaser.Scene, private player: Player, region: string) {
+ this.enabled = region === 'rusty-creek';
+ this.key = scene.input.keyboard!.addKey('J');
+ this.debug = scene.add.graphics().setDepth(10003);
+ this.label = scene.add.text(20, 260, '', { fontFamily: 'monospace', fontSize: '18px', backgroundColor: '#17242d' })
+ .setDepth(10004).setScrollFactor(0).setVisible(false);
+ const toggle = () => { this.visible = !this.visible; this.label.setVisible(this.visible); };
+ scene.input.keyboard?.on('keydown-F2', toggle);
+ scene.events.once('shutdown', () => scene.input.keyboard?.off('keydown-F2', toggle));
+ if (import.meta.env.DEV && new URLSearchParams(location.search).has('combat-test')) this.dummy();
+ }
+ register(target: Damageable) {
+ this.targets.set(target.id, target);
+ return () => { if (this.targets.get(target.id) === target) this.targets.delete(target.id); };
+ }
+ private spark(box: Rect) {
+ sound.play('chair-hit');
+ for (let i = 0; i < 6; i++) {
+ const pixel = this.scene.add.rectangle(box.x + box.w / 2, box.y + box.h / 2, 3, 2, 0xfff4cb).setDepth(120);
+ this.scene.tweens.add({ targets: pixel, x: pixel.x - 12 + i * 5, y: pixel.y - 14 + i % 3 * 8,
+ alpha: 0, duration: 140, onComplete: () => pixel.destroy() });
+ }
+ }
+ private dummy() {
+ const table = this.scene.cache.json.get('enemy-rover.anims');
+ const distance = SWEEP_CONTACT.reach + ROVER_WIDTH / 2 - FIXTURE_KNOCK - GRAIN;
+ const actor = this.scene.add.sprite(this.player.body.x + distance, this.player.body.y, 'enemy-rover', Object.keys(table)[0] + '-00')
+ .setOrigin(0.5, 1).setDepth(101);
+ const spawn = { x: actor.x, y: actor.y };
+ let count = 0;
+ const counter = this.scene.add.text(actor.x, actor.y - 40, 'hits 0', { fontSize: '14px', backgroundColor: '#17242d' }).setDepth(102);
+ const bounds = () => ({ x: actor.x - ROVER_WIDTH / 2, y: actor.y - ROVER_HEIGHT, w: ROVER_WIDTH, h: ROVER_HEIGHT });
+ this.register({ id: 'verification-rover', bounds, hit: hit => {
+ count++; actor.x += hit.direction * FIXTURE_KNOCK; actor.setTintFill(0xffffff);
+ counter.setPosition(actor.x, actor.y - 40).setText('hits ' + count);
+ this.scene.time.delayedCall(100, () => actor.clearTint()); return true;
+ } });
+ this.player.life.register('verification-rover', () => {
+ count = 0; actor.setPosition(spawn.x, spawn.y); counter.setPosition(spawn.x, spawn.y - 40).setText('hits 0');
+ });
+ }
+ update(milliseconds: number) {
+ const pad = this.scene.input.gamepad?.getAll().find(p => p.connected);
+ const down = this.key.isDown || !!pad?.X;
+ const keyReleased = Phaser.Input.Keyboard.JustUp(this.key);
+ if (this.enabled && this.player.canAttack && !down && (this.wasDown || keyReleased)) {
+ const first = this.sweeps.phase === 'idle';
+ this.sweeps.press(this.player.facing);
+ if (first) sound.play('chair-whoosh');
+ }
+ this.wasDown = down;
+ if (!this.enabled || !this.player.canAttack) this.sweeps.cancel();
+ let remaining = milliseconds / 1000;
+ while (remaining > 1e-9) {
+ const dt = Math.min(remaining, 1 / 120), previous = this.sweeps.phase;
+ this.sweeps.step(dt);
+ if (previous !== this.sweeps.phase && (this.sweeps.phase === 'sweep-a' || this.sweeps.phase === 'sweep-b')) sound.play('chair-whoosh');
+ const hits = this.sweeps.hit(this.player.body, this.targets.values());
+ if (hits.length) { hits.forEach(box => this.spark(box)); this.pause.trigger(); break; }
+ remaining -= dt;
+ }
+ if (this.sweeps.phase !== 'idle') {
+ const frame = this.sweeps.phase + '-' + String(this.sweeps.frame).padStart(2, '0');
+ if (this.scene.textures.get('chair-side').has(frame)) this.player.sprite.setTexture('chair-side', frame)
+ .setFlipX((this.sweeps.direction < 0) !== (this.sweeps.phase === 'sweep-b'));
+ }
+ this.debug.clear();
+ const box = this.sweeps.box(this.player.body);
+ if (this.visible && box) this.debug.lineStyle(2, 0xff7835).strokeRect(box.x, box.y, box.w, box.h);
+ this.label.setText('chair ' + this.sweeps.phase + ' ' + this.sweeps.frame + ' | hit-stop ' + this.pause.remaining.toFixed(0) + 'ms');
+ }
+}
diff --git a/game/src/combat/sweeps.test.ts b/game/src/combat/sweeps.test.ts
new file mode 100644
index 0000000..c4559cf
--- /dev/null
+++ b/game/src/combat/sweeps.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { HitStop, Sweeps, type Damageable } from './sweeps';
+import clips from '../../public/assets/sprites/chair-side.anims.json';
+describe('light chair sweeps', () => {
+ it('alternates eight-frame sweeps and completes two recoil frames, with a one-tap buffer', () => {
+ expect(clips['sweep-a'].frames).toBe(8); expect(clips['sweep-b'].frames).toBe(8); expect(clips.recoil.frames).toBe(2);
+ const sweep = new Sweeps(); sweep.press(1); expect(sweep.phase).toBe('sweep-a');
+ sweep.press(1); sweep.step(8 / 12); expect(sweep.phase).toBe('recoil'); expect(sweep.frame).toBe(0);
+ sweep.step(1 / 12); expect(sweep.phase).toBe('recoil'); expect(sweep.frame).toBe(1);
+ sweep.step(1 / 12); expect(sweep.phase).toBe('sweep-b'); expect(sweep.frame).toBe(0);
+ });
+ it('enables hitboxes only on A04 and B03, with a single hit per target per swing', () => {
+ const sweep = new Sweeps(), position = { x: 100, y: 500 }; let hits = 0;
+ const target: Damageable = { id: 'dummy', bounds: () => ({ x: 128, y: 475, w: 25, h: 25 }), hit: () => { hits++; return true; } };
+ sweep.press(1); sweep.step(3 / 12); expect(sweep.box(position)).toBeNull();
+ sweep.step(1 / 12); expect(sweep.hit(position, [target])).toHaveLength(1);
+ expect(sweep.hit(position, [target])).toHaveLength(0); expect(hits).toBe(1);
+ sweep.step(1 / 12); expect(sweep.box(position)).toBeNull();
+ sweep.step(5 / 12); sweep.press(1); sweep.step(3 / 12);
+ expect(sweep.hit(position, [target])).toHaveLength(1); expect(hits).toBe(2);
+ });
+ it('hits in the facing direction on both sides and never retries a rejected strike', () => {
+ for (const direction of [-1, 1] as const) {
+ const sweep = new Sweeps(); let attempts = 0;
+ const target: Damageable = { id: 'guarded', bounds: () => ({ x: direction > 0 ? 130 : 50, y: 475, w: 25, h: 25 }),
+ hit: hit => { attempts++; expect(hit.direction).toBe(direction); return false; } };
+ sweep.press(direction); sweep.step(4 / 12);
+ expect(sweep.hit({ x: 100, y: 500 }, [target])).toHaveLength(0);
+ sweep.hit({ x: 100, y: 500 }, [target]); expect(attempts).toBe(1);
+ }
+ });
+ it('cancels pending attacks on damage, death or climbing', () => {
+ const sweep = new Sweeps(); sweep.press(1); sweep.press(-1); sweep.cancel(); sweep.step(2);
+ expect(sweep.phase).toBe('idle'); expect(sweep.box({ x: 0, y: 0 })).toBeNull();
+ });
+ it('matches the documented 8–33 px reach and 23–58 px vertical envelope on both sides', () => {
+ const right = new Sweeps(), left = new Sweeps(), position = { x: 100, y: 500 };
+ right.press(1); left.press(-1); right.step(4 / 12); left.step(4 / 12);
+ expect(right.box(position)).toEqual({ x: 108, y: 442, w: 25, h: 35 });
+ expect(left.box(position)).toEqual({ x: 67, y: 442, w: 25, h: 35 });
+ const beyond: Damageable = { id: 'beyond', bounds: () => ({ x: 134, y: 475, w: 25, h: 25 }),
+ hit: () => { throw new Error('A short chair cannot hit beyond the approved pose'); } };
+ expect(right.hit(position, [beyond])).toHaveLength(0);
+ });
+ it('consumes exactly 60ms of hit-stop, including a partial rendered frame', () => {
+ const stop = new HitStop(); stop.trigger(); let active = 0;
+ for (let i = 0; i < 6; i++) active += stop.consume(1000 / 60);
+ expect(active).toBeCloseTo(40, 7); expect(stop.remaining).toBe(0);
+ });
+});
diff --git a/game/src/combat/sweeps.ts b/game/src/combat/sweeps.ts
new file mode 100644
index 0000000..7f2fbfb
--- /dev/null
+++ b/game/src/combat/sweeps.ts
@@ -0,0 +1,68 @@
+export interface Rect { x: number; y: number; w: number; h: number }
+export interface ChairHit { damage: number; direction: -1 | 1; position: { x: number; y: number }; kind: 'sweep' }
+export interface Damageable { id: string; bounds(): Rect; hit(hit: ChairHit): boolean }
+export const intersects = (a: Rect, b: Rect) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
+
+// docs/02 measured contact: chair-side A04/B03, cell 86×81, foot pivot (43,81).
+export const SWEEP_CONTACT = { inner: 8, reach: 33, top: 58, bottom: 23 } as const;
+const HIT_STOP_MS = 60; // docs/02: the chair is light; every accepted hit stops for 60 ms.
+
+export class HitStop {
+ remaining = 0;
+ trigger() { this.remaining = HIT_STOP_MS; }
+ consume(milliseconds: number) {
+ const paused = Math.min(this.remaining, milliseconds);
+ this.remaining -= paused;
+ return milliseconds - paused;
+ }
+}
+
+/** Contact poses inspected in the approved atlas: A=04, B=03. */
+export class Sweeps {
+ phase: 'idle' | 'sweep-a' | 'sweep-b' | 'recoil' = 'idle';
+ frame = 0;
+ direction: -1 | 1 = 1;
+ private nextB = false;
+ private elapsed = 0;
+ private queued: -1 | 1 | null = null;
+ private struck = new Set();
+ private begin(direction: -1 | 1) {
+ this.phase = this.nextB ? 'sweep-b' : 'sweep-a'; this.nextB = !this.nextB;
+ this.direction = direction; this.frame = 0; this.elapsed = 0; this.struck.clear();
+ }
+ press(direction: -1 | 1) {
+ if (this.phase === 'idle') this.begin(direction);
+ else this.queued = direction;
+ }
+ cancel() { this.phase = 'idle'; this.frame = 0; this.elapsed = 0; this.queued = null; this.struck.clear(); }
+ step(dt: number) {
+ if (this.phase === 'idle') return;
+ this.elapsed += dt;
+ while (this.elapsed + 1e-9 >= 1 / 12 && this.phase !== 'idle') {
+ this.elapsed = Math.max(0, this.elapsed - 1 / 12); this.frame++;
+ if (this.frame < (this.phase === 'recoil' ? 2 : 8)) continue;
+ if (this.phase !== 'recoil') { this.phase = 'recoil'; this.frame = 0; }
+ else {
+ this.phase = 'idle'; this.frame = 0;
+ if (this.queued !== null) { const direction = this.queued; this.queued = null; this.begin(direction); }
+ }
+ }
+ }
+ box(position: { x: number; y: number }): Rect | null {
+ const contact = this.phase === 'sweep-a' && this.frame === 4 || this.phase === 'sweep-b' && this.frame === 3;
+ const { inner, reach, top, bottom } = SWEEP_CONTACT;
+ return contact ? { x: position.x + (this.direction > 0 ? inner : -reach), y: position.y - top,
+ w: reach - inner, h: top - bottom } : null;
+ }
+ hit(position: { x: number; y: number }, targets: Iterable) {
+ const box = this.box(position), hits: Rect[] = [];
+ if (!box) return hits;
+ for (const target of targets) {
+ const bounds = target.bounds();
+ if (this.struck.has(target.id) || !intersects(box, bounds)) continue;
+ this.struck.add(target.id);
+ if (target.hit({ damage: 1, direction: this.direction, position: { ...position }, kind: 'sweep' })) hits.push(bounds);
+ }
+ return hits;
+ }
+}
diff --git a/game/src/core/scaffold.ts b/game/src/core/scaffold.ts
index 68a12d2..2704ae9 100644
--- a/game/src/core/scaffold.ts
+++ b/game/src/core/scaffold.ts
@@ -6,6 +6,7 @@ import { loadCollisions, type CollisionChunk } from '../stage/collision';
import { collisionDebug } from '../stage/collision-debug';
import { Player } from '../player/player';
import { cameraFollow } from '../stage/camera';
+import { ChairCombat } from '../combat/chair';
// Bootstrap-only scenes live in lane B until lane A provides story/UI scenes.
export class Boot extends Phaser.Scene {
@@ -31,6 +32,9 @@ export class Stage extends Phaser.Scene {
private player?: Player;
private defeatedBosses = new Set();
private follow?: ReturnType;
+ private combat?: ChairCombat;
+ get playerActor() { return this.player; }
+ get chair() { return this.combat; }
constructor() { super('Stage'); }
create(data: { manifest: StageManifest; state: RegionState; collisions: CollisionChunk[] }) {
drawStage(this, data.manifest, data.state);
@@ -40,6 +44,7 @@ export class Stage extends Phaser.Scene {
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.follow = import.meta.env.DEV && new URLSearchParams(location.search).has('inspect')
? undefined : cameraFollow(this, data.manifest);
this.scene.launch('UI');
@@ -54,8 +59,11 @@ export class Stage extends Phaser.Scene {
}
}
update(_time: number, delta: number) {
- this.player?.update(delta);
- if (this.player) this.follow?.(this.player.body, delta);
+ const active = Math.min(100, this.combat?.pause.consume(delta) ?? delta);
+ if (active <= 0) return;
+ this.player?.update(active);
+ this.combat?.update(active);
+ if (this.player) this.follow?.(this.player.body, active);
}
}
diff --git a/game/src/player/player.ts b/game/src/player/player.ts
index 43967fd..09d1865 100644
--- a/game/src/player/player.ts
+++ b/game/src/player/player.ts
@@ -25,6 +25,7 @@ export class Player {
private traversal = traversal();
private animation: LocomotionAnimation;
get canAttack() { return this.traversal.ladder === null && this.life.dead === 0 && this.life.knock === 0; }
+ get facing() { return this.motion.facing; }
constructor(private scene: Phaser.Scene, private chunks: CollisionChunk[],
private region = 'rusty-creek', bosses = new Set()) {
this.spawn = chunks.flatMap(c => c.world.spawn).find(p => p.kind === 'player') ?? { x: 120, y: 461 };
diff --git a/game/verification/39-chair-contact-compare.png b/game/verification/39-chair-contact-compare.png
new file mode 100644
index 0000000..98608f7
Binary files /dev/null and b/game/verification/39-chair-contact-compare.png differ