Built for hordes
How Emberdeep keeps two thousand monsters on screen without a hitch, the budgets it is held to, and the numbers measured so far.
One coal dies. A heap of them burns till morning.
Emberdeep is built to fight crowds: hundreds of monsters on screen, thousands in a level, and effects everywhere. Performance is treated as a feature. Every system has a budget, and every change that touches a hot path is measured before and after.
The simulation
The game world is a simulation written in plain Rust, separate from the graphics. It is the source of truth: what it says happened, happened.
- It runs in fixed steps, 30 a second. Wall-clock time never leaks in.
- It is deterministic. The same seed gives the same world, the same monsters and the same drops. A test checks this.
- It doesn't know about graphics. The same code runs inside the game, on a dedicated server with no window, and in benchmarks.
- Distances are in tiles. A level is 128 by 128 tiles; how fast a fallen walks is on Characters.
How it stays fast
| Problem | What the game does |
|---|---|
| Finding who is near whom | A spatial grid, rebuilt every tick. Nothing loops over every monster to find its neighbours. |
| Thousands of monsters walking to you | One shared flow field per tick, spreading out from every living fallen. Each monster reads its next step from it; none runs its own pathfinding. |
| Crowds piling up | No more than 8 monsters fit in a 2x2-tile area. This keeps crowds plausible and caps the cost of pushing them apart. |
| Monsters far away | Awake monsters more than 45 tiles from every fallen freeze until someone comes back. |
| Memory churn | No memory is allocated per tick in hot paths. Buffers are reused. |
| Drawing 2,000 monsters | Monsters of one kind share one mesh, and materials are shared per rarity, so the GPU draws each kind in one batch. Shadows, not vertices, are what grow with detail, so distant monsters will use simpler models. |
| Effects | Particles and floating damage numbers are capped. Past the cap, new effects are dropped rather than queued. |
| Using more cores | Only where it measured faster: below a few thousand monsters, threads cost more than they save. |
Pushing overlapping monsters apart was once 85-98% of the simulation's time. The crowd cap and keeping monster data in grid order fixed it.
Measured
The budgets and every measured number live in the engineering notes, docs/performance.md,
and are shown here as they are written there, so the site never disagrees with them. All on an
Apple M2 Pro laptop, release builds.
From docs/performance.md, Measuring
Measure before and after any change that touches a hot path:
cargo run --release -p sim --example bench --features profile -- 2000 600 passive
cargo run --release -p sim --example bench --features profile -- 10000 900
cargo run --release -p emberdeep -- --bench 1500 --bench-secs 20
cargo run --release -p net --example bandwidth -- 2000 600 passive [players=4] [loss=0.05 snap-delay=3 ack-delay=3]
cargo run --release -p bots -- --bots 4 --secs 20 --horde 2000 --god --passive [--lag-ms 50 --loss 0.05]
The first two print per-tick cost with a per-phase breakdown. The client bench disables vsync,
keeps the window on top (macOS throttles occluded windows), forces continuous updates and logs
FPS, frame time and net stats. bandwidth measures snapshot bytes and encode/apply cost on a
simulated lossy network and checks the mirror against the sim every tick. emberdeep-bots runs
a real server with N bot clients and reports server tick cost (sim / encode / net), bandwidth,
RTT and consistency. EMBERDEEP_MONSTER_GLB=path.glb gives every monster kind that mesh in the
client, for vertex-budget runs (design/perf/vertex-budget/run.sh).
Budgets: sim tick < 2 ms at 2,000 awake monsters; server tick (sim + encode for 4 clients) < 4 ms; per client < 64 KB/s typical, < 150 KB/s worst case.
The tight-loop sim bench understates real server cost by ~3.5x. The same tick costs 0.45 ms
back-to-back but 1.6 ms when paced at 30 Hz with sleeps in between (the M2 drops clocks while
idle; a QoS hint didn't help). On a busy machine, e.g. with clients rendering, it is back near the
tight numbers. Judge server budgets with emberdeep-bots, judge sim changes with bench.
From docs/performance.md, Baselines (M2 Pro, 2026-10-23; sim rows re-measured after the N-player refactor, which changed the seed-42 map)
| What | Result |
|---|---|
| Sim, 2,000 awake monsters crowding the player | 0.46 ms/tick avg, p99 0.66 (1.4% of budget) |
| Sim, same with 4 players | 0.45 ms/tick avg, p99 0.59 |
| Sim, 10,000 awake monsters at realistic density | 0.35 ms/tick avg, p99 0.50 |
| Same after the loot port (~1,000 kills, drops rolled) | within noise (interleaved A/B: 0.396 -> 0.406 ms median) |
| Client, Retina 3200x1800, FXAA, 2,000 monsters + combat | 82 FPS |
| Client, same, 5,000 monsters + combat | 85 FPS (display-pacing ceiling, GPU has headroom) |
| 4x MSAA instead of FXAA | about -20% FPS at Retina |
| Monster shadows on | 82 -> 67 FPS at 2,000 monsters |
Target-shot-1 assets (Blender models, rim material, light wells, new HUD), --bench 2000 --offscreen, 1600x900, load avg ~10 (2026-10-24) |
290-306 -> 273-284 FPS (3.3 -> 3.55 ms/frame, about -7%), interleaved A/B |
| Target shot 1 v2 (Kindled per slot, new ghoul/archer, sprite VFX, water shader, darker lighting and rim), same bench, interleaved A/B against v1, load 7-25 (2026-10-24) | 235 -> 216 FPS mean of six runs each (4.26 -> 4.63 ms/frame, about -8%) |
| Every monster at 386 / 1.8k / 3.9k / 21k vertices, same bench, load 4-13 (2026-10-24) | shadows off: 3.87 / 3.71 / 3.68 / 8.36 ms; --monster-shadows: 3.82 / 3.96 / 6.67 / 22.6 ms (design/perf/vertex-budget.md) |
Skinned, animated characters (spike, 2026-10-25)
The client draws no skeletal animation yet. --skinned N (crates/client/src/skinned.rs)
spawns N copies of a rigged concept model through Bevy's own glTF loader, out of step with each
other, without shadows, to price it: --bench 0 --bench-secs 20 --offscreen --skinned 1000 [--skinned-still] [--skinned-model PATH]. Sereth (Tripo, 65-bone Mixamo skeleton, 2K PBR
textures; a 4.9k-vertex variant decimated in Blender), 1,000 copies, 1600x900, load 8-9, baseline
4.7-5.0 ms (201-214 FPS):
| 1,000 copies of | ms/frame | over baseline |
|---|---|---|
| 25k vertices, skinned, walking | 50 | +45 |
| 25k vertices, no skeleton | 26 | +21 |
| 4.9k vertices, skinned, walking | 13.2 | +8.4 |
| 4.9k vertices, skinned, standing still | 13.9 | +9.1 |
| 4.9k vertices, no skeleton | 8.9 | +4.1 |
- Playing clips is nearly free; skinning is not. Walking and standing still cost the same, so sampling the animation isn't the cost. A skin costs about twice the same mesh without one, and vertex count dominates (25k vertices skinned is ~5x the 4.9k one).
- Heroes and NPCs can be skinned as they are: a handful of copies is well under 1 ms even at 25k vertices.
- Hordes can't, as built: ~8 µs per skinned 4.9k-vertex copy is ~17 ms at 2,000 monsters, the whole frame budget. Hordes need something cheaper: vertex animation baked into textures (instancing-friendly, read in the vertex shader), fewer bones, or skinning only near the camera.
- Even unskinned, a textured copy here costs far more than a vertex-coloured monster (2,000 monsters at 21k vertices: 8.4 ms in the table above). Not split yet between the textures, the per-copy entity hierarchy and batching. macOS reports no GPU pass timings, so CPU and GPU shares are unknown too (Xcode's Metal frame capture would tell).
GPU frame (M2 Pro, 2026-10-25): the 120 Hz target
Alex's display is 120 Hz (ProMotion) and the window is 3200x1800 pixels. Under vsync a frame over 8.3 ms shows at 60, so the GPU frame needs to stay under ~7.5 ms. The game is fill-bound at Retina: cost scales with pixels, not with monsters.
Measuring it. Compare builds with --offscreen-size 3200x1800 and interleaved rounds:
python3 design/perf/gpu_ab.py target/release/emberdeep 3 8 'a||--start cinder-galleries --offscreen-size 3200x1800' 'b|BIN=/path/to/other|--start cinder-galleries --offscreen-size 3200x1800'
Each config is label|ENV=v BIN=path|args; the script prints median fps and ms. Every run opens
an always-on-top window and saturates the GPU, so keep batches small: Alex works on this machine
at the same time. Pitfalls:
- A windowed bench on a locked screen reports ~250-370 fps for anything (macOS skips the frames). Offscreen images still render.
- AutoNoVsync (the bench's mode) caps a windowed, near-empty frame at ~80-90 fps on macOS; vsync reaches 115-120. Don't read a windowed bench's fps as the game's.
- Background load shifts results by 10-20%. Interleave A and B in the same run; never compare against a number from another day.
What a 3200x1800 frame of the Cinder start costs (frame 19.4 ms after the two fixes below; savings when each piece is turned off): stone floor and walls 9-11 ms, post-processing and the HUD together 6.9 ms (SSAO 3.0, bloom 2.0, HUD 1.2, SMAA 0.7, tonemapping 0.6, grade 0.4), shadows 1.7, environment map 1.3. In the stone shader, the textures, voronoi cracks and anisotropy explain only ~3 ms; the rest is the lit material over the whole screen.
| What (Cinder start unless said, offscreen, median of 3) | ms/frame |
|---|---|
| Before: procedural stone, binding arrays on | 37.0 |
Stone reads a tiling noise texture (cave::noise_texture) instead of hashing ~13 fbm per pixel |
22.4 |
Plus binding arrays off (main.rs render_plugin; on Metal they slow every fragment shader) |
19.4 |
| 2400x1350 (render scale 0.75) / 2000x1125 (0.625) | 12.4 / 10.8 |
| 2400x1350 without SSAO / without bloom / without both | 10.3 / 10.8 / 8.7 |
| 2000x1125 without SSAO | 7.6 |
| Flood: 3200x1800 / 2400x1350 / 3200x1800 without SSAO | 10.4 / 6.4 / 7.9 |
Load average was 6-9 during these runs.
Netcode baselines (M2 Pro, 2026-10-24, release; load average 3-9 from other work, so p99s are noisy)
| What | Result |
|---|---|
| Snapshot, 2,000 monsters crowding 1 player (1,679 in view) | 2.5 KB, 74 KB/s per client (naive full dump was 64 KB, 1.9 MB/s) |
Same, 5% loss + 3-tick ack lag (bandwidth example) |
3.6 KB, 109 KB/s; 15% of snapshots lost (3-4 fragments each) |
| 10,000 monsters spread, combat | 1.6 KB, 49 KB/s |
| Encode, tight loop, 4 clients x 2,000 in view | 0.35 ms/tick total |
| Server, 4 bots x 2,000-monster horde, idle machine (paced) | 2.4-3.2 ms/tick avg, p99 4.5 (sim 1.1-1.5, encode 1.1-1.5, net 0.3); 60-65 KB/s down, 0.8 KB/s up per client |
| Same through proxy, 50 ms each way + 5% loss | ~92 KB/s per client, 85% of snapshots arrive, mirrors 1.3 ticks behind on average |
| Server, 2 real clients + 1 bot, 1,500-monster horde, clients rendering | 0.8 ms/tick avg, p99 1.2; 49-75 KB/s per client |
| Prediction error walking at 100 ms RTT (client agent, real client) | mean 0.0012 tiles, 1 correction (initial sync) in 24 s |
Client bench --bench 1500, screen locked (not display-paced) |
~370 FPS before and after networking |
Lessons learned: separation was 85-98% of sim time until the cell cap and cell-ordered data;
per-model vertex count matters mostly through shadows (the main pass takes ~4k vertices per
monster at 2,000 for free; see the vertex-budget row); point-light shadows render six passes, so
the player's shadow caster is a spotlight. Metal doesn't report per-pass GPU timings through
RenderDiagnosticsPlugin, and under heavy load its counter buffers fail to allocate and the GPU
device is lost, so the bench doesn't add it on macOS: use Xcode's Metal frame capture for GPU
attribution.
How to host and join is on Co-op.