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() => numberin[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 wrapMath.random(). - Formatting:
formatNumber(n)— integer with thousands commas. - Weighted picking:
weightedPick(items, weights)(unseeded,Math.random) and thePoolManagersingleton (seeded, table-driven). PoolEntrytype andPoolManagermodule-local_poolsmap keyed by pool id.
READS FROM
Math.randomfor the unseededrand*family andweightedPick.Math.pow,Math.hypot,Math.atan2,Math.floor,Math.min,Math.max,Math.imul,Math.PI— standard library only.- For
PoolManager.init, the caller-suppliedrows: [poolId, entryId, weight][]array (typically a CSV/data-table import). - For
PoolManager.pick, a caller-supplied seededrng: () => numberclosure (usually one made byseededRandom).
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.
lerpwitht > 1extrapolates). - Does not seed
Math.random; the unseededrand*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.hypotor memoize results; every call recomputes. PoolManagerdoes not persist pools, does not load files itself, and does not validate that weights are non-negative —pickfalls back tofiltered[0].idif 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 exponential9560 * 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 withdistanceDifficultyMult.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)— usesMath.random, returnsitems[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:
mulberry32is the standard 32-bit Mulberry variant —(seed + 0x6d2b79f5) | 0, twoMath.imulmixing rounds, return divided by2^32. The closure captures and mutates its ownseed, so each call advances state independently of other RNG closures. - Chunk seeding:
seededRandommixesworldSeed,cx,cyvia 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/weightedPickare not. Callers in replay paths must reach for the seeded variants and pass the closure explicitly. - Clamp semantics:
clampuses ternary, notMath.min(Math.max(...)), so it short-circuits on the common in-range case. The easing functions all internallyclamp(t, 0, 1)so callers can pass un-normalizedtsafely — exceptlerpitself, which extrapolates by design. swapRemoveis O(1) and unordered — it overwrites slotiwith the last element thenpop()s. Safe when iterating backwards through the array; will skip an element if used during forward iteration.xpForLevelis 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.distanceDifficultyMultis hard-clamped at the endpoints (<800returns 0.5,>20000returns 10) before the quadratic, avoiding domain-violation negative-distance or unbounded-distance cases at boundaries.pointToSegDistshort-circuits toMath.hypoton zero-length segments to avoid divide-by-zero, then projects the point onto the segment using the clamped parametert ∈ [0,1].distvsdist2: preferdist2for any comparison (d2 < r*r) —circleOverlapandpointInCircleboth do this internally.Math.hypotis only called when a length value is actually consumed.chunkKeyproduces a stringified"cx,cy"so chunk-coord lookups can use a plainRecord<string, T>rather than aMap. Thesizedivisor is passed in rather than hard-coded — different systems (terrain, spawn, AI) can use different chunk sizes against the same helper.normalizeAngleuseswhileloops rather than modular arithmetic — fine for the realistic input range (a few revolutions at most) and avoids%sign issues on negative numbers.PoolManager.pickexclude-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 usesrng() * total; the trailingreturn filtered[filtered.length - 1].idis a floating-point-rounding safety net.PoolManager.pickNbuilds its ownexcludelist incrementally to enforceallowDupes = false— N independent weighted picks, each excluding what’s already been drawn. Returns fewer thancountitems only ifpickreturnsnull(missing pool).weightedPickis the unseeded sibling ofPoolManager.pick— same algorithm butMath.random()and parallel arrays instead of a registered pool. Used where determinism is not required and pool registration would be overkill.randIntis inclusive on both ends becauserandRange(min, max+1)thenMath.floorshifts the open upper bound back to inclusive. Off-by-one trap: callers must remember the+1happens internally.- All functions are
export functionat top level, no default export.PoolManageris anexport constobject literal — picked over a class because there is only ever one instance and module-scope state is the desired pattern.