Kowloon Knockout — Phase 4: Skeletal Characters — Design Spec¶
Date: 2026-06-26. Phase 4 of the Kowloon Knockout graphics overhaul (see
2026-06-25-kowloon-knockout-graphics-overhaul-design.md). Builds on merged Phase 0–3 (WebGPU/TSL render path, tiers, environment, particles). Replaces the proceduralStickFighterwith real skeletal-animated characters on the higher tiers, while keepingStickFighteras the low-tier path and universal fallback.
Goal¶
Give the fighters real skeletal animation — a rigged humanoid playing motion-captured clips (idle, walk, the four punches, block, hit, stun, KO) crossfaded by the existing sim state machine — so combat reads as a fight, not articulated primitives. The combat sim, networking, input, and HUD are untouched: the render layer only reads the per-frame RenderFighter snapshot, exactly as StickFighter does today. Animation is a cosmetic layer.
Asset decision (locked in brainstorm)¶
Rig: Mixamo Y-Bot (standard
mixamorig:skeleton). The user sources the assets; the runtime is built here to Y-Bot’s conventions.Identity: one shared Y-Bot rig for all 9 classes, differentiated by per-class material tint + procedural accessories (headband in class
accent, belt) — not 9 distinct models. Carries over the existingStickFighterheadband concept and theCLASS_DISPLAYcolor/accentdata.Packaging: separate GLB files (no Blender merge). The user converts each Mixamo FBX → GLB individually (e.g. via the three.js editor) and drops them in. The runtime assembles the clips onto the shared skeleton by bone name (all share the
mixamorig:rig, so no retargeting).
Asset contract — exact files the runtime loads¶
public/kowloon/fighter/:
ybot.glb (skinned mesh, T-pose)
idle.glb walk.glb (loops; walk = Mixamo "In Place")
jab.glb cross.glb hook.glb uppercut.glb (one-shots)
block.glb hit.glb stunned.glb ko.glb (block loop; hit/ko one-shots; stunned loop)
Until these exist, every fighter falls back to StickFighter (no regression). Mixamo source terms: Fighting Idle, Walking (In Place), Jab, Cross Punch, Hook Punch, Uppercut, Blocking, Hit Reaction, Stunned/Drunk Idle, Knocked Out/Falling Back Death. Animations downloaded “Without Skin”; the runtime maps by our filename, not Mixamo’s clip name, so close matches are fine.
Architecture — clip-based skeletal layer behind a dispatcher¶
A new skeletal renderer is selected per seat by a small dispatcher; StickFighter is retained unchanged as the low-tier path and the universal fallback (missing assets, load error, or while loading).
Load the shared assets once (drei
useGLTF, cached):ybot.glb+ the 10 clip GLBs. Clone the skinned mesh per seat viaSkeletonUtils.cloneso each fighter has an independent skeleton +AnimationMixer. ≤4 fighters → 4 cheap mixers.Each frame, read
framesRef.current.find(seat)and drive everything imperatively (no React state, same pattern asStickFighter): root position/yaw (damped), the state-machine crossfade,mixer.update(delta).Approach chosen over a hand-rolled
GLTFLoader/AnimationMixer(more code, no benefit) and over shared-skeleton instancing (overkill at ≤4 fighters).
Components & boundaries¶
Pure, headlessly-testable core — lib/kowloon-knockout/render/fighter/¶
clips.ts— the clip manifest:type ClipKey = 'idle'|'walk'|'jab'|'cross'|'hook'|'uppercut'|'block'|'hit'|'stunned'|'ko'; the filename map; per-clip params (loop: boolean,oneShot: boolean,fade: number). Single source of truth shared by loader + state machine.stateMachine.ts—resolveClip(rf: RenderFighter) → { clip: ClipKey; loop: boolean }. Pure mapping from simstate/punch→ clip. Unit-tested (TDD).rootMotion.ts—stripRootMotionXZ(clip: THREE.AnimationClip): void— zeroes the hips XZ position track (keeps Y so the KO topple still reads). Pure transform over three’sAnimationClip/KeyframeTrack(run in node). Unit-tested.
Render layer — components/kowloon-knockout/arena/¶
SkeletalFighter.tsx— loads/clones the rig, builds the mixer + actions, applies identity + accessories + Mixamo normalization, drives per-frame fromframesRef. RUN-OBSERVE.Fighter.tsx— the per-seat dispatcher mounted byArena3D:lowtier →StickFighter; otherwise an ErrorBoundary + Suspense (both falling back toStickFighter) wrappingSkeletalFighter.FighterTrappings.tsx— the floating nameplate + blob shadow extracted fromStickFighterand shared by both renderers (avoids duplication).StickFighter.tsx— unchanged except for having its nameplate/shadow factored intoFighterTrappings.Arena3D.tsx— the seat map renders<Fighter>instead of<StickFighter>.
State machine + clip mapping¶
resolveClip maps each RenderFighter:
sim |
clip |
playback |
|---|---|---|
idle |
|
loop |
walking |
|
loop |
punching |
|
one-shot |
blocking |
|
loop (hold) |
hit |
|
one-shot |
stunned |
|
loop |
knockedOut |
|
one-shot, clamp last frame |
(unknown) |
|
loop |
The mixer crossfades to the target action over the clip’s fade (~0.12s; ~0.08s for punches). One-shots use LoopOnce + clampWhenFinished, triggered with reset().play() on state-entry. Punch sync: v1 triggers the punch one-shot on entering punching with timeScale fit to the punch window; exact frame-locking via rf.punchFrame (action.time = (punchFrame/total)*duration) is a refinement for browser sign-off.
Mixamo normalization¶
The runtime absorbs Mixamo’s quirks so the dropped-in assets “just work”:
Scale: auto-measure the cloned model’s bounding-box height and scale to
TARGET_HEIGHT ≈ 1.8units (matchingStickFighter) — robust to whatever units the FBX→GLB conversion produced.Forward axis: a tunable
MODEL_YAW_OFFSETconstant rotates the model so its facing aligns with the sim’s+Zpunch direction (reusingStickFighter’syaw → faceYmath).Root motion:
stripRootMotionXZon every clip at load (sim owns x/z; vertical kept).
Identity & per-frame visuals¶
Tint: clone the Y-Bot material per seat; set
colorto the classcolor. (color/accentcome from the existingRenderFighter/CLASS_DISPLAY.)Accessories: two small procedural meshes parented to bones — a headband (class
accent) on the head bone (mixamorig:Head), a belt on the hips bone (mixamorig:Hips) — so they animate with the body.Hit flash:
rf.hitFlash > 0→ emissive red pulse on the body material (asStickFighter).Alive:
rf.alive === false→ dim the body color.Trappings: nameplate (
Html) + blob shadow via the sharedFighterTrappings.
Data flow & fallback¶
Data flow: identical to
StickFighter— per frame:const rf = framesRef.current.find(f => f.seat === seat); damp root x/z and rotation.y;const { clip, loop } = resolveClip(rf); crossfade to that action if changed;mixer.update(delta). No React state.Fallback/tier:
low→StickFighter.medium/high/ultra→SkeletalFighter, withStickFighteras both the Suspense fallback (while the GLBs load) and the ErrorBoundary fallback (missing/failed assets). This mirrors the Phase 3 GPU-layer boundary pattern and means: no assets → allStickFighter, zero regression; assets present → skeletal lights up.
Testing¶
UNIT (Vitest, node env):
resolveClip— everystateand everypunch→ correct clip key + loop flag, default → idle;stripRootMotionXZ— syntheticAnimationClipwith a hips position track → XZ zeroed, Y preserved, other tracks untouched. Runnode_modules/.bin/vitest run lib/kowloon-knockout/render.RUN-OBSERVE (deferred to user), two-stage:
Before assets exist: confirm
StickFighterstill renders for all fighters across tiers — the merge gate (no regression from the dispatcher/refactor).After the user adds the GLBs: confirm Y-Bot loads, scales and faces correctly, and clips play + crossfade per state (idle/walk/punches/block/hit/stun/KO); confirm
lowtier and asset-error still fall back toStickFighter.
Per project workflow: every PR is preceded by a
senior-swe-reviewerpass.
Out of scope¶
lib/kowloon-knockout/game/**,net/**,game/input.ts, HUD, lobby — untouched. No game-logic, sim, or netcode changes.No new combat states or
RenderFighterfields. No per-class distinct models or per-class bone-scale proportions (a possible later refinement on top of tint+accessories). No facial animation, no IK, no ragdoll (KO uses the baked clip).three stays at
^0.183.2.
Risks & mitigations¶
Assets are user-sourced and browser-only verifiable. The skeletal path can’t be headlessly tested and can’t be confirmed until the user adds the GLBs. Mitigation: pure-logic core is unit-tested;
StickFighterfallback keeps the game fully working until/if assets land or fail.Mixamo convention drift (scale/forward/bone-naming differences from the FBX→GLB conversion). Mitigation: auto-scale by bbox, tunable
MODEL_YAW_OFFSET, and bone lookups by name with guards; if a bone isn’t found, skip that accessory rather than crash.SkeletonUtils.clonecorrectness for skinned meshes (shared geometry, independent skeleton). Mitigation: the documented three.js pattern; verify each fighter animates independently during sign-off.Suspense/fallback flash as assets load. Mitigation:
StickFighteris the Suspense fallback (always something visible); assets cache after first load.