PURPOSE

Shared math, RNG, geometry, formatting, and weighted-pool helpers used by every other engine module. This is the lowest-level utility layer — no game state, no signals, no side effects beyond the seeded RNG closures it returns. Ported from the original 04-utils.js flat-file engine. Anything written here is meant to be inlined-cheap and depended on by gameplay, terrain, spawning, progression, geometry collisions, and HUD formatting.

OWNS

  • Easing/interpolation: lerp, lerpClamped, clamp, smoothstep, smoothstepRange, easeOut, easeIn, easeInOut, easeOutCubic.
  • Array op: swapRemove<T>(arr, i) — O(1) unordered removal by swap-and-pop.
  • Seeded RNG: mulberry32(seed) returns a closure () => number in [0,1); seededRandom(worldSeed, cx, cy) derives a per-chunk RNG by XOR-mixing the seed with prime-scaled chunk coords.
  • Progression/difficulty curves: xpForLevel, enemyLevelMult, getEnemyLevel, distanceDifficultyMult, spawnRateMult, spawnDecay.
  • Geometry: pointToSegDist, angle, angleDiff, normalizeAngle, dist, dist2, circleOverlap, pointInCircle, chunkKey, deg2rad, rad2deg.
  • Unseeded random: rand, randTo, randRange, randInt — all wrap Math.random().
  • Formatting: formatNumber(n) — integer with thousands commas.
  • Weighted picking: weightedPick(items, weights) (unseeded, Math.random) and the PoolManager singleton (seeded, table-driven).
  • PoolEntry type and PoolManager module-local _pools map keyed by pool id.

READS FROM

  • Math.random for the unseeded rand* family and weightedPick.
  • Math.pow, Math.hypot, Math.atan2, Math.floor, Math.min, Math.max, Math.imul, Math.PI — standard library only.
  • For PoolManager.init, the caller-supplied rows: [poolId, entryId, weight][] array (typically a CSV/data-table import).
  • For PoolManager.pick, a caller-supplied seeded rng: () => number closure (usually one made by seededRandom).

No imports from elsewhere in the codebase. No reads of game state, time, or DOM.

PUSHES TO

Nothing. Every function is pure (or, in the case of mulberry32/seededRandom, returns a stateful closure that the caller owns). PoolManager.init mutates only the module-local _pools record; pick/pickN/getAll/has are read-only against that record.

DOES NOT

  • Does not import or know about Clock, Signals, state, entity records, or rendering.
  • Does not log, telemetry-emit, or throw on bad input — out-of-range values are clamped where the API documents it, otherwise they pass through (e.g. lerp with t > 1 extrapolates).
  • Does not seed Math.random; the unseeded rand* family is non-deterministic and must not be used in code that participates in deterministic replay.
  • Does not provide vector types or matrix math — geometry helpers work on raw (x, y) number pairs.
  • Does not cache Math.hypot or memoize results; every call recomputes.
  • PoolManager does not persist pools, does not load files itself, and does not validate that weights are non-negative — pick falls back to filtered[0].id if total weight is ≤ 0.

Signals

None. This module is signal-free by design — it sits below the signal bus and is consumed by code that owns signal emission.

Entry points

  • Easing: lerp(a, b, t), lerpClamped(a, b, t), clamp(v, lo, hi), smoothstep(t), smoothstepRange(edge0, edge1, x), easeOut(t), easeIn(t), easeInOut(t), easeOutCubic(t).
  • Array: swapRemove<T>(arr: T[], i: number): void.
  • RNG: mulberry32(seed: number): () => number, seededRandom(worldSeed, cx, cy): () => number.
  • Progression: xpForLevel(lv) — piecewise linear with kinks at level 20, 40, 60, then exponential 9560 * 1.08^(lv-100) past 100. enemyLevelMult(lv) = 1.4^(lv-1). getEnemyLevel(progress) = min(10, floor(progress/22) + 1). distanceDifficultyMult(d) floors at 0.5 below 800, caps at 10 above 20000, quadratic between. spawnRateMult(progress, distance, enemyCountMult) four-piece curve in progress, blended with distanceDifficultyMult. spawnDecay(progress) = max(0.25, 1 - progress*0.003).
  • Geometry: pointToSegDist(px, py, x1, y1, x2, y2), angle(x1, y1, x2, y2), angleDiff(a, b), normalizeAngle(a), dist(x1, y1, x2, y2), dist2(x1, y1, x2, y2), circleOverlap(x1, y1, r1, x2, y2, r2), pointInCircle(px, py, cx, cy, r), chunkKey(x, y, size), deg2rad(d), rad2deg(r).
  • Unseeded random: rand()[0,1), randTo(max)[0,max), randRange(min, max)[min,max), randInt(min, max) → integer in [min,max] inclusive.
  • Formatting: formatNumber(n): string — floors and inserts ASCII commas every three digits.
  • Weighted: weightedPick(items, weights) — uses Math.random, returns items[0] if total weight ≤ 0 and last item as roll-overflow guard.
  • PoolManager: init(rows), pick(poolId, rng, exclude?), pickN(poolId, count, rng, allowDupes = false), getAll(poolId), has(poolId).

Pattern notes

  • Algorithm: mulberry32 is the standard 32-bit Mulberry variant — (seed + 0x6d2b79f5) | 0, two Math.imul mixing rounds, return divided by 2^32. The closure captures and mutates its own seed, so each call advances state independently of other RNG closures.
  • Chunk seeding: seededRandom mixes worldSeed, cx, cy via XOR of prime-scaled values (73856093, 19349663, 83492791) — the standard spatial-hash primes — so adjacent chunks produce uncorrelated streams.
  • Determinism contract: seededRandom-backed code paths are byte-identical replay-safe; rand/randTo/randRange/randInt/weightedPick are not. Callers in replay paths must reach for the seeded variants and pass the closure explicitly.
  • Clamp semantics: clamp uses ternary, not Math.min(Math.max(...)), so it short-circuits on the common in-range case. The easing functions all internally clamp(t, 0, 1) so callers can pass un-normalized t safely — except lerp itself, which extrapolates by design.
  • swapRemove is O(1) and unordered — it overwrites slot i with the last element then pop()s. Safe when iterating backwards through the array; will skip an element if used during forward iteration.
  • xpForLevel is data-shaped as inline numeric constants rather than table-driven. Each kink commented with its boundary values so balance tweaks can be audited line-by-line.
  • distanceDifficultyMult is hard-clamped at the endpoints (<800 returns 0.5, >20000 returns 10) before the quadratic, avoiding domain-violation negative-distance or unbounded-distance cases at boundaries.
  • pointToSegDist short-circuits to Math.hypot on zero-length segments to avoid divide-by-zero, then projects the point onto the segment using the clamped parameter t ∈ [0,1].
  • dist vs dist2: prefer dist2 for any comparison (d2 < r*r) — circleOverlap and pointInCircle both do this internally. Math.hypot is only called when a length value is actually consumed.
  • chunkKey produces a stringified "cx,cy" so chunk-coord lookups can use a plain Record<string, T> rather than a Map. The size divisor is passed in rather than hard-coded — different systems (terrain, spawn, AI) can use different chunk sizes against the same helper.
  • normalizeAngle uses while loops rather than modular arithmetic — fine for the realistic input range (a few revolutions at most) and avoids % sign issues on negative numbers.
  • PoolManager.pick exclude-list semantics: linear-scan filter (acceptable because pool sizes are small), falls back to the unfiltered pool when filtering would empty it (prevents reward-roll deadlock when all options are excluded). Roll uses rng() * total; the trailing return filtered[filtered.length - 1].id is a floating-point-rounding safety net.
  • PoolManager.pickN builds its own exclude list incrementally to enforce allowDupes = false — N independent weighted picks, each excluding what’s already been drawn. Returns fewer than count items only if pick returns null (missing pool).
  • weightedPick is the unseeded sibling of PoolManager.pick — same algorithm but Math.random() and parallel arrays instead of a registered pool. Used where determinism is not required and pool registration would be overkill.
  • randInt is inclusive on both ends because randRange(min, max+1) then Math.floor shifts the open upper bound back to inclusive. Off-by-one trap: callers must remember the +1 happens internally.
  • All functions are export function at top level, no default export. PoolManager is an export const object literal — picked over a class because there is only ever one instance and module-scope state is the desired pattern.