Skip to content

Melee prediction desyncs once in ~12 canonical runs, and nothing explains it #13

Description

@WilsonNet

Summary

The canonical run intermittently fails with FAIL: 1 melee prediction desyncs
meleeSummary.meleeDesyncFrames incrementing once, with no other counter moving.

This is the metric doing its job: it means the client's replay landed on a
different sword state than it had predicted, for a reason the reconciler could
not excuse. Unlike the respawn case fixed in 8fcf0ec, there is no known
legitimate explanation for it, so either the state machine genuinely ran
differently on the two sides, or the metric has a second blind spot.

It is not new. It reproduces on 18b6cc9, before any of the training-room
work, so nothing recent introduced it.

Evidence

Observed at least twice across ~35 canonical online runs, on both 18b6cc9 and
the training-room branch:

FAIL: 1 melee prediction desyncs
  meleeDesyncFrames: 1
  frameDataViolations: 0        # sometimes 1, from the separate respawn bug
  illegalActions: 0, blockedUnblockables: 0, stuckActionFrames: 0

Roughly one run in ten to fifteen. Zero occurrences in the 10 runs after the
respawn fix — which is nowhere near enough to call it gone
, and is exactly the
sample size that would miss a 1-in-12 event more often than not.

What the counter actually means

PredictedPlayer.reconcile (src/game/online/Prediction.ts) rewinds to the
authoritative state, replays every unacknowledged input, and then compares:

const diverged = !interrupted &&
  (this.state.meleeAction !== predictedAction ||
   this.state.blocking   !== predictedBlocking);

interrupted is the set of things only the server can know, and each is a
deliberate exemption: stunTimer > 0, iframeTimer > 0, and a newly armed
massiveReady (a parry arms it, and a parry is server-only knowledge). Match
additionally excludes respawn-sized corrections.

So a desync frame means: the sword state changed under the client, and none of
stun, invulnerability, a fresh Massive arm or a respawn explains it.

Already ruled out

  • The parry → massiveReady path. 31s of deliberate stress in the training
    room (behaviour: "slash", player throwing freshly-pressed guards to force
    parries) produced 6 parries, 6 Massives armed server-side and 5 performed,
    with frameDataViolations: 0 and meleeDesyncFrames: 0.
  • The respawn race. That was a real bug and is fixed in 8fcf0ec, but it
    fired frameDataViolations, not this counter, and Match was already
    excluding respawn-sized corrections from the desync count.
  • A recent regression. Reproduced on the parent commit.

Prime suspects

Both are the same failure in the end: the client simulated a tick the server
never did
— the mirror image of the invariant the codebase already states in
the other direction, and one nothing currently detects.

If a press edge is in an input the server never simulates, the client starts a
move the server does not. The client keeps that input in pending and replays it
on every reconcile, so its own state keeps the move — until a later seq is
acknowledged, at which point the pending input is dropped without ever having
been simulated server-side, the client rewinds into a state with no move, and the
move vanishes. That is precisely a desync frame.

1. Input datagrams are unreliable, by default

OnlineManager.sendInput calls channel.emit("input", input) with no options.
Geckos defaults to maxRetransmits: 0, so an input packet that is lost is simply
gone. The seq/replay design recovers position from this, but it cannot
recover a press edge the server never saw.

Loss on localhost is near zero, which makes this the less likely of the two for
the measured runs — but it is the likely one in the field, and the two are
indistinguishable from the report as it stands.

2. The server's input queue drops its oldest entries

server/GameRoom.ts:

player.queue.push(input);
if (player.queue.length > MAX_QUEUED_INPUTS) {
  player.queue.splice(0, player.queue.length - MAX_QUEUED_INPUTS);   // drops the OLDEST
}

The cap (10) exists so a flooding or lagging client cannot make the server
simulate an unbounded backlog in one tick, which is right. Discarding the
oldest is the part worth questioning: those are the inputs the client
simulated first and is still waiting to have acknowledged, and dropping them
silently deletes ticks from the middle of an input stream that both sides are
supposed to run identically.

