Showcase: Overrun
Sixty seconds in an arena. You steer and you dash; the weapon aims itself at the nearest enemy, which is what keeps the demo one-thumb on a phone. Chasers converge, and every fifteen seconds the run freezes on a choice of two modifiers. It is the only showcase on this site you play, and the only one where the interesting engineering is not the simulation — it is the handful of engine contracts that are easy to read past and expensive to get wrong.
0. Auto-aim, and why
Section titled “0. Auto-aim, and why”The weapon tracks the nearest live enemy — a linear scan over the enemy query on the fixed step. At
the cap of ~60 that is a few dozen squared-distance comparisons, and @flare-engine/physics’s own
SpatialIndex documents itself as brute-force below 1000 entities, so there is nothing to gain by
reaching for it.
Two details are load-bearing. When the arena is momentarily empty between waves the last heading is kept rather than snapped to +X, so the muzzle does not jitter at nothing. And dash never reads the aim: it goes where you are steering, or where you last steered when standing still. Aim means “the nearest enemy”, so falling back to it would fire a stationary player straight into the thing chasing them — the opposite of what a dash is for.
1. The one bound that is the whole game
Section titled “1. The one bound that is the whole game”The chasers do not path. They steer, under a maximum turn rate, and that is one call:
Vec2.turnVelocityToward(vx, vy, dx, dy, turnRate * dt, velocity);Everything that makes the demo playable falls out of that bound. Run in a straight line and they close on you exactly as fast as their speed allows. Cut a hard corner and they physically cannot follow — they carry their old heading through the turn, swing wide, and have to come back around. The prow arc drawn on each chaser points at the heading it is currently committed to, which is what makes the overshoot readable rather than merely present.
maxStep is radians per call, not per second, so under a fixed timestep it is turnRate * dt.
Get that wrong and the turn rate silently scales with frame rate.
Two degenerate cases the signature hides, both guarded in steering.ts:
- A chaser at rest never steers.
turnVelocityTowardpreserves speed, so a body born with zero velocity rotates a zero-length vector forever. Every spawn seeds a non-zero heading. - A coincident target steers toward +X.
atan2(0, 0)is0, so a chaser exactly on top of you drifts east rather than holding still.
2. Two clocks, on purpose
Section titled “2. Two clocks, on purpose”Clock.elapsed, Clock.schedule and Clock.repeat all count scaled time. The demo scales time
to zero on every hit — that is the hit-stop — so anything that must keep counting through a freeze
reads clock.rawDt instead:
const rawDt = loop.clock.rawDt; // wall time, unaffected by timescaleloop.clock.timescale = hitStopStep(hitStop, rawDt);clock.tick(rawDt); // the 60 s bell and the 15 s mod cadencedata.stamina = staminaStep(data.stamina, rawDt, stats);sparks.update(rawDt, 0, 0); // the kill burstcamera.update(loop.clock.rawDt); // the screen shakeThe last two are the ones a reader is most likely to get wrong, because nothing about them looks
like a clock. onUpdate and onLateUpdate both carry rawDt * timescale, so a burst and a shake
riding that dt are frozen by the very freeze they exist to sell — the particles hang motionless in
the air and the camera holds perfectly still, and the whole kill reads as a dropped frame instead of
a punch. They are effects, not gameplay: a variable step costs them nothing, and the randomness that
has to stay seed-reproducible is drawn on the fixed step when the burst spawns, not here.
The shake has a second raw-timed piece for the same reason. Camera2D.shake() overwrites any
in-flight shake rather than maxing against it, so sim.ts tracks the live one itself and lets the
stronger of the two win — a 3.5 px kill kick must not truncate the 9 px damage kick. That
remaining-time accumulator is decremented with rawDt alongside hitStopStep, because both shakes
fire during a hit-stop, which is exactly when a scaled clock reads zero.
The deadlock worth naming: a hit-stop that sets timescale = 0 and then schedules its own release
on the same Clock freezes forever, because the schedule it is waiting on counts scaled time
that is no longer advancing. The run clock is a separate, raw-timed object for exactly that reason.
3. Freeze, do not pause
Section titled “3. Freeze, do not pause”When the mod cards appear, the arena stays visible behind them. That rules out two obvious moves:
loop.pause()early-returns fromstep()before any signal fires, soonPreRenderstops and the canvas goes stale behind the cards.SceneManager.pushupdates and draws only the top of the stack, so the arena goes black.
Instead the run clock takes a "frozen" phase: onFixedUpdate is phase-gated so nothing advances,
onPreRender keeps running so the arena keeps drawing, and the timescale has exactly one writer.
The pause button uses the same phase for the same reason.
4. Layer masks are OR-combined
Section titled “4. Layer masks are OR-combined”CollisionWorld.step() skips a pair only when neither side sees the other:
if ((a.layer & b.mask) === 0 && (b.layer & a.mask) === 0) continue;The runtime default is layer = 1, mask = 0xFFFFFFFF. One collider left at the default therefore
drags every other collider into narrowphase, and one-way filtering is not expressible at all. So
every add() in this demo passes both fields explicitly, from one table, and a test asserts the
pairs are symmetric — an asymmetric mask silently enables a pairing the other side declared it did
not want.
5. Zero allocations, honestly
Section titled “5. Zero allocations, honestly”The HUD prints “allocations this frame”, and the number is real rather than decorative:
- Component data is pooled with the slot.
World.addstores your object by reference, so a{ remaining: feel.bulletLife }literal at the spawn site is one fresh object per round. The lifetime payload lives in the pooled slot instead. tryAcquire, neveracquire.acquire()is uncapped and documented as “may exceed capacity”;tryAcquire()refuses at the cap and returnsnull, so a round is simply not fired rather than the pool growing without bound.- Every return routes through one function.
Pool.releasedoes not verify ownership, and over-releasing drives the live count negative — which permanently defeats the capacity check. - The readout says “this frame” because
prewarmis excluded fromcreated. The pool is deliberately prewarmed below the steady-state round count, so the counter can actually move during a cold start and then go quiet. Prewarm above it and the number is a hard-coded zero.
6. The three packages the design named and the demo does not use
Section titled “6. The three packages the design named and the demo does not use”The first draft of this demo claimed sixteen packages. Three did not survive contact with the
shipped 0.3.0 artifacts — not with the docs, with the published dists, read and run. That
verification is the part worth keeping, so all three are written down here.
@flare-engine/shapes — RN-Skia only, on a CanvasKit page
Section titled “@flare-engine/shapes — RN-Skia only, on a CanvasKit page”The design named it: every visible object drawn from shape primitives, zero art assets. Verified
against 0.3.0:
Skia.Path.Make()does not exist on CanvasKit — path construction lives onPathBuilder.paint.setStyle(0 | 1)passes the React Native Skia numeric enum, and on CanvasKit that silently renders every stroke as a solid fill. Pixel-verified. No error is thrown.- Seven of thirteen kinds allocate a wasm
Pathper call and neverdelete()it. - Its
.d.tshard-imports@shopify/react-native-skiadespite the peer being optional, so a web consumer’s typecheck depends on an RN-only package staying installed.
renderer.ts draws directly instead, in about thirty lines, using only drawCircle, drawLine,
drawArc, drawOval and drawRect with pre-allocated rects — so it never constructs a wasm
Path and therefore cannot leak one.
@flare-engine/audio — no synthesis to call
Section titled “@flare-engine/audio — no synthesis to call”The design called for audio “synthesized at boot, muted until the Start gesture”. The complete
fifteen-export surface has no oscillator, no envelope, no createBuffer and no decodeAudioData;
AudioContextLike declares only createGain / createBufferSource / resume / suspend /
close. Every waveform would have been bespoke Web Audio written here, while the seam table
credited the package for the interesting part. Two smaller problems came with it:
react-native-audio-api is a non-optional peer, so a web-only consumer gets an unmet-peer
warning on every install, and a real DOM AudioContext is not assignable to AudioContextLike —
the DOM lib’s 'interrupted' state member forces an as unknown as cast, which this repo bans.
The demo ships silent. That is a smaller claim than “audio on the engine”, and it is true.
@flare-engine/leaderboards — a side effect under sideEffects: false
Section titled “@flare-engine/leaderboards — a side effect under sideEffects: false”The package declares sideEffects: false, but its bundled barrel runs a bare top-level
createContext(...) with no /*#__PURE__*/ annotation. Importing anything from it therefore
pulls React into the importing chunk and executes at import time — a hard coupling introduced by a
tree-shaking hint that is not true, in a game layer that is otherwise React-free.
The run is compared against the visitor’s own persisted best instead, held in an
@flare-engine/storage document store on IndexedDB with a memory adapter as the fallback — no
identity, no network, nothing to rank against but yourself.
What the seam actually is
Section titled “What the seam actually is”The engine gave the demo its loop, its ECS, its steering maths, its collision filter, its camera,
its pooling and its post-FX finish. It did not give it geometry on the web, a synthesiser, or a
leaderboard. The showcase page renders the claim list from claims.ts, and
a test fails the build if a package is claimed there without being imported — or imported without
being claimed. The three above are absent from it, which is the point.