Skip to content

Draw the joined player's arena to the frame's clock, not the wire's - #27

Open
sbddesign wants to merge 2 commits into
mainfrom
feat/snapshot-pacing
Open

Draw the joined player's arena to the frame's clock, not the wire's#27
sbddesign wants to merge 2 commits into
mainfrom
feat/snapshot-pacing

Conversation

@sbddesign

Copy link
Copy Markdown
Contributor

The first two-device test of #25/#26 connected, flew, and was jerky on the joined player while the host was smooth. The host's pairs of poses advance exactly once per tick by construction. The client's did not: it applied every snapshot the moment it arrived, and the frame then blended between the last two poses at a factor taken from the local clock, so a pair that advanced twice between frames jumped and one that had not advanced slid backwards.

What changes

  • The client applies one snapshot per tick of its own. createClient holds what arrives early in a queue sorted by tick; a tick that finds nothing coasts; a gap at the head of the queue is coasted once before it is applied, so a snapshot reordered past its predecessor waits for it instead of skipping it, and a lost one is a coast rather than a jump; a queue deeper than SNAPSHOT_DEPTH (four ticks) drains two a tick, bounded at SNAPSHOT_QUEUE. The queue fills only to the depth the wire's jitter needs, because every late arrival leaves one more waiting the tick after. pace: false keeps the old behaviour so the difference is measurable.
  • Game.coast (new): every hull and bolt the mirror does not fly itself, carried one tick along its last velocity, the same integration Ship.step and the bolt pool do, so on straight flight it lands where the missing snapshot would have. Orientation is held (turn rate is not sent).
  • A snapshot that decodes but cannot be applied is counted as malformed on the tick it would have been applied, rather than thrown on. The mutation gate found this one: with respawn forced off, a host whose match resolved keeps sending a roster of nobody.
  • main.ts is untouched: the joiner already ticked its client once per step.

Evidence

Measured in simcheck on the host's own hull as the client draws it, over a loopback with two ticks of latency, three of jitter and 5% duplicates, against the host's own per-tick motion so a real event (two hulls bouncing) is not mistaken for the wire:

stalls jumps world byte-exact on applied ticks coasted tick vs the snapshot it stood in for
applied on arrival 319 / 1200 222 / 1200
paced 0 0 1195 / 1195 0.005 units

The queue settled three deep by tick 300 and stayed there; the picture never trailed the wire by more than latency plus jitter. A deterministic probe loses exactly one snapshot on a perfect wire with the host flying straight: the hull moves 5.86 units to within 0.002 of where that snapshot had it, the eight bolts in flight likewise, and the tick after is the host's world again. A client that stopped ticking for forty ticks holds sixteen and is back within depth ten ticks after it resumes.

The two existing wire checks now compare against the host's world at the tick the client applied, on the ticks it applied one, which is the property they were always after. The bad-wire check gains "the world never went back in time" and "lost snapshots were coasted through" (393 of 1200 at 30% loss). The prediction check's "trails by the latency" became a signed along-track measurement: the predicted hull flies 16 units ahead of where the host has it, the unpredicted one 17 behind.

check:sim 548 → 567, check:balance holds, check:mutants 91 of 91 caught cleanly (+7), npm run build clean.

Not here

An interpolation delay adapted to measured jitter rather than grown from it; coasting orientation; the host-side twin (a peer's intents are still flown as they arrive). Milestone 8 (match rules / scoring / kill feed) is next, on top of this.

🤖 Generated with Claude Code

sbddesign and others added 2 commits September 2, 2026 17:24
The first two-device test of #25/#26 connected, flew, and was jerky on the
joined player while the host was smooth. The host's pairs of poses advance
exactly once per tick by construction. The client's did not: it applied
every snapshot the moment it arrived, and the frame then blended between
the last two poses at a factor taken from the *local* clock -- so a pair
that advanced twice between frames jumped, and one that had not advanced
slid backwards.

Now the client applies one snapshot per tick of its own. `createClient`
holds what arrives early in a queue sorted by tick; a tick that finds
nothing to apply coasts (`Game.coast`, new: every hull and bolt the mirror
does not fly itself, carried one tick along its last velocity -- the same
integration `Ship.step` and the bolt pool do -- which on straight flight
lands where the missing snapshot would have); a gap at the head of the
queue is coasted once before it is applied, so a snapshot reordered past
its predecessor waits for it instead of skipping it, and a lost one is a
coast rather than a jump; and a queue deeper than `SNAPSHOT_DEPTH` (four
ticks) drains two a tick, bounded at `SNAPSHOT_QUEUE`. The queue fills only
to the depth the wire's jitter needs, because every late arrival leaves
one more waiting the tick after. `pace: false` keeps the old behaviour so
the difference is measurable, and `main.ts` is untouched: the joiner
already ticked its client once per step.

Measured in `simcheck` on the host's own hull as the client draws it, over
a loopback with two ticks of latency, three of jitter and 5% duplicates,
against the host's own per-tick motion so a real event (two hulls
bouncing) is not mistaken for the wire. Applied on arrival: 319 stalls and
222 jumps in 1200 ticks. Paced: none of either; whatever was applied is
still the host's world byte for byte (1195 ticks; the seat the client
flies itself is predicted ahead and masked); the one coasted tick was
within 0.005 units of the snapshot it stood in for; the queue settled
three deep by tick 300 and stayed there; the picture never trailed the
wire by more than latency plus jitter. A deterministic probe loses exactly
one snapshot on a perfect wire with the host flying straight: the hull
moves 5.86 units to within 0.002 of where that snapshot had it, the eight
bolts in flight likewise, and the tick after is the host's world again. A
client that stopped ticking for forty ticks holds sixteen and is back
within depth ten ticks after it resumes.

The two existing wire checks now compare against the host's world at the
tick the client applied, on the ticks it applied one, which is the
property they were always after (the same-iteration comparison was an
accident of applying on arrival). The bad-wire check gains "the world
never went back in time" and "lost snapshots were coasted through" (393
of 1200 at 30% loss). The prediction check's "trails by the latency"
became a signed along-track measurement: the predicted hull flies 16
units ahead of where the host has it, the unpredicted one 17 behind.

Simulation checks 548 -> 566, seven new mutations.

Not here: an interpolation delay adapted to measured jitter rather than
grown from it, coasting orientation (turn rate is not sent), and the
host-side twin -- a peer's intents are still flown as they arrive.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Stephen DeLorme <stephen@d.elor.me>
…own tick

Applying moved from the wire's handler, where a throwing `Game.apply` was
already caught and counted, into the client's tick, where it was not. The
mutation gate found it: with respawn forced off the host match resolves,
`finish` clears the roster, and the host keeps sending snapshots of nobody
-- which the paced client now applied outside any catch and took the
whole suite down with. Caught, counted as malformed, and the tick goes
on; a check sends a one-seat snapshot to a two-seat client to say so.
Also re-anchors one existing mutation to the moved replay-window line.

Simulation checks 566 -> 567.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Stephen DeLorme <stephen@d.elor.me>
@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
neon-orbit Ready Ready Preview Sep 2, 2026 9:35pm UTC

Request Review

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