Ten slots is ~167ms at 60Hz, and MAX_STARVED_TICKS (6) can legitimately consume
six of them while the server is frozen waiting for input — so the working margin
is nearer 4 ticks (~67ms) than 10. A GC pause or a scheduling hiccup on either
side is enough.

3. blocking flipping on a tick boundary

diverged also fires on a blocking mismatch. blocking is derived state
(s.meleeAction === "none" && s.blockTimer >= BLOCK_STARTUP_MS) and
BLOCK_STARTUP_MS is 0, so it flips on the same tick the button does. Worth
confirming a one-tick offset between the two sides cannot produce a
single-frame mismatch that is otherwise harmless — if it can, this counter is
partly measuring the snapshot rate rather than a defect.

The instrumentation is already there

8fcf0ec added what this investigation needs, and it has not yet been pointed at
this failure:

  • meleeSummary.meleeReplacements — every sword-state replacement with the
    reconciler's verdict: stun, iframe, massive-armed, respawn, or
    unexplained. A desync frame is an unexplained entry, and the entry
    carries predictedAction, actualAction, predictedBlocking,
    actualBlocking, stunTimer and iframeTimer.
  • [DESYNC] {...} on the console, which now actually prints — it previously
    could not, because the call site passed three of the callback's four arguments
    and the detail was dropped on the floor.

So the next step is mostly a matter of running until it trips and reading the
record, rather than adding anything.

Investigation

  1. Run enough. node scripts/diagnose.mjs --mode=online --runs=15, more than
    once. A single clean run proves nothing at this rate — see
    .agents/skills/feedback-loop/SKILL.md on judging coverage across runs.
  2. Read the unexplained entry: which move, which direction (did the client
    start a move the server did not, or the reverse), and what blocking was
    doing.
  3. Count what the server dropped. Add a counter for
    queue.splice discards and for the gap between the highest seq received and
    the highest consumed, and surface it. If it is never non-zero, suspect 2 is
    out
    — and that is a result worth having either way.
  4. Try emit("input", input, { reliable: true }) as a discriminator, not as
    a fix: reliable inputs on a UDP transport reintroduce head-of-line blocking
    and are the wrong long-term answer, but if the desync disappears, suspect 1 is
    confirmed.
  5. If the cause is a dropped press edge, the fix is likely to be on the
    server side — drop the newest input rather than the oldest, or make the
    cap large enough that the starvation freeze cannot eat it — since deleting a
    tick from the middle of a shared deterministic stream is the thing that must
    not happen.

Constraints

  • Do not silence the metric. The respawn fix was legitimate because a respawn
    is a genuine discontinuity the client can see. This one has no such
    justification yet, and widening interrupted without knowing why would turn a
    working detector into a permanently green one.
  • Never simulate a tick the client did not send — and, as this issue argues,
    the converse deserves the same status: never fail to simulate a tick the
    client did send. See docs/invariants.md.
  • The canonical test is online AI vs AI. An offline run cannot see this at
    all: there is no prediction, no reconciliation and no reconcile step to
    diverge.
  • Restart the server after touching server/, src/game/simulation/,
    src/game/characters/ or src/game/training/ — tsx does not hot-reload, and a
    stale server makes a fix look like it worked.

Definition of done

  • The cause is named from a captured unexplained replacement record, not
    inferred.
  • Either it is a real bug and is fixed, or it is a legitimate discontinuity, in
    which case the exemption is added with the evidence and a unit test — as the
    respawn case was, in src/game/diagnostics/PhysicsDiagnostics.test.ts.
  • 15+ canonical online runs with meleeDesyncFrames: 0, and the move
    counters non-zero throughout: a run where nobody swung satisfies this
    trivially.
  • docs/invariants.md records whatever bit, so the next agent does not have to
    rediscover it.

Out of scope

  • The frameDataViolations respawn race — fixed in 8fcf0ec.
  • Lag compensation or rewind for hit detection.
  • Making the input channel reliable as a product decision; step 4 above is a
    diagnostic only.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions