Skip to content

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.

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.

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. turnVelocityToward preserves 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) is 0, so a chaser exactly on top of you drifts east rather than holding still.

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 timescale
loop.clock.timescale = hitStopStep(hitStop, rawDt);
clock.tick(rawDt); // the 60 s bell and the 15 s mod cadence
data.stamina = staminaStep(data.stamina, rawDt, stats);
sparks.update(rawDt, 0, 0); // the kill burst
camera.update(loop.clock.rawDt); // the screen shake

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

When the mod cards appear, the arena stays visible behind them. That rules out two obvious moves:

  • loop.pause() early-returns from step() before any signal fires, so onPreRender stops and the canvas goes stale behind the cards.
  • SceneManager.push updates 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.

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.

The HUD prints “allocations this frame”, and the number is real rather than decorative:

  • Component data is pooled with the slot. World.add stores 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, never acquire. acquire() is uncapped and documented as “may exceed capacity”; tryAcquire() refuses at the cap and returns null, so a round is simply not fired rather than the pool growing without bound.
  • Every return routes through one function. Pool.release does not verify ownership, and over-releasing drives the live count negative — which permanently defeats the capacity check.
  • The readout says “this frame” because prewarm is excluded from created. 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:

  1. Skia.Path.Make() does not exist on CanvasKit — path construction lives on PathBuilder.
  2. 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.
  3. Seven of thirteen kinds allocate a wasm Path per call and never delete() it.
  4. Its .d.ts hard-imports @shopify/react-native-skia despite 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.

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.