Projects · 02 of 08
Fighting Feel
A beat-synced gun-fu prototype set in a Kowloon tower in 1987, built to show character, camera, and controls. Every press is judged against the beat.
Live demo →Post-mortem: the attempt at a cohesive art and look pass →

- Year
- 2026
- Built with
- TypeScript, Three.js, Web Audio, Gamepad API, Vite
A beat-synced gun-fu prototype, set in 1987 in Cheung Yeh Mansions (長夜大廈), a tower in Kowloon. You start at a wall of CCTV monitors, calibrate, and fight up five floors: a courtyard, a wet market, tenements, a plant room, and a rooftop. My references were Sleeping Dogs and John Wick. It is a portfolio piece for the three Cs of an action game, the character, the camera, and the controls, and it is a work in progress.
It opens with capsules: capsule bodies, a fixed three-quarter camera, and a plain HUD, so the feel can be judged on its own. Press G, or Create on a DualSense, and the page reloads in the CCTV look, with a mounted camera in every room, film cuts on the beat, a kill-cam replay at a quarter speed, and eight characters on one 66-joint rig. The art is temporary and AI-assisted. The characters are AI-generated models from Rodin, the animations come from the free Mesh2Motion library, and the rooms are a Blender kit that AI coding agents built with CC0 textures.

There is no physics engine, and all of the sound is generated. Each floor’s reverb is an impulse fitted to published reverberation times. The game took nine days and 652 commits, and from the seventh day it ran as six parallel AI coding sessions with an orchestrator. The post-mortem covers that pass.
The hard part
Every press is judged against the beat, and a browser’s frames are a poor clock. A frame can come late, or two long frames can run back to back with nothing between them, and a judgment taken from frame times drifts with them. With frames 250 ms long, my first beat scheduler lost 4 of every 8 beats. Then a playtest showed the second problem: mashing worked. Pressing early and often got through floors that were meant to need timing.
How I solved it
Game time comes from the audio clock, AudioContext.currentTime, and never from the frame. Each press is stamped with the raw audio-clock time, and a latency calibration offsets it. The scheduler now writes beats ahead from a timer and from every frame, so a long frame cannot drop one, and at 250 ms frames it loses none. PERFECT is ±85 ms on the first floor and narrows to ±60 ms higher up, GOOD is twice that, and the tempo climbs from 95 to 125 BPM.
Each move has a gate: a light attack takes one beat, a heavy two, and a gun strike half. A press before its gate is early. It counts as a miss and pushes the gate half a beat later, once per gate, so mashing costs a lockout where it used to pay. Every one of these values can be tuned live in the game’s panel, on the backquote key.
The checks, 310 in the browser and 220 unit tests, proved the code did what it was meant to do. Only playing showed whether it looked and felt right: my own play found the worst bugs, a model drawn facing 90° to 180° off its target, and the mashing. The finisher animations are not synced, so some film cuts look disjointed, and there is no motion capture. It needs a desktop browser, Chrome for preference, a keyboard or a DualSense, and the sound on, ideally in headphones. It has been measured only on an M4 Pro Mac.