Reference
Performance benchmarks
Edit on GitHubThe headless benchmark harness and honest before/after numbers behind the engine’s per-frame cost.
A headless benchmark harness for the runtime’s per-frame cost, independent of
rendering. It steps a GameSession (packages/runtime/src/session.ts)
wall-clock-free via session.step(), so timings measure script/physics/
particle/event simulation only, not pixi, the DOM, or setTimeout/rAF jitter.
This doc exists to give Wave E’s perf work (broadphase, caching, pooling) an honest before so later tasks have something concrete to compare against. Tasks 9-11 re-run the harness and update the table below with after numbers. The “Current bottlenecks” section is expected to shrink or change as they land.
Status: Wave E (Tasks 9-11) complete. See “After Wave E” below for the final numbers and what each task contributed; “Current bottlenecks” has been rewritten to reflect what’s actually left.
How to run
npm run bench # full run: 120 warmup + 1000 timed frames per scenario
npm run bench -- --smoke # fast harness check: 10 + 10 frames, no thresholds
npm run bench -- --check # CI regression fence: median of key scenarios vs loose budgets
npm run bench -- --json # machine-readable output instead of the aligned table
npm run bench chains build:packages first. The harness itself
(packages/runtime/bench/bench.mjs) is plain Node ESM that imports
@hearth/core/@hearth/runtime from their compiled dist output, so once
packages are built it can also be run directly with
node packages/runtime/bench/bench.mjs.
Regression fences
CI runs node packages/runtime/bench/bench.mjs --check after
build:packages, reusing the compiled package output instead of rebuilding.
The fence runs three full benchmark passes for the scenarios most likely to
catch an accidentally-lost broadphase and compares the median mean
ms/frame against loose absolute budgets:
| scenario | fenced budget |
|---|---|
| colliders-1500 | 15ms median mean |
| mixed-horde | 14ms median mean |
Those budgets are intentionally generous: they are roughly 5x the Apple M3 Pro reference means below (2.912ms and 2.717ms), because GitHub-hosted runners are slower and noisier than the reference machine. The fence is a tripwire for order-of-magnitude regressions, such as losing the spatial-hash broadphase (colliders-1500 was ~13.5x slower before Wave E), not a profiling target. Real performance numbers still come from the reference-machine full bench run documented below, not from CI.
Every scenario (packages/runtime/bench/scenarios.mjs) builds a fully
in-memory project (MemoryFileSystem, never touches disk) with a seeded
PRNG (mulberry32) driving initial positions/velocities/tilemap layout, so
every run of a given scenario is byte-identical frame-for-frame. This is
what makes before/after comparisons meaningful rather than noise.
Baseline
Machine: Apple Silicon dev machine (Apple M3 Pro, arm64), macOS (Darwin). Node: v22.17.0. Mode: full (120 warmup / 1000 timed frames).
scenario mean ms p95 ms max ms entities errors
-------------- ------- ------ ------- -------- ------
colliders-100 0.224 0.300 0.501 104 0
colliders-500 4.702 5.092 5.598 504 0
colliders-1500 39.207 41.317 134.393 1504 0
tilemap-arena 4.126 4.396 5.622 201 0
particles 0.109 0.144 0.483 50 0
mixed-horde 14.317 15.367 17.678 804 0
(Numbers are one representative run; ±10-20% run-to-run variance is normal on a dev laptop under thermal/scheduler noise. Treat single-digit-percent deltas between before/after runs as inconclusive, not a regression.)
Fix round 1 (see .superpowers/sdd/task-8-report.md): the first baseline
had two scenario bugs that made its numbers untrustworthy. mixed-horde’s
per-layer collidesWith lists never included the arena walls’ default
layer, so layersInteract (which requires both sides to match) never let
the walls contain the horde and it silently dispersed over the run; and
every mover/wall used restitution: 0.9, so kinetic energy drained on every
contact instead of staying constant. Both are fixed (movers now include
'default' in collidesWith, and all mover/wall bodies use
restitution: 1), and the table above is the corrected re-run. The most
visible effect is mixed-horde: mean rose (12.7 → 14.3 ms) because the
horde now stays genuinely dense for the whole window instead of thinning
out, but p95/max dropped sharply (13.5/60.5 → 15.4/17.7 ms) because that
old max was a transient spike from the still-dense early frames before
dispersal thinned things out. The new numbers are steady state, not a
decaying average.
Interpretation (60 Hz = 16.6 ms/frame budget)
- colliders-100 (104 entities: 100 movers + 4 walls), 0.22 ms mean, ~74x under budget. This is comfortably “any small game” territory; not a useful stress case on its own, but the floor every other scenario is measured against.
- colliders-500 (504 entities), 4.7 ms mean, still well under budget (~28% of the frame), but 5x colliders-100’s mover count produced a ~21x slowdown (0.22 → 4.7 ms), consistent with the mover-vs-mover pass being quadratic in mover count (see below): 5x the movers is ~25x the pairs.
- colliders-1500 (1504 entities), 39.2 ms mean, over 2x the 16.6 ms budget. This scenario cannot hit 60 Hz today. p95/max (41/134 ms) show it’s slow and spiky: a single frame can cost >3x the mean. This is the clearest evidence of the O(n²) collision cost below; nothing in the engine currently supports a 1500-collider scene at interactive framerates.
- tilemap-arena (201 entities: 1 tilemap + 200 movers over a 100x60 solid grid), 4.1 ms mean, ~25% of budget. Entity count is low, but the tilemap contributes far more than 200 static colliders’ worth of obstacle-list entries (every non-empty grid cell becomes one; see the tilemapBoxes cost below). This scenario exists specifically to isolate that cost from mover-vs-mover.
- particles (50 entities, ~12,800 live particles at steady state: 50 emitters x 256-particle cap), 0.11 ms mean, negligible. Particle simulation is currently the cheapest per-entity system in the engine by a wide margin, even saturated at its hard cap. See “unpooled particles” below for why this is expected to get more visible (not less) once collision is no longer the dominant cost.
- mixed-horde (804 entities: 800 movers across 3 collision layers + 4
walls, every mover scripted with a real
onCollisionhandler), 14.3 ms mean, ~86% of budget. A survivors-like horde of this size is close to saturating the frame budget today, with very little headroom left for anything else (rendering, scripts beyond the one-line handler used here, audio, particles). p95/max (15.4/17.7 ms) are tight around the mean. This scenario keeps a genuinely dense, arena-contained horde live for the entire window (fix round 1 corrected a layer-filter bug that had let the horde disperse and thin out mid-run), so unlike colliders-1500 there’s no large spike: the cost is high but steady, not bursty.ember-horde(packages/examples/ember-horde) is this scenario’s playable companion, a real, scripted, all-Lua game that spawns waves of enemies up to 300 concurrent and stays well under budget on the “After Wave E” numbers below.
After Wave E (Tasks 9-11)
Machine: same Apple M3 Pro dev machine. Node: v22.17.0. Mode: full (120 warmup / 1000 timed frames). Two consecutive runs at Task 11’s HEAD agreed within noise; the table below is the second (representative).
scenario mean ms p95 ms max ms entities errors
-------------- ------- ------ ------ -------- ------
colliders-100 0.156 0.222 0.639 104 0
colliders-500 0.943 1.389 1.966 504 0
colliders-1500 2.912 3.086 3.341 1504 0
tilemap-arena 0.406 0.548 0.638 201 0
particles 0.094 0.104 0.264 50 0
mixed-horde 2.717 3.220 3.835 804 0
| scenario | before (mean ms) | after (mean ms) | speedup | before p95 | after p95 |
|---|---|---|---|---|---|
| colliders-100 | 0.224 | 0.156 | 1.4x | 0.300 | 0.222 |
| colliders-500 | 4.702 | 0.943 | 5.0x | 5.092 | 1.389 |
| colliders-1500 | 39.207 | 2.912 | 13.5x | 41.317 | 3.086 |
| tilemap-arena | 4.126 | 0.406 | 10.2x | 4.396 | 0.548 |
| particles | 0.109 | 0.094 | 1.2x | 0.144 | 0.104 |
| mixed-horde | 14.317 | 2.717 | 5.3x | 15.367 | 3.220 |
colliders-1500’s max also dropped from a 134ms spike to 3.3ms. The old number wasn’t just slow, it was spiky (see the original “Interpretation” above); that spikiness is gone along with the O(n²) cost that caused it. particles and colliders-100 move the least in absolute terms because they were never the bottleneck. Both stay comfortably under budget before and after, and their small deltas are within the ±10-20% run-to-run noise band called out above (still directionally consistent with pooling/caching removing some cost, just not a dominant one).
What each task contributed:
- Task 9 (
packages/runtime/src/runtime.ts): cachedgetEntities()(invalidated only when entities/destroyedIds actually change, instead of a fresh.filter()on every call) and a per-Tilemap collider cache (tilemapBoxescomputed once at load instead of rebuilt from scratch every frame). This is most of tilemap-arena’s win (4.1 -> ~0.4ms once combined with Task 10) and a baseline improvement felt by every scenario. - Task 10 (
packages/runtime/src/broadphase.ts, new): replaced the O(n²) mover-vs-obstacle and mover-vs-mover sweeps with a spatial-hash broadphase (SpatialHash.query()), order-preserving so surviving pairs are still visited in the exact naive order (ascending, deduped). This is the overwhelming majority of colliders-1500’s and mixed-horde’s win. Two deliberate deviations from the original brief, both required and both test-proven (see.superpowers/sdd/task-10-report.md):- Cell size from the 90th-percentile shape extent, not the max. Using the max degenerates when one giant collider (e.g. arena walls) is far larger than everything else. It forces a cellSize so large the whole scene falls into 1-2 cells, which is O(n²) again plus hash overhead (measured: colliders-1500 got 2x slower, 38.5 -> 77.1ms, with max-extent). p90 tracks the typical object size instead; outlier giants just span more cells on insert (bounded, see below) and are still found by any query that reaches them.
- Exact, displacement-tracked requery instead of a fixed inflation margin. A mover can get shoved well past a single cellSize of slack in one pair resolution (e.g. ejected out of a giant collider it spawned inside). stepPhysics tracks cumulative per-mover push displacement and forces a requery whenever accumulated displacement could have escaped the original query’s cell-inflation radius, checked both mid-loop and at loop exit (a real bug during development: checking only mid-loop let a push from the final candidate in a list escape re-checking). This keeps pruning exact for arbitrarily violent same-frame displacement, at the cost of two float compares per pair on the hot path.
- A follow-up fix bounded
SpatialHash.insert’s per-shape cell count (MAX_INSERT_CELL_SPAN), routing pathologically large finite AABBs to the same always-candidate list non-finite AABBs already used, closing a freeze/OOM the naive loops never had.
- Task 11 (this task):
EmitterState(packages/runtime/src/particles.ts) now keeps a per-emitter free-list (pool: Particle[]);spawnOnereuses a detached object (overwriting every field) instead of allocating, and both expiry (splice) and cap-eviction (shift) release the removed object into the pool (capped atmaxParticles) instead of discarding it. Live-particle order is untouched (still splice/shift, unchanged), so render order and the golden hashes are unaffected, pinned exactly bypackages/runtime/tests/particles.test.ts’s per-particle trajectory snapshots. Also: the pixi presentation layer (packages/runtime/src/pixi/index.ts) now coalesces nativepointermoveevents to at most onesendPointer(..., 'move')dispatch per ticker frame (latest position wins;pointerdown/pointerupflush any pending move first, then stay immediate/unthrottled) instead of dispatching every native move event (which can fire many times per rendered frame, each re-runningresolveUiPositions/hit-testing over every entity). This doesn’t show up in the headless bench numbers above (bench never touches the pixi layer), but removes redundant UI-position resolution work from real browser input; the headlesssendPointer/playtest path inruntime.tsis untouched, so drag semantics (UISlider, Wave D playtests) are unaffected.
Current bottlenecks
Wave E removed every bottleneck identified in the original baseline below
(O(n²) collision, per-frame tilemap rebuild, getEntities() churn, unpooled
particles); none of those costs exist in the current implementation. What’s
left, roughly in order of remaining cost:
- Per-frame spatial-hash rebuild.
stepPhysicsresets and reinserts into two persistentSpatialHashinstances every frame, one for obstacles, one for movers (packages/runtime/src/runtime.ts,obstacleBroadphase/moverBroadphase), rather than incrementally updating one across frames. The instances themselves live for the whole session (so their internal stamp/scratch buffers don’t reallocate), but every cell they hold is cleared and rebuilt from scratch each step. This is why colliders-1500 and mixed-horde still cost more than colliders-100/tilemap-arena per entity: building the hash and running every query is O(n) with a real constant, just no longer O(n²). An incremental/persistent hash (insert moved entities only) is the next lever if these scenarios need to shrink further, but neither scenario is anywhere near the 16.6ms budget anymore (2.9ms and 2.7ms respectively, both under 20% of budget). - Script dispatch cost. mixed-horde’s 800 movers each run a real
onCollisionhandler; per-script call overhead (marshalingctx, running the JS/Lua VM) is now a proportionally larger slice of the frame than it was when collision dominated. Not measured in isolation here: the bench harness’s own scripted scenario is exactly mixed-horde, so its current 2.7ms mean already includes this cost; it just isn’t broken out from broadphase overhead. - Rendering sync. None of the numbers above touch pixi at all (the
bench harness is deliberately headless).
PixiView.syncEntitiesand friends (packages/runtime/src/pixi/index.ts) still walk every live entity every tick to keep display objects in sync, and were entirely out of scope for this doc’s measurements. If a future task wants an “after” number for rendering, it needs its own (non-headless) harness.
None of these are urgent: every bench scenario now finishes well under the 16.6ms/frame (60Hz) budget, including colliders-1500 and mixed-horde, which were the only two scenarios over budget in the original baseline.
Bundle sizes
Wave G Task 12 code-split the editor’s eagerly-bundled Terminal (@xterm/xterm
addon-fit+xterm.css) andSliceDialogbehindReact.lazy, alongside Task 7’s already-lazyCodeEditor(CodeMirror). Both now load only when the Agent panel actually mounts a terminal or a spritesheet asset opens Slice…, instead of shipping in the editor’s main chunk on every load.
Editor main chunk (apps/editor/dist/assets/index-*.js, fresh
npm run build -w @hearth/editor, no manualChunks tuning):
| raw | gzip | |
|---|---|---|
| before (pre-split, Wave G code included) | 1,251,480 B | 331,466 B |
| after (Terminal + SliceDialog lazy) | 910,263 B | 244,444 B |
| delta | -341,217 B (-27.3%) | -87,022 B (-26.3%) |
The removed weight now lives in its own on-demand chunks: Terminal-*.js
(335,759 B, xterm + the fit addon) plus a small Terminal-*.css (5,240 B,
xterm.css), and SliceDialog-*.js (5,390 B). CodeEditor-*.js (519,856 B,
already lazy since Task 7) is unchanged by this task.
hearth-player.js (packages/runtime/player/hearth-player.js, the
standalone player every exported game ships): 1,366,476 B, +16,827 B (~1.2%)
over the pre-post-effects 0.10 baseline of 1,349,649 B, from the Wave G
hand-written Pixi post-effect + SpriteEffects filters (final numbers from
Task 6’s fix). Stays well under the 1.45 MB budget enforced by
packages/runtime/tests/player-bundle.test.ts’s stays under the 1.45 MB player budget test.
Re-measuring: rebuild fresh (rm -rf apps/editor/dist && npm run build -w @hearth/editor) before recording numbers. A stale dist/ from an earlier
task understates or overstates the real delta. ls -S apps/editor/dist/assets/*.js | head -8 | xargs -I{} sh -c 'printf "%8d %s\n" "$(wc -c < {})" "{}"' lists
the largest chunks by raw byte size (same command CI runs after “Build
editor”); gzip -c <file> | wc -c gives the gzip size for any one of them.