Build a fighting game
Goal
Two studio characters fight at their real heights: every move a captured take from the fight pack, the body posed by the engine's fight driver (the posture, the lean, the planted feet, the guard, the aimed strike), knocked down into the engine's rag-doll, staggered by the engine's blow physics, framed by the engine's fight camera. The game keeps its rules: its states, its inputs, its damage, its strings. A script game (a defineGame, in the sandbox) names the moves and says what each body is doing (character.fight); the host does the rest. A three.js page can run the same maths itself (gameable/animation, gameable/three). Moved from Fight Lab (the fight kit, 28 and 29 Oct 2026), which plays on it; examples/fight-kit is the script game.
Files you will edit
src/game.ts(your fighter: its states, its inputs, the calls below)src/main.ts(the page: each fighter's package at its real height, optional bridge overrides)
Steps
A script game: the guest calls
The fight pack ships with the engine. A script game needs no page wiring or fight.set: the bridge reads the pack once when the first character.fight asks. The page supplies each fighter's real height:
ts
const characters = createCharacterBridge({ engine });
// the manifest: { id: 'char.a', type: 'character', src: 'characters/jake/character.json', rig: { backend: 'aosrig-splat', height: 1.854 } }A page can optionally choose its own pack, seeded random numbers, ring bounds and camera randomness. Pass fight: false to disable fighting:
ts
const characters = createCharacterBridge({
engine,
fight: { set: 'sets/fight-v1.motion.bin', random: myRand },
});
// the manifest: { id: 'char.a', type: 'character', src: 'characters/jake/character.json', rig: { backend: 'aosrig-splat', height: 1.854 } }The game, every step, places the entity and says what the body is doing; the host's fight driver makes the pose on the character's own body (built from its package's skeleton) and lays it on the bones after its own clips:
ts
import { camera, character, markMoved, Transform } from 'gameable';
// the pack's own table of moves, once, as the pack carries it: your rules' numbers (no table of your own)
for (const e of ctx.events) if (e.tag === 'fight-set') moves = JSON.parse(e.val.json).strikes;
Transform.x[me] = x;
Transform.z[me] = z;
Transform.qy[me] = Math.sin((facing * Math.PI) / 4);
Transform.qw[me] = Math.cos((facing * Math.PI) / 4);
markMoved(me);
character.fight(me, {
base: { name: 'fight_idle_bounce', loop: true, fade: 10 },
facing,
mirrored: facing < 0,
look: theirHead,
bounce: 0.7,
target: them,
reach: 1.93,
bulk: 1.15,
});
character.fight(me, { base: null, facing, target: them, strike: { id: 'jab', frame, speed } }); // the pack's jab, aimed at their body as the pack says
for (const e of ctx.events)
if (e.tag === 'fight-contact' && e.val.entity === me) land(e.val.zone, e.val.point, e.val.leg); // it met them: your rules decide the blow
character.fightHit(them, {
zone: 0,
k: 0.7,
side: 1,
reaction: { clip: 'hit_head_left', frames: 22 },
stagger: { dir: facing },
});
character.fightFall(them, { x: facing * 1.6, y: 2.6, z: 0 }, -facing * 3.5); // the rag-doll; `motion-event` `down` says where it lies
character.fightGetUp(them); // the take by how it lies; `motion-event` `risen` says where it stands and which way
character.fight(me, null); // a round's reset: the clips have the body again
camera.fight({ a: me, b: them, shake: 0.05, wideFor: 2.5 });The host's words come back in ctx.events: fight-set once (the pack's fight block, JSON text), fight-contact every step a strike's part is in the other body while it can hit (the part, the point, the nearer leg: take the first; your rules decide the blow), fight-body every step a thrown or rising body is the host's (its place), and motion-events fight (ready, amount the body's scale), rise (started, its facing), down (landed, amount 1 face up) and risen (done). Name the other fighter (target) and a strike with no aim of its own is aimed at that body as the pack says; reach and bulk are the fighter's. The rules (damage, stun, the push back, the counter, the strings, the computer) are the game's: examples/fight-kit/src/rules has Fight Lab's.
A three.js page: the same maths yourself
What the engine gives you:
| piece | what it is |
|---|---|
rig.height, height on load | A character drawn at its real height, scaled from the floor; scale says the factor. |
parseFightPack, FIGHT_MOTION_URL | Every fighting move as data: frames, damage, stun, reach, limb, take, aim, styles (fight pack). |
FightBody, fightBodyOf | The fighter's body off its skeleton file (the joints the takes move), scaled to its height. |
FightSolver, FightPose, FightModelPose | A handful of numbers (hips, spine, head, fists, feet) solved onto any body; the solid body; the aim. |
fightClipsFromSet, setFightClips, FightMotion | The pack's takes as clips, the base clip under every state, cross-faded and mirrored. |
fightClipInfo, fightClipFrame, sampleFightMove | A strike's take re-timed so its impact lands on the move's first hitting frame; the reaction on top. |
FightPoser | The pose layer: posture, lean, wind-up, crouch, planted feet, guard, aim, neck cap, the springs. |
FightRagdoll, FightStagger, FightImpactLayer | The body thrown and falling, the upper body loosened by a big blow, every blow's push on a joint. |
FightCamera | Side-on between the two, back as they part, the wide shot on a knockdown, in close on the winner. |
character.setBonePose(pose) | Your pose on the character's bones each frame, after its own clips and head aim. |
1. Load the pack and a body
ts
import {
FIGHT_MOTION_URL,
decodeMotionSet,
fightBodyOf,
fightClipsFromSet,
parseFightPack,
setFightClips,
} from 'gameable/animation';
const bytes = new Uint8Array(await (await fetch(FIGHT_MOTION_URL)).arrayBuffer());
const set = decodeMotionSet(bytes);
const pack = parseFightPack(set); // pack.strikes by id; pack.refused says what was left out and why
setFightClips(fightClipsFromSet(set)); // the takes every fighter shares
const body = fightBodyOf(await (await fetch('data/body-jake.json')).json(), 1.854); // his real height, metresThe body file is the character's skeleton (the joints the takes move) with its stature as the engine measures it (characterMeasures), so the skeleton and the drawn character scale by the same number.
2. Pose a frame, knock it down
Each frame the game decides the state and the move; FightDriver makes the pose (the strike's take re-timed to the move's frames and aimed, the posture, the crouch, the planted feet, the guard; a blow's push, the stagger, the rag-doll, the get-up) from one FightFrame a step, as the bridge does for a script game:
ts
import { FightDriver } from 'gameable/animation';
const driver = new FightDriver(body, pack, {
random: myRand,
bounds: { half: 5, ropeGive: 0.22, ropeSpring: 0.2 },
});
driver.step(frame); // the base, the strike by id and frame, the target body: driver.pose, driver.world, driver.hurt, driver.strike
driver.hit({
zone: 0,
k: 0.7,
side: 1,
rising: false,
leg: 1,
reaction: { clip: 'hit_head_left', amount: 0.9, frames: 22 },
stagger: { dir: 1, side: 0, up: 0, turn: 0 },
blocked: false,
});
driver.fall(velocity, spin); // thrown; driver.fell when it lies still; driver.getUp() stands it, driver.risen when upGive it your own random numbers (a seeded sequence replays the same fight, and a test of one never passes by chance) and your ring (FightBounds: the walls' half width and the ropes' contact). The pieces underneath (FightPoser, FightMotion, FightImpactLayer, FightStagger, FightRagdoll) are yours to use apart.
4. Draw it
ts
import { loadGameableCharacter } from 'gameable/three';
const character = await loadGameableCharacter(renderer, 'characters/jake/character.json', {
height: 1.854,
});
const handed = {
names: body.names,
rotations: pose.local,
hips: { joint: body.names[body.j.Hips], x: 0, y: 0, z: 0 },
};
// each frame, after the pose is made:
handed.hips.x = pose.hips.x;
handed.hips.y = pose.hips.y;
handed.hips.z = pose.hips.z;
character.object3D.position.set(x, y, depth);
character.setBonePose(handed);
character.update(dt, camera);
// the select screen: its own idle again
character.setBonePose(null);5. Frame it
ts
import { FightCamera } from 'gameable/animation';
const cam = new FightCamera(ring); // ring: space, half, ropeLine, sides, sideNormal(k)
cam.update(camera, a, b, dt); // a, b: x, depth, y, facing, ko, scale, headY, lift
cam.shake(0.05); // a heavy hit
cam.wideFor(2); // a knockdown: out to the wide shot and back6. The screens
A script game ships no HTML, so its screens are the engine's: gameable/screens, the screen kit. A page game names the slots in its own markup (data-screen="fight", data-slot="p1-health"); a script game sends the screens block of its HUD model and the host draws the kit's default markup.
ts
import { createScreens } from 'gameable/screens';
const screens = createScreens({
roster: [{ id: 'jake', name: 'JAKE PAUL', tile: 'fighters/jake/tile.jpg' }],
touch: { buttons: [{ id: 'P', label: 'PUNCH', hold: 'power' }] },
on: { pick: (id) => console.log('picked', id), fight: () => console.log('fight') },
});
screens.show('select');
screens.hud.set({
p1: { name: 'JAKE PAUL', health: 0.8 },
p2: { name: 'JAKE PAUL', health: 1 },
clock: '57',
});What stays yours
The states and their timings, the inputs, the damage and the stun, the strings and the computer, the screens' words and look (the screens themselves are the screen kit's, section 6), the ring's geometry and the fisheye pass. Fight Lab is the worked example; its gold fingerprint (every joint of both fighters every frame of a scripted fight) is how a change to any piece above is checked.
Verify
Keep a gold fingerprint: every joint of both fighters, every frame, of one scripted fight on a seeded random (the computer against itself), recorded from the page in a headless browser; after a change, record it again and compare frame for frame. The same maths on the same inputs gives the same bones, so a change that should not alter the fight shows as identical in every frame, and one that should (a new take, a new reach) shows the first frame and joint that differ and by how much. The engine's own tests for every piece above are in packages/animation/src/motion/fight/.
See also
- The fight pack: the moves' contract and how to make a pack.
- The three.js door: loading,
height,setBonePose. - The kit guide, rule 8: the fighting rules every game on the kit keeps; rule 14: the screens.
- The screen kit: the slots, screen by screen.
examples/fight-kit: the script game, two fighters on the engine's doors alone.