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
- 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.
- 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.
- 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.
- 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.
- 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.
Summary
The canonical run intermittently fails with
FAIL: 1 melee prediction desyncs—meleeSummary.meleeDesyncFramesincrementing 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 knownlegitimate 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-roomwork, so nothing recent introduced it.
Evidence
Observed at least twice across ~35 canonical online runs, on both
18b6cc9andthe
training-roombranch: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 theauthoritative state, replays every unacknowledged input, and then compares:
interruptedis the set of things only the server can know, and each is adeliberate exemption:
stunTimer > 0,iframeTimer > 0, and a newly armedmassiveReady(a parry arms it, and a parry is server-only knowledge).Matchadditionally 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
massiveReadypath. 31s of deliberate stress in the trainingroom (
behaviour: "slash", player throwing freshly-pressed guards to forceparries) produced 6 parries, 6 Massives armed server-side and 5 performed,
with
frameDataViolations: 0andmeleeDesyncFrames: 0.8fcf0ec, but itfired
frameDataViolations, not this counter, andMatchwas alreadyexcluding respawn-sized corrections from the desync count.
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
pendingand replays iton every reconcile, so its own state keeps the move — until a later
seqisacknowledged, 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.sendInputcallschannel.emit("input", input)with no options.Geckos defaults to
maxRetransmits: 0, so an input packet that is lost is simplygone. The
seq/replay design recovers position from this, but it cannotrecover 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: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 consumesix 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.
blockingflipping on a tick boundarydivergedalso fires on ablockingmismatch.blockingis derived state(
s.meleeAction === "none" && s.blockTimer >= BLOCK_STARTUP_MS) andBLOCK_STARTUP_MSis 0, so it flips on the same tick the button does. Worthconfirming 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
8fcf0ecadded what this investigation needs, and it has not yet been pointed atthis failure:
meleeSummary.meleeReplacements— every sword-state replacement with thereconciler's verdict:
stun,iframe,massive-armed,respawn, orunexplained. A desync frame is anunexplainedentry, and the entrycarries
predictedAction,actualAction,predictedBlocking,actualBlocking,stunTimerandiframeTimer.[DESYNC] {...}on the console, which now actually prints — it previouslycould 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
node scripts/diagnose.mjs --mode=online --runs=15, more thanonce. A single clean run proves nothing at this rate — see
.agents/skills/feedback-loop/SKILL.mdon judging coverage across runs.unexplainedentry: which move, which direction (did the clientstart a move the server did not, or the reverse), and what
blockingwasdoing.
queue.splicediscards and for the gap between the highestseqreceived andthe highest consumed, and surface it. If it is never non-zero, suspect 2 is
out — and that is a result worth having either way.
emit("input", input, { reliable: true })as a discriminator, not asa 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.
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
is a genuine discontinuity the client can see. This one has no such
justification yet, and widening
interruptedwithout knowing why would turn aworking detector into a permanently green one.
the converse deserves the same status: never fail to simulate a tick the
client did send. See
docs/invariants.md.all: there is no prediction, no reconciliation and no reconcile step to
diverge.
server/,src/game/simulation/,src/game/characters/orsrc/game/training/— tsx does not hot-reload, and astale server makes a fix look like it worked.
Definition of done
unexplainedreplacement record, notinferred.
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.meleeDesyncFrames: 0, and the movecounters non-zero throughout: a run where nobody swung satisfies this
trivially.
docs/invariants.mdrecords whatever bit, so the next agent does not have torediscover it.
Out of scope
frameDataViolationsrespawn race — fixed in8fcf0ec.diagnostic only.