PURPOSE

Module-level cache of per-frame derived data that multiple subsystems would otherwise re-compute by full-scanning world.enemies 5-8 times per tick. Built exactly once at the top of each frame via rebuildFrameCache(); consumed read-only by collision, render, AI, targeting, and spawner code. Every cached field is stale until the rebuild call completes for the current tick.

OWNS

  • Module-private singletons reused across frames with no per-frame allocation:
    • _visibleEnemies: any[] — alive enemies whose padded screen-space bounds intersect the camera frustum.
    • _visibleEnemyCount: number — valid prefix length of _visibleEnemies.
    • _aliveEnemies: any[] — tight reference list of alive enemies, no dead/dying entries.
    • _aliveEnemyCount: number — valid prefix length of _aliveEnemies.
  • Exported type FrustumBounds (left, right, top, bottom) — axis-aligned screen-space rectangle for frustum culling.
  • Padding constant PAD = 60 — extra slack on the frustum check so enemies whose sprite extends past their collision radius stay “visible” when the center is just off-screen.

READS FROM

  • Caller-supplied enemies array (typically world.enemies) and each entry’s .alive, .x, .y, .radius fields.
  • Caller-supplied frustum bounds, toScreen projection function (Camera-style world-to-screen), and zoom factor.
  • Default enemy radius 14 is used when e.radius is missing or zero.

PUSHES TO

  • Overwrites its own module-level arrays and counters in place; mutates no caller state.
  • Returns no values from rebuildFrameCache() or clearFrameCache(); accessors return live references to the internal arrays.
  • After rebuild, both arrays have their .length trimmed to the valid count so consumers iterating .length do not see stale tail entries from previous frames.

DOES NOT

  • Does not allocate new arrays per frame — both backing arrays are reused for the lifetime of the module.
  • Does not own or update enemy state (alive flag, position, radius); only inspects it.
  • Does not project world coordinates itself; delegates to the injected toScreen callback.
  • Does not perform visibility culling when frustum or toScreen is null — only the alive list is populated in that case.
  • Does not cache players, projectiles, pickups, particles, terrain, or any non-enemy entity.
  • Does not detect or guard against being called more than once per frame, nor against accessors being called before rebuild.
  • Does not deep-copy entries — returned arrays hold live references; mutating them mutates the underlying enemy objects.

Signals

None — frame-cache is a pure synchronous module with no event emission or subscription.

Entry points

  • rebuildFrameCache(enemies, frustum, toScreen, zoom) — single per-tick scan that resets counts, walks enemies, appends each alive entry to _aliveEnemies, and (when frustum and toScreen are supplied) tests the padded screen-space AABB and appends visible ones to _visibleEnemies. Trims both arrays’ lengths to the valid counts before returning.
  • getVisibleEnemies() — returns the reused visible-enemy array reference; do not retain across frames.
  • getVisibleEnemyCount() — valid length of the visible array.
  • getAliveEnemies() — returns the reused alive-enemy array reference.
  • getAliveEnemyCount() — valid length of the alive array; used by spawner.ts (live cap enforcement) and director.ts (pressure/density reads).
  • clearFrameCache() — zeros both counts and sets both arrays’ .length to 0; intended for tests and phase transitions where the cache should appear empty without a full rebuild.

Pattern notes

  • Single-write / many-read per tick. The contract is “call rebuild once before any reader runs”; callers do not coordinate with each other, they just trust the cache is fresh.
  • Counters plus pre-allocated arrays instead of .push() and per-frame [] allocation. Slot assignment via arr[count++] = e is the hot-loop write pattern.
  • Length trim after the loop (_aliveEnemies.length = _aliveEnemyCount) lets readers use arr.length or for…of without seeing ghosts from a longer previous frame.
  • Visibility test uses an inflated radius (radius * zoom + PAD) compared against the frustum rectangle on all four sides — symmetric padded AABB overlap, no per-axis early exit.
  • null frustum / null toScreen is the documented way to skip visibility culling (used by the warm-up rebuild before camera state exists).
  • Diagnostics: the rebuild step is registered under the frameCache slot in render-diag.ts for frame-budget accounting.
  • Bridge integration: bridge.ts imports the module, calls rebuildFrameCache(world.enemies, null, null, 1) during init warm-up, calls the full version with frustum + camera projection inside the per-frame loop, and re-exports getAliveEnemyCount() through the bridge surface declared in bridge-types.ts.
  • Typed loosely (any[]) on purpose — the cache is engine-internal and would otherwise need to import the full enemy type from a layer it should not depend on.