2026-09-24 · 37 min · explainer · open-source · systems · performance · 3d · procedural-generation · realtime
transcript
Hi, I'm Pixel! Voxel Musou: one hero against a voxel army, in a browser tab. A fixed pool of three hundred slots. A director keeps about eighty-four on the hero, and recycles the dead. Every soldier is a slot in a pool sized once at boot. Every twenty frames, a director counts fighters. Under eighty-four, the nearest waiting block marches in. A fallen soldier's slot frees three and a half seconds later, for reinforcements. The enemies setting only changes how many wait. The fight stays eighty-four strong. Here is that fight, in the project's own screenshot: the hero at the centre of a dense crowd. Top right, the minimap: a red cluster around a white arrow. Each frame banks its time, capped at a tenth of a second. It spends that in sixtieth-of-a-second steps, at most four per frame. Still behind? It drops the rest, so a slow machine plays in slow motion. Then it draws. Two seeded random streams make fights repeatable. Each hit freezes the hero a few frames, which would drag the combo off its beat. One rule fixes that. Move T: frames the move has played. Stop: frames spent frozen by hitstop. Absorb is eight: up to eight frozen frames count as played. So the next strike starts on time, even in a ring of twenty. From the move table: strikes twenty-five, twenty-six, twenty-four and twenty-six frames apart, then a late thirty-five. The feel is data: a pool, a clock and a frame table you can check by hand. A recycled pool, a clock that slows instead of spiralling, and hitstop folded into the beat. Every source is in the full article. I'm Pixel. Bye!
Dynasty Warriors has had one idea since its second instalment in 2000: one warrior, and a field so full of enemy soldiers that the combo counter reads in the hundreds. The idea is expensive. It needs a crowd that is cheap to draw and cheap to think, combat that feels heavy when a single swing connects with fifteen bodies, and a special attack that turns the screen into a painting for three seconds.
voxel-musou does that in a browser tab: Zhao Yun, the Wei army built out of voxels, and the Musou with its azure dragon. It was created on 24 September 2026, the day I read it, under the MIT licence. The README is short, and the engineering claims in it are specific enough to check:
No build step: plain ES modules, Three.js r186 vendored in
vendor/three/, deterministic fixed 60 Hz simulation.
Dense voxel crowds of Wei soldiers (~300, InstancedMesh) blasted apart into voxel debris
- license
- MIT
- branch
- main
- tests
- none found
- source
- 605.8 kB
- commit date
- 2026-09-24
by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded
local clone, 2026-09-24 at 5702d90 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow
shallow clone: counts describe the pinned tree, not the history
So I read all of it — every module under src/, the index.html that carries the HUD's styles, and enough of the vendored three.js to know what it does with the buffers it is handed — and set each README line against the code that implements it.
| Project | mike007jd/voxel-musou · MIT · plain JavaScript, no build |
| Version read | commit 5702d90 (2026-09-24), from a shallow clone |
| Game code | 9,254 lines in 32 modules under src/, plus a 215-line index.html; 1,299 lines are comment-only |
| Vendored | three.js r186: 7 files, 2,180,232 bytes, 82,658 lines — 3.7× the game's own 586,790 bytes of JavaScript |
| Payload | src/main.js reaches all 39 modules through static imports; about 2.79 MB of unminified JavaScript and HTML, about 0.63 MB under gzip -9 |
| Game assets | none besides a 41,068-byte font subset: no audio files, no images, no models. Every mesh, texture and sound is built at boot (the 7.7 MB in media/ is README material, excluded from the deploy by .vercelignore) |
| Tests | none; two self-checks in moves.js that print a console.error if the move table is inconsistent |

Where the 9,254 lines go
The headline feature is the crowd, but the crowd is not where most of the code is.
| subsystem | files | lines | what it holds |
|---|---|---|---|
hero/ | 9 | 2,580 | the rig with analytic two-bone IK, the voxel model, keyed attack clips, locomotion, spring chains for ribbons and cape, the move table, the combo state machine |
crowd/ | 2 | 1,081 | the soldier simulation and its instanced renderer |
world/ | 5 | 1,054 | terrain, castle, banners, fires, the sky |
vfx/ | 1 | 983 | slash ribbons, sparks, KO debris, dust, the finisher effects |
audio/ | 2 | 865 | a sound bank synthesised at boot, and its mixer |
musou/ | 2 | 759 | the special attack's script and its presentation |
combat/ | 2 | 438 | hit detection, hitstop, knockback and juggle physics |
ui/ | 1 | 406 | the DOM HUD |
camera/ | 2 | 380 | the follow camera and the see-through cutout |
core/ | 4 | 314 | the RNG, the event bus, input, voxel meshing |
post/ | 1 | 275 | the renderer and the post-processing chain |
main.js | 1 | 119 | boot and the frame loop |
Measured: the hero is 27.9% of the game and the crowd is 11.7%. That ratio is the first honest thing the code says about the genre. A Musou game is a character-action game with a crowd attached, and the character is where the frames get counted.
The code is also written by someone who explains themselves. One line in seven is a comment, and the comments carry numbers — frame counts, luma targets, loudness — with the reasoning behind each change. That makes this a far easier read than its size suggests, and it is why the rest of this article can quote the author's intent next to the code's behaviour.
Three hundred is a pool size
src/crowd/crowd.js stores the army as a struct of arrays: one typed array per field, one slot per soldier. Measured: 39 fields, 19 of them Float64Array and 20 Int32Array, which is 232 bytes of state per soldier. Position and velocity, yaw, hit points, an 11-value state machine (OFF, IDLE, ADVANCE, GUARD, ATTACK, HURT, KNOCK, AIR, DOWN, GETUP, DEAD), squad membership and slot, ring band, attack token, cooldowns, and the tumble angles the combat code writes when a body is launched.
The size of those arrays is fixed at boot from the URL:
// src/main.js
const ENEMIES = Math.max(0, Math.min(2000, params.get('enemies') ? Number(params.get('enemies')) | 0 : 300));createCrowd adds four officers — the HUD names them 夏侯恩, 晏明, 淳于導 and 張郃 — with 520 hit points against a grunt's 30, so the default army is 304 slots. That is the entire population, forever. Nothing allocates a soldier after boot.
What makes the field feel bottomless is recycling. A dead soldier's slot returns to OFF 210 frames (3.5 s) after he dies, and reinforcement columns of 8–15 soldiers are built from free slots whenever the fight around the hero is under strength, spawned 16–26 m out in front of the camera so they run into shot. The README's own screenshots show K.O. counts as high as 390. At the default setting, that is only reachable because slots come back.

The more interesting number is not 300. It is 84.
A director in squads() counts soldiers who are free and standing in the fight — reacting or downed bodies do not count, so a sweep immediately frees the next block — plus a third of every block already marching. Every 20 frames, if that count is below CROWD.engaged: 84 and fewer than 72 soldiers are en route, it orders the nearest waiting block to march. The block wheels to face the hero at a limited turn rate, marches in formation to 15 m, halts for 36 frames, charges, and at 7.5 m breaks into the ring.
The ring itself is choreographed to the metre. An inner ring at 1.9–2.8 m holds 14–18 soldiers in 16 angular slots; a second row at 3.4–5.6 m holds 20–30; everyone else stands in an outer crowd on the far side of the hero as the camera sees it, so the lens stays clear. Every 6 frames a ring manager refills each row's whole deficit at once. Three attack tokens circulate, favouring soldiers the camera can see; at most two of them wind up at once, each with a 40-frame (0.67 s) telegraph. And once a blow lands, every strike for the next 150–240 frames is a feint: a full wind-up that stops a pace short. The hero also cannot die — hurt() floors his hit points at 1, with the comment "v0: the hero cannot die (demo keeps running)".
None of those numbers mentions ?enemies. Measured: the director's target, the ring sizes, the token count and the strike cap are all constants. So the thing ?enemies=2000 changes is how many blocks stand at the edge of the frame waiting for orders, not how big the fight is. Below 84, the director can never meet its target and sends every block in.

Twenty-two meshes, and a buffer that scales anyway
src/crowd/view.js draws each soldier as a small hierarchy of rigid voxel parts: pelvis, torso, head, two arms, two thighs, two shins, and a weapon on the right hand. Each part is sculpted at 4.2 cm voxels into one shared geometry, and each soldier contributes one instance of each part per frame, with the transforms chained on the CPU — pelvis → torso → arm → weapon, pelvis → thigh → shin — so knees bend, the torso twists and the spear follows the hand. Measured: a spearman is 10 instances; a swordsman, a standard-bearer and a captain are 11 each (shield, flag or crest).
The instancing is where the README's "InstancedMesh" earns its place, and the count is clean. Measured: 22 InstancedMesh objects for the whole army — 12 part and weapon meshes for grunts, 6 part meshes for officers, the banners, the officer markers and two telegraph-star meshes — plus 4 shadow proxies, un-voxelised boxes that stand in for the torso and limbs in the shadow pass so the shadow map never rasterises voxel detail. 16 meshes cast shadows. None of those numbers depends on the army's size: at 300 soldiers or 2,000, the crowd costs at most 26 draw calls in the main pass and 16 in the shadow pass. (The four proxies are submitted in the main pass too, with a material that writes neither colour nor depth. A small waste, and a constant one.)
Two tricks keep the CPU side honest. Soldiers standing idle in their ranks are recomputed only on every fourth frame and replayed from a per-soldier cache of up to 16 recorded instance writes in between. And each soldier's pose is a set of 26 blended channels, so nothing snaps between states.
What does scale is the upload. Every frame, every visible crowd mesh gets instanceMatrix.needsUpdate = true and instanceColor.needsUpdate = true, and nothing sets updateRanges. The vendored three.js decides what that means:
// vendor/three/three.module.js, r186 — WebGLAttributes.updateBuffer
if ( updateRanges.length === 0 ) {
// Not using update ranges
gl.bufferSubData( bufferType, 0, array );
}The whole array goes up, and the arrays were sized at boot from ?enemies, not from what is on screen. Measured from the capacities: 88 bytes per slot on the shared crowd material (a 64-byte matrix, a 12-byte colour, and a 12-byte hit-flash attribute the combat code drives), 76 bytes on the four meshes without the hit flash — banners, officer markers and the two telegraph stars. Reasoned from that and the r186 path above: with every mesh visible, the crowd re-uploads up to 427,488 bytes a frame at the default, about 0.43 MB, or 25.65 MB/s at 60 fps; at ?enemies=2000 it is 2,800,688 bytes, 2.80 MB a frame and 168.04 MB/s.
- draw calls: at most 26 in the main pass (4 of them shadow proxies that write nothing) and 16 in the shadow pass
- the director's target: about 84 soldiers on the hero, at most 72 more marching in
- the rings: 14–18 in the inner ring, 20–30 in the second row, 3 attack tokens, at most 2 winding up at once
The default. A bigger N buys more blocks standing in formation at the edge of the frame, not a bigger fight around the hero.
On a desktop GPU neither number is alarming, and the README does ask for a desktop GPU. The shape is the point: the thing ?enemies buys in bandwidth, it buys whether or not anyone is looking. The CPU work has the same shape. Measured: every sim step makes at least eight full passes over the soldier arrays — the AI loop, the striker count, the token timeout, three passes of the separation grid, the director's census and the combat reaction integrator — before the renderer's own pass that writes each visible soldier's instances.
The separation grid is the part that keeps 2,000 from being quadratic: a 128×128 uniform grid of 1.2 m cells, rebuilt every step as a linked list through two Int32Array buffers, each soldier checking the 3×3 block of cells around it. Hit detection is not gridded. Every hitbox tick loops over all soldiers and tests the shape analytically — arc, circle or line, from the move table — which is fine at the rate hitboxes tick.
Debris is the README's last crowd claim, and it is exactly what it says. A K.O. breaks the soldier into nine voxel clumps in his own palette (fourteen for an officer, four during the Musou so the dragon stays visible), plus helmet, torso, shield and headband blocks, all written into a 2,000-slot ring buffer of 84-triangle clumps that bounce, settle and persist until their slot is reused. The particle pools draw only up to the last live slot.
Fixed-step, yes. Deterministic, within one engine
The README's second claim is the one engineers will care about. Here is the whole loop:
// src/main.js
let acc = 0, last = performance.now();
const frame = (now) => {
requestAnimationFrame(frame);
// clamp at 0 too: the first rAF timestamp can precede the performance.now() taken at module init
acc += Math.min(0.1, Math.max(0, (now - last) / 1000));
last = now;
if (paused) { acc = 0; input.sample(); return; }
let n = 0;
while (acc >= 1 / 60 && n < 4) { step(); acc -= 1 / 60; n++; }
if (n === 4) acc = 0;
render();
};That is a textbook fixed-timestep accumulator with two deliberate choices. The frame delta is clamped to 100 ms, and at most four steps run per display frame; when the cap is hit, the remaining time is thrown away rather than carried. So the sim never spirals trying to catch up. It slows down instead. Reasoned from the arithmetic, and replayed exactly below: the game keeps real time down to 15 frames a second, and below that it runs at fps / 15 speed, 0.67× at 10 fps. The other choice is what render() does not do: there is no interpolation between sim states. On a 144 Hz display most frames draw the same state as the frame before.
Each frame runs as many whole steps as fit, up to four. Down to 15 frames a second the sim keeps real time exactly.
Inside step() the discipline is strict. The comment at the top of main.js states the rule — sim modules advance only in step(), render modules read sim state and never write it — and I found no violation. Randomness comes in two seeded mulberry32 streams, seeded in start() with rng.seed(1); vrng.seed(7936);: rng for the simulation, vrng for anything visual. Measured: the simulation stream has exactly one consumer, crowd.js, with 32 call sites. Combat never draws a random number at all; its per-soldier variety (which way a body spins, whether it somersaults) comes from hash01, a stateless integer hash on the soldier index. The only Math.random in the codebase is in the audio, 26 lines of it, with a comment saying it must never touch either RNG. Hit detection is computed from hitbox data in the move table, "never from animated bones → deterministic", and even the camera's yaw is split into a sim half and a render half so the sim never reads the rendered camera.
So given the same sequence of per-step inputs, the same browser will produce the same fight. Two things keep that from being the stronger claim a reader might take from "deterministic".
Nothing records the inputs. input.sample() is called once per step and latches presses so a tap between steps is never lost, but which step a keypress lands on is decided by the wall clock, and there is no recorder, no replay, no lockstep, no seed display. Grep the source for capture and the purpose shows up in the comments instead: "shake uses sim frames, so captures are deterministic", "a capture that renders every 2nd frame shows the same effect state as real-time play". The determinism exists so the author's capture tooling can re-render a fight frame-exactly. That tooling is not in the repository.
It is determinism per engine. Measured: the seven pure-simulation modules make 156 calls to Math.sin, Math.cos, Math.atan2, Math.hypot and Math.pow — 66 of them in the crowd AI alone. ECMAScript does not require those functions to be correctly rounded, only an "implementation-dependent approximation", and engines do differ in the last bit. This site has a whole module, lib/dmath, because a one-ULP disagreement between Node and Chrome was enough to break hydration of an SVG. Reasoned: in a crowd simulation, where 300 soldiers push each other apart every step, a last-bit difference in one atan2 does not stay in the last bit. A fight recorded in Chrome would be expected to drift in Firefox or Safari. The fix is known and unglamorous — a fixed-point sim, or a small deterministic trig library — and nothing in this project needs it yet, because nothing replays across machines.
The combo is a frame table
This is the part of the codebase I would copy.
src/hero/moves.js is data only. Measured: 15 moves — the normal string N1–N6, the charge attacks C1–C6, a dash attack, an air string and an air charge — with 25 hit windows between them. Every timing is in 60 Hz sim frames:
// src/hero/moves.js — N1, the overhead diagonal chop
n1: { frames: 35, next: 'n2', charge: 'c2', cancel: 23, branch: 11, dodgeCancel: 11, steer: 5, lunge: [[2, 9, 0.3]],
hits: [{ f: [7, 10], every: ONCE, shape: 'arc', range: 2.5, ang: 110, dir: -20, dmg: 12, kb: 'flinch', force: 3, hitstop: 3 }] },cancel is the frame from which a buffered attack starts the next move — the beat of the string. branch is the frame from which a buffered charge starts the Cn finisher that hangs off this normal. The hitbox is a 110° arc of 2.5 m, pointed 20° to the right, live on frames 7–10. Because the table is data, the timing is checkable by hand, and it checks. The comment above the table promises strike onsets "25, 26, 24, 26 sf apart, then the late N6 accent 35 sf after N5", and says the spacing is cancel of one move plus the tell of the next minus its own tell. Measured, from the table: N1 starts striking on frame 7, then 32, 58, 82, 108 and 143. The gaps are 25, 26, 24, 26 and 35. The whole string, untouched, runs 176 frames, 2.93 s.
Hitstop is where most games break that arithmetic, and this one has a specific answer to it. The freeze is local to the hero:
// src/hero/hero.js
if (game.hitstop > 0) { game.hitstop--; return; } // frozen by hitstop; presses stay bufferedThe crowd keeps moving; struck soldiers shudder for at most 3 frames of their own. The hero's freeze scales with how many bodies a light window caught — 1 frame, plus 1 per five extra victims, capped at 4 — while a heavy finisher pays 6–8 frames once per window:
// src/combat/combat.js — heroStop
if (hit.heavy && key !== heavyKey) { // once per window: late stragglers get the mook stop
heavyKey = key;
return Math.max(COMBAT.stopHeavy[0], Math.min(COMBAT.stopHeavy[1], base));
}
return Math.min(COMBAT.stopMax, 1 + Math.floor((count - 1) / COMBAT.stopPer));Scaled hitstop would normally drag the combo off its rhythm in a packed ring: the more you hit, the slower you swing. The fix is one constant in combo.js:
// src/hero/combo.js
function beatOk(h, m, game) {
const stop = m.armor || m.air ? 0 : Math.min(ABSORB, game.frame - h.moveF0 - h.moveT);
return h.moveT + stop >= m.cancel;
}The cancel test counts the frames the hero spent frozen, up to ABSORB = 8, as though the move had kept playing. Eight is not arbitrary: it is two windows at the four-frame cap, and N5, the only normal with two windows, is exactly that. So on the shipped setting the five light strikes land on the same frames in an empty field and in a ring of twenty. Armoured moves — N6 and every charge — do not absorb; the freeze is added on top, which is where the weight of a finisher comes from.
The cancel test counts frames the hero spent frozen, up to eight, as if the move had kept playing. Two light windows at the four-frame cap freeze him for exactly eight, so on the shipped setting the five light strikes land on the same frames in an empty field and in a packed ring. Only N6, which is armoured and never absorbs, gets longer. Switch absorption off to see what the one constant is worth.
Reasoned, from the rules above with a perfect mash: with 16 or more soldiers caught per window, N6 would land on frame 167 instead of 143 without ABSORB — 24 frames, 0.4 s, late. One constant is the difference between a combo that keeps its rhythm in a crowd and one that sags exactly when the game is most fun.
The rest of the input layer is equally deliberate. A press stays buffered for BUF = 14 frames once it is eligible to fire, and waits at most WAIT = 40 frames for its window before it is dropped — long enough to cover every normal's cancel point, short enough that a tap made at the start of a two-second charge does not fire 1.8 s later. The charge tells run 25, 16, 13, 24, 24 and 10 frames for C1–C6. An air string is capped at AIR_CHAIN_MAX = 10 swipes. Soft-lock prefers an enemy who is winding up a strike this move can still beat to the punch, judged from where the lunge will have carried the hero by the first active frame.

The Musou is 200 frames, and the clock is stopped for 132 of them
The special attack is a script in src/musou/musou.js, and it is one of the better-engineered pieces of spectacle I have read. It runs 200 sim frames, 3.33 s. Measured: for the first 132 of them the Musou re-arms game.freeze every step, so the crowd's AI and its hit reactions are skipped entirely — the world is paused while Zhao Yun poses, the camera cuts to a head-and-shoulders close-up, a DOM overlay stamps 無雙 in brush calligraphy, and he breaks into a sprint the player can steer. Nothing can be hit before frame 132. Then everything happens at once.
Two-thirds of the Musou is theatre with the clock stopped: nothing can be hit before frame 132, and the hero has invulnerability frames for the whole script. Every damage tick after contact is sampled where something is drawn — the dragon's hits are taken at the head's position on the same path the renderer follows, and the ring wave's radius is the radius the light ring is drawn at.
The README's screenshots agree with the freeze, which is a nice thing to find. crowd.jpg shows a 423-hit chain and 94 K.O.s. musou.jpg, the close-up cut-in, shows the same 423 and the same 94: the Musou was triggered, and for 132 frames nothing in the world could be hit.

At contact, a radial blast hits a 210° sector out to 9.5 m, and a shock front rolls on from 4 m to 15 m over 24 frames, hitting every second frame. The dragon bursts from the spear tip. The dragon is worth describing, because it is a small lesson in keeping gameplay and presentation on one source of truth.
Its path is 35 keyframes — 10 hand-placed, then a 25-point coil that spirals up around the hero for the finisher — interpolated with a Catmull-Rom spline and tabulated by arc length at 1/480 s resolution. Measured: the body is 46 segments spaced 0.3 m along that path behind the head, and the whole dragon — segments, belly plates, fins, four legs, horns, whiskers, a five-spine mane — is 166 boxes in a single InstancedMesh, drawn with an unlit material in colours above 1.0 so the fins bloom. The sim and the renderer both evaluate the same exported functions, dragonArc(s) and dragonAt(a). The damage ticks every second frame at the head's position on that path (skipped while the head is above 4.2 m), which means the dragon hurts exactly what it is drawn passing through. The finisher's ring wave works the same way: its hit radius and its drawn radius are one variable, mu.waveR.
The ring wave does 60 damage. A grunt has 30 hit points. Anything it reaches that is not a captain or an officer dies.
Measured from the GIF's HUD, frame by frame: at least 117 K.O.s from one Musou before the edit cuts away. That is a number from the author's capture rather than a controlled run, but it is consistent with the code: a 60-damage ring against 30-hit-point grunts should produce exactly that kind of single-frame jump.

Filling the gauge is data too. A hit adds 0.3 to a 100-point gauge, a K.O. another 0.55, and taking damage adds 15% of the damage taken. One Musou spends one of three 33.3-point segments; the comment reckons a segment at roughly 90 hits (Reported by the comment, and plausible from the constants).
The look is a post chain, and ACES is not in it
The README lists "atmospheric haze, depth of field, bloom, retro pixel look". All four are in src/post/post.js, in five passes:
| pass | resolution | what it does | cost, counted from the shader |
|---|---|---|---|
| scene | full, half-float, 4× MSAA | the lit scene, plus a depth texture | — |
| atmos | full | aerial perspective (mauve away from the sun, peach toward it) and backlit in-scatter; writes view distance into alpha | 2 texture reads |
| dof | half | single-pass gather bokeh on a golden-angle spiral, squared off so blurred voxels read as soft blocks | 83 taps |
| bloom | half | three.js UnrealBloomPass, five mips, HDR threshold 1.5, with its prefilter rewritten | — |
| final | full | sharp/blur mix, bloom, horizontal streaks, chromatic fringe, split tone, a Lottes tone curve, per-channel shoulder, vignette, grain, dither, quantise | 33 texture reads |
The final pass samples the scene three times for the chromatic fringe, each sample itself a five-tap unsharp mask plus a DoF and a bloom read, then twelve more taps for the streaks. None of this is exotic, and all of it is tuned: the comment above the tunables records luma targets ("luma mean ≈ 0.36, p5 ≤ 0.08, p95 ≥ 0.78, saturation ≈ 0.33") against the medians the author measured on captures.
The "retro pixel look" is four ordinary things. Measured: the renderer's pixel ratio is pinned to 1 whenever post-processing is on, the canvas is upscaled with CSS image-rendering: pixelated, a 4×4 Bayer dither is laid on a 2-pixel grid, and the output is quantised to 41 levels per channel (uLevels = 40), 68,921 colours. Reasoned: on a 2× display every rendered pixel is a 2×2 block of device pixels before any shader runs, so part of the pixel look is just pixels.
One setting is dead. post.js sets renderer.toneMapping = THREE.ACESFilmicToneMapping, and two comments in the Musou presentation reason about ACES — DOM layers are used "so ACES can't swallow them", the dragon is "saturated enough that ACES keeps the hue". Measured, in the vendored r186: three.js applies the renderer's tone mapping only to materials with toneMapped set that render to the default framebuffer. The scene renders to a render target, and the one pass that reaches the screen is a ShaderMaterial created with toneMapped: false; the renderer is built without an outputBufferType, so r186's built-in output pass is never created either. With post-processing on — the only mode reachable from the URL, since createPost is never called with enabled: false — ACES never runs. The tone curve that actually shapes every frame is the Lottes curve in the final pass. The comments are about an earlier pipeline, and the colours they tuned are being judged by a curve they do not mention.
The world around the fight is lit by a 2048² shadow map over a 56 m square that follows the hero, snapped to texels to stop shimmer — 2.7 cm per texel.
141 sounds, zero audio files
"Procedural WebAudio sound" undersells it. src/audio/bank.js builds the entire sound bank at boot with OfflineAudioContext and bakes it into AudioBuffer objects; audio.js plays them with random rate, gain and pan, and never the same variant twice in a row.
Measured: 141 buffers from 39 named sounds. Ten combat groups (slashes, thrusts, spins, heavy swings, light and heavy impacts, crunches, clanks, crowd voices, mass hits) account for 71 variants. Zhao Yun's kiai — ha, hah, sei, toh, tah, hyah, seiya, uora, haa — are 9 lines baked at two pitches each. Enemy grunts, death cries, officer cries, body falls, blow-aways, enemy swings and dodges are 35 more; the hero's hurt, hop, Musou shout and landing are 8; six stingers (the gauge chime, the Musou flash, the finishing blast, a death chorus of twelve voices, a reinforcement war horn and an army roar) and three loops finish it.
The voices are formant synthesis: a sawtooth with jitter and vibrato through four band-pass filters per vowel, from a six-vowel table of shouted male formants, with a sub-harmonic growl for strain and a breath layer because "a shout is half air". The impacts are layered noise bursts — click, crack, a falling "shk", an armour crunch, a pitched body thump through a waveshaper. The music is a 16-second power-chord riff in D minor at 120 BPM, eight bars of double-tracked sawtooth chords through a tanh shaper, locked to an 8-second taiko loop; the distant-battle bed under it is 16 seconds assembled from 52 baked crowd voices, 34 clanks and a few hundred noise ticks. Loops bake at 24 kHz, everything else at 48 kHz.
At runtime the mixer caps itself at 56 live voices before it starts dropping low-priority sounds, and every hit sidechains the whooshes, the bed, the voices and the reverb return for 50–100 ms so impacts own the transient. The comments put the mix at "≈ -17 LUFS in crowd-fight" and the boot bake at about 0.9 s (both Reported, by the comments). There is also an option the README does not document: ?music=0 drops the riff and keeps the drums and the bed.
The brush font, and where it runs out
index.html explains its own font stack in a comment:
Portable brush fallback for machines without the macOS Xingkai/Kaiti assets: Yuji Boku (SIL OFL 1.1, Kinuta Font Factory), subset to the HUD's glyphs (src/ui/brush.woff2). Only fetched when the fonts before it are missing.
The HUD's primary fonts are macOS system fonts, Xingkai SC and Kaiti SC. Everywhere else, HudBrush — a subset of Yuji Boku — is the fallback. I opened the subset with fontTools. Measured: 95 glyphs and 80 mapped code points, of which 68 are CJK (66 ideographs and two full-width punctuation marks); the rest are the digits, a space and a middle dot. Its name table reads "Copyright 2021 The Yuji Project Authors (https://github.com/Kinutafontfactory/Yuji)", "Version 3.002", and carries the OFL's URL. The README credits it correctly.
Two loose ends. The subset has the copyright line and a link to the licence, but not the licence text, and the repository ships no OFL.txt; the OFL asks for the licence to travel with the font, so that is a one-file fix. And the subset does not quite cover the text it is meant to render. Measured, comparing its code points with the CJK the code writes: the HUD uses 70 distinct CJK characters and the subset lacks two, 說 (in the intro card's key hint) and 郃 (in 張郃, the fourth officer's name). The start menu uses 31 and the subset lacks seven, including both characters of the 出陣 start button. The Musou cut-in's CSS and the canvas that paints the 魏 on the banners do not list HudBrush at all.
Reasoned: off macOS, fallback is per character, so 張郃's tag and the start button come out in two typefaces, and the cut-in's 無雙 comes out in the browser's default serif — even though the subset contains both of those glyphs. The README's screenshots show 郃 and 出陣 in a brush hand, so they were captured on a machine with the macOS fonts. The fix is the one the comment already describes: re-subset with the missing characters, and add the fallback to the two stacks that lack it.
The benchmark that isn't in the repository
Read the comments long enough and a second project comes into view. Measured: the code refers to its benchmark 46 times (bench:, benchmark, bench) and gives seven paths under bench/ — bench/BENCHMARK.md, bench/concept.png and notes on camera, charge attacks, hit feedback, locomotion and the Musou. It names Dynasty Warriors 8 34 times, DW8XL 10 times and DW9 5 times. Frame counts are justified against it ("bench: vault 42→90, neutral 126"), audio against it ("matched to the benchmark clips' octave balance"), camera framing ("hero ≈ 45 % of frame height, feet ≈ 89 %"), luma, loudness. The comments are also tagged by owner and round: 76 references to eight named parts — the musou part, combo-system, locomotion-dodge, integration, hit-impact, spear-anim, the camera part, the HUD part — through revisions r1–r4.
None of bench/ is in the repository. So every target the code was tuned toward — the DW8XL onset spacings that the move table reproduces to the frame, the ±20% band the charges are said to land in, the luma and loudness numbers — is Reported by a comment and cannot be checked from here. I can confirm that the table does what its comments say. I cannot confirm that what its comments say matches the game it is imitating, and neither can anyone else until those notes are published. Given how much care went into them, they are the most valuable thing the author has not yet released.
What it adds up to
The README is accurate, and in two places it is accurate in a narrower sense than it reads.
"~300 soldiers, InstancedMesh" is true, and 300 is the size of a pool. The fight is a choreographed ring of about 84, fed by recycled slots; the draw calls do not grow with the army, and the bytes and the CPU passes do. "Deterministic fixed 60 Hz simulation" is true of the loop and of the discipline around it, and it is determinism for one engine, used for captures, with no replay to exercise it.
Neither of those is a complaint. Both are the right trade for a browser game with a desktop GPU in mind, and the code is candid about them in its own comments. What stands out is the part the README undersells: the feel of a Musou game — the beat of a six-hit string, the weight of a hit that catches fifteen bodies, a dragon that hurts exactly what it passes through — lives here as tables and constants that a reader can check with a calculator. That is rarer than instanced rendering, and more worth copying.
What would change my mind
6 claims above, and what would falsify each
The fight around the hero stays near 84 soldiers whatever ?enemies says.
Read from constants:
CROWD.engaged: 84,transit: 72, the ring bands and the token count never reference the army's size. The check is a one-line log ofc.engagedat?enemies=300and?enemies=2000over a minute of play. If the larger army routinely holds far more than 84 plus the marching allowance on the hero, the director is not the bottleneck I think it is.The crowd's draw calls are constant in N, and its per-frame upload is not.
Reasoned from 22 fixed meshes plus 4 proxies, and from r186 uploading the whole array when
updateRangesis empty. A WebGL capture with Spector.js at both army sizes settles it: count the draw calls, and read the byte counts on thebufferSubDatacalls for the instance buffers. If they do not grow with?enemies, three.js is doing something withcountthat I did not find inupdateBuffer.The simulation is deterministic within one engine, and only within one.
The first half fails if two runs in the same browser, fed the same recorded per-step inputs, produce different soldier arrays; recording
input.sample()per step and hashingcrowd.xat a fixed frame is an afternoon's work. The second half is a prediction: the same recording replayed in Chrome and Firefox should diverge eventually, because of the 156 transcendental calls in the sim. If long replays stay identical across engines, the concern is theoretical for this code.ABSORB keeps the five light strikes on the same frames in an empty field and in a packed ring.
From the table, strikes land 25, 26, 24, 26 and 35 frames apart. Log the frame of every
attack:swingevent while mashing into an empty field and into a full ring. If the gaps grow with the crowd, hitstop is leaking into the beat through a path I did not model — the sweep windows, which pay their stop on the first tick that connects, are the likeliest place.ACES tone mapping never runs with the post chain on.
Set
renderer.toneMappingtoTHREE.NoToneMappingand diff two screenshots of the same deterministic frame. If they differ, some material in the scene is reaching the default framebuffer withtoneMappedon, and the two comments that reason about ACES are right after all.Nothing can be hit during the first 132 frames of a Musou.
game.freezeis re-armed every step whiletis belowMUSOU.contact, and both the crowd AI and the reaction integrator return early while it is set. The README's two screenshots are consistent with that, showing identical counters. A single counterexample — a chain or K.O. count that changes during the close-up — would mean a hit path that ignores the freeze.
Everything above was read from mike007jd/voxel-musou at commit 5702d90, MIT, cloned and not executed: src/main.js for the loop, src/core/rng.js and src/core/input.js for determinism, src/crowd/crowd.js and src/crowd/view.js for the army, src/hero/moves.js, src/hero/combo.js, src/hero/hero.js and src/combat/combat.js for the combo and hitstop, src/musou/musou.js and src/musou/view.js for the special, src/post/post.js for the look, src/audio/bank.js and src/audio/audio.js for the sound, src/ui/hud.js, index.html and src/ui/brush.woff2 (inspected with fontTools) for the HUD, and vendor/three/three.module.js for what r186 does with instance buffers and tone mapping. Line and call counts are wc and grep over those files; the bandwidth figures are arithmetic on the buffer capacities, not measurements. Screenshots and the GIF are the repository's own, served locally with a NOTICE and the MIT licence text in public/articles/voxel-musou/. For a very different use of voxels — where the block is the data, not the art style — see Dream-Cubed.