Formalize the Core API surface and introduce the glemy/games/<name>/ convention, moving all Suika-specific rule logic out of pe
Context
The user asked for stable, fundamental Core APIs to be defined deliberately -- the basic surface any game built on this engine would need -- rather than continuing straight into a second game. docs/technical-architecture.md §2.3 (decision 0042) already commits to a boundary requirement ("no genre-specific rule logic introduced into pe") but stops at physical package extraction, deferred until a second, genre-distinct game exists (RM-027). Auditing the actual code against that stated requirement found two real violations, not just theoretical risk: render.gleam directly imported glemy/pe/tier (a game-specific module) to compute colors -- a backwards Shell-importing-genre-logic dependency -- and pe.gleam's own Model/tick/Input/GameEvent carried Suika-specific fields (current_tier, next_tier, danger_timer, game_over, cooldown_remaining, score) and the hardcoded same-tier merge rule directly inside collision_sweep, despite pe being documented as the genre-agnostic layer.
Options considered
Decision
Generalized glemy/pe/collision_sweep.gleam's merge logic into a caller-supplied `interact(a, b) -> PairInteraction(event)` callback (`Bounce` or `Consume(replacement, event)`), with `resolve_all_collisions(entities, interact) -> #(List(Entity), List(event))` replacing the old tier-aware sweep. Renamed Entity.tier to Entity.kind everywhere, redocumented as "a plain per-entity classification tag with no built-in meaning at this level" -- pe never inspects it itself. Fixed render.gleam's backwards dependency: render_entities_to_bytes/render_entities_to_canvas now take an explicit `colors: List(#(Float, Float, Float))` parameter, positionally paired with entities, with zero import of any genre-specific module. Reduced pe.gleam to Model(entities, bounds, gravity), update(model, dt, interact) -> #(Model, List(event)), spawn_entity, and max_dt -- removed Input, GameEvent, tick, preview_entity, danger_line_y, and every Suika-specific constant. Created glemy/games/tiers/rules.gleam (relocated from the deleted glemy/pe/tier.gleam: radius/color/score_for/next/merge/droppable-tier logic, unchanged) and glemy/games/tiers.gleam (the reference game's own Model wrapping pe.Model plus score/current_tier/next_tier/preview_x/cooldown_remaining/danger_timer/game_over, its own Input/GameEvent, and tick, which calls pe.update with a local interact function built from rules.gleam). Updated glemy/game.gleam to glue io/render to games/tiers instead of pe directly. This establishes glemy/games/<name>/ as the standing convention for where a game's own rules/state live, documented in docs/technical-architecture.md §2.4 alongside an explicit statement of what the Core API (pe.gleam, pe/*, collision_sweep, render, io) covers and doesn't, and Rust API Guidelines-informed design notes (naming, predictability, flexibility, documentation) applied to that surface.
Verification
gleam test and gleam test --target javascript both pass (164 Erlang / 187 JavaScript, all tests either migrated in place or rewritten for the new module layout -- collision_sweep_test.gleam rewritten against the new generic API with local test-only interact functions that don't depend on any game's rules module; pe_test.gleam slimmed to its now-generic-only surface; a new test/glemy/games/tiers_test.gleam holds every tick-specific test, migrated to the new Model/tick/Input/GameEvent shapes). deno task check-warnings passes after updating the baseline (52/52) -- every new/changed warning was confirmed as the already-documented @target(javascript)-only target-gating artifact (ARCHITECTURE.md's "Compiler warnings" section), not a real regression. deno task browser-check passes for real (181 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 works end-to-end through the new games/tiers-mediated path, not just that it type-checks.
Consequences
pe is now honestly genre-agnostic in practice, not just by stated policy -- closing the concrete backwards-dependency bug this audit found in render.gleam, and giving the eventual RM-027 extraction a real, already-clean boundary to formalize rather than a redesign to do at that point. A second, genre-distinct game gets its own glemy/games/<name>/ tree beside games/tiers/, following the same wrap-pe.Model-as-a-field pattern, with no changes needed to pe itself unless that game's physics genuinely needs a new generic primitive. The Core API is still explicitly pre-1.0 and unversioned (docs/technical-architecture.md §2.4's stability posture) -- this decision establishes which surface is the right one to eventually freeze, not a stability guarantee made ahead of a real second consumer. Every current call site that previously reached into pe for Suika-specific state now goes through games/tiers instead, a one-time, already-completed migration cost across game.gleam and every affected test file.
References
- src/glemy/pe.gleam
- src/glemy/pe/entity.gleam
- src/glemy/pe/collision_sweep.gleam
- src/glemy/render.gleam
- src/glemy/games/tiers.gleam
- src/glemy/games/tiers/rules.gleam
- src/glemy/game.gleam
- test/glemy/pe_test.gleam
- test/glemy/pe/collision_sweep_test.gleam
- test/glemy/games/tiers_test.gleam
- test/glemy/games/tiers/rules_test.gleam
- docs/technical-architecture.md
- ARCHITECTURE.md
- https://rust-lang.github.io/api-guidelines/