← All decisions

Drop mechanic: cursor-follow preview, cooldown-gated drop, randomized next tier -- and gleam_stdlib already has a cross-target float.random(), no new FFI needed

merge-gameinputdrop-mechanicrandomness

Context

Phase 5 of the approved merge-puzzler plan: replace the old free-for-all continuous click-anywhere-to-spawn (always tier 0, no cooldown, no preview) with the genre-standard mechanic -- a preview circle follows the cursor horizontally, clicking drops it (gated by a cooldown so holding the button doesn't spam-drop), and the next tier to drop is randomized from a small low-tier subset. The plan's own draft called for a new FFI export in game_ffi.mjs, randomFloat() wrapping Math.random(), reasoning that this version of gleam_stdlib had 'no portable seedless PRNG'.

Options considered

Add game_ffi.mjs's randomFloat() as planned, wrapped by a Gleam @external — rejected
Turned out to be based on an unverified assumption. Reading the actually-vendored gleam_stdlib source directly (build/packages/gleam_stdlib/src/gleam/float.gleam) before implementing -- rather than trusting the plan's own claim -- showed float.random() -> Float already exists there, and is genuinely cross-target (@external(erlang, "rand", "uniform") / @external(javascript, "../gleam_stdlib.mjs", "random_uniform")), not JS-only. Adding a redundant custom FFI export for something the standard library already solves, in pure typed Gleam, on both targets, would directly violate the project's standing FFI-minimization rule (decision 0016) for no benefit.
Use gleam_stdlib's float.random() directly, called from glemy/game.gleam's loop (not from inside pe.tick itself) — chosen
Zero new FFI code at all -- strictly better than the plan's own proposal. Calling it from the JS-only game loop (not from pe.tick) keeps tick itself pure and deterministic-given-input: the random float is threaded in through Input.next_tier_random exactly like cursor_x and drop_requested already are, so pe/tier.gleam's tier.random_droppable and pe.tick's cooldown/drop logic stay fully gleam test-covered with fixed, repeatable inputs -- no hidden randomness inside the actual game-logic layer.

Decision

pe.Input reshaped from Input(spawn_at: Option(Vector2)) to Input(cursor_x: Float, drop_requested: Bool, next_tier_random: Float). pe.Model gained current_tier: Int, preview_x: Float, cooldown_remaining: Float. pe.tick now: decrements cooldown_remaining by dt (floored at 0.0 via float.max), computes preview_x by clamping cursor_x to keep the current tier's circle fully inside bounds (clamp_preview_x), and -- if drop_requested, cooldown has expired, and there's room under max_entities -- drops an entity of current_tier at (preview_x, a fixed spawn height 15.0 world units below bounds.max.y), resets cooldown_remaining to a 0.5s constant, and advances current_tier via the new glemy/pe/tier.random_droppable(next_tier_random, tier.droppable_tiers) (droppable_tiers = [0, 1, 2], the reference game's own scope decision). glemy/game.gleam's loop now tracks cursor_x every frame (not just while the button is held, since the preview needs to follow the cursor at rest too) and supplies float.random() as next_tier_random each frame.

Verification

127 Erlang / 161 JavaScript tests passing (up from 119/153 before this phase), all additive: new pe/tier_test.gleam coverage for random_droppable (boundary-exact index mapping for a 3-element subset, single-element subset, empty-subset fallback, out-of-range-input clamping) and new pe_test.gleam coverage for preview_x computation/clamping, drop-integrates-same-frame, cooldown resets and blocks an immediate second drop, cooldown allows a drop again once fully elapsed, current_tier advances via a fixed random input, cooldown decay and its zero-floor, and the max-entities cap still blocking a drop. One test-design bug was caught and fixed during this work, not in the implementation: an early version of the cooldown-elapsed test dropped two same-tier entities at the exact same position and expected 2 entities afterward, not accounting for the fact that two same-tier, exactly-overlapping entities legitimately merge in the very next update pass (Phase 2's own documented behavior) -- fixed by making the two drops different tiers via distinct next_tier_random inputs, not by changing any production code. Real-browser verification via tools/browser_check.ts: a click-and-hold that previously (pre-cooldown) grew the entity count 3 -> 7 in ~500ms now grows it only 3 -> 4, direct confirmation the cooldown gate is real and active end-to-end, not just unit-tested in isolation; the existing world-x click-accuracy check (decision 0021) still passes unchanged.

Consequences

index.html's hint text updated from 'Click (and hold) to spawn more' to 'Click to drop', matching the new mechanic. Any future change to drop timing/tier variety only needs to touch pe.gleam's drop_cooldown constant or pe/tier.gleam's droppable_tiers list -- both already isolated, tuning is explicitly deferred to Phase 8. The old free-for-all continuous-spawn behavior (used by earlier decisions 0014's O(n^2) stress measurement) no longer exists as reachable game behavior, though pe.spawn_entity itself is unchanged and still directly testable/usable.

References