DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

The Cursor Jumps 28 Pixels or Glides 4: Same Packets, One Delayed Clock

A multiplayer cursor looks like a drawing problem. It is a timing problem.

Everyone else in the file is a stream of sparse positions that arrive late, out of order, and sometimes not at all. The screen redraws about sixty times a second. The positions do not. Almost every frame you paint is therefore something you made up, and the whole craft of the feature is in how you make it up.

👉 Live, with every table computed in your browser by the same engine: https://dev48.in/design/day83-presence-cursors.html

🔴 The wire is simulated

There is no socket here. A seeded transport models a 90 ms latency floor, 100 ms of uniform jitter and a 12% drop rate, and the clock is an explicit fixed 16 ms step, so the replay is a pure function of (seed, settings) and reproduces byte for byte. The digest over every rendered position of every frame is 31da09e9 on every machine.

What that costs: the point estimates below are properties of this draw. What transfers is the shape — that jitter wider than the send interval reorders packets at all, that a leading-edge throttle strands the last sample — follows from how clocks and queues work, not from the seed.

240 frames, 3824 ms, 4 peers, 240 packets sent, 202 delivered, 34 dropped.

Teleporting versus gliding, from identical packets

Assign the newest packet straight to the element and the cursor sits still, then hops. Interpolate between the last two samples and it never stops moving.

Same replay, same 202 delivered packets. The only difference is whether the frames between two packets are drawn or skipped:

drawing strategy worst single-frame jump
newest packet, assigned to the element 28.3 px
interpolated on a delayed clock 4.2 px

6.8× smaller, for free, out of data you already had.

The eye is very good at spotting things that move in discrete hops, because nothing physical does. A cursor that is smoothly 300 ms behind reads as presence. A cursor that is exactly right in lurches reads as a bug.

Why the renderer has to run in the past

To interpolate between two samples you need the second one, and it has not arrived yet. So the renderer draws what the world looked like a moment ago.

Sweeping that one number over the whole replay:

render delay interpolated held (buffer ran dry) worst jump
0 ms 0.0% 100.0% 28.3 px
100 ms 1.9% 98.1% 28.3 px
180 ms 57.0% 42.1% 18.3 px
220 ms 80.6% 17.7% 14.1 px
260 ms 92.7% 4.3% 8.8 px
300 ms 94.7% 1.3% 4.2 px

At zero delay there is nothing in front of the render time, ever, so every frame holds the last known point — which is the hopping you were trying to remove, dressed up as interpolation. The delay has to clear the latency floor plus the jitter plus one send interval before any of this works at all. Below 180 ms, almost none of it does.

Throttling is coalescing, not sampling

A pointer fires once a frame. Sixty-plus messages a second per person, through a socket that also carries the edits.

The slot matters more than the interval: push never sends, it overwrites a single pending position, so what finally goes out is where the pointer is, not where it happened to be at a tick boundary.

63 pointer events on a 16 ms clock, one second:

interval messages sent coalesced traffic cut min gap
0 ms 63 0 0% 16 ms
33 ms 21 41 67% 48 ms
50 ms 16 46 75% 64 ms
100 ms 9 53 86% 112 ms
200 ms 5 57 92% 208 ms

63 events become exactly 16 messages. Nothing visible changes, because the interpolator fills the gaps.

The trailing edge is the half everyone forgets

A leading-edge throttle fires at once and then ignores everything until the interval is up. Stop the pointer inside that window — which is what happens every single time somebody finishes a gesture and parks on a word — and the final position is sitting in the pending slot when the events stop.

Nothing ever sends it.

A pointer moving 0 → 60 → 130 → 190 → 220 and stopping at t=48 ms, with a 50 ms throttle:

throttle last x reported truth error
leading edge only 0 220 220 px, forever
leading + trailing 220 220 0 px

The trailing emission landed at t=60 ms, 12 ms after the pointer stopped. One timer, and the remote cursor lands where the person actually put it.

Jitter reorders, so the receiver distrusts arrival order

With a 50 ms throttle the peers send every 64 ms, and the jitter is 100 ms wide. That is enough for two messages to swap places in flight: 103 packets in this replay were scheduled to land before one sent earlier.

If you append in arrival order, one of those walks the cursor backwards and then forwards again — a visible stutter from a packet that was otherwise harmless. So every message carries the sender's timestamp and the buffer refuses anything not strictly newer. 14 of 202 delivered samples were rejected; 188 made it into a timeline.

Dropped packets need no handling beyond that. A gap is just a longer segment to interpolate across, which is why a lossy cursor channel still looks fine — until it doesn't:

throttle @ loss delivered frames held worst jump
16 ms @ 12% 808 0.0% 9.6 px
50 ms @ 12% 202 1.3% 4.2 px
50 ms @ 35% 143 9.5% 20.3 px
200 ms @ 12% 60 33.0% 64.8 px

🔴 Presence is a timeout, never a leave event

The obvious design: send join on open, leave on close, broadcast both.

Nobody sends leave when the lid shuts, the tunnel drops, or the tab is killed. The ghost cursor still sitting in the document an hour after someone left the meeting is always this bug.

So a peer exists while packets arrive, and is removed when none has for a while. One peer here goes silent at t=1200 ms with no goodbye of any kind, and is evicted at t=2800 ms — one 1500 ms timeout after its last packet. The other three never flicker. There is no leave message in the protocol at all; the store throws on any message that is not a cursor.

Colour is identity, so it cannot come from a counter

Palette plus join counter: first to connect is amber, second is rose. It survives until somebody's connection blips — they drop, the timeout removes them, they reconnect two seconds later, and now they are a different colour. In this five-person room, one reconnect recolours four of the five.

Worse, eight swatches is not enough to be safe anyway. Five people drawing from eight slots collide four times out of five, in the one feature whose entire job is telling people apart.

So the hue comes off a hash of the stable id — FNV-1a with a murmur3 avalanche, because raw FNV-1a puts u-1 and u-2 in neighbouring buckets. 4000 sequential ids land across all twelve 30° zones of the wheel with no zone above 1.4× its share, and the five peers in the demo sit at least 49° apart.

Name tags flip per axis

The tag hangs down and to the right of the hotspot, which is wrong at every edge. Clipping is the worst option — a half-visible name is unreadable and still takes the space.

Compute the preferred box, test each axis against the viewport, flip that axis if it does not fit, clamp only when the tag cannot fit on either side. Checked against a search model that enumerates all four corner candidates, at 71,736 anchors including positions outside the viewport: zero escapes, zero disagreements.

What is checked

The page's engine is lifted out of the HTML and run in Node against a reference model written from the rules rather than the code — a brute-force interpolator that scans the whole buffer, a throttle expressed as a predicate over the tick times, a label placer that searches candidates instead of flipping, an independent hash.

164 verifier asserts. 49 in-page checks. 0 failures.

The replay is the point. Every number above moved while the settings were being chosen, and none of them moved by accident.

Top comments (0)