/** * Everybody else, drawn between the things the server said about them. * * A remote body is only ever heard about a few dozen times a second, so it * has to be drawn somewhere in between. The old way was an exponential chase * — `now.x += (x - now.x) * 0.35` every frame — which is wrong twice: it is a * different speed at 60Hz than at 144Hz, and it is a pursuit that never * arrives, so every other player was permanently behind and rubbery. * * This is entity interpolation instead. Each body keeps the last few samples * with the times they landed, and the frame is drawn at `now - delay`, about * a snapshot interval in the past. That moment falls between two samples on * hand, so the body moves at exactly the speed the server moved it and * arrives exactly where the server put it — one interval late, which nobody * can see. A handful of samples rather than strictly two is what buys the * delay a margin: a snapshot that arrives late or early still brackets the * moment being drawn instead of stalling the body. * * When a snapshot really is missed the newest pair's own velocity is carried * forward for one interval and then held: a guess that long is better than a * stall, and a guess longer than that is fiction. The delay follows the rate * snapshots actually arrive at, so the same code is right at 15Hz and at * 30Hz without being told which it is. * * Nothing here is ever sent anywhere: it is a drawing, and the server is * still the only thing that says where anybody is. */ /** The render delay, as a multiple of the interval snapshots arrive at. */ export const LAG = 1.3; /** ...never tighter than this, never looser, in seconds. */ export const LAG_MIN = 0.045; export const LAG_MAX = 0.13; /** How many samples a body keeps: enough to bracket the delay, and no more. */ export const KEEP = 4; /** How fast the measured interval follows the arrivals. */ const INTERVAL_PULL = 0.1; /** How fast the even clock snapshots are filed under is pulled toward the arrivals. */ const STAMP_PULL = 0.1; /** How much of the even clock's lateness is read as the interval being wrong. */ const INTERVAL_TRIM = 0.02; /** How far the moment drawn may run fast or slow to reach its delay: a share of real time. */ export const SLEW = 0.02; /** A render clock this far off its mark is not late, it is stopped: start it again. */ const RESYNC = 0.25; /** A gap this long means the body was gone, not slow: start it again. */ export const GONE = 0.4; /** Faster than anything can run: a respawn, or a body back from nowhere. */ const TOP_SPEED = 400; const clamp = (n, low, high) => (n < low ? low : n > high ? high : n); /** The short way round from one angle to another, in (-π, π]. */ export const shortWay = (from, to) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI; /** * One timeline for the whole snapshot stream: the bodies, the rate, the even * clock (`stamp`) and the moment being drawn (`drawAt`). `restarts` counts * the times a body was started again rather than walked: every one is a jump. */ export const makeTimeline = (interval = 1 / 30) => ({ bodies: new Map(), interval, at: 0, stamp: 0, drawAt: 0, drawnNow: 0, restarts: 0, }); /** * A snapshot landed at `at` seconds. The gap since the last one is what the * render delay is built out of; a gap wilder than any real rate (a tab that * was in the background, a reconnect) says nothing about the rate and is * left out of it. * * Returns the time the snapshot is filed under, which is not the time it * landed. The server sends thirty a second, evenly; the network hands them * over in a rhythm of its own, a few milliseconds early or late, sometimes * two nearly together. A sample filed under its arrival time carries that * wobble into the walk between samples — a body drawn between a sample that * came early and one that came late moves slow, then fast — and into the * age of your own snapshot, which is how far ahead your own keys are * carried. So arrivals are filed on an even clock instead: one interval * after the last, pulled a tenth of the way toward when they really came so * it never drifts. A rate is not a rhythm; this is the rhythm. */ export function arrive(timeline, at) { const gap = at - timeline.at; const steady = timeline.at > 0 && gap > 1 / 120 && gap < 0.5; if (steady) timeline.interval += (gap - timeline.interval) * INTERVAL_PULL; const expected = timeline.stamp + timeline.interval; const off = at - expected; // Within an interval of the beat, keep the beat; further out (a pause, a // reconnect, the rate itself changing) the arrival is the only clock there is. let stamp = at; if (timeline.at > 0 && gap < 0.5 && Math.abs(off) < timeline.interval) { stamp = expected + off * STAMP_PULL; // Arrivals that keep landing on the same side of the beat mean the beat // is wrong, not late: trim the interval by a little of the lateness. timeline.interval += off * INTERVAL_TRIM; } // A sample is never filed before the one before it, and never more than // half a beat before it actually came: a burst of snapshots (a tab coming // back from the background) must not walk the clock off into the future. if (stamp <= timeline.stamp) stamp = timeline.stamp + timeline.interval / 4; stamp = Math.max(Math.min(stamp, at + timeline.interval / 2), timeline.stamp); timeline.at = at; timeline.stamp = stamp; return stamp; } /** How far behind the present this timeline is drawn, in seconds. */ export const delay = (timeline) => clamp(timeline.interval * LAG, LAG_MIN, LAG_MAX); /** * The moment being drawn, given the clock now. * * Not simply `now - delay`: the delay follows the measured rate, and a delay * that moves between frames moves the moment being drawn with it, so a * frame after a late snapshot drew twelve milliseconds of motion and the * next nineteen — the beat of the wire showing through as a stutter. The * moment drawn now advances at real time, and creeps toward where the delay * says it should be at no more than two percent of that: a speed nobody can * see. Only a clock a quarter second off — a tab that was in the background, * the first frame — is started again where it belongs. */ export function renderTime(timeline, now) { const want = now - delay(timeline); const dt = now - timeline.drawnNow; timeline.drawnNow = now; const ahead = timeline.drawAt + dt; if (timeline.drawAt === 0 || dt <= 0 || dt > RESYNC || Math.abs(want - ahead) > RESYNC) { timeline.drawAt = want; return want; } timeline.drawAt = ahead + clamp(want - ahead, -dt * SLEW, dt * SLEW); return timeline.drawAt; } const mark = (at, body) => ({ t: at, x: body.x, y: body.y, angle: body.angle }); /** * What the server just said about one body. Out-of-order samples are * dropped; a body that has been away, or that has moved further than anyone * can run, starts fresh rather than sliding across the map to its new place. * * The drawn position lives on the track — one object per body, so a caller * may hold the answer it was given while asking about somebody else. */ export function feed(timeline, id, body, at) { const track = timeline.bodies.get(id); if (!track) { timeline.bodies.set(id, { marks: [mark(at, body)], out: { x: 0, y: 0, angle: 0 } }); return timeline; } const last = track.marks[track.marks.length - 1]; if (at <= last.t) return timeline; const gap = at - last.t; // Two samples filed close together are still a tick apart on the server, // so a body is allowed at least an interval's run between them. const jumped = Math.hypot(body.x - last.x, body.y - last.y) > TOP_SPEED * Math.max(gap, timeline.interval) + 4; if (gap > GONE || jumped) { track.marks.length = 0; timeline.restarts += 1; } track.marks.push(mark(at, body)); if (track.marks.length > KEEP) track.marks.shift(); return timeline; } /** * Where a body was at a moment, given the samples it has — ascending in * time, and at least one. * * Between two samples this is a straight walk from one to the other at the * server's own speed. Before the oldest it holds, because running backwards * to meet a moment we have no answer for would be inventing one. After the * newest it carries the last pair's velocity for one interval — no more, * ever — and then holds there. */ export function sample(marks, when, out = { x: 0, y: 0, angle: 0 }) { const last = marks[marks.length - 1]; if (marks.length === 1 || when >= last.t) return beyond(marks, when, out); if (when <= marks[0].t) return at(marks[0], marks[0], 0, out); let i = marks.length - 2; while (i > 0 && marks[i].t > when) i -= 1; const from = marks[i]; const to = marks[i + 1]; const span = to.t - from.t; return at(from, to, span > 0 ? (when - from.t) / span : 1, out); } /** Past the newest sample: one interval of the last pair's velocity, held. */ function beyond(marks, when, out) { const last = marks[marks.length - 1]; const before = marks[marks.length - 2]; if (!before) return at(last, last, 0, out); const span = last.t - before.t; if (span <= 0) return at(last, last, 0, out); return at(before, last, clamp((when - before.t) / span, 1, 2), out); } /** A point `u` of the way from one sample to another; the turn is short. */ function at(from, to, u, out) { out.x = from.x + (to.x - from.x) * u; out.y = from.y + (to.y - from.y) * u; out.angle = from.angle + shortWay(from.angle, to.angle) * u; return out; } /** * Where to draw a body out of a snapshot. One with no samples yet — the * first frame it is seen on — is drawn where the server says, which is the * only honest answer available. */ export function bodyAt(timeline, body, when, out = null) { const track = timeline.bodies.get(body.id); if (!track) return body; return sample(track.marks, when, out ?? track.out); }