Kowloon Knockout — Animation-Committed Punches Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix the Phase-4 T-pose/spam by making punches animation-committed — the fighter locks into a punch for its full window (snappy hit + recovery), a buffered punch fires on recovery, and every one-shot clip (punch/hit/KO) is driven by sim progress so it always plays fully.
Architecture: Deterministic sim changes (per-punch commit window, early hit frame, one-punch input buffer) in game/combat + game/fighters + game/world; a pure actionProgress helper exposed through the render snapshot (RenderFighter.actionProgress) computed identically in both getRenderFighters paths; and the skeletal renderer drives one-shot clip time from actionProgress. The combat sim stays deterministic and identical on both networked clients.
Tech Stack: TypeScript, the existing Kowloon combat sim (60 Hz fixed step), React Three Fiber + three.js ^0.183.2 (AnimationAction), Vitest ^4.1.8 (node env).
Global Constraints¶
Sim is deterministic netcode — both host and guest run identical logic. All new timing is pure constants/functions;
actionProgressis derived only from already-synced snapshot fields. A divergence desyncs the two players.three stays at
^0.183.2.No net-protocol change.
actionProgressis computed fromstate/punch/punchFrame/stateFrame, which both snapshot paths already carry.Tunable starting values (60 Hz):
PUNCH_COMMIT_FRAMES = { jab: 28, cross: 31, hook: 34, uppercut: 37 }; hit frame= floor(commit * 0.25);HIT_FRAMES = 12;KO_FRAMES = 35. These are tuning constants for browser sign-off.Test runner:
node_modules/.bin/vitest run <path>(node env). Lint:node_modules/.bin/eslint <files>.Out of scope — do not change: block/stun durations, damage, stamina costs, AI, combos, the net protocol, or any file not listed in a task.
Verification approach¶
(UNIT) —
punches.tsconstants/helper,actionProgress.ts, and the sim timing/buffer infighter.tsare pure and node-testable: real Vitest TDD. Runnode_modules/.bin/vitest run lib/kowloon-knockout.(RUN-OBSERVE) —
world.tswiring andSkeletalFighter.tsxrendering: browser-only, [SMOKE] deferred to user;eslintis the headless gate.Per project workflow: a
senior-swe-reviewerpass before the PR (this is deterministic combat — extra important).
Parallelization guidance¶
Tasks 1 and 2 are independent pure modules. Task 3 depends on Task 1. Task 4 depends on Task 3 (the bufferedPunch field). Task 5 depends on Task 2. Task 6 depends on Task 5 (the actionProgress field). Build 1→2→3→4→5→6 (or 1,2 in parallel then the rest in order).
Task 1: punches.ts — commit window + hit frame (UNIT, TDD)¶
Files:
Modify:
lib/kowloon-knockout/game/combat/punches.tsCreate:
lib/kowloon-knockout/game/combat/__tests__/punches.test.ts
Interfaces:
Consumes:
PunchTypefrom../fighters/types.Produces:
const PUNCH_COMMIT_FRAMES: Record<PunchType, number>— total frames a fighter stays inpunching.function punchHitFrame(punch: PunchType): number— the single active hit frame (early/snappy).
Step 1: Write the failing test
Create lib/kowloon-knockout/game/combat/__tests__/punches.test.ts:
import { describe, it, expect } from 'vitest';
import { PUNCH_COMMIT_FRAMES, punchHitFrame } from '../punches';
import type { PunchType } from '../../fighters/types';
const TYPES: PunchType[] = ['jab', 'cross', 'hook', 'uppercut'];
describe('PUNCH_COMMIT_FRAMES', () => {
it('preserves the jab < cross < hook < uppercut ordering', () => {
const c = PUNCH_COMMIT_FRAMES;
expect(c.jab).toBeLessThan(c.cross);
expect(c.cross).toBeLessThan(c.hook);
expect(c.hook).toBeLessThan(c.uppercut);
});
it('uses windows around half a second at 60Hz (24..40 frames)', () => {
for (const t of TYPES) {
expect(PUNCH_COMMIT_FRAMES[t]).toBeGreaterThanOrEqual(24);
expect(PUNCH_COMMIT_FRAMES[t]).toBeLessThanOrEqual(40);
}
});
});
describe('punchHitFrame', () => {
it('lands early in the window (snappy) and before it ends', () => {
for (const t of TYPES) {
const hit = punchHitFrame(t);
expect(hit).toBeGreaterThan(0);
expect(hit).toBeLessThan(PUNCH_COMMIT_FRAMES[t]);
expect(hit).toBeLessThanOrEqual(10); // ~0.16s startup max
}
});
});
Step 2: Run to verify it fails
Run: node_modules/.bin/vitest run lib/kowloon-knockout/game/combat/__tests__/punches.test.ts
Expected: FAIL — PUNCH_COMMIT_FRAMES/punchHitFrame are not exported.
Step 3: Implement in
punches.ts
Append to lib/kowloon-knockout/game/combat/punches.ts (keep the existing PUNCH_DEFS, calculateDamage, calculatePunchSpeed, getStaleMoveMultiplier as-is):
/** Total frames a fighter is committed to a punch (locked out of new actions)
* at the sim's 60Hz step. The animation plays across this whole window, so the
* fighter reads as committed and can't spam. Tunable; preserves the
* jab<cross<hook<uppercut speed ordering. */
export const PUNCH_COMMIT_FRAMES: Record<PunchType, number> = {
jab: 28, cross: 31, hook: 34, uppercut: 37,
};
/** The single frame a punch becomes active (connects). Kept early in the commit
* window so hits stay snappy — only the recovery/lock is extended. */
export function punchHitFrame(punch: PunchType): number {
return Math.floor(PUNCH_COMMIT_FRAMES[punch] * 0.25);
}
Step 4: Run to verify it passes
Run: node_modules/.bin/vitest run lib/kowloon-knockout/game/combat/__tests__/punches.test.ts
Expected: PASS (3 tests).
Step 5: Lint + commit
node_modules/.bin/eslint lib/kowloon-knockout/game/combat/punches.ts lib/kowloon-knockout/game/combat/__tests__/punches.test.ts
git add lib/kowloon-knockout/game/combat/punches.ts lib/kowloon-knockout/game/combat/__tests__/punches.test.ts
git commit -m "feat(kowloon): per-punch commit window + early hit frame"
Task 2: actionProgress.ts — one-shot progress helper (UNIT, TDD)¶
Files:
Create:
lib/kowloon-knockout/game/combat/actionProgress.tsCreate:
lib/kowloon-knockout/game/combat/__tests__/actionProgress.test.ts
Interfaces:
Consumes:
PUNCH_COMMIT_FRAMES(Task 1);PunchTypefrom../fighters/types.Produces:
const HIT_FRAMES = 12,const KO_FRAMES = 35.interface ActionProgressInput { state: string; punch: PunchType | null; punchFrame: number; stateFrame: number; }function actionProgress(f: ActionProgressInput): number→[0,1], how far through the current one-shot (punch/hit/KO); 0 for looping states.
Step 1: Write the failing test
Create lib/kowloon-knockout/game/combat/__tests__/actionProgress.test.ts:
import { describe, it, expect } from 'vitest';
import { actionProgress, HIT_FRAMES, KO_FRAMES } from '../actionProgress';
import { PUNCH_COMMIT_FRAMES } from '../punches';
const base = { state: 'idle', punch: null as 'jab' | null, punchFrame: 0, stateFrame: 0 };
describe('actionProgress', () => {
it('is 0 for looping states (idle/walk/block/stunned)', () => {
for (const state of ['idle', 'walking', 'blocking', 'stunned']) {
expect(actionProgress({ ...base, state })).toBe(0);
}
});
it('tracks punch progress over the commit window', () => {
const half = Math.round(PUNCH_COMMIT_FRAMES.jab / 2);
expect(actionProgress({ state: 'punching', punch: 'jab', punchFrame: 0, stateFrame: 0 })).toBe(0);
expect(actionProgress({ state: 'punching', punch: 'jab', punchFrame: half, stateFrame: 0 }))
.toBeCloseTo(half / PUNCH_COMMIT_FRAMES.jab, 5);
});
it('clamps punch progress to 1 past the window', () => {
expect(actionProgress({ state: 'punching', punch: 'jab', punchFrame: 999, stateFrame: 0 })).toBe(1);
});
it('is 0 for a punching state with no punch type', () => {
expect(actionProgress({ state: 'punching', punch: null, punchFrame: 5, stateFrame: 0 })).toBe(0);
});
it('tracks hit and KO progress and clamps at 1', () => {
expect(actionProgress({ ...base, state: 'hit', stateFrame: HIT_FRAMES / 2 })).toBeCloseTo(0.5, 5);
expect(actionProgress({ ...base, state: 'hit', stateFrame: HIT_FRAMES + 9 })).toBe(1);
expect(actionProgress({ ...base, state: 'knockedOut', stateFrame: KO_FRAMES })).toBe(1);
});
});
Step 2: Run to verify it fails
Run: node_modules/.bin/vitest run lib/kowloon-knockout/game/combat/__tests__/actionProgress.test.ts
Expected: FAIL — Cannot find module '../actionProgress'.
Step 3: Implement
actionProgress.ts
Create lib/kowloon-knockout/game/combat/actionProgress.ts:
import type { PunchType } from '../fighters/types';
import { PUNCH_COMMIT_FRAMES } from './punches';
/** Hit-reaction state duration in frames (matches updateFighter's `hit` case). */
export const HIT_FRAMES = 12;
/** KO topple window in frames (matches the StickFighter KO fall). */
export const KO_FRAMES = 35;
export interface ActionProgressInput {
state: string;
punch: PunchType | null;
punchFrame: number;
stateFrame: number;
}
function clamp01(v: number): number {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
/** How far through the current one-shot action (punch/hit/KO) a fighter is, in
* [0,1]. Looping states return 0. Pure and derived only from snapshot-carried
* fields, so host and guest compute it identically. The renderer uses it to
* drive one-shot clip time so the animation always plays fully. */
export function actionProgress(f: ActionProgressInput): number {
switch (f.state) {
case 'punching':
return f.punch ? clamp01(f.punchFrame / PUNCH_COMMIT_FRAMES[f.punch]) : 0;
case 'hit':
return clamp01(f.stateFrame / HIT_FRAMES);
case 'knockedOut':
return clamp01(f.stateFrame / KO_FRAMES);
default:
return 0;
}
}
Step 4: Run to verify it passes
Run: node_modules/.bin/vitest run lib/kowloon-knockout/game/combat/__tests__/actionProgress.test.ts
Expected: PASS (5 tests).
Step 5: Lint + commit
node_modules/.bin/eslint lib/kowloon-knockout/game/combat/actionProgress.ts lib/kowloon-knockout/game/combat/__tests__/actionProgress.test.ts
git add lib/kowloon-knockout/game/combat/actionProgress.ts lib/kowloon-knockout/game/combat/__tests__/actionProgress.test.ts
git commit -m "feat(kowloon): actionProgress helper for one-shot animation sync"
Task 3: fighter.ts + types.ts — commit window, snappy hit, input buffer (UNIT, TDD)¶
Files:
Modify:
lib/kowloon-knockout/game/fighters/types.ts(addbufferedPunch)Modify:
lib/kowloon-knockout/game/fighters/fighter.tsCreate:
lib/kowloon-knockout/game/fighters/__tests__/committedPunch.test.ts
Interfaces:
Consumes:
PUNCH_COMMIT_FRAMES,punchHitFrame(Task 1);createFighter,startPunch,updateFighter,isHitFrame(existing).Produces:
Fighter.bufferedPunch: PunchType | null; punching now lastsPUNCH_COMMIT_FRAMES[type];isHitFramefires atpunchHitFrame; a buffered punch auto-starts on return to idle.Step 1: Write the failing test
Create lib/kowloon-knockout/game/fighters/__tests__/committedPunch.test.ts:
import { describe, it, expect } from 'vitest';
import { createFighter, startPunch, updateFighter, isHitFrame } from '../fighter';
import { PUNCH_COMMIT_FRAMES, punchHitFrame } from '../../combat/punches';
function fresh() {
const f = createFighter({ seat: 0, className: 'stone_tiger', team: 0, isAI: false, isLocal: true, x: 0, z: 5, displayName: 'P1' });
f.stamina = 100; // ample for back-to-back punches in the test
return f;
}
describe('committed punch timing', () => {
it('stays in punching for the full commit window then returns to idle', () => {
const f = fresh();
expect(startPunch(f, 'jab')).toBe(true);
for (let i = 1; i < PUNCH_COMMIT_FRAMES.jab; i++) {
updateFighter(f);
expect(f.state).toBe('punching');
}
updateFighter(f); // frame === commit
expect(f.state).toBe('idle');
});
it('fires isHitFrame once, early, at punchHitFrame', () => {
const f = fresh();
startPunch(f, 'jab');
const hits: number[] = [];
for (let frame = 1; frame <= PUNCH_COMMIT_FRAMES.jab; frame++) {
updateFighter(f);
if (f.state === 'punching' && isHitFrame(f)) hits.push(f.punchFrame);
}
expect(hits).toEqual([punchHitFrame('jab')]);
});
it('starts a buffered punch on the frame it returns to idle', () => {
const f = fresh();
startPunch(f, 'jab');
// advance partway, then buffer a cross (as world.ts would when busy)
for (let i = 0; i < 5; i++) updateFighter(f);
f.bufferedPunch = 'cross';
// advance to the end of the jab window
while (f.currentPunch?.type === 'jab') updateFighter(f);
expect(f.state).toBe('punching');
expect(f.currentPunch?.type).toBe('cross');
expect(f.bufferedPunch).toBeNull();
});
});
Step 2: Run to verify it fails
Run: node_modules/.bin/vitest run lib/kowloon-knockout/game/fighters/__tests__/committedPunch.test.ts
Expected: FAIL — bufferedPunch not on the fighter / punch reverts to idle too early.
Step 3a: Add
bufferedPunchto theFightertype
In lib/kowloon-knockout/game/fighters/types.ts, in the Fighter interface, add after punchFrame: number; (line ~72):
/** A punch pressed during the current punch's commit window, fired on the
* frame the fighter returns to idle (one-slot input buffer). */
bufferedPunch: PunchType | null;
Step 3b: Initialize and reset
bufferedPunch
In lib/kowloon-knockout/game/fighters/fighter.ts:
In
createFighter’s returned object, afterpunchFrame: 0,(line ~45) add:bufferedPunch: null,.In
resetFighter, afterfighter.punchFrame = 0;add:fighter.bufferedPunch = null;.Step 3c: Use the commit window + snappy hit frame, and consume the buffer
In fighter.ts, update the import (line 11) to bring in the new helpers and drop the now-unused calculatePunchSpeed:
import { PUNCH_DEFS, calculateDamage, PUNCH_COMMIT_FRAMES, punchHitFrame } from '../combat/punches';
Replace the punching case body in updateFighter (currently lines ~215-227) with:
case 'punching': {
if (!fighter.currentPunch) { fighter.state = 'idle'; break; }
const dur = PUNCH_COMMIT_FRAMES[fighter.currentPunch.type];
fighter.punchFrame++;
if (fighter.punchFrame >= dur) {
if (!fighter.punchConnected) fighter.comboHistory = [];
fighter.state = 'idle';
fighter.currentPunch = null;
fighter.punchFrame = 0;
fighter.stateFrame = 0;
fighter.punchConnected = false;
// Fire a buffered punch immediately so it feels responsive.
if (fighter.bufferedPunch) {
const queued = fighter.bufferedPunch;
fighter.bufferedPunch = null;
startPunch(fighter, queued);
}
}
break;
}
Replace the body of isHitFrame (currently lines ~259-264) with:
export function isHitFrame(attacker: Fighter): boolean {
if (attacker.state !== 'punching' || !attacker.currentPunch) return false;
return attacker.punchFrame === punchHitFrame(attacker.currentPunch.type);
}
Step 4: Run to verify it passes
Run: node_modules/.bin/vitest run lib/kowloon-knockout/game/fighters/__tests__/committedPunch.test.ts
Expected: PASS (3 tests). Also run the existing fighter suite to confirm no regression: node_modules/.bin/vitest run lib/kowloon-knockout/game.
Step 5: Lint + commit
node_modules/.bin/eslint lib/kowloon-knockout/game/fighters/fighter.ts lib/kowloon-knockout/game/fighters/types.ts lib/kowloon-knockout/game/fighters/__tests__/committedPunch.test.ts
git add lib/kowloon-knockout/game/fighters/fighter.ts lib/kowloon-knockout/game/fighters/types.ts lib/kowloon-knockout/game/fighters/__tests__/committedPunch.test.ts
git commit -m "feat(kowloon): committed punch window + snappy hit + input buffer slot"
Task 4: world.ts — buffer a punch pressed while busy (RUN-OBSERVE)¶
Files:
Modify:
lib/kowloon-knockout/game/world.ts
Interfaces:
Consumes:
Fighter.bufferedPunch(Task 3).Produces: a punch command pressed mid-
punchingis stored inbufferedPunch(instead of dropped).Step 1: Buffer instead of drop
In lib/kowloon-knockout/game/world.ts, replace the punch-apply block (currently lines ~152-154):
if (cmd.punch && (f.state === 'idle' || f.state === 'walking' || f.state === 'blocking')) {
startPunch(f, cmd.punch);
}
with:
if (cmd.punch) {
if (f.state === 'idle' || f.state === 'walking' || f.state === 'blocking') {
startPunch(f, cmd.punch);
} else if (f.state === 'punching') {
// Locked into the current punch — buffer this one (latest wins);
// updateFighter fires it on the frame the window ends.
f.bufferedPunch = cmd.punch;
}
}
Step 2: Lint
Run: node_modules/.bin/eslint lib/kowloon-knockout/game/world.ts
Expected: clean.
Step 3: [SMOKE] (RUN-OBSERVE, deferred to user)
pnpm dev → local match. Expected: punches commit (you can’t mash out a second punch mid-swing); a punch pressed during a swing fires once, right as the swing ends. Deferred to user.
Step 4: Commit
git add lib/kowloon-knockout/game/world.ts
git commit -m "feat(kowloon): buffer a punch pressed mid-swing instead of dropping it"
Task 5: session.ts — expose actionProgress in the snapshot (RUN-OBSERVE)¶
Files:
Modify:
lib/kowloon-knockout/net/session.ts
Interfaces:
Consumes:
actionProgress(Task 2).Produces:
RenderFighter.actionProgress: number, set in bothgetRenderFighterspaths.Step 1: Add the field + compute it in both paths
In lib/kowloon-knockout/net/session.ts:
Add the import near the other game imports:
import { actionProgress } from '@/lib/kowloon-knockout/game/combat/actionProgress';In the
RenderFighterinterface (afterstateFrame: number;, line ~43) add:actionProgress: number;In the local-sim
getRenderFighters(line ~237-245), add to the returned object (next topunchFrame/stateFrame):
actionProgress: actionProgress({ state: f.state, punch: f.currentPunch?.type ?? null, punchFrame: f.punchFrame, stateFrame: f.stateFrame }),
In the net
getRenderFighters(line ~416-423), add to the returned object:
actionProgress: actionProgress({ state: nf.state, punch: nf.punch, punchFrame: nf.punchFrame, stateFrame: nf.stateFrame }),
Step 2: Lint
Run: node_modules/.bin/eslint lib/kowloon-knockout/net/session.ts
Expected: clean. (If nf.punch is typed as PunchType | null already — it is per the existing punch: nf.punch mapping — no cast is needed.)
Step 3: Commit
git add lib/kowloon-knockout/net/session.ts
git commit -m "feat(kowloon): expose actionProgress on the render snapshot"
Task 6: SkeletalFighter.tsx — drive one-shots from actionProgress + harden crossfade (RUN-OBSERVE)¶
Files:
Modify:
components/kowloon-knockout/arena/SkeletalFighter.tsx
Interfaces:
Consumes:
RenderFighter.actionProgress(Task 5).Produces: one-shot clips play fully, synced to the sim; no T-pose.
Step 1: Sync one-shot clip time to actionProgress; harden the crossfade
Read the current SkeletalFighter.tsx useFrame crossfade block first. It currently selects a clip via resolveClip, and on a clip change does next.reset() (for one-shots) + next.fadeIn(fade).play() + prev.fadeOut(fade), then mixer.update(delta).
Make two changes inside useFrame, keeping the existing dance-override and the rest of the body:
Harden the crossfade so the incoming action always has weight before the outgoing loses it (no bind-pose gap). When the clip changes:
// clip already resolved above (with the dance override) as `clip`
const next = actions[clip];
if (next && currentClip.current !== clip) {
const prev = currentClip.current ? actions[currentClip.current] : undefined;
if (!CLIPS[clip].loop) next.reset();
next.enabled = true;
next.play();
if (prev && prev !== next) {
next.crossFadeFrom(prev, CLIPS[clip].fade, false); // prev→next weight ramp, no gap
} else {
next.setEffectiveWeight(1);
}
currentClip.current = clip;
}
Drive one-shot time from the sim so the punch/hit/KO clip is a pure function of sim progress (never cut off / T-posed). After the crossfade block and before
mixer.update(delta):
// One-shot clips (punch/hit/ko) are positioned by sim progress so they
// always play fully across the action window regardless of its length.
const active = actions[clip];
if (active && !CLIPS[clip].loop) {
active.paused = true; // we set time manually
active.time = rf.actionProgress * active.getClip().duration;
} else if (active) {
active.paused = false; // looping clips advance with the mixer
}
mixer.update(delta);
Spike notes (browser-only verifiable):
Setting
action.paused = true+action.time = …and still callingmixer.update(delta)is the documented way to scrub an action manually; confirm the one-shots track the sim (a slow jab should visibly play start→finish over its window) and that switching back to a looping clip un-pauses it.
crossFadeFromrequires both actions enabled with weight; the block above setsnext.enabled/weight before fading. If a first-ever clip has noprev, it justplay()s. Confirm no bind-pose flash remains between idle and punches.
rf.actionProgressis 0 for looping states, so theelsebranch keeps idle/walk/etc. advancing normally.
Step 2: Lint
Run: node_modules/.bin/eslint components/kowloon-knockout/arena/SkeletalFighter.tsx
Expected: clean.
Step 3: [SMOKE] (RUN-OBSERVE, deferred to user)
pnpm dev → match on a WebGPU desktop with the FBX assets present. Expected: punches play as full, committed swings synced to the hit; hit and KO reactions play fully; no T-pose flashes; mashing produces no extra punches. Deferred to user.
Step 4: Commit
git add components/kowloon-knockout/arena/SkeletalFighter.tsx
git commit -m "feat(kowloon): drive one-shot fighter clips from sim actionProgress; harden crossfade"
Final integration¶
After all tasks:
Full unit run:
node_modules/.bin/vitest run lib/kowloon-knockout— punches + actionProgress + committedPunch + all prior suites pass.[SMOKE] (user, WebGPU desktop, assets present): punches are committed/weighty and synced; no T-pose; no spam; buffered punch fires on recovery; hit/KO play fully. Tune
PUNCH_COMMIT_FRAMESif the pace feels off.Multiplayer smoke test (host + guest): confirm both clients show identical committed punches and stay in sync (determinism).
senior-swe-reviewerpass on the branch diff before opening the PR — emphasize the deterministic-sim timing and the buffer logic.