← All decisions

Same-tier overlapping entities merge into the next tier instead of bouncing, one pair per sweep pass

merge-gamecollisiontierscore

Context

Phase 2 of the approved merge-puzzler plan: the core game mechanic. Two same-tier, overlapping entities should combine into a single entity of the next tier instead of bouncing off each other, and each merge should add to a running score. This has to compose with the existing pairwise collision sweep (resolve_all_collisions/resolve_against_rest, both length-preserving) without breaking it, and needs a clear answer for what happens when three or more same-tier entities mutually overlap in the same frame.

Options considered

Chain merges fully within a single pass (a cluster of N same-tier overlapping entities collapses to one entity in one frame) — rejected
Requires either unbounded same-pass recursion on a dense cluster or explicit 'has this entity already merged this pass' bookkeeping neither of which the existing length-preserving sweep structure supports cleanly, and it buys nothing visible at 60fps over spreading the collapse across a couple of frames.
Restructure the sweep around a ResolveOutcome sum type (Survived vs Merged) that lets the entity list shrink; defer any further merging of the newly-merged entity or of entities skipped this pass to the next frame — chosen
The existing resolve_against_rest recursion already threads updated entity state forward pair by pair; adding a Merged variant that short-circuits the rest of that entity's own sweep (but leaves every later entity's own full sweep untouched) is a minimal, structurally obvious extension. A three-way same-tier cluster merges its first eligible pair immediately and leaves the rest for the next frame(s) simplest correct behavior, and imperceptible in a real-time game.

Decision

glemy/pe/tier.gleam gained color(tier), score_for(tier), next(tier) -> Option(Int) (None at the highest tier), and merge(a, b) -> Entity (next tier, midpoint position, averaged velocity, tier-derived radius). glemy/pe.gleam's Model gained a score: Int field. The collision sweep was restructured around a new private ResolveOutcome type (Survived(final_target, resolved_rest) | Merged(merged, remaining_rest, score)): resolve_target_against_rest checks each entity in rest against target in order, merging immediately (via tier.merge) on the first same-tier, overlapping, not-already-max-tier match it finds, otherwise falling back to the existing collision.resolve bounce and recursing. resolve_all_collisions threads the merged-or-survived outcome back into a shrinking entity list plus a total score-gained for the pass; pe.update adds that to model.score.

Verification

106 Erlang / 139 JavaScript tests passing (up from 101/134 pre-Phase-2, purely additive: 9 new pe/tier_test.gleam tests plus 5 new pe_test.gleam integration tests). New integration tests cover: two overlapping same-tier entities merge into the correct next-tier entity with the correct score; two overlapping max-tier (unmergeable) entities bounce instead; two overlapping different-tier entities never merge; a three-entity same-tier mutual-overlap cluster merges only the first pair this pass, leaving the third entity completely untouched; score accumulates correctly across two independent merges in one update call. Six pre-existing bounce-behavior tests that happened to use overlapping same-tier (tier 0) entities were updated to use the max tier (5, deliberately unmergeable) instead so they keep exercising pure bounce behavior unchanged rather than being broken by the new merge path.

Consequences

A dense same-tier cluster's full collapse now visibly spreads across multiple frames rather than happening instantaneously within one -- an intentional, documented simplification, not a bug. Any future code reasoning about 'how many entities exist after this frame' must account for the list shrinking on a merge. Model.score is now authoritative running game score, feeding directly into Phase 7's HTML score overlay.

References