Skip to content

[fix] 29-F: a peer receiving simulate while simulating yields by a deterministic rule - #236

Merged
AlexZ005 merged 2 commits into
release/nextfrom
fix/29f-sim-race
Sep 21, 2026
Merged

AlexZ005 merged 2 commits into
release/nextfrom
fix/29f-sim-race

Conversation

@AlexZ005

Copy link
Copy Markdown
Collaborator

Closes the dual-simulator race the 24-B football lane measured and recorded as a core ticket.

The bug

Two Play presses inside the sim's start-up window both pass playMode.maybeSimOnPlay's
simulating || remoteSimulating guard, because the other side's simulate has not landed
yet — a window that spans warmup() plus the whole of startSimulation. Both peers then
step a world and broadcast move at 30 Hz; each stream reads as an EXTERNAL write on the
other, and every dynamic body sits under a hold: 'external' refreshed long before its
250 ms timeout can expire. Measured on a real two-peer match: 74 moves in ~2 s, the ball
snapping back, applyThrow eaten, and no goal able to score.
A late joiner that is
already simulating meets the same shape.

The rule

The lower peer id keeps the world. The guard cannot be fixed where it stands — a peer
cannot know it is racing until the message arrives — so the rule lives on the RECEIVE side
and reads only two facts both sides already hold (my id, the id in the message), so both
reach the same verdict with no round trip and no new message.

Checked against what already exists before committing to it: PeerJS ids are non-empty
strings and < is a total order, so exactly one winner is elected; remoteSimulating is
already set from that same peerId; the handshake push is symmetric, so the joiner shape
resolves identically; and the football module's game.isAuthority() already falls back
to the lowest live id when no sim runs — core's winner and a module's fallback authority
are one peer by construction.

What is in it

  • src/lib/simAuthority.js (new, imports nothing) — simulateVerdict
    keep | yield | adopt | clear | ignore. Additive: a message with no peerId takes the
    pre-29-F path verbatim.
  • Yielding is CLEAN, not merely quiet: stopSimulation({yielded: true}) also withholds the
    settling move per body (each would pin one of the winner's copies one last time — the
    very shape the yield exists to end) and the transformSet undo entry.
  • The winner's mirror duties: it drops the holds the loser's stream already claimed, and it
    ANSWERS the competing claim with its own start — the only thing that reaches a peer which
    never heard ours (one that travelled into the room after the run began).
  • A spectator agrees with the racers, and a loser's stop no longer blanks its
    remoteSimulating (that store arms the knock probes and play-mode grab).

Gates

  • svelte-check 336 / 47 — identical LIST to base, zero new messages · npm run build green
  • vitest 178 → 192 (tests/unit/simAuthority.test.js, 14: the truth table and the
    symmetry property two browsers cannot show in reasonable time)
  • game-football 102 → 134, section 7 = the race as it happens, then forced both ways,
    then a goal that SCORES
  • held green: knock-physics 77/0 · throw-peer 28/0 · knock-node 50/0 · play-interact 46/0 ·
    game-towers 20/0 · game-stars-room 36/0
  • pre-existing reds, unchanged: physics-kinematic 10/5 (already listed in CLAUDE.md's
    standing set) and flow-physics-actions 35/2 — A/B'd: identical 35/2, same check 8.5,
    on base code in this worktree

Counterfactuals (each broken, measured, restored byte-identically)

broken result
the whole rule → always adopt 111/8 — 7.1-7.4, 7.8 and "A GOAL SCORES" red; that run the HIGHER id kept the world
the yielded suppression 132/3 — one settling move, undo 4 → 5
the spectator start rule 132/3 — the spectator adopts the higher id
the stop-side ignore 129/6 — a loser's stop blanks the spectator
the keep re-announce 131/4 — the forced higher-id yield never resolves

Worth knowing: two real Play presses do not reliably race. An early revision of section 7
passed for that reason, so the section forces the race in both directions as well, and the
yielded and spectator halves are asserted directly — with either removed the press race
alone stayed green.

Paired with modules PR (the football flight asserts the rule instead of riding it).

🤖 Generated with Claude Code

AlexZ005 and others added 2 commits September 20, 2026 12:39
…deterministic rule

