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

<Aside type="tip" title="Run it">
It runs live on its own page: **[Overrun](/showcases/overrun/)**. This guide is how it is built.
</Aside>

## 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

The chasers do not path. They steer, under a **maximum turn rate**, and that is one call:

```ts
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.

## 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:

```ts
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.

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

## 4. Layer masks are OR-combined

`CollisionWorld.step()` skips a pair only when **neither** side sees the other:

```ts
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

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

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

The design named it: every visible object drawn from shape primitives, zero art assets. Verified
against `0.3.0`:

<Steps>

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.

</Steps>

`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

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`

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

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](/showcases/overrun/) 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.

<Aside type="note">
Run it on its [showcase page](/showcases/overrun/) and press the backtick key to open the dev panel —
dragging **Turn rate** from 3.2 up to 12 makes the overshoot disappear in front of you. Back to the
[Foundations](/guides/foundations) overview, or the [package catalog](/reference/packages/).
</Aside>
