A One-Thumb Action RPG in PixiJS: Keeping a Crowd of Monsters Moving
How DankLoot, a one-thumb dungeon game that runs in a browser tab, keeps hundreds of monsters on screen: a fixed-step simulation under PixiJS's frame loop, object pools that stop allocating once a fight starts, one sprite per pool slot drawn from one atlas, sleeping packs, skills that aim at the thickest crowd, fog with a corner map, and a canvas that draws at lower resolution when frames slip. Every number is measured, with its command.
📅
PixiJS 8 / WebGL / fixed-step simulation / object pools
DankLoot is a dungeon game for one thumb. You steer the hero; he and his skills fight on their own, and every floor ends with the same choice: go deeper, or go home with what you carry. The codex lists every monster, boss and skill with the game’s own numbers. This page is about the part a player never sees: how a browser tab keeps a few hundred monsters walking, pushing past each other and dying without stalling.
- Renderer: PixiJS 8.21.0 (MIT), one minified file vendored into the repository, WebGL preferred
- Simulation: plain ES modules under
html/game/engine/, no DOM, no clock and noMath.random, stepped at a fixed 60 Hz - Crowd: up to 300 awake in the repository’s own stress test; up to 585 monsters placed on one open floor in the run measured below
- Drawing: one display object per engine pool slot, every monster frame a window on one atlas texture
- No build step: the browser loads the modules as they are in the repository
Every number on this page was measured on 2026-10-09 against the dankloot repository at commit d10f995, or is quoted from a file at that commit with its path and line. Commands were run from the repository root unless a path says otherwise; nothing in that repository was changed.
The loop: fixed steps under a variable frame
PixiJS calls the game once per drawn frame, and frames are not evenly spaced: 16.7 ms on a 60 Hz screen when all goes well, longer when it does not, and 8.3 ms on a 120 Hz screen. If the simulation moved monsters by “time since the last frame”, a slow frame would let a monster jump through a wall and a replay with the same seed would come out different. So the engine never sees the frame time. The floor scene pours the frame’s milliseconds into an accumulator and runs as many fixed steps of 1/60 s as it holds. This is createStepClock in html/game/juice.js (lines 108 to 119):
export function createStepClock(dt, maxSteps, juice) {
let acc = 0;
return {
frame(ms) {
if (juice && juice.hold(ms)) return;
acc = Math.min(acc + ms / 1000, maxSteps * dt);
},
next() { if (acc >= dt) { acc -= dt; return true; } return false; },
clear() { acc = 0; },
get acc() { return acc; },
};
}
Two details matter. The accumulator is capped at maxSteps * dt, and the floor builds it with MAX_STEPS = 5 (html/game/scenes/floor.js:134), so after a long stall (a tab switch, a garbage collection) the game catches up at most five steps in one frame instead of fast-forwarding through a fight the player did not see. And juice.hold is the hit-stop: for the few milliseconds a heavy hit freezes the picture, the accumulator does not fill, so the freeze is real time lost to the clock, not to the simulation.
The floor scene’s update(ms) then drains it. The engine step is the only thing inside the loop; drawing happens once after it, whatever the number of steps:
while (steps.next()) {
input.cast = castBits;
castBits = 0;
if (lootOn) holdHidden();
step(state, drv ? mk.companion.act(state, drv, input) : input, DT); // a companion: [player, its bot's input]
if (lootOn) releaseHidden();
juice.observe(state);
juice.advance();
if (state.skills.offer.open) { steps.clear(); break; }
}
A level-up offer clears the accumulator and stops the loop, which is how the fight holds still while you pick a card. Off the floor (the title, the town, the gear screen) html/game/main.js stops PixiJS’s ticker entirely once nothing has moved for 500 ms, so a menu left open draws no frames at all; the floor scene is marked live and keeps it running.
Pools: nothing is allocated once the fight is on
A crowd game creates and destroys objects all the time: monsters, XP shards, damage numbers, coins, item drops. In JavaScript every new object is future garbage, and a collection in the middle of a 300-monster fight is a dropped frame. html/game/engine/pool.js is 50 lines and holds the whole answer: every kind of thing the floor spawns in numbers lives in a fixed array, each item carries an alive flag, and taking one is a walk to the next dead slot (lines 21 to 34):
export function take(pool, factory) {
const items = pool.items;
const n = items.length;
let i = pool.cursor;
for (let k = 0; k < n; k++, i++) {
if (i >= n) i = 0;
const it = items[i];
if (!it.alive) {
pool.cursor = i + 1 >= n ? 0 : i + 1;
it.alive = true;
pool.alive++;
return it;
}
}
Monsters, XP shards, hit numbers, coins and drops all live in fixed-size pools that are built before the fight. A dead monster is not freed; its slot is marked dead and handed out again. When every slot is in use the pool doubles once and counts it in grows, so a burst never loses a monster, and the floor sizes the enemy pool up front so that does not happen in practice (html/game/engine/sim.js:642-643):
let enemyCap = 64;
while (enemyCap < maxAlive + 1 + (area ? area.packed : 0)) enemyCap *= 2;
That is room for the floor’s wave cap, a boss, and every sleeping monster the layout placed. The repository’s own test checks the claim instead of trusting it. test/game-horde-perf.test.cjs builds a floor with an unkillable hero and a wave table that spawns one monster a step up to 300, steers the hero in circles through the crowd for 3,600 steps (one minute of game time), and asserts that no pool grows after step 600. Run on this VPS (an AMD EPYC-Milan virtual machine with 4 vCPUs, Node v23.11.1), three times:
$ node test/game-horde-perf.test.cjs
horde: peak 300 alive, mean 297.1 alive over steps 600..3600
horde: mean step 0.260 ms over 3600 steps (limit 4 ms)
horde: kills 1314, xp collected 1900, minor GCs during steps 600..3600: 0
...
game-horde-perf: all passed
The three runs gave a mean step() of 0.260, 0.261 and 0.252 ms, with zero minor garbage collections in the last 3,000 steps of each, and the enemy pool at capacity 512 from step 600 to the end. The kill count, 1,314, was the same in all three, because the engine is deterministic: the same seed and the same input give the same fight. A step costing about a quarter of a millisecond is between 1.5 and 1.6 % of a 16.7 ms frame; the rest of the frame belongs to drawing.
One sprite per slot, one texture for the horde
The renderer mirrors the pools. The header of html/game/scenes/floor.js states the rule (lines 12 to 15): monsters and shards “are drawn by display pools that mirror the engine’s pools slot for slot (slot i of the enemy pool is always drawn by display slot i), built up front and grown only if the engine pool grew; per frame only positions, tints, alphas and visibility change.” So the scene never creates or destroys a PixiJS object when a monster spawns or dies. syncPools() adds display slots only when an engine pool has grown, and the per-frame draw is a straight walk over both arrays together (html/game/scenes/floor.js:2958-2960):
for (let i = 0; i < items.length; i++) {
const e = items[i];
const f = foes[i];
A dead slot hides its display object; a slot that comes back as a different kind of monster swaps its texture and shape. Dying monsters get a fixed budget too: 48 dissolving bodies on screen at once, the oldest reused (CORPSES, floor.js:204), and 24 death puffs (PUFFS, floor.js:137).
The other half is batching. PixiJS draws a run of sprites in one draw call as long as the textures they use can be bound together, and every switch to a texture outside the batch costs another call. html/game/monster-art.js cuts every frame of every monster kind out of one atlas image, and says why in its header (lines 22 to 23): “Every frame of every kind is a window on ONE texture source (the atlas), so a 300-monster horde draws as one batch”. The code that makes those windows (lines 85 to 86):
frames: dirs.map((d) => new Texture({ source: base.source, frame: new Rectangle(d.x, d.y, d.w, d.h) })),
flash: dirs.map((d) => new Texture({ source: white.source, frame: new Rectangle(d.x, d.y, d.w, d.h) })),
flash is a second copy of the atlas, whitened once on a canvas at load, because a hit has to make a monster flash white and a multiply tint can only darken. Animating a monster is pointing its sprite at another rectangle of the same source: no upload, no allocation.
How many monsters a floor holds, and how many are awake
There are two kinds of floor. Floor 1 and every boss floor are a single walled room where timed waves pour monsters in. Every other floor is an open area: rooms, caverns and corridors, up to about 130 by 115 tiles (html/game/area-view.js:5), with packs of monsters placed at the start and asleep.
For the walled rooms the cap is a row in html/game/engine/floors.js: maxAlive is 160 on floor 1 (line 60) and rises floor by floor. On the open floors that cap means less than it looks. There is no single cap on awake monsters. maxAlive in engine/floors.js only stops the timed waves from adding more; the sleeping packs a floor is built with are fielded on top of it.
A pack wakes when the hero’s walk to it is 8 tiles or less (WAKE_WALK, html/game/engine/area.js:94), when one of its members is struck, or when the hero has lingered 10 seconds with nothing awake (STIR_S, line 96), in which case the nearest sleeping pack comes hunting. So the number on screen depends on how the floor is played. To put numbers on it, the floor was played by the game’s own computer player (html/game/engine/bot.js, the same policy the balance simulator uses), with a fresh hero made unkillable, taking level-up cards by the bot’s “auto” policy, one floor per band, the floor number as the seed, until the bot reached the exit or 300 seconds passed. From html/game/engine/:
node --input-type=module -e '
const sim=await import("./sim.js"),K=await import("./sides.js"),SK=await import("./skills.js"),W=await import("./waves.js"),B=await import("./bot.js");
const E=B.kitOf(sim,K,SK,W,{bot:B});
for(const f of [2,7,12,17,22,27]){
const s=sim.createState(f,{area:K,zones:true,floor:f,heroHp:1e9}),I={moveX:0,moveY:0},nav=B.newNav(false);
const start=s.enemies.alive;let peak=0,n=0;
for(;n<sim.HZ*300&&!s.portal.open;n++){
const sk=s.skills;while(sk.offer.open)sim.pickCard(s,B.chooseCard(E,"auto",sk.offer,sk));
nav.steps=n;nav.tick=Math.floor(n/30);B.steer(E,s,I,nav);sim.step(s,I,sim.DT);
let a=0;for(const e of s.enemies.items)if(e.alive&&!e.idle)a++;if(a>peak)peak=a;}
console.log("floor",f,s.room.cols+"x"+s.room.rows,"asleep at start",start,"maxAlive",s.maxAlive,"peak awake",peak,"kills",s.stats.kills,"seconds",Math.round(n/sim.HZ),"portal",s.portal.open);}'
| Floor | Area (tiles) | Asleep at start | maxAlive | Most awake at once | Kills | Seconds |
|---|---|---|---|---|---|---|
| 2 | 111 × 67 | 121 | 180 | 30 | 187 | 115 |
| 7 | 121 × 64 | 256 | 230 | 91 | 333 | 217 |
| 12 | 71 × 115 | 376 | 245 | 105 | 346 | 235 |
| 17 | 127 × 84 | 418 | 250 | 95 | 323 | 300 (no exit) |
| 22 | 121 × 70 | 493 | 260 | 263 | 177 | 119 |
| 27 | 70 × 109 | 585 | 265 | 442 | 254 | 184 |
Read it with its limits. Every monster counted at the start was asleep (a separate check counted alive && idle and got the same six numbers). The hero starts every floor here fresh, with no experience and no gear (he levels up as he fights), which a real player on floor 27 is not: an unkillable hero who cannot thin the crowd lets it pile up, so the 442 on floor 27 is what the engine and renderer can be asked to carry, not what a normal dive looks like. The run is deterministic; it was run twice and printed identical output both times.
The engine side of a crowd is mostly one function, separate() in engine/sim.js, which pushes overlapping monsters apart in three passes a step using a spatial hash with one cell per tile (html/game/engine/spatial.js). The repository’s own record, docs/PERF.md, measured it with node tools/bench-engine.mjs N on the developer’s mini PC: one step with 1,000 monsters went from 1.235 ms to 0.575 ms after a rewrite (docs/PERF.md:266). Part of that rewrite runs the second and third passes only within 4 tiles of the hero once more than 300 monsters are alive (engine/sim.js:1575):
const thin = pass > 0 && pool.alive > SEPARATION_CROWD;
pool.alive counts sleeping monsters too, so on the open floors above, from floor 12 down, the thinned passes are on from the first step. (The constant’s comment, engine/sim.js:214, still says every floor stops at 250; that was true of the walled rooms it was written for.)
Skills that aim at the thickest crowd
With one thumb on the stick, the player cannot aim. Every skill slot is on auto by default, and an area skill aims where the monsters are thickest. densestCluster in html/game/engine/target.js answers that from the same spatial hash in three steps: count the living monsters in every cell near the hero, find the 3x3-cell block with the most whose centre is within range, and aim at the centroid of the monsters in it (lines 32 to 56, the middle step):
// 2) the best 3x3 block whose centre cell is in range
let best = 0;
let bestD = Infinity;
let bx = -1;
let by = -1;
const r2 = range * range;
for (let cy = cy0; cy <= cy1; cy++) {
for (let cx = cx0; cx <= cx1; cx++) {
const dx = cx + 0.5 - x;
const dy = cy + 0.5 - y;
const d2 = dx * dx + dy * dy;
if (d2 > r2) continue;
let n = 0;
for (let oy = -1; oy <= 1; oy++) {
const yy = cy + oy;
if (yy < 0 || yy >= rows) continue;
for (let ox = -1; ox <= 1; ox++) {
const xx = cx + ox;
if (xx < 0 || xx >= cols) continue;
n += counts[yy * cols + xx];
}
}
if (n > best || (n === best && n > 0 && d2 < bestD)) { best = n; bestD = d2; bx = cx; by = cy; }
}
}
Ties go to the block nearer the hero, and nothing is allocated: the counts go into a scratch Int32Array the state owns, and the answer into a reused object. An area skill on auto fires only when densestCluster finds at least 3 monsters in one 3x3-tile block within its range, or when the boss is in range. That is AUTO_CLUSTER = 3 (engine/sim.js:266), and it is why a skill is not wasted on a single straggler. Single-target skills use nearestTarget from the same file, which prefers the nearest elite to the nearest monster.
Fog and the corner map
An open floor starts dark. Every tile no hero has come within 6 tiles of (SEE_R, engine/area.js:99) is covered by fog drawn above the figures, with a lighter rim round the explored ground. A monster in the fog is still drawn; it is just covered. The fog is not a texture the size of the floor. drawFog in html/game/area-view.js works only over the tile window in view, and turns each row of unexplored tiles into as few rectangles as it can (lines 594 to 607):
for (let y = y0; y <= y1; y++) {
let run = -1;
for (let x = x0; x <= x1 + 1; x++) {
let on = false;
if (x <= x1) {
const i = y * cols + x;
if (pass === 0) on = st[i] === 0;
else on = st[i] === 1 && ((x > 0 && st[i - 1] === 0) || (x < cols - 1 && st[i + 1] === 0) || (y > 0 && st[i - cols] === 0) || (y < rows - 1 && st[i + cols] === 0));
}
if (on && run < 0) run = x;
else if (!on && run >= 0) { fog.rect(run * TILE, y * TILE, (x - run) * TILE, TILE); run = -1; fogRuns++; }
}
}
if (fogRuns) fog.fill({ color: fogCol, alpha: pass === 0 ? FOG_A : RIM_A });
The first pass covers the unexplored tiles at alpha 0.93 (FOG_A, line 74); the second covers explored tiles that touch unexplored ones at 0.42 (RIM_A, line 75). It is redrawn only when the hero steps onto a new tile or the view moves by a tile, not every frame. The corner map, 112 CSS pixels wide on a phone and 176 on a desktop (MAP_PHONE and MAP_DESK, lines 69 and 70), uses the same row runs in one Graphics, centred on the hero; per frame it only slides under its mask. Tapping it opens the whole floor’s map and holds the fight still. The explored tiles are saved with the floor, so a reload comes back to the same fog.
Drawing fewer pixels when frames slip
The canvas starts at the device’s pixel ratio, capped at 2 (MAX_DPR = 2, html/game/main.js:105), so a phone with a ratio of 3 already draws fewer pixels than its screen has. When frames still slip on a floor, html/game/ui/adaptive-res.js steps the drawing resolution down. The steps are NOTCHES = [2, 1.5, 1] (line 30) and a frame counts as slow over SLOW_MS = 20 (line 31). Every drawn frame’s time goes into a fixed ring of the last second; the decision is one line (line 108):
if (this.slow * 10 <= this.n) return; // the 90th percentile is 20 ms or under
When more than 10 % of the last second’s frames took over 20 ms, the canvas is drawn one notch lower: 2, then 1.5, then 1 times the CSS pixel size. After a step the window starts empty, so the next decision is made on frames drawn at the new resolution, and no second step comes within 2 seconds of the first. It never steps back up on the same floor; the next floor starts again at full resolution. The world, the HUD and the buttons keep their sizes; only the number of pixels behind them changes, so text edges go softer.
Frame rate, as measured
Three measurements exist, and they answer different questions.
The engine on this VPS. The perf test above: a mean step of 0.252 to 0.261 ms with 300 awake, in plain Node, no drawing. That says the simulation has room to spare; it says nothing about frames.
Drawing, in the repository’s own record. docs/PERF.md holds the results of test/game-horde-render.test.cjs, which opens the game in headless Chrome at a phone-sized 390 × 844 viewport, keeps 300 monsters alive and reads the game’s own frame-rate readout for 10 s. It was run on the developer’s mini PC (an Intel Core i7-14650HX using its integrated GPU, docs/PERF.md:33), not on this VPS. Eight runs on 2026-10-01 (docs/PERF.md:42): the mean frame was at or within 3 ms of 16.7 ms in every run, but the 99th-percentile frame was over 16.7 ms in every run, and in five of the eight it was 23 to 41 ms (docs/PERF.md:56); the worst run drew 508 of 600 frames (docs/PERF.md:51). In a follow-up experiment in the same record, drawn at 780 × 1688 pixels, the same crowd managed 56 fps in one round and 28 in the next (docs/PERF.md:78); at 390 × 844 it held 60 fps with a 17.0 ms 99th percentile in both (docs/PERF.md:79), while the main thread used 5.5 to 7.3 ms of each frame (docs/PERF.md:83). The cost is pixels on the GPU, not JavaScript, which is what the adaptive resolution is for. Across eight before-and-after pairs the record shows the adaptive version drawing 4,618 frames to the fixed version’s 4,171 (docs/PERF.md:157).
What was not measured. No browser run was made on this VPS for this page: it has no GPU, so a software-rendered frame rate would describe the VPS, not the game. And no number on this page comes from a phone.
What it cannot do
- Frame rate on a phone is unknown. No number on this page comes from a phone. The game’s own record says so too, and the frame-rate readout exists so the answer can be read off a real one. The record is
docs/PERF.md:339; to read a phone yourself, add?fps=1to the address, fight a big wave and read the two numbers. - The resolution only goes down. Once a floor has stepped to 1, it stays there until the next floor, even if the slow patch was a moment of background work.
- A resize is a hitch. The frame in which the canvas changes resolution is itself a slow frame, at most two per floor.
- The renderer does not cull monsters. Floor tiles and fog are cut to the view, but every live monster is positioned and left visible every frame, including the sleeping ones; on the floors above that is up to 585 sprites, most of them off screen or under fog. Nothing in the game’s code sets PixiJS’s
cullableflag. - The thinned separation is approximate. Far from the hero, on a floor with more than 300 alive, a squeezed crowd settles over a few steps instead of one.
- It is one player. A dive has one human player. The optional companion you can choose in town is a second hero steered by the same computer player used for the table above, not another person.
- It needs WebGL. The game asks PixiJS for WebGL; a browser without it gets no game.