THE BUG, measured on a real two-peer Football match (24-B's handover): two Play
presses inside the sim's start-up window both pass `playMode.maybeSimOnPlay`'s
`simulating || remoteSimulating` guard, because the other side's `simulate` has
not landed yet. Both peers then step a world and broadcast `move` at 30 Hz, each
stream reads as an EXTERNAL write on the other, and every dynamic body sits under
a `hold: 'external'` refreshed long before its 250 ms timeout can expire — 74
moves in ~2 s, the ball snapping back, `applyThrow` eaten, NO GOAL COULD SCORE.
A late joiner that is already simulating meets the same shape.

THE RULE: **the lower peer id keeps the world.** The guard cannot be fixed where
it stands — a peer cannot know it is racing until the message arrives — so the
rule is on the RECEIVE side, and it reads only two facts both sides already hold
(my id, the id in the message), so both reach the same verdict with no round trip
and no new message. Checked against what is already there before committing to
it: PeerJS ids are non-empty strings and `<` is a total order, so exactly one
winner is elected; `remoteSimulating` is already set from that same `peerId`; the
handshake push is symmetric (both sides send one), so a joiner race resolves the
same way; and the football module's `isAuthority()` ALREADY falls back to the
lowest live id when no sim runs, so core's winner and a module's fallback
authority are one peer by construction.

- `src/lib/simAuthority.js` (NEW, imports nothing): `simulateVerdict` ->
  keep | yield | adopt | clear | ignore, with the reasoning. ADDITIVE: a message
  with no `peerId` (an older build) takes the pre-29-F path verbatim, and a
  session with no race in it never reaches a verdict but `adopt` and `clear`.
- `physics.applySimulate` acts on the verdict. YIELDING IS CLEAN, NOT MERELY
  QUIET: `stopSimulation({yielded: true})` withholds the settling `move` per body
  (each would pin one of the winner's copies one last time — the very shape the
  yield exists to end) and the transformSet undo entry (Ctrl+Z over a layout
  nobody ever saw).
- The winner has two mirror duties: `releaseExternalHoldsBy(peerId)` drops the
  holds the loser's moves had already claimed (the same `releaseHold` the 250 ms
  timeout would run, only sooner), and it ANSWERS the competing claim with its
  own start — redundant in an ordinary race, where the two starts cross, and the
  only thing that ever reaches a peer which never heard ours (one that travelled
  into the room after the run began: the push rides `sendHandshake` and is not
  repeated on arrival).
- A SPECTATOR agrees with the racers: told about two simulators it keeps the
  lower id, and a stop from a peer it was not watching no longer blanks
  `remoteSimulating` — that store is what arms the knock probes and play-mode
  grab (24-A A2), so blanking it silently disarms a third peer mid-match.
- `tests/unit/simAuthority.test.js` (14): the truth table, including that the two
  sides of a race reach OPPOSITE verdicts over a spread of real-shaped ids — the
  property two browsers cannot show in reasonable time.
- `tests/e2e/game-football.test.cjs` section 7 (+32 checks, 102 -> 134): the race
  as it happens (both presses, nothing between them), then FORCED both ways
  because two presses do not reliably race, then the goal that scores. Section 2
  keeps its ordered entry so the rest of the suite has a known authority.

Counterfactuals, each broken and restored byte-identically:
- the whole rule -> `adopt`: 111/8, section 7 red incl. "A GOAL SCORES" (and the
  winner was the HIGHER id that run — the nondeterminism the rule removes).
- the `yielded` suppression removed: 132/3 — one settling move, undo 4 -> 5.
- the spectator start rule removed: 132/3 — the spectator adopts the higher id.
- the stop-side `ignore` removed: 129/6 — a loser's stop blanks the spectator.
- the keep re-announce removed: 131/4 — the forced higher-id yield never resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ally carry

FOUND by running the modules football flight against the fix: two real Play presses
do NOT reliably race. The first peer's `simulate` often lands before the second
peer's guard is read, and then nothing raced at all and whoever pressed first keeps
the world — higher id or not. Measured on the flight: A (ce526) kept it while B
(33ec2) never started, which is correct behaviour and made a "the lower id wins"
assertion red.

So the presses assert what they can carry — EXACTLY ONE simulator, and the other
peer knowing who it is — and the ID RULE is left to 7b, where the race is forced and
has no timing in it. Section 7 then hands the world to the lower id explicitly (a
no-op when the presses did race) so 7b starts from the state the rule elects.

Without this, 7.1 was a coin: it passed three runs and would have gone red the first
time the presses happened not to race. The first counterfactual run had already shown
the shape — with the rule removed the HIGHER id kept the world — and that reading was
mistaken for the counterfactual biting rather than for what it also was.

game-football 134 -> 136 checks, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AlexZ005
AlexZ005 merged commit 7d0010d into release/next Sep 21, 2026
4 checks passed
@AlexZ005
AlexZ005 deleted the fix/29f-sim-race branch September 21, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant