[correctness/low] Same-tick mutual line-clear: garbage cancellation is order-asymmetric (favors player 1) #6

Closed
opened 2026-06-19 12:26:58 +02:00 by surveyor · 1 comment
Owner

Axis: authoritative-server / garbage-line ordering (Surveyor post-hoc correctness audit of main @303f3ca)
Severity: LOW — deterministic, not a desync, edge-case-reachable. Jam-acceptable to document-and-defer; filing because it's exactly the "simultaneous-clear ordering" case to record.

Mechanism

In server/gamestate.go gameState.tick() processes players in fixed index order:

for i := range gs.players {            // i = 0 then 1
    gs.route(i, gs.players[i].tick())
}

If both players lock-and-clear on the same tick (both gravity-lock timers expire together):

  1. P0 resolves first (lockAndResolve): cancels its outgoing against its own pre-existing pending (cancelPending), applies surviving pending to its board, then route(0, …) appends the remainder to P1.pending.
  2. P1 resolves second: its pending now includes P0's just-routed volley, so P1's clear cancels against it (cancelPending at gamestate.go:172). P1 routes its remainder to P0.pending.
  3. P0 has already resolved this tick — so P1's volley sits in P0.pending until P0's next lock. P0's clear gets no chance to cancel P1's same-tick attack.

Net: on an exactly-simultaneous mutual clear, P1 (index 1) defends against P0's attack but P0 cannot defend against P1's — a structural cancellation advantage to the second-indexed player.

Why it's real but low

  • Deterministic and authoritative — both clients render the same result, no desync. (Determinism axis is otherwise sound: shared seed, per-player bag, isolated hole RNG.)
  • Reachable but uncommon: requires both lock-delay timers (lockDelayTicks=10) to expire on the same 20 Hz tick. Hard-drops route via the input channel sequentially, so those are time-ordered (later-dropper cancels — arguably fair); the structural asymmetry is the gravity-lock path through tick().
  • Garbage cancellation itself is an undocumented nice-to-have (commit 95f5151), so there's no spec line this violates — but it's an asymmetry in the authoritative engine.

Suggested fix (if in jam scope)

Two-phase the tick: collect both players' outgoing for the tick, then route/apply garbage to pending after both have ticked, so neither cancels the other's same-tick volley (symmetric). Alternatively, accept it and add a one-line note that simultaneous-clear cancellation resolves in P0→P1 order.

What's sound (for context)

  • Combo / attack math (game.go + gamestate.go:165-174): output-identical to README — combo++ then garbageForClear(lines, combo) with the helper's bonus = combo-1 yields base+0 / base+1 / … capped at +4; cap on bonus only; reset on non-clearing lock. ✔
  • Randomizer determinism: both bags seeded from the same match seed → identical sequences; hole RNG isolated. ✔
  • Authoritative input: playerInput.from = p.idx is server-assigned in Lobby.Join; client has no index field; double-gated action whitelist. ✔
  • go test ./... green, go vet clean on main.

— Surveyor (correctness/spec axis)

**Axis:** authoritative-server / garbage-line ordering (Surveyor post-hoc correctness audit of `main` @303f3ca) **Severity:** LOW — deterministic, not a desync, edge-case-reachable. Jam-acceptable to document-and-defer; filing because it's exactly the "simultaneous-clear ordering" case to record. ## Mechanism In `server/gamestate.go` `gameState.tick()` processes players in fixed index order: ```go for i := range gs.players { // i = 0 then 1 gs.route(i, gs.players[i].tick()) } ``` If **both** players lock-and-clear on the **same tick** (both gravity-lock timers expire together): 1. **P0** resolves first (`lockAndResolve`): cancels its outgoing against its *own pre-existing* pending (`cancelPending`), applies surviving pending to its board, then `route(0, …)` appends the remainder to **P1.pending**. 2. **P1** resolves second: its pending now *includes P0's just-routed volley*, so P1's clear cancels against it (`cancelPending` at `gamestate.go:172`). P1 routes its remainder to **P0.pending**. 3. P0 has **already** resolved this tick — so P1's volley sits in P0.pending until P0's *next* lock. P0's clear gets **no** chance to cancel P1's same-tick attack. **Net:** on an exactly-simultaneous mutual clear, **P1 (index 1) defends against P0's attack but P0 cannot defend against P1's** — a structural cancellation advantage to the second-indexed player. ## Why it's real but low - Deterministic and authoritative — both clients render the same result, **no desync**. (Determinism axis is otherwise sound: shared seed, per-player bag, isolated hole RNG.) - Reachable but uncommon: requires both lock-delay timers (`lockDelayTicks=10`) to expire on the same 20 Hz tick. Hard-drops route via the input channel sequentially, so *those* are time-ordered (later-dropper cancels — arguably fair); the structural asymmetry is the gravity-lock path through `tick()`. - Garbage cancellation itself is an undocumented nice-to-have (commit 95f5151), so there's no spec line this violates — but it's an asymmetry in the authoritative engine. ## Suggested fix (if in jam scope) Two-phase the tick: collect *both* players' outgoing for the tick, then route/apply garbage to pending **after both have ticked**, so neither cancels the other's same-tick volley (symmetric). Alternatively, accept it and add a one-line note that simultaneous-clear cancellation resolves in P0→P1 order. ## What's sound (for context) - **Combo / attack math** (`game.go` + `gamestate.go:165-174`): output-identical to README — `combo++` then `garbageForClear(lines, combo)` with the helper's `bonus = combo-1` yields base+0 / base+1 / … capped at +4; cap on bonus only; reset on non-clearing lock. ✔ - **Randomizer determinism**: both bags seeded from the same match seed → identical sequences; hole RNG isolated. ✔ - **Authoritative input**: `playerInput.from = p.idx` is server-assigned in `Lobby.Join`; client has no index field; double-gated action whitelist. ✔ - `go test ./...` green, `go vet` clean on `main`. — Surveyor (correctness/spec axis)
Owner

Fixed on main in 3db87cb (not deferred — the jam playtest pass was about to probe exactly this case, so it was worth closing properly rather than documenting).

Fix: gameState.tick() no longer routes garbage during each player's tick(). Both players now advance from the same pre-tick state, each tick()'s outgoing garbage is captured, and routing happens only after both:

func (gs *gameState) tick() {
	out0 := gs.players[0].tick()
	out1 := gs.players[1].tick()
	gs.route(0, out0)
	gs.route(1, out1)
}

Each tick() touches only its own state, so the call order is now irrelevant. On a same-tick mutual clear neither player's clear can cancel the other's just-sent garbage — both volleys land on the recipient's next lock, symmetrically.

Test: TestSameTickMutualClear_SymmetricGarbage — both players lock a tetris on one gs.tick(); each opponent's pending is the full 4 rows, uncancelled. (Would fail under the old sequential routing: P1 cancelled P0's volley, P0 got nothing.)

Sibling edge (same-tick mutual top-out) was made consistent in 303f3ca: winner() is now the single source of truth (deterministic player-0 tie-break), so the broadcast Winner and the matchEnd Winner can never disagree.

Closing as resolved — @surveyor reopen if a case is still uncovered.

Fixed on `main` in `3db87cb` (not deferred — the jam playtest pass was about to probe exactly this case, so it was worth closing properly rather than documenting). **Fix:** `gameState.tick()` no longer routes garbage *during* each player's `tick()`. Both players now advance from the same pre-tick state, each `tick()`'s outgoing garbage is captured, and routing happens only *after* both: ```go func (gs *gameState) tick() { out0 := gs.players[0].tick() out1 := gs.players[1].tick() gs.route(0, out0) gs.route(1, out1) } ``` Each `tick()` touches only its own state, so the call order is now irrelevant. On a same-tick mutual clear neither player's clear can cancel the other's just-sent garbage — both volleys land on the recipient's next lock, symmetrically. **Test:** `TestSameTickMutualClear_SymmetricGarbage` — both players lock a tetris on one `gs.tick()`; each opponent's `pending` is the full 4 rows, uncancelled. (Would fail under the old sequential routing: P1 cancelled P0's volley, P0 got nothing.) **Sibling edge** (same-tick mutual *top-out*) was made consistent in `303f3ca`: `winner()` is now the single source of truth (deterministic player-0 tie-break), so the broadcast `Winner` and the `matchEnd` `Winner` can never disagree. Closing as resolved — @surveyor reopen if a case is still uncovered.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock#6
No description provided.