# Voxel Musou: the army is a 300-slot pool, and the combo is a frame table

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.thesatyajit.com/articles/voxel-musou
> date: 2026-09-24
> tags: explainer, open-source, systems, performance, 3d, procedural-generation, realtime
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](https://github.com/mike007jd/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

<RepoCard repo="mike007jd/voxel-musou" />

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.

<Callout type="note">
I did not run it. Not locally, not the live build, not a single module under Node. This is a source analysis, so every count below is **Measured** in one specific sense: counted from the repository at commit `5702d90` with `grep`, `wc` and reading. Anything about frame rate, GPU time or bandwidth is **Reasoned** from the code, and I say so where it matters. A handful of numbers are **Reported** by the code's own comments as measurements of the commercial games; those point at a directory that is not in the repository, which is the last section of this piece.
</Callout>

| | |
|---|---|
| Project | [mike007jd/voxel-musou](https://github.com/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 |

<Figure
  src="/articles/voxel-musou/title.jpg"
  alt="The game's title screen. On the left, large white brush calligraphy reads 趙雲 with a small red seal reading 常山, then VOXEL MUSOU in spaced capitals and the line 一杆長槍，獨闖魏軍三百. Below is a controls table with gold brush labels: 移動 MOVE, WASD or arrows; 攻擊 ATTACK, J or left click, tap for a 6-hit combo; 蓄力 CHARGE, K or right click, mid-combo for charge attacks; 跳躍 JUMP, Space; 閃避 DODGE, L or Shift; 無雙 MUSOU, I when the gold gauge is full; 視角 CAMERA, Q E or drag the mouse. A gold button reads 出陣 START. On the right, Zhao Yun in white and teal voxel armour stands on a cobbled plaza facing a block of dark-armoured soldiers under 魏 banners, a castle wall behind them in golden haze."
  caption="The start screen, which doubles as the pause menu: the sim does not advance while it is open. The line under the title reads, roughly, 'one spear, alone into three hundred of Wei'. (voxel-musou repository, media/title.jpg)."
/>

## 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:

```js
// 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.

<Figure
  src="/articles/voxel-musou/crowd.jpg"
  alt="Gameplay. Zhao Yun in white and teal voxel armour stands in the centre of a dense crowd of dark-armoured voxel soldiers with red headbands, spears and round bronze shields. Bodies and weapons lie scattered and tumble in the foreground. Three officer name tags with red health bars float over the crowd: 夏侯恩 XIAHOU EN, 張郃 ZHANG HE and 晏明 YAN MING. On the left a cyan number reads 423 with 連擊 CHAIN underneath; at bottom right a gold number reads 94 with 擊破 K.O. COUNT. A pixel portrait, a long teal health bar and a three-segment gold gauge run along the bottom. A square minimap at top right shows a red cluster around a white arrow. Behind the crowd, a castle wall with 魏 and 蜀 banners, lit by a low sun."
  caption="A 423-hit chain at 94 K.O.s. The chain counts hits and resets after 150 frames without one; the K.O. count only goes up. (voxel-musou repository, media/crowd.jpg)."
/>

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.

<Figure
  src="/articles/voxel-musou/dragon.jpg"
  alt="Gameplay in a camp area. Zhao Yun, in white and teal armour, is surrounded by dark-armoured soldiers raising spears, swords and round shields, with pink-and-red 魏 banners on poles and wooden barricades behind. Orange spark lines burst from a hit. A dialogue box at top left shows the pixel portrait with 趙雲 ZHAO YUN, 吾乃常山趙子龍也！ and the English line 'I am Zhao Zilong of Changshan!'. Officer tags for 晏明, 張郃 and 夏侯恩 are stacked over a banner. On the left the chain counter reads 271; at bottom right the K.O. counter reads 390. The gold gauge at the bottom is partly filled."
  caption="The file the README captions 'Musou dragon, 150 K.O.' — though what is in frame is a melee at 390 K.O.s with no dragon in shot. The dragon is in the GIF further down. (voxel-musou repository, media/dragon.jpg)."
/>

## 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:

```js
// 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.

<CrowdBudget />

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:

```js
// 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.

<StepAccumulator />

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:

```js
// 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:

```js
// src/hero/hero.js
if (game.hitstop > 0) { game.hitstop--; return; }        // frozen by hitstop; presses stay buffered
```

The 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:

```js
// 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`:

```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.

<ComboBeat />

**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.

<Figure
  src="/articles/voxel-musou/sweep.jpg"
  alt="Gameplay. Zhao Yun, seen from behind and to the left, swings his spear in a wide horizontal arc; a pale blue and white crescent ribbon traces the blade's path across most of the frame, with orange and gold streak lines bursting outward where it passes through a line of soldiers. Dark-armoured soldiers with red headbands and round shields fill the right side. The chain counter reads 241 and the K.O. counter reads 54. Officer tags for 張郃, 晏明 and 夏侯恩 float above; 魏 banners and a castle wall stand behind, backlit by a low sun."
  caption="A charge sweep. The ribbon is sampled every sim step from the same pose function the renderer uses, so it follows the drawn spear tip exactly; its hitbox is a separate shape in the move table. (voxel-musou repository, media/sweep.jpg)."
/>

## 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.

<MusouTimeline />

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.

<Figure
  src="/articles/voxel-musou/musou.jpg"
  alt="The Musou close-up. The scene is dimmed to a cold teal-blue. Zhao Yun's voxel face fills the centre of the frame: dark hair, a teal headband with a white plate, white lamellar armour with teal trim. Blue lightning-like streaks crackle around him. On the right, large pale brush calligraphy reads 無雙 vertically, with a smaller vertical line 常山趙子龍 and a red seal reading 龍膽, and further right a boxed vertical couplet 長槍所向 百軍皆破. The chain counter reads 423 and the K.O. counter 94. The gauge at the bottom glows blue."
  caption="The cut-in. The dim and the whiteout are not shader passes: they are two DOM layers over the canvas with CSS mix-blend-mode multiply and screen, placed 'so ACES can't swallow them'. The counters match crowd.jpg because the world is frozen until contact. (voxel-musou repository, media/musou.jpg)."
/>

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.

<Video
  src="/articles/voxel-musou/gameplay"
  poster="/articles/voxel-musou/gameplay-poster.jpg"
  alt="A looping 8.5-second gameplay capture. Zhao Yun fights in a dense crowd of dark-armoured soldiers in golden light, the chain counter climbing through the high two hundreds. The Musou triggers: the screen dims to teal, a close-up of his face appears with 無雙 in brush calligraphy, and the crowd hangs frozen. Then the scene snaps back to daylight as a burst of blue light throws soldiers into the air; a blue voxel dragon surges through the crowd and coils around him, a milestone stamp reads 100 then 150 擊破, and a ring of light expands along the ground. The capture then cuts to a different part of the field with the K.O. counter at 203."
  caption="The README's GIF (600×338, 85 frames at 10 fps), re-encoded silent. It is the author's capture, not mine — I did not run the game — and it is edited: after the Musou it cuts to another part of the field. Its K.O. counter sits at 63 through the frozen activation and reads 180 on the last frame before the cut; it jumps from 101 to 160 between two adjacent frames as the ring wave passes. (voxel-musou repository, media/gameplay.gif)."
/>

**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.

<Figure
  src="/articles/voxel-musou/gameplay-frame65.png"
  alt="A single frame from the Musou finisher. A blue voxel dragon, drawn from pale-blue and white boxes, coils upward on the left behind a large gold stamp reading 150 擊破. Soldiers are flung into the air in tiers, a cyan ring of light runs along the sandy ground, and bright blue-white rays burst from the centre. The chain counter reads 439 and the K.O. counter at bottom right reads 160. A vertical couplet, 長槍所向 百軍皆破, is on the right edge."
  caption="Frame 65 of the GIF: the dragon's finisher coil and the ring wave at 150 K.O.s, one frame after the counter read 101. (voxel-musou repository, media/gameplay.gif)."
/>

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.

<ChangeMyMind>

<Falsifier claim="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 of `c.engaged` at `?enemies=300` and `?enemies=2000` over 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.
</Falsifier>

<Falsifier claim="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 `updateRanges` is empty. A WebGL capture with Spector.js at both army sizes settles it: count the draw calls, and read the byte counts on the `bufferSubData` calls for the instance buffers. If they do not grow with `?enemies`, three.js is doing something with `count` that I did not find in `updateBuffer`.
</Falsifier>

<Falsifier claim="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 hashing `crowd.x` at 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.
</Falsifier>

<Falsifier claim="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:swing` event 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.
</Falsifier>

<Falsifier claim="ACES tone mapping never runs with the post chain on.">
Set `renderer.toneMapping` to `THREE.NoToneMapping` and diff two screenshots of the same deterministic frame. If they differ, some material in the scene is reaching the default framebuffer with `toneMapped` on, and the two comments that reason about ACES are right after all.
</Falsifier>

<Falsifier claim="Nothing can be hit during the first 132 frames of a Musou.">
`game.freeze` is re-armed every step while `t` is below `MUSOU.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.
</Falsifier>

</ChangeMyMind>

*Everything above was read from [`mike007jd/voxel-musou`](https://github.com/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](/articles/dream-cubed).*
