← All decisions

Extend the Core API with three small utility modules (cooldown, stopwatch, random) and physics.entity_count, and reject a generic PreviewQueue abstraction

architecturecore-apitimerrandomrefactor

Context

The user requested proactively growing the Core API to give the architecture 'the feel of a real game engine' and to simplify glemy/games/tiers.gleam by building on top of it, rather than waiting for a second game to justify further extraction -- a deliberate departure from decisions 0047/0048's stricter build-nothing-ahead-of-need posture, explicitly requested and research-backed rather than assumed. A close read of games/tiers.gleam's tick function found three genuinely generic, already-duplicated-in-spirit patterns: a countdown timer (cooldown_remaining), a hold-while-condition-then-reset accumulator (danger_timer), and generic index-selection-from-a-list math already hand-rolled Int-only inside games/tiers/rules.random_droppable. A fourth candidate, generalizing the current_tier/next_tier two-ahead preview-advance pattern into a reusable PreviewQueue(a), was also seriously considered.

Options considered

Wrap cooldown_remaining/danger_timer in new opaque Cooldown/Stopwatch record types on games/tiers.Model — rejected
Checked against this repo's own closest existing precedent: physics/entity.gleam's resting_time is a plain Float field governed by a pure function (settle), and physics/collision.gleam is a whole domain module with no type of its own -- direct, already-shipped evidence that a domain module in this codebase is a named concept with pure functions, not necessarily an eponymous ADT. Concretely, games/tiers.Model's cooldown_remaining/danger_timer fields are constructed and asserted on by name in roughly 20 of tiers_test.gleam's tests plus game_test.gleam/game.gleam; wrapping them in new record types would force every one of those call sites to change for zero behavioral gain. Keeping the fields as plain Float and extracting only the pure functions that advance them let the actual rewiring touch nothing outside tiers.gleam itself.
Bake a threshold/'exceeded' concept into the stopwatch module (e.g. start(threshold) + is_exceeded(timer)) — rejected
danger_timer_threshold is a games/tiers tuning constant with exactly one caller. Baking a threshold comparison into a Core utility for a single-caller concern would smuggle a game-specific value into the genre-agnostic layer -- the same discipline physics.Model's own doc comment already states (no score, no win/lose state, 'a specific game wraps this... rather than this type growing a field per game'). Bevy's own bevy::time::Stopwatch was checked as direct precedent for this exact shape: count-up only, no built-in duration or finished concept -- that's layered on top by Bevy's separate Timer type, which glemy has no equivalent single caller to justify yet.
Generalize the current_tier/next_tier two-ahead advance into a Core PreviewQueue(a) module — rejected
Unlike physics/collision_sweep's PairInteraction(event) (decision 0047's precedent -- genericity over an already-fully-specified algorithm, with only the per-pair decision varying), a PreviewQueue(a) would be genericity over a data shape and trigger condition with exactly one real data point to generalize from: whether a second game even wants a preview concept, how far ahead it looks, or what triggers an advance is unknowable right now. This is precisely the failure mode docs/technical-architecture.md sections 2.3/2.4 already defer until a second, genre-distinct game exists, and the same one this project already paid for once (glemy/cs, decision 0031). The two lines of bookkeeping it would replace are cheap to leave inline; the cost of guessing the wrong shape is not. Deferred, not built.
Add a physics.has_capacity_for/similar capacity-check helper alongside entity_count — rejected
max_entities is a games/tiers tuning constant, not a physics-layer concern -- the same 'Core shouldn't own a game's threshold' reasoning as the stopwatch decision above. entity_count alone (a plain count, no comparison) is the right-sized cut; the >= max_entities comparison stays in games/tiers.gleam exactly as before.

Decision

Added three new top-level Core modules, siblings to glemy/physics/glemy/render/glemy/io rather than nested under physics/ (every engine surveyed -- Bevy's bevy_time vs. its physics crates, Godot's Timer node vs. PhysicsServer2D, LOVE2D's love.timer/love.math vs. love.physics -- keeps timer/random utilities separate from physics, direct precedent for not re-diluting the physics name decision 0048 just fixed): glemy/cooldown.gleam (tick(remaining, dt) -> Float, floored at 0.0; is_ready(remaining) -> Bool), glemy/stopwatch.gleam (tick(elapsed, dt, holding) -> Float, accumulates while holding else resets to 0.0, no threshold concept), and glemy/random.gleam (pick(random, options) -> Result(a, Nil), a verified byte-for-byte generalization of random_droppable's existing index-selection algorithm, taking the random float as a plain argument rather than generating it internally -- validated against Godot's own confirmed, documented seeded-RNG-non-reproducibility problem across engine versions, godot/godot#27856, as a concrete reason never to let a Core utility own random state). Added physics.entity_count(model) -> Int. Rewired games/tiers/rules.random_droppable into a thin wrapper (random.pick |> result.unwrap(0)) and games/tiers.tick's three corresponding expressions (cooldown_remaining, at_capacity, danger_timer) to call the new primitives -- Model's field types unchanged (still plain Float/Int), so no test file outside the new modules' own required any edit.

Verification

gleam test and gleam test --target javascript both pass -- 186 Erlang / 209 JavaScript, exactly the prior baseline (164/187) plus the new modules' own test counts (8 random_test + 7 cooldown_test + 5 stopwatch_test + 2 physics_test additions), with zero changes to any pre-existing test file's pass count, confirming the rewiring of games/tiers.gleam was behavior-preserving by construction, not just by inspection. deno task check-warnings passes with the existing baseline completely unchanged (51/51, zero new warnings) -- none of the new modules are FFI or @target(javascript)-gated, so none produce the usual target-gating artifact. deno task browser-check passes for real (180 real requestAnimationFrame frames, a real click-and-hold grew the entity count 4 -> 5, a real entity spawned near the expected world-x, zero page errors), confirming the actual running game still behaves identically through the rewired tick path.

Consequences

glemy/games/tiers.tick now reads as composition of named Core primitives (cooldown.tick, stopwatch.tick, physics.entity_count) rather than hand-rolled float arithmetic inline, the concrete 'feel of a real engine' improvement this decision was made to produce. A second, genre-distinct game gets the same three primitives for free, without needing to re-derive countdown/accumulate/random-pick math from scratch. The explicitly rejected PreviewQueue(a) remains a real, named candidate for revisiting once a second game's own tick demonstrates the same shape -- not silently dropped, written down here so a future reader doesn't have to rediscover why it wasn't built now.

References