diff --git a/session-server/oskiewar-live-manager.mjs b/session-server/oskiewar-live-manager.mjs
index 5e63c09437..1738d27ff0 100644
--- a/session-server/oskiewar-live-manager.mjs
+++ b/session-server/oskiewar-live-manager.mjs
@@ -71,6 +71,37 @@ function ball(value) {
finite(value.y) && finite(value.z) && finite(value.radius, 10000);
}
+// Projectiles, as the flat number rows the game packs (see spectatorState).
+// A watcher positions render objects straight off these, so they are bounded
+// here the same way a fighter is: an exact row length, finite coordinates, an
+// owner that is one of the two seats, and a flag word with no spare bits. The
+// counts are the game's own caps — 24 rounds in the air, 12 lobs.
+//
+// shot: x y z previousX previousY vx vy owner flags(spit|heavy|rubber)
+// lob: x y z vx vy owner flags(rocket|exploding) blastRadius fuseMs
+const projectileRows = (value, count, length, check) => {
+ if (value === undefined) return true;
+ return Array.isArray(value) && value.length <= count &&
+ value.every((row) => Array.isArray(row) && row.length === length &&
+ row.every((entry) => finite(entry, 10000000)) && check(row));
+};
+
+const shots = (value) => projectileRows(value, 24, 9, (row) =>
+ integer(row[7], 0, 1) && integer(row[8], 0, 7));
+
+const lobs = (value) => projectileRows(value, 12, 9, (row) =>
+ integer(row[5], 0, 1) && integer(row[6], 0, 3) &&
+ finite(row[7], 100000) && row[7] >= 0 && finite(row[8], 600000) && row[8] >= 0);
+
+// The host's impact track:
+// mark: id x y z durationMs lifeMs flags(death|explosion)
+// A watcher spawns render objects off these and keys duplicate suppression on
+// the id, so the id has to be a real ascending integer, not any finite number.
+const marks = (value) => projectileRows(value, 12, 7, (row) =>
+ integer(row[0], 0, 2147483647) &&
+ integer(row[4], 0, 60000) && integer(row[5], 0, 60000) &&
+ integer(row[6], 0, 3));
+
// How fast the publishing client is actually drawing. @jeffrey plays in a
// browser on an Xbox, which has no devtools, so this is the only way to read a
// console's frame rate from anywhere else. Every field is optional because a
@@ -117,8 +148,13 @@ export function validateOskiewarLiveState(value) {
!integer(value.wind.mph, 0, 200))) return "Invalid wind";
if (!value.round || typeof value.round !== "object" ||
!integer(value.round.remainingMs, 0, 3600000) ||
+ (value.round.elapsedMs !== undefined &&
+ !integer(value.round.elapsedMs, 0, 3600000)) ||
typeof value.round.result !== "string" || value.round.result.length > 80)
return "Invalid round";
+ if (!shots(value.shots)) return "Invalid shots";
+ if (!lobs(value.lobs)) return "Invalid lobs";
+ if (!marks(value.impacts)) return "Invalid impacts";
if (!perf(value.perf)) return "Invalid performance";
if (value.replayUrl !== undefined &&
!/^\/api\/oskiewar-replays\?id=ow-[a-z0-9-]+$/.test(value.replayUrl))
@@ -132,6 +168,15 @@ function send(ws, type, content) {
catch { return false; }
}
+// A frame already serialized. The state fan-out is the one place in this file
+// where the same bytes go to up to sixty-nine sockets, and stringifying the
+// whole 2 KB payload once per watcher was most of the cost of a full room.
+function sendRaw(ws, frame) {
+ if (ws?.readyState !== 1) return false;
+ try { ws.send(frame); return true; }
+ catch { return false; }
+}
+
export class OskiewarLiveManager {
constructor({
now = () => Date.now(),
@@ -181,7 +226,8 @@ export class OskiewarLiveManager {
room = { matchId, publisher: null, publisherSurface: "unknown",
challenger: null, viewers: new Set(), agents: new Set(), state: null,
liveStarted: false, updatedAt: this.now(), publishedAt: 0,
- nudgedAt: 0, flaggedAt: 0, inputAt: 0 };
+ nudgedAt: 0, flaggedAt: 0, inputAt: 0, inputDown: null,
+ flushTimer: null, flushedSeq: -1 };
this.rooms.set(matchId, room);
}
if (role === "publisher") this.addPublisher(room, ws, surface);
@@ -220,6 +266,11 @@ export class OskiewarLiveManager {
room.state = null;
room.publishedAt = 0;
}
+ // A frame still waiting on the trailing flush belongs to the host that
+ // just left. It must not land on the room after the successor's first.
+ clearTimeout(room.flushTimer);
+ room.flushTimer = null;
+ room.flushedSeq = -1;
room.publisher = ws;
room.publisherSurface = surface;
room.updatedAt = this.now();
@@ -354,7 +405,15 @@ export class OskiewarLiveManager {
if (input.colors !== undefined && (!Array.isArray(input.colors) ||
input.colors.length > 4 || !input.colors.every(color))) return;
const now = this.now();
- if (now - room.inputAt < MIN_INPUT_INTERVAL_MS) return;
+ // The 15 ms floor exists to pace a stick, which moves continuously. A
+ // button edge is a discrete event and must never be the thing it drops:
+ // the client marks a frame as sent the moment the socket takes it, so a
+ // press the relay swallowed here was invisible to both ends until the
+ // next change or the idle heartbeat.
+ const buttons = input.down.join(" ");
+ const held = buttons === room.inputDown;
+ if (held && now - room.inputAt < MIN_INPUT_INTERVAL_MS) return;
+ room.inputDown = buttons;
room.inputAt = now;
room.updatedAt = now;
send(room.publisher, "oskiewar:input", { seq: input.seq,
@@ -430,11 +489,43 @@ export class OskiewarLiveManager {
const invalid = validateOskiewarLiveState(state);
if (invalid) return send(ws, "oskiewar:error", { message: invalid });
const now = this.now();
- if (now - room.publishedAt < MIN_PUBLISH_INTERVAL_MS) return;
if (room.state && state.seq <= room.state.seq) return;
room.state = state;
- room.publishedAt = now;
room.updatedAt = now;
+ // The 25 ms floor coalesces a burst. It must not throw the burst away,
+ // and it used to: the gate ran BEFORE the store, so of two frames landing
+ // in one instant — which is exactly what a host's catch-up tick emits —
+ // the older one went out to the room and the newer one was gone for good.
+ // Measured against the real relay that was a quarter of all frames on a
+ // wired link and well over half on a busy one, and it fell hardest at the
+ // moments the fight was busiest. Now the newest frame is always the one
+ // kept, and a trailing flush hands it over a few milliseconds late.
+ // Coalescing is allowed to cost latency. That is what it is for. It is
+ // not allowed to cost information.
+ if (now - room.publishedAt < MIN_PUBLISH_INTERVAL_MS) {
+ this.armFlush(room);
+ return;
+ }
+ this.flush(room);
+ }
+
+ // One trailing timer per room, never a queue of them: whatever is newest
+ // when it fires is what the room gets. Unreferenced, because a pending
+ // frame is not a reason to keep a process alive.
+ armFlush(room) {
+ if (room.flushTimer) return;
+ room.flushTimer = setTimeout(() => {
+ room.flushTimer = null;
+ if (this.rooms.get(room.matchId) === room) this.flush(room);
+ }, MIN_PUBLISH_INTERVAL_MS);
+ room.flushTimer.unref?.();
+ }
+
+ flush(room) {
+ const state = room.state;
+ if (!state || state.seq === room.flushedSeq) return;
+ room.flushedSeq = state.seq;
+ room.publishedAt = this.now();
if (!room.liveStarted) {
room.liveStarted = true;
this.analytics.capture("live_started", {
@@ -443,8 +534,8 @@ export class OskiewarLiveManager {
phase: state.phase,
});
}
- for (const watcher of this.watchers(room))
- send(watcher, "oskiewar:state", state);
+ const frame = JSON.stringify({ type: "oskiewar:state", content: state });
+ for (const watcher of this.watchers(room)) sendRaw(watcher, frame);
}
// Agents read the frame numbers out of the same state payload a phone gets,
@@ -452,9 +543,12 @@ export class OskiewarLiveManager {
// a watcher too — their own fighter reaches them the same way it reaches
// the grandstand.
*watchers(room) {
+ // The challenger first. They are playing the fight; everyone after them
+ // is watching it, and a seat that waited behind sixty-four spectators for
+ // its own fighter was paying for their view.
+ if (room.challenger) yield room.challenger;
yield* room.viewers;
yield* room.agents;
- if (room.challenger) yield room.challenger;
}
status(room) {
@@ -495,8 +589,11 @@ export class OskiewarLiveManager {
const oldest = this.now() - ROOM_TTL_MS;
for (const [matchId, room] of this.rooms) {
if (!room.publisher && !room.challenger && room.viewers.size === 0 &&
- room.agents.size === 0 && room.updatedAt < oldest)
+ room.agents.size === 0 && room.updatedAt < oldest) {
+ clearTimeout(room.flushTimer);
+ room.flushTimer = null;
this.rooms.delete(matchId);
+ }
}
}
}
diff --git a/session-server/oskiewar-live-manager.test.mjs b/session-server/oskiewar-live-manager.test.mjs
index 3e2b51aa16..f46f026ce2 100644
--- a/session-server/oskiewar-live-manager.test.mjs
+++ b/session-server/oskiewar-live-manager.test.mjs
@@ -85,21 +85,63 @@ test("a fixed match broadcast fans out and caches state for late spectators", ()
assert.equal(late.sent.at(-1)?.content.seq, 1);
});
-test("duplicate and over-rate publisher frames are dropped", () => {
+// Coalescing is allowed to cost latency. It is not allowed to cost
+// information — and for a long time it cost both: the rate gate ran before
+// the store, so a burst of two frames in one instant (a host catch-up tick,
+// or a link stall batching them) delivered the OLDER one and lost the newer
+// for good. Against the real relay that was 25% of frames on a wired link
+// and 57% on a busy one. The newest frame now always survives the gate.
+test("over-rate publisher frames coalesce to the newest, and duplicates drop", () => {
let now = 100;
const manager = new OskiewarLiveManager({ now: () => now });
const host = new FakeSocket(), viewer = new FakeSocket();
const url = "/oskiewar-live?match=bafegu-dorimi-kunapo";
manager.handleConnection(host, { url: `${url}&role=publisher` });
manager.handleConnection(viewer, { url });
+ const publish = (seq) => host.emit("message",
+ Buffer.from(JSON.stringify({ type: "oskiewar:state", content: state(seq) })));
now += 30;
- host.emit("message", Buffer.from(JSON.stringify({ type: "oskiewar:state", content: state(1) })));
+ publish(1);
const count = viewer.sent.length;
+ assert.equal(viewer.sent.at(-1).content.seq, 1);
+ // Three more inside the 25 ms floor: nothing goes out yet, and what is held
+ // is the last of them, not the first.
+ now += 1;
+ publish(2);
now += 1;
- host.emit("message", Buffer.from(JSON.stringify({ type: "oskiewar:state", content: state(2) })));
+ publish(3);
+ now += 1;
+ publish(4);
+ assert.equal(viewer.sent.length, count, "the burst is coalesced, not fanned out");
+ const room = manager.rooms.get("ow-bafegu-dorimi-kunapo");
+ assert.equal(room.state.seq, 4, "and the newest is what is held");
+ // A frame the room has already passed is a duplicate whenever it arrives.
now += 30;
- host.emit("message", Buffer.from(JSON.stringify({ type: "oskiewar:state", content: state(1) })));
- assert.equal(viewer.sent.length, count);
+ publish(2);
+ assert.equal(room.state.seq, 4);
+ // The trailing flush hands the room whatever is newest when it fires.
+ manager.flush(room);
+ assert.equal(viewer.sent.length, count + 1);
+ assert.equal(viewer.sent.at(-1).content.seq, 4);
+ // And it fires once for that frame, however often it is asked.
+ manager.flush(room);
+ assert.equal(viewer.sent.length, count + 1);
+});
+
+test("the seat hears its own fighter before the grandstand does", () => {
+ const manager = new OskiewarLiveManager({ now: () => 1000 });
+ const host = new FakeSocket(), rival = new FakeSocket(), fan = new FakeSocket();
+ const url = "/oskiewar-live?match=sezzi7";
+ manager.handleConnection(host, { url: `${url}&role=publisher` });
+ manager.handleConnection(fan, { url });
+ manager.handleConnection(rival, { url: `${url}&role=challenger` });
+ const order = [];
+ for (const [name, socket] of [["fan", fan], ["rival", rival]])
+ socket.send = ((inner) => (data) => { order.push(name); inner.call(socket, data); })(socket.send);
+ const room = manager.rooms.get("ow-sezzi7");
+ room.state = state(9);
+ manager.flush(room);
+ assert.deepEqual(order, ["rival", "fan"]);
});
test("a second live publisher cannot take over a match", () => {
@@ -222,11 +264,23 @@ test("bent or over-rate challenger input is dropped in silence", () => {
assert.equal(host.sent.length, count);
press({ seq: 5, down: ["A"], leftX: 0, leftY: 0 });
assert.equal(host.sent.length, count + 1);
- press({ seq: 6, down: ["B"], leftX: 0, leftY: 0 });
+ // The 15 ms floor paces a stick, which moves continuously — so the same
+ // buttons arriving again inside it are dropped, leftX and all.
+ press({ seq: 6, down: ["A"], leftX: .5, leftY: 0 });
assert.equal(host.sent.length, count + 1);
- now += 30;
+ // A button edge is not a stick. The client marks a frame sent the moment
+ // the socket takes it, so a press swallowed here was invisible to both ends
+ // until the next change or the idle heartbeat a quarter of a second later.
press({ seq: 7, down: ["B"], leftX: 0, leftY: 0 });
- assert.equal(host.sent.length, count + 2);
+ assert.equal(host.sent.length, count + 2, "a press goes through at once");
+ assert.deepEqual(host.sent.at(-1).content.down, ["B"]);
+ press({ seq: 8, down: [], leftX: 0, leftY: 0 });
+ assert.equal(host.sent.length, count + 3, "and so does the release");
+ press({ seq: 9, down: [], leftX: 0, leftY: 0 });
+ assert.equal(host.sent.length, count + 3, "holding nothing is still a hold");
+ now += 30;
+ press({ seq: 10, down: [], leftX: 0, leftY: 0 });
+ assert.equal(host.sent.length, count + 4);
});
test("live rooms emit minimized server milestones once", () => {
@@ -416,6 +470,54 @@ test("published frame timing is bounded and closed to unknown keys", () => {
"Invalid performance");
});
+test("projectiles and impacts ride out bounded, and a full frame still fits", () => {
+ const shot = [820, -140, 0, 750, -140, 4200, 0, 1, 4];
+ const lob = [910, -80, 0, 1950, -400, 0, 1, 0, 1150];
+ assert.equal(validateOskiewarLiveState({ ...state(), shots: [], lobs: [] }), null);
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ shots: Array.from({ length: 24 }, () => shot),
+ lobs: Array.from({ length: 12 }, () => lob) }), null);
+ // The caps are the game's own, and a row is an exact shape: a watcher
+ // positions render objects off these numbers without re-checking them.
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ shots: Array.from({ length: 25 }, () => shot) }), "Invalid shots");
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ shots: [shot.slice(0, 8)] }), "Invalid shots");
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ shots: [[...shot.slice(0, 8), 8]] }), "Invalid shots");
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ shots: [[...shot.slice(0, 7), 2, 4]] }), "Invalid shots");
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ shots: [[NaN, ...shot.slice(1)]] }), "Invalid shots");
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ lobs: Array.from({ length: 13 }, () => lob) }), "Invalid lobs");
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ lobs: [[...lob.slice(0, 7), -1, 1150]] }), "Invalid lobs");
+ // The host's impact track, whose ids are what let a watcher tell a repeated
+ // mark from a new one — so an id has to be a real ascending integer, not
+ // any finite number.
+ const mark = [9999, 5200, 11940, 0, 550, 520, 1];
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ impacts: Array.from({ length: 12 }, () => mark) }), null);
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ impacts: Array.from({ length: 13 }, () => mark) }), "Invalid impacts");
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ impacts: [[1.5, ...mark.slice(1)]] }), "Invalid impacts");
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ impacts: [[...mark.slice(0, 6), 4]] }), "Invalid impacts");
+ assert.equal(validateOskiewarLiveState({ ...state(),
+ impacts: [mark.slice(0, 6)] }), "Invalid impacts");
+ // A full sky is the frame the relay has to carry, so it is measured here
+ // rather than discovered in play: the size cap turns a busy fight away
+ // silently, and a watcher would see the projectiles stop, not an error.
+ const full = JSON.stringify({ ...state(),
+ shots: Array.from({ length: 24 }, () => shot),
+ lobs: Array.from({ length: 12 }, () => lob),
+ impacts: Array.from({ length: 12 }, () => mark) });
+ assert.ok(Buffer.byteLength(full) < 8192,
+ `a full sky is ${Buffer.byteLength(full)} bytes`);
+});
+
test("the rollback lane's packets pass seat to seat and never reach the grandstand", () => {
const manager = new OskiewarLiveManager({ now: () => 1000 });
const host = new FakeSocket(), rival = new FakeSocket(), fan = new FakeSocket();
diff --git a/xbox/live/mac-test.html b/xbox/live/mac-test.html
index a21bec8c53..7bc3ac9fdf 100644
--- a/xbox/live/mac-test.html
+++ b/xbox/live/mac-test.html
@@ -1012,17 +1012,23 @@
return true;
} catch { return false; }
};
+ // `payload` arrives as JSON the game already serialized. It used to be
+ // parsed to read one field, parsed again to be re-wrapped, and
+ // stringified back — four passes over the whole state per publish, on a
+ // console, thirty times a second. The frame is a string concatenation now
+ // and the one field it needs is read as text: `nextRoundId` is only ever
+ // emitted when it is a real room id.
+ const liveFrame = (payload) =>
+ '{"type":"oskiewar:state","content":' + payload + "}";
function publishLive(matchId, payload) {
- let retiresRoom = false;
- try { retiresRoom = Boolean(JSON.parse(payload).nextRoundId); } catch {}
+ const retiresRoom = /"nextRoundId":"ow-/.test(payload);
let state = liveSockets.get(matchId);
if (!state) {
const socket = new WebSocket(`wss://session-server.aesthetic.computer/oskiewar-live?match=${encodeURIComponent(matchId)}&role=publisher&surface=web`);
state = { socket, pending: payload, retire: retiresRoom };
liveSockets.set(matchId, state);
socket.addEventListener("open", () => {
- if (state.pending) socket.send(JSON.stringify({ type: "oskiewar:state",
- content: JSON.parse(state.pending) }));
+ if (state.pending) socket.send(liveFrame(state.pending));
state.pending = "";
if (state.retire) setTimeout(() => socket.close(1000, "next round"), 0);
});
@@ -1095,7 +1101,7 @@
}
state.retire ||= retiresRoom;
if (state.socket.readyState === WebSocket.OPEN) {
- state.socket.send(JSON.stringify({ type: "oskiewar:state", content: JSON.parse(payload) }));
+ state.socket.send(liveFrame(payload));
if (state.retire) setTimeout(() => state.socket.close(1000, "next round"), 0);
} else state.pending = payload;
}
diff --git a/xbox/live/oskiewar-multiplayer.md b/xbox/live/oskiewar-multiplayer.md
index db56621445..db803871a8 100644
--- a/xbox/live/oskiewar-multiplayer.md
+++ b/xbox/live/oskiewar-multiplayer.md
@@ -10,6 +10,17 @@ streams it to the other. Jump to "Part two" for that. The streamed lane below
still exists and still matters: it is what spectators watch, and it is the
fallback whenever one side is an older build or the net channel is blocked.
+**Part three (2026-09-11) rebuilt the streamed lane's watching half**, and
+closes every item part one left open except local prediction. Projectiles and
+the host's impact track are on the wire, the relay no longer discards the
+newest frame of a burst, the grandstand interpolates between two real frames
+instead of extrapolating past one, a watcher has the match HUD and the round's
+own clock, button edges no longer wait on a floor written for a stick, a
+denied chair actually reaches the grandstand, a desync is repaired instead of
+counted, and both lanes draw a connection meter. Part one below is the
+diagnosis as it stood on 2026-09-09; where it says a thing is missing, check
+part three before believing it.
+
---
# Part one: the streamed lane, and why the guest lagged
@@ -115,7 +126,9 @@ camera pose, `wind`, `round {remainingMs, timed, result, cause}`. About
Not on the wire but read by the guest: `hit`, `blockFlash`, `attackTicks`
(`:3388-3393`). Not on the wire at all: bullets, grenades, detached parts,
-impacts, ammo, command stream, stance, hit stun.
+impacts, ammo, command stream, stance, hit stun. *(Part three put `hit`,
+`blockFlash`, `attackTicks`, bullets and grenades on the wire; detached parts,
+impacts, ammo, command stream, stance and hit stun are still host-only.)*
**Pads (guest → host)** `sendChallengerInput` (`:3530-3556`), called every
sim tick. Sends `{seq, down[], leftX, leftY, name, colors}` when the pad
@@ -301,8 +314,9 @@ Items 3, 4, 5 and 8 below are answered by the rollback lane in part two: the
guest now has the HUD and the intro because it runs the round itself, it does
not interpolate a stream because there is no stream to interpolate, it sees
every effect because it spawns them, and its own fighter is locally
-simulated. Items 1, 2, 6 and 7 are still open and still apply to spectators,
-who continue to watch the streamed lane.
+simulated. Part three answers 1 through 7 for spectators and for the streamed
+guest. Item 8's first half — local prediction of the guest's own fighter — is
+the only one left, and it is moot on the lane that superseded it.
## The original ranking, kept for the spectator lane
@@ -497,3 +511,310 @@ ticks holding for each other and read like a netcode fault that is not one.
against that baseline rather than trusting the exit code.
- Prior two-window recording method and the 2026-09-07 numbers are in the
session memory note `oskiewar-multiplayer-e2e`.
+
+---
+
+# Part three: the streamed lane, watched (2026-09-11)
+
+@jeffrey: "i notice that projectiles in oskiewar dont show up across
+multiplayer matches", then "we need to make sure physics has strong rendering
+sync etc", then "can we show a connection health like wifi meter... on the top
+right of the game", then "so we have an understanding of ping / connection to
+the other player?"
+
+Four changes. The first three are the grandstand's half of part one's ranking
+(items 1, 4, and half of 5); the fourth is new.
+
+## Projectiles are on the wire
+
+`spectatorState` now carries `shots` and `lobs` beside the fighters, as flat
+number rows rather than named objects:
+
+```
+shot: x y z previousX previousY vx vy owner flags(spit|heavy|rubber)
+lob: x y z vx vy owner flags(rocket|exploding) blastRadius fuseMs
+```
+
+Twenty-four rounds and twelve grenades are the game's own caps, and the relay
+validates both shapes (`shots`/`lobs` in `validateOskiewarLiveState`). A frame
+carrying a full sky measures 2.4 KB against the relay's 8 KiB cap, up from
+1.27 KB for an empty one. A frame with no rows at all is an older host or a
+stored demo, and the watcher leaves its arrays alone rather than clearing
+them; an empty row set is a host saying the air is clear.
+
+Velocity rides along so the playout buffer can carry a round smoothly between
+arrivals instead of stepping it 140 units at each one. Projectiles have no
+identity on the wire, so a watcher rebuilds them each frame rather than
+matching this frame's third bullet to the last frame's third — the match would
+be a guess, and a wrong guess teleports a streak across the arena.
+
+The same frame now also carries `hit`, `blockFlash` and `attackTicks` per
+fighter: three numbers the watcher already knew how to read (`:3388-3393`) and
+had never been sent, which is why the strike spark and the limb burst were
+dead code in LIVE and every swing froze at one canned mid-pose.
+
+`netStateHash` hashes projectile positions now, not just counts. A round whose
+flight differed between two rollback seats killed different fighters, and the
+count matched right up to the frame one seat's bullet connected.
+
+## The relay keeps the newest frame of a burst
+
+`publish()` used to run the 25 ms rate gate **before** the store, so of two
+frames landing in one instant — a host catch-up tick, or a link stall batching
+them — the older one went out and the newer was gone for good. That is the
+whole of the 25-57% loss in part one's tables, and it fell hardest exactly
+when the fight was busiest.
+
+Now the store comes first and a single trailing `setTimeout` (unreferenced,
+one per room, cleared on takeover and on prune) hands the room whatever is
+newest when it fires. The fan-out also serializes the frame once for the whole
+room instead of once per socket, and `watchers()` yields the challenger first:
+a seat that waited behind sixty-four spectators for its own fighter was paying
+for their view. Coalescing is allowed to cost latency. It is not allowed to
+cost information.
+
+The relay is no longer "no timers".
+
+## The grandstand interpolates instead of extrapolating
+
+A frame is no longer painted in the socket callback. It is filed with its
+arrival time (`queueRoundViewerState`), and `updateRoundViewer` reads a playout
+clock held a little behind the newest arrival, then paints the blend of the two
+frames that bracket it (`blendRoundViewerStates`). Continuous quantities
+interpolate; everything discrete — who is alive, which limbs are gone, what
+phase the round is in — comes from the frame already reached, so a fighter
+cannot die before the pose that killed them has finished arriving.
+
+- **One clock.** The queue and the coast fuse are both wall milliseconds.
+ Part one's drift bug — an arrival stamped by `performance.now()` compared
+ against the driver's sim time, so the 150 ms fuse stopped tripping as
+ dropped ticks accumulated — is gone because there is no longer a mixture.
+- **The delay is measured, not fixed.** `gap + 2 × jitter + 8 ms`, clamped to
+ 45-220 ms, and moved at a twentieth per frame so resizing it never
+ time-warps the picture. The playout clock advances on the simulation's own
+ dt and leans toward `now − delay`; pinning it to the wall each tick would
+ replay the network's jitter as motion.
+- **Extrapolation is the exception.** Past the end of the buffer the newest
+ frame stands and bodies coast on the velocity it reported, measured from
+ that frame's own position so the reckoning never compounds, bounded by the
+ same 150 ms horizon as before.
+- **A dark room drops the queue.** A status frame saying `live: false`, a room
+ change, a takeover and a claim all reset it, so nothing stale is blended
+ into whatever timeline arrives next.
+
+## Both lanes draw a connection meter
+
+Four bars and one number, top right, under the round's QR code
+(`netHealth`/`drawNetHealth`). It reports only what the seat it is drawn on can
+actually measure, and draws nothing at all on a lane with no rival:
+
+| Lane | Reading | Where it comes from |
+|---|---|---|
+| Rollback fight | ping, `48MS` | a real round trip, see below |
+| Grandstand or older-build guest | jitter, `±14MS`, plus `9% LOST` when any | arrival spacing and holes in the host's sequence numbers |
+| Host in the streamed lane | age of the last pad, `120MS` | the only thing a host hears from its rival |
+
+Bars: 4 under 50 ms, 3 under 100, 2 under 170, else 1; a desync clamps a
+rollback fight to one bar however fast the wire is, because two machines that
+are no longer playing the same fight do not have a healthy connection.
+
+**Ping, by echo.** Every rollback input packet carries `w`, the sender's own
+wall-clock mark. The rival holds the newest `w` it heard and returns it as `e`
+on its next packet. The round trip is therefore a subtraction in the asking
+seat's **own** clock, so the two machines never have to agree on what time it
+is — which they cannot, and which is why nothing measured this before. It
+reads up to one frame long, because the rival holds the mark until its next
+tick; that is an honest part of what a press costs. `pings` counts
+measurements separately from `pingMs`, so a sub-millisecond LAN round trip
+reads as four bars and `0MS` rather than "no measurement yet".
+
+## The watcher has a HUD, and a round clock that means something
+
+`gameplayStarted` was never true on the watcher boot path, and the whole match
+HUD hangs off it: no round clock, no `VS` / `WAITING FOR HOST` label, no
+"you are ..." seat line, no status tray. That was part one's "no state updates
+on screen when the game starts". It is set now, in the boot branch and in the
+fallback-bridge takeover.
+
+The clock itself needed the wire to say more. A watcher derived
+`roundStartedAt` from `remainingMs`, which is zero on every untimed round — so
+it pinned itself a full round duration in the past, `counting` was never true,
+and the intro card never drew. `spectatorState` now carries `round.elapsedMs`,
+the round's own age with the intro included, and the watcher sets its clock
+from that directly. Joining mid-intro lands mid-intro.
+
+`roundIsTimed()` also answered from the machine it ran on, which for a watcher
+is not in the fight. It now returns the wire's `round.timed` while watching
+live, and `false` while a watcher is still waiting for its first frame —
+otherwise a connected-but-unserved watcher painted a phantom thirty-second
+countdown over somebody else's lobby.
+
+The top-right corner is now measured once (`hudTopRightLeft`). The QR owns it,
+the connection meter sits under the QR or takes the corner when no code is up,
+and the `VS` / `LIVE` label and the update-ready affordance both measure from
+the corner's left edge rather than assuming it is free.
+
+## The watcher runs its own sparks and limbs
+
+The reconstruction from flag edges was already written and had never fired,
+because `hit` and `blockFlash` were not on the wire (part three fixes that
+above) and because `updateResultImpactDebris` ran only inside a demo or a
+hosted sim. A watcher's `impacts` array therefore grew for the life of the
+connection without ever producing a mote.
+
+- `updateRoundViewerEffects` runs both `updateResultImpactDebris` and the
+ fragment physics on every watched tick.
+- `detachPart` is split: `spawnDetachedPart` throws the pieces,
+ `detachPart` does the simulation's bookkeeping around it. A watcher diffs
+ `removedParts` frame to frame and spawns the pieces for any limb that just
+ went, posed from a copy of the fighter with that limb restored — a body's
+ geometry no longer carries the limbs it has lost, so asking the fighter as
+ it stands returns nothing to throw.
+- `updateDetachedParts(dt, combat)` takes the same switch `updateBullets`
+ already had. A watcher's fragments tumble, settle and draw but damage
+ nobody, and they expire rather than renewing their own life the way a hosted
+ fragment does — scenery that never expires is a leak, and nothing on the
+ wire would ever say to remove it.
+- A fighter who comes back whole is a new round, and takes the wreckage with
+ it.
+
+Nothing here is authoritative and none of it is on the wire. It does not have
+to match the host bit for bit. It has to happen.
+
+## A denied chair reaches the grandstand
+
+Part one's item 7, and the reason none of the above was visible in practice.
+The bridge had one timer slot. A versus room has no stored replay and 404s
+forever, so the 1800 ms replay retry held that slot nearly all the time, and a
+socket closing in that window asked to reconnect and was dropped in silence.
+A third visitor — denied the chair, closed with 4409 — sat on "this match
+already has a challenger" and never joined the room at all. `schedule` now
+takes a named slot (`reconnect`, `replay`) and `open()` clears its own.
+
+Verified with three real browsers on a local instance of the real relay: the
+third shows the host's fighters, their handles, the `live` label and the meter.
+
+## A press is not a stick
+
+Part one's item 6. Two rate floors were written to pace an analog axis that
+moves every frame, and both were swallowing button edges: the client's 33 ms
+gate held a press for up to two frames, and the relay's 15 ms gate dropped one
+outright. The client marks a frame sent the moment the socket takes it
+(`:3554`), so an edge the relay swallowed was invisible at BOTH ends until the
+next change or the quarter-second heartbeat.
+
+A changed `down[]` now leaves at once on the client and passes the relay's
+floor unconditionally; an unchanged frame echoes once after 50 ms before
+settling into the 250 ms heartbeat, so a lost edge has a second chance without
+an ack protocol. Stick drift still waits out the floor, which is what the
+floor was for.
+
+## The host publishes on the wall clock
+
+Part one's item 2. `publishVersus` was gated on 33 ms of SIM time, and the
+driver runs up to four owed ticks in one instant to catch up — so a catch-up
+pump emitted three full state builds in the same millisecond, arriving at the
+relay as a burst it could only coalesce. Catch-up is exactly when the fight is
+busiest. The gate reads `runtime().unixMs` now, so a burst is one frame.
+
+The shell's `publishLive` also parsed the payload to read one field, parsed it
+again to re-wrap it, and stringified it back: four passes over the whole state
+per publish, on a console, thirty times a second. The frame is a string
+concatenation now and `nextRoundId` is read as text.
+
+## The host's impact track rides out
+
+A watcher reconstructing sparks from flag edges covers a punch landing and
+nothing else: a ricochet, a spit splat, a ground pound's crater and a
+grenade's own blast raise no flag on any fighter, so they happened in silence
+on every screen but the host's.
+
+Each impact is numbered as it is spawned (`spawnImpact`), because a stateless
+frame that repeats the same list ten times cannot otherwise say which sparks a
+watcher has already been shown — a position is not an identity. The newest
+twelve ride out as `[id, x, y, z, durationMs, lifeMs, flags]`, the watcher
+spawns only the numbers it has not seen at the age the frame reports, and the
+flag-edge reconstruction stays as the fallback for a host too old to send a
+track. `impactSequence` is in `netSimScalars`: a rollback that re-spawns an
+impact has to re-issue the same number.
+
+A full frame — 24 shots, 12 lobs, 12 marks, both fighters wrecked — measures
+2856 bytes against the relay's 8 KiB cap, and the relay test pins that.
+
+## A desync is repaired by dropping a lane
+
+Two machines no longer playing the same fight used to go on playing their
+different ones; the two people found out when one died on a screen where they
+had not been hit.
+
+The repair is to stop pretending there are two authorities. The host has one,
+so both seats fall back to the lane where the host IS it: the challenger goes
+back to watching the stream with its pads on the wire, and the host resumes
+the streamed versus fight it was already publishing for the grandstand.
+Nothing rewinds, nothing stops, and no state has to fit through a 2 KiB
+channel. The cost is the rollback lane's latency — the honest price of one
+machine having been wrong.
+
+The seat that notices tells the rival (`{t:"desync"}`) so the other does not
+eat the four-second timeout, and both then refuse to open a rollback session
+for sixty seconds. Without that cooldown the challenger's next hello would
+deal a fresh session into the same divergence one second later, forever.
+
+One trap this opened: a rollback seat sends net packets, not pads, so the
+moment the lane closes the host has heard nothing on the pad wire for as long
+as the fight lasted. Read literally that is an empty chair, and an empty chair
+resets the match — turning a repaired desync into a lost score. A three-second
+grace covers the changeover; a rival who has genuinely gone still times out.
+
+## A watcher keeps the round's address
+
+`spectatorQrBox` asked `versusLane()`, a fact about the fight on the local
+machine — so an untimed versus round, watched, lost its code at exactly the
+moment somebody might want to pass it on. A live watcher is now a lane that
+keeps its code up.
+
+## A corpse in the waiting room falls
+
+Not a networking bug, found while looking at one. @jeffrey: "it seems possible
+to die in the waiting room but keep jumping stilll". A dead fighter takes no
+input, but nothing moved it either: the dead branch in `updatePlayer` skips
+every rule including gravity, which is right in a round — the round ends and
+rebuilds the body, and the killcam wants the pose held — but the waiting room
+has no round. A fighter blasted out of a hop kept the hop's height, and its
+jump pose, for the whole two-and-a-half-second respawn beat. `settleCorpse`
+drops it: gravity, the ground under it, no ledges (a corpse must not catch a
+rung it would have caught alive), and then it stops.
+
+## What this did not change
+
+- **Desyncs are repaired by changing lanes, not by resynchronising.** There is
+ still no path that hands a diverged seat the other's state.
+- **Local prediction of the guest's own fighter** (part one, item 8) is still
+ unwritten, and is moot between two current builds: the rollback lane
+ simulates that fighter locally already.
+- **Same engine only**, as part two says. Transcendental functions are not
+ bit-identical across V8, JavaScriptCore and the console's QuickJS, and limb
+ poses are the hitboxes. The desync repair above is now what happens when
+ that bites, instead of two people quietly playing different fights.
+
+## Method
+
+- `npm run xbox:test:oskiewar:netplay` — eleven tests, including the echo ping
+ and the desync repair.
+- `node --test session-server/oskiewar-live-manager.test.mjs` — twenty-one,
+ including the coalescing contract and the projectile row bounds.
+- `node --test xbox/live/tests/oskiewar.test.mjs` — nine new tests here: the
+ published frame carries the shots, a watcher blends two frames rather than
+ snapping, the meter reads the wire it is on, a watcher gets the match HUD
+ and the round's own clock, a watcher runs its own sparks and limbs, the
+ host's impact track rides out, button edges leave at once, a versus host
+ publishes on the wall clock, and a corpse in the waiting room falls.
+ Baseline is 36 failures at HEAD; diff failure names rather than trusting the
+ exit code.
+- `node --test xbox/live/tests/round-room.test.mjs` — five, including the
+ denied chair reaching the grandstand while the replay retry runs.
+- `node xbox/live/tests/netplay-browser.mjs --seconds 10 --shots
` — two
+ real browsers over a local instance of the real relay. The frames it leaves
+ are how the meter was eyeballed.
+- Any `oskiewar.js` edit needs `npm run xbox:burn:oskiewar-social`; the social
+ manifest is hash-bound and a test pins it.
diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js
index 9ac135275c..bfbdb5e06c 100644
--- a/xbox/live/oskiewar.js
+++ b/xbox/live/oskiewar.js
@@ -989,6 +989,20 @@ const players = [
const activePlayers = () =>
survivalActive() || lobbyActive() ? [players[0]] : players;
const impacts = [];
+
+// Every impact gets a number, in order, for the life of the run. A watcher
+// cannot be told "here is a new spark" by a stateless frame that repeats the
+// same list ten times — it has to be able to tell which ones it has already
+// seen, and a position is not an identity. The counter is simulation state:
+// a rollback that re-spawns an impact must re-issue the same number.
+let impactSequence = 0;
+
+function spawnImpact(impact) {
+ impact.id = ++impactSequence;
+ impacts.push(impact);
+ return impact;
+}
+
const detachedParts = [];
const bullets = [];
const grenades = [];
@@ -1226,7 +1240,7 @@ const versusChallengerGraceMs = 2500;
// The fight streams faster than a grandstand needs, because for the
// challenger this feed IS the game — their own presses come back to them
// as pictures through it.
-const versusSnapshotIntervalUs = 33000;
+const versusSnapshotIntervalMs = 33;
// How long a visitor who arrived through a shared address waits for a host
// before deciding the room is theirs to claim.
const versusClaimAfterUs = 2500000;
@@ -1239,10 +1253,14 @@ let versusInputMinNextAt = 0;
// race can walk them right back on as a challenger.
let versusFallbackBridge = null;
+let versusSeatGraceUntil = 0;
+
function versusChallengerFresh() {
// In a rollback fight the rival's presence is the session itself; the
// sim must not read the wall clock to decide whether the fight goes on.
if (netSession) return true;
+ // And for a moment after one ends badly, while the rival is changing lanes.
+ if (Date.now() < versusSeatGraceUntil) return true;
const remote = globalThis.__oskiewarRemotePad;
if (!remote || !Number.isFinite(remote.at)) return false;
return Date.now() - remote.at < versusChallengerGraceMs;
@@ -1379,12 +1397,47 @@ let viewerSystemPrevious = [];
let roundViewerStatus = "CONNECTING";
let roundViewerDemo = null;
let roundViewerDemoStartedAt = 0;
-// When the last authoritative LIVE frame landed, in engine microseconds. The
-// grandstand runs at display rate but the wire only arrives ~30×/s and
-// irregularly, so the frames in between dead-reckon off this mark (see
-// updateRoundViewer) instead of freezing on the last snap.
+// When the last authoritative LIVE frame landed, on the WALL clock in
+// milliseconds. Wall, not sim: the frame arrives in a socket callback, where
+// the sim clock is not running, and comparing an arrival stamped by one clock
+// against a deadline read from the other drifted by every dropped tick until
+// the coast fuse below stopped tripping at all.
let roundViewerLiveAt = 0;
+// The grandstand's playout buffer. Wire frames are no longer painted the
+// instant they land — they queue here with their arrival time and the render
+// reads a clock held a little behind the newest of them, so every frame is
+// drawn by interpolating the two that bracket it. Extrapolation is what is
+// left when the buffer runs dry, not the normal case: a body that coasts on
+// stale velocity walks through the floor and then snaps back, which is the
+// whole of what "glitchy" meant on a guest screen.
+let roundViewerFrames = [];
+// The playout clock, in the same wall milliseconds the queue is stamped in.
+// It advances on the simulation's own dt so the picture moves at the sim's
+// rate, and leans gently toward (now − delay) so it neither runs dry nor
+// falls further and further behind.
+let roundViewerPlayoutMs = 0;
+// How far behind the newest arrival the playout clock sits. One wire frame
+// plus the jitter we have actually measured — enough that the next frame is
+// usually already in hand when the clock reaches the last one.
+let roundViewerDelayMs = 70;
+// Wire health, measured from arrivals alone: mean gap, jitter (mean absolute
+// deviation of the gap), and the share of sequence numbers that never came.
+// These are what the connection meter reads, and what sizes the delay above.
+let roundViewerGapMs = 33;
+let roundViewerJitterMs = 0;
+let roundViewerLossPct = 0;
+let roundViewerSeq = -1;
+let roundViewerSeqHeard = 0;
+let roundViewerSeqExpected = 0;
let roundViewerImpactTick = -1;
+// The newest impact number this watcher has spawned. The host repeats a mark
+// on every frame it is still burning, so this is what tells a repeat from a
+// new spark.
+let roundViewerImpactId = 0;
+// Whether the round being watched has a clock. The wire says so; the watcher
+// must not answer from its own `roundIsTimed()`, which reads a local fight
+// that is not happening.
+let roundViewerTimed = true;
let livePublishFailed = false;
// Match names are public URLs and must not collide, so they take real
@@ -1502,6 +1555,13 @@ function startReplay(now) {
// this wire too, by choice rather than freedom: it runs one room, one stream,
// no series and no demo, because the shared URL must keep meaning this fight.
function roundIsTimed() {
+ // A watcher is answering about somebody else's round. Everything below reads
+ // this machine's own fight, which for a watcher is not running — and a
+ // watcher that has not been served a frame yet has no round to report at
+ // all, so it must not paint a countdown for one. A stored demo still falls
+ // through: it IS a local re-run, and it was a timed round when recorded.
+ if (roundViewer && roundViewerMode !== "DEMO")
+ return roundViewerMode === "LIVE" && roundViewerTimed;
if (survivalActive()) return false;
// Training runs without a clock — except under the reel harness, where a
// scripted dummy bout wants the full round apparatus (clock, demo, result
@@ -1559,7 +1619,48 @@ function spectatorState(now, nextRoundId = "") {
blocking: player.blocking, score: player.score,
roundWins: player.roundWins, attack: player.attackKind || "",
removedParts: player.removedParts.slice(),
+ // Three numbers the grandstand already knew how to read and had never
+ // been sent. `applyRoundViewerState` reconstructs the strike spark, the
+ // limb burst and the swing's real arc from them; without them a live
+ // watcher saw a body come apart in silence and every swing froze at one
+ // canned mid-pose. Two decimals is all a fade needs.
+ hit: Math.round((player.hit || 0) * 100) / 100,
+ blockFlash: Math.round((player.blockFlash || 0) * 100) / 100,
+ attackTicks: player.attackKind
+ ? Math.max(0, Math.round((now - player.attackStartedAt) / replayTickUs))
+ : 0,
})),
+ // Shots and lobs, the fight's projectiles. These used to stay home: a
+ // watcher — and a challenger on an older build — watched fighters flinch
+ // and die with nothing visible in the air between them. Flat number rows
+ // rather than objects, because the relay caps a frame at 8 KiB and 24
+ // rounds plus 12 grenades named longhand is most of that cap on its own.
+ // Velocity rides along so the playout buffer can carry a round smoothly
+ // between arrivals instead of stepping it 140 units at a time.
+ shots: bullets.filter((shot) => shot.life > 0).map((shot) => [
+ Math.round(shot.x), Math.round(shot.y), Math.round(shot.z),
+ Math.round(shot.previousX ?? shot.x),
+ Math.round(shot.previousY ?? shot.y),
+ Math.round(shot.vx || 0), Math.round(shot.vy || 0), shot.owner | 0,
+ (shot.spit ? 1 : 0) | (shot.heavy ? 2 : 0) | (shot.rubber ? 4 : 0)]),
+ // The host's own impact track. A watcher was reconstructing sparks from
+ // the flag edges it could see, which covers a punch landing and nothing
+ // else: a ricochet, a spit splat, a ground pound's crater and a grenade's
+ // own blast raise no flag on any fighter, so they happened in silence on
+ // every screen but the host's. Newest twelve, each with the number that
+ // says whether a watcher has already seen it, and its remaining life so
+ // it is spawned at the age it really is.
+ impacts: impacts.filter((mark) => mark.life > 0).slice(-12)
+ .map((mark) => [mark.id | 0, Math.round(mark.x), Math.round(mark.y),
+ Math.round(mark.z), Math.round((mark.duration || .3) * 1000),
+ Math.round(Math.max(0, mark.life) * 1000),
+ (mark.death ? 1 : 0) | (mark.explosion ? 2 : 0)]),
+ lobs: grenades.filter((lob) => lob.alive).map((lob) => [
+ Math.round(lob.x), Math.round(lob.y), Math.round(lob.z),
+ Math.round(lob.vx || 0), Math.round(lob.vy || 0), lob.owner | 0,
+ (lob.rocket ? 1 : 0) | (lob.exploding ? 2 : 0),
+ Math.round(lob.blastRadius || 0),
+ Math.round(Math.max(0, lob.fuse || 0) * 1000)]),
ball: { active: ball.active, x: ball.x, y: ball.y,
z: ball.z, radius: ball.radius, type: ball.type, mass: ball.mass },
balls: balls.map((item) => ({ active: item.active, x: item.x,
@@ -1572,7 +1673,13 @@ function spectatorState(now, nextRoundId = "") {
roll: cameraDoll.roll },
wind: { direction: windDirection, mph: windMph },
round: { remainingMs, timed, result: roundResult || "",
- cause: roundCause || "" },
+ cause: roundCause || "",
+ // How long this round has been running, intro included. A timed round
+ // could be reconstructed from `remainingMs` alone, but the versus lane
+ // reports zero there — and a watcher deriving its clock from that zero
+ // pinned itself thirty seconds in the past, which is why it never
+ // counted a round off and never played an intro card.
+ elapsedMs: clamp(Math.round((now - roundStartedAt) / 1000), 0, 3600000) },
// @jeffrey plays oskiewar.com in Edge on an Xbox, where there are no
// devtools and the console's own AC_NATIVE_PROFILE line only reaches a
// Device Portal on the same LAN. The live socket is the one channel that
@@ -2672,8 +2779,16 @@ function publishVersus(now) {
// the room's one publisher socket.
if (netSession && netSession.seat !== 0) return;
if (netSilent || !versusLane() || !versusRoomName || livePublishFailed ||
- typeof publishLive !== "function" || now < versusNextAt) return;
- versusNextAt = now + versusSnapshotIntervalUs;
+ typeof publishLive !== "function") return;
+ // Paced on the WALL clock, not the simulation's. The driver runs up to four
+ // owed ticks in one instant to catch up, and a sim-clocked gate let every
+ // one of those emit its own frame — two or three full state builds in the
+ // same millisecond, arriving at the relay as a burst it can only coalesce.
+ // Catch-up is exactly when the fight is busiest, so the cost landed where
+ // there was least room for it. Wall time makes a burst one frame.
+ const wall = runtime().unixMs || 0;
+ if (wall && wall < versusNextAt) return;
+ versusNextAt = wall + versusSnapshotIntervalMs;
try {
publishLive("ow-" + versusRoomName, JSON.stringify(spectatorState(now)));
} catch (error) {
@@ -2698,7 +2813,9 @@ function updateVersusConflict(now) {
roundViewerMode = "";
roundViewerDemo = null;
roundViewerStatus = "CONNECTING";
+ resetRoundViewerPlayout();
shellMode = "GAME";
+ gameplayStarted = true;
selecting = false;
roundResult = "";
matchOver = false;
@@ -2737,6 +2854,23 @@ function updateVersusSeat(now) {
}
}
+// A dead body falling. No input, no collision with anybody, no ledges: a
+// corpse is not a fighter and must not catch a rung it would have caught
+// alive. It drops, it lands on the ground under it, and it stops.
+function settleCorpse(player, dt) {
+ if (player.grounded) return;
+ player.vy += fallGravity * dt;
+ player.x += player.vx * dt;
+ player.y += player.vy * dt;
+ player.vx *= .96;
+ const terrainY = terrainFloorAt(player.x);
+ if (player.y < terrainY) return;
+ player.y = terrainY;
+ player.vy = 0;
+ player.vx = 0;
+ player.grounded = true;
+}
+
// The waiting room owes its lone fighter a real death. In a fight the
// round system rebuilds a destroyed body, but the lobby has no rounds — a
// fighter blasted apart would hop the empty room forever as a ghost among
@@ -2759,12 +2893,13 @@ function updateLobbyMortality(now) {
player.alive = false;
player.lobbyDeathDressed = true;
player.respawnAt = now + 2500000;
+ // Horizontal drive goes; the fall does not. Zeroing both used to be
+ // invisible because nothing moved the body afterwards either.
player.vx = 0;
- player.vy = 0;
player.stance = "HIT";
player.lastButton = "DESTROYED";
player.lastButtonAt = now;
- impacts.push({ x: player.x, y: player.y - 60, z: player.z, life: .55,
+ spawnImpact({ x: player.x, y: player.y - 60, z: player.z, life: .55,
duration: .55, death: true, explosion: false });
playDrum("whoosh", 1.15, panPlayer(player));
}
@@ -3437,7 +3572,7 @@ function applyRoundViewerState(state, now, dt = 1 / 60) {
// impact track (replayViewerImpacts), so this reconstruction is LIVE-only.
const wasAlive = player.alive;
const wasHit = player.hit || 0;
- const priorLimbs = player.removedParts?.length || 0;
+ const priorParts = player.removedParts?.slice() || [];
for (const key of ["name", "nation", "color", "x", "y", "z", "facing", "alive",
"grounded", "ducking", "blocking", "score", "roundWins", "removedParts"])
if (source[key] !== undefined) player[key] = source[key];
@@ -3465,13 +3600,37 @@ function applyRoundViewerState(state, now, dt = 1 / 60) {
// burst from the flag edges the wire DOES carry — a fresh hit, a new
// missing part, a death — so live watchers see the blow land.
if (roundViewerMode === "LIVE") {
- if (player.hit > .4 && wasHit <= .4)
+ // Reconstructing the strike spark is the fallback for a host too old to
+ // send its impact track. When the real track is on the frame it wins:
+ // it is the same spark, in the right place, and doubling them reads as
+ // a heavier hit than the one that landed.
+ if (!state.impacts && player.hit > .4 && wasHit <= .4)
impacts.push({ x: player.x, y: player.y - 90, z: player.z,
life: .32, duration: .32, death: false, explosion: true });
- const lostLimb = (player.removedParts?.length || 0) > priorLimbs;
- if (lostLimb || (wasAlive && !player.alive))
+ const nowParts = player.removedParts || [];
+ // A fighter who came back whole is a new round, not a healed limb.
+ const lost = nowParts.length < priorParts.length ? []
+ : nowParts.filter((part) => !priorParts.includes(part));
+ if (nowParts.length < priorParts.length) clearRoundViewerEffects();
+ if (!state.impacts && (lost.length || (wasAlive && !player.alive)))
impacts.push({ x: player.x, y: player.y - 60, z: player.z,
life: .55, duration: .55, death: true, explosion: false });
+ // And the limb itself goes flying. The wire says which part is gone; it
+ // does not say where the pieces went, and the pieces are the part of a
+ // dismemberment anybody actually watches. They are spawned from the
+ // fighter's own pose here and then fall under local physics, so they
+ // land where the body stands rather than where a packet said it did.
+ if (lost.length && detachedParts.length < 48) {
+ // Posed from BEFORE the loss, on a copy: the wire's removedParts are
+ // already applied to the live fighter, and a body's geometry no
+ // longer carries the limbs it has lost — so asking the fighter as it
+ // stands now returns no segments to throw.
+ const pose = runnerWorldGeometry({ ...player, removedParts: priorParts },
+ (now - startedAt) / 1000000);
+ const rival = players[player.pad === 0 ? 1 : 0];
+ for (const part of lost)
+ spawnDetachedPart(player, part, pose, rival?.x ?? player.x, now);
+ }
}
}
const sources = state.balls || [state.ball];
@@ -3482,6 +3641,13 @@ function applyRoundViewerState(state, now, dt = 1 / 60) {
"type", "mass", "heldBy"])
if (source[key] !== undefined) balls[index][key] = source[key];
}
+ // A frame that carries no projectile rows is an older host or a stored
+ // demo, and its shots are simply unknown — leaving the arrays alone is the
+ // honest answer. A frame that carries an empty row set is a host saying the
+ // air is clear, and that clears it.
+ if (state.shots) applyWireShots(state.shots);
+ if (state.lobs) applyWireLobs(state.lobs);
+ if (state.impacts) applyWireImpacts(state.impacts);
cameraCenter = state.camera.x;
cameraCenterY = state.camera.y;
cameraWidth = state.camera.width;
@@ -3494,9 +3660,24 @@ function applyRoundViewerState(state, now, dt = 1 / 60) {
roundResult = state.round.result || "";
roundCause = state.round.cause ||
(roundResult.includes("BALLED") ? "BALLED" : roundResult ? "ROUND" : "");
- roundElapsedUs = Math.max(0, roundDurationUs - state.round.remainingMs * 1000);
+ // Whether the round on the wire has a clock is the HOST's fact, not a thing
+ // to re-derive locally: `roundIsTimed()` answers from the machine it runs
+ // on, and a watcher's machine is not in the fight. A demo carries no flag
+ // and was always a timed, recorded round.
+ roundViewerTimed = state.round.timed !== false;
+ // The round's own age, intro included, straight off the wire. Deriving it
+ // from `remainingMs` instead is what broke the versus lane: that field is
+ // zero on an untimed round, so the clock landed a full round duration in
+ // the past, `counting` was never true, and the intro card never drew.
+ const elapsedUs = Number.isFinite(state.round.elapsedMs)
+ ? state.round.elapsedMs * 1000 : null;
+ roundElapsedUs = roundViewerTimed
+ ? Math.max(0, roundDurationUs - state.round.remainingMs * 1000)
+ : elapsedUs === null ? 0
+ : Math.max(0, elapsedUs - roundIntroDurationUs());
matchOver = state.phase === "match";
- roundStartedAt = now - roundIntroDurationUs() - roundElapsedUs;
+ roundStartedAt = elapsedUs === null
+ ? now - roundIntroDurationUs() - roundElapsedUs : now - elapsedUs;
if (roundResult && !hadResult) roundOverAt = now;
const target = state.camera.target || { x: cameraCenter, y: cameraCenterY, z: 0 };
cameraDoll.track({ target,
@@ -3506,6 +3687,60 @@ function applyRoundViewerState(state, now, dt = 1 / 60) {
fov: state.camera.fov || 55, roll: state.camera.roll || 0 }, dt, 1000);
}
+// Shots and lobs arrive as the flat number rows spectatorState packs (owner
+// and kind folded into one flag word). They are rebuilt rather than merged:
+// a round has no identity on the wire, so matching this frame's third bullet
+// to last frame's third bullet would be a guess, and a wrong guess teleports
+// a streak across the arena. The arrays themselves keep their identity — the
+// camera's swept-path packer and the render interpolator both hold references
+// to them.
+function applyWireShots(rows) {
+ bullets.length = 0;
+ for (const row of rows) {
+ const [x, y, z, previousX, previousY, vx, vy, owner, flags] = row;
+ bullets.push({ x, y, z, previousX, previousY, vx, vy,
+ owner: owner === 1 ? 1 : 0, life: 1, safeUntil: 0,
+ spit: Boolean(flags & 1), heavy: Boolean(flags & 2),
+ rubber: Boolean(flags & 4),
+ // No bounce history came up the wire, so a watched round draws its
+ // streak and no ricochet tail. drawBulletTrail reads the count first
+ // and an empty one costs it nothing.
+ trail: [], trailCount: 0 });
+ }
+}
+
+// The host's impact track, which repeats each mark for as long as it burns.
+// The id is what makes a repeat readable as a repeat: a watcher spawns only
+// the numbers it has not seen, at the age the frame reports, and then runs
+// them out on its own physics.
+function applyWireImpacts(rows) {
+ if (!rows.length) return;
+ const newest = rows[rows.length - 1][0];
+ // Numbers only climb within one host's run. A lower one is a different
+ // host, or the same one restarted, and nothing before it has been seen.
+ if (newest < roundViewerImpactId) roundViewerImpactId = 0;
+ for (const row of rows) {
+ const [id, x, y, z, durationMs, lifeMs, flags] = row;
+ if (id <= roundViewerImpactId) continue;
+ impacts.push({ x, y, z, duration: durationMs / 1000,
+ life: Math.max(.001, lifeMs / 1000),
+ death: Boolean(flags & 1), explosion: Boolean(flags & 2) });
+ }
+ roundViewerImpactId = Math.max(roundViewerImpactId, newest);
+}
+
+function applyWireLobs(rows) {
+ grenades.length = 0;
+ for (const row of rows) {
+ const [x, y, z, vx, vy, owner, flags, blastRadius, fuseMs] = row;
+ // Only live grenades ride out, so anything here is alive by definition —
+ // which is also what puts it in the render list.
+ grenades.push({ x, y, z, vx, vy, owner: owner === 1 ? 1 : 0, alive: true,
+ rocket: Boolean(flags & 1), exploding: Boolean(flags & 2),
+ blastRadius, blastAge: 0, fuse: fuseMs / 1000, hitPlayers: 0 });
+ }
+}
+
function handleRoundViewer(message) {
const now = runtime().monotonicUs;
matchName = message.roundName || matchName;
@@ -3513,10 +3748,14 @@ function handleRoundViewer(message) {
roundViewerDemo = null;
roundViewerMode = "";
roundViewerStatus = "CONNECTING";
+ resetRoundViewerPlayout();
return;
}
if (message.type === "status") {
roundViewerStatus = String(message.content?.label || "waiting").toUpperCase();
+ // A room that goes dark keeps no queue: whatever arrives next belongs to
+ // a new timeline, and a stale frame blended into it is a teleport.
+ if (message.content?.live === false) resetRoundViewerPlayout();
return;
}
if (message.type === "demo") {
@@ -3537,6 +3776,7 @@ function handleRoundViewer(message) {
roundViewerDemo = message.content;
roundViewerDemoStartedAt = now;
roundViewerMode = "DEMO";
+ resetRoundViewerPlayout();
globalThis.__oskiewarReplayReady = true;
return;
}
@@ -3550,18 +3790,91 @@ function handleRoundViewer(message) {
if (roundViewerDemo && !message.live) return;
if (roundViewerDemo && message.content?.phase === "match") return;
roundViewerMode = "LIVE";
- applyRoundViewerState(message.content, now);
- roundViewerLiveAt = now;
+ queueRoundViewerState(message.content);
}
}
-// How far past the last authoritative frame the grandstand keeps coasting on
-// wire velocity before it holds still. One frame carries fresh vx/vy, so a
-// short coast is honest and self-correcting — the next frame snaps to truth.
-// Past this the velocity is stale enough that coasting would walk a fighter
-// through a wall or float them past a jump's apex, so a long wire gap freezes
-// the pose instead of inventing motion.
-const liveDeadReckonMaxUs = 150000;
+// A fresh timeline: a room change, a takeover, a host that went away. The
+// queue, the playout clock and the measured health all start over, because
+// every one of them is a statement about a wire that no longer exists.
+// Sparks, debris and fallen limbs belong to one round on one wire. A new
+// round, a new room or a host that went away takes them with it.
+function clearRoundViewerEffects() {
+ impacts.length = 0;
+ detachedParts.length = 0;
+ roundViewerImpactId = 0;
+}
+
+function resetRoundViewerPlayout() {
+ clearRoundViewerEffects();
+ roundViewerFrames = [];
+ roundViewerPlayoutMs = 0;
+ roundViewerLiveAt = 0;
+ roundViewerGapMs = 33;
+ roundViewerJitterMs = 0;
+ roundViewerLossPct = 0;
+ roundViewerSeq = -1;
+ roundViewerSeqHeard = 0;
+ roundViewerSeqExpected = 0;
+}
+
+// A frame lands here, in a socket callback, between one simulation tick and
+// the next paint. Painting it from inside that callback is what smeared every
+// correction across two frames: the render interpolator had already captured
+// the pre-frame pose, so the next paint landed partway into the new state and
+// the one after finished the jump. So the frame is only filed — stamped with
+// its arrival and measured — and the tick decides when it is due.
+function queueRoundViewerState(state) {
+ const at = Date.now();
+ const gap = roundViewerLiveAt ? at - roundViewerLiveAt : 0;
+ roundViewerLiveAt = at;
+ if (gap > 0 && gap < 2000) {
+ roundViewerGapMs += (gap - roundViewerGapMs) * .12;
+ roundViewerJitterMs +=
+ (Math.abs(gap - roundViewerGapMs) - roundViewerJitterMs) * .12;
+ }
+ // Loss read off the host's own sequence numbers, over a rolling window.
+ // This is where the relay's coalescing shows: a frame it dropped inside its
+ // 25 ms gate leaves a hole in the count that nothing else on this side can
+ // see. A takeover or a fresh room rewinds the sequence, which restarts the
+ // window rather than reading as a hundred lost frames.
+ const seq = Number(state?.seq);
+ if (Number.isFinite(seq)) {
+ if (roundViewerSeq >= 0 && seq > roundViewerSeq) {
+ roundViewerSeqExpected += seq - roundViewerSeq;
+ roundViewerSeqHeard += 1;
+ if (roundViewerSeqExpected >= 180) {
+ roundViewerLossPct = clamp(Math.round(
+ (1 - roundViewerSeqHeard / roundViewerSeqExpected) * 100), 0, 100);
+ roundViewerSeqExpected = 0;
+ roundViewerSeqHeard = 0;
+ }
+ } else {
+ roundViewerSeqExpected = 0;
+ roundViewerSeqHeard = 0;
+ }
+ roundViewerSeq = seq;
+ }
+ // One wire frame plus twice the jitter actually measured, held inside a
+ // window where a buffer is still worth more than it costs. The delay itself
+ // moves slowly: resizing it quickly would time-warp the picture.
+ const want = clamp(roundViewerGapMs + roundViewerJitterMs * 2 + 8, 45, 220);
+ roundViewerDelayMs += (want - roundViewerDelayMs) * .05;
+ roundViewerFrames.push({ state, at });
+ // A ceiling, not a working depth: the playout holds about three frames.
+ // This only catches a tab whose simulation parked while the wire kept
+ // arriving, and the clock's own thousand-millisecond reset sorts out the
+ // rest when the tab comes back.
+ if (roundViewerFrames.length > 32) roundViewerFrames.shift();
+}
+
+// How far past the newest frame in hand the grandstand keeps coasting on wire
+// velocity before it holds still. Inside the buffer nothing coasts at all —
+// the pose is blended between two frames the host really sent. Past the
+// buffer the velocity goes stale fast enough that coasting walks a fighter
+// through a wall or floats them past a jump's apex, so a long gap freezes the
+// pose instead of inventing motion.
+const liveDeadReckonMaxMs = 150;
function updateRoundViewer(now, dt) {
if (roundViewerDemo && roundViewerMode === "DEMO") {
@@ -3570,25 +3883,146 @@ function updateRoundViewer(now, dt) {
applyRoundViewerState(state, now, dt);
replayViewerImpacts(state.tick, dt);
}
- } else if (roundViewerMode === "LIVE" &&
- now - roundViewerLiveAt < liveDeadReckonMaxUs) {
- // Between the ~30 irregular frames a second the wire delivers, coast every
- // moving body along its last reported velocity so the render runs the same
- // smooth 60 as a hosted fight instead of teleporting on each arrival.
- for (const player of players) {
- if (!player.alive) continue;
- // Coast the delta only — the host's reported position is truth and may
- // sit anywhere it says (legacy rooms even ran off the cube). The 150ms
- // horizon above bounds any overshoot, and the next snap corrects it.
- player.x += (player.wireVx || 0) * dt;
- player.y += (player.wireVy || 0) * dt;
- }
- for (const ball of balls) {
- if (!ball.active) continue;
- ball.x += (ball.vx || 0) * dt;
- ball.y += (ball.vy || 0) * dt;
- }
+ return;
+ }
+ if (roundViewerMode !== "LIVE" || !roundViewerFrames.length) return;
+ const wall = Date.now();
+ const target = wall - roundViewerDelayMs;
+ // A first frame, or a wire that stopped and came back, sets the clock
+ // outright. Every other tick advances it by the simulation's own dt and
+ // leans a twentieth of the way toward where it belongs. Leaning rather than
+ // snapping is what keeps the picture's rate the sim's rate: a clock pinned
+ // to the wall every tick replays the network's jitter as motion.
+ if (!roundViewerPlayoutMs || Math.abs(target - roundViewerPlayoutMs) > 1000)
+ roundViewerPlayoutMs = target;
+ else roundViewerPlayoutMs += dt * 1000 +
+ (target - roundViewerPlayoutMs) * .05;
+ const play = roundViewerPlayoutMs;
+ // Hold exactly one frame behind the clock: it is the left end of the pair
+ // being blended, and the one to coast from if the buffer runs dry.
+ while (roundViewerFrames.length > 1 && roundViewerFrames[1].at <= play)
+ roundViewerFrames.shift();
+ const before = roundViewerFrames[0].at <= play ? roundViewerFrames[0] : null;
+ const after = before ? roundViewerFrames[1] : roundViewerFrames[0];
+ if (before && after) {
+ const span = Math.max(1, after.at - before.at);
+ const alpha = clamp((play - before.at) / span, 0, 1);
+ applyRoundViewerState(blendRoundViewerStates(before.state, after.state,
+ alpha, (play - before.at) / 1000), now, dt);
+ updateRoundViewerEffects(dt);
+ return;
+ }
+ // Starved, or not yet arrived at the first frame. The newest thing in hand
+ // is the truth; past it the bodies coast on the velocity that frame
+ // reported — measured from that frame's own position, so the reckoning is
+ // never compounded and the next arrival needs no correcting snap.
+ const edge = before || after;
+ applyRoundViewerState(edge.state, now, dt);
+ if (before)
+ coastRoundViewer(Math.min(play - before.at, liveDeadReckonMaxMs) / 1000);
+ updateRoundViewerEffects(dt);
+}
+
+// The half of the fight a watcher is allowed to run for itself. Sparks, their
+// debris and the limbs that came off are cosmetic: nothing here decides a hit
+// or a score, so none of it has to agree with the host bit for bit — it only
+// has to happen. Before this, `impacts` grew for the life of the connection
+// and never drew a mote, because the function that grows an impact's debris
+// ran only in a demo or a hosted sim.
+function updateRoundViewerEffects(dt) {
+ updateResultImpactDebris(dt);
+ if (detachedParts.length) updateDetachedParts(dt, false);
+}
+
+// Dead reckoning, and only past the end of the buffer.
+function coastRoundViewer(seconds) {
+ if (seconds <= 0) return;
+ for (const player of players) {
+ if (!player.alive) continue;
+ // Coast the delta only — the host's reported position is truth and may
+ // sit anywhere it says (legacy rooms even ran off the cube).
+ player.x += (player.wireVx || 0) * seconds;
+ player.y += (player.wireVy || 0) * seconds;
}
+ for (const item of balls) {
+ if (!item.active) continue;
+ item.x += (item.vx || 0) * seconds;
+ item.y += (item.vy || 0) * seconds;
+ }
+ // Both ends of a shot move together: the streak's length is the round's
+ // speed, not the size of the gap we are papering over.
+ for (const shot of bullets) {
+ shot.x += (shot.vx || 0) * seconds;
+ shot.y += (shot.vy || 0) * seconds;
+ shot.previousX += (shot.vx || 0) * seconds;
+ shot.previousY += (shot.vy || 0) * seconds;
+ }
+ for (const lob of grenades) {
+ if (lob.exploding) continue;
+ lob.x += (lob.vx || 0) * seconds;
+ lob.y += (lob.vy || 0) * seconds;
+ }
+}
+
+// The pose the grandstand actually paints: two frames the host really sent,
+// blended. Continuous quantities interpolate; everything discrete — who is
+// alive, which limbs are gone, what phase the round is in — comes from the
+// frame already reached, because a fighter must not die before the pose that
+// killed them has finished arriving.
+const blendFighterKeys = ["x", "y", "z", "vx", "vy", "vz", "hit",
+ "blockFlash", "attackTicks"];
+const blendCameraKeys = ["x", "y", "width", "perspective", "fov", "roll"];
+const blendPointKeys = ["x", "y", "z"];
+
+function blendNumbers(from, to, keys, t) {
+ const out = { ...from };
+ if (!to) return out;
+ for (const key of keys) {
+ const a = Number(from?.[key]);
+ const b = Number(to[key]);
+ if (Number.isFinite(a) && Number.isFinite(b)) out[key] = a + (b - a) * t;
+ }
+ return out;
+}
+
+function blendRoundViewerStates(from, to, t, seconds) {
+ const state = { ...from };
+ state.fighters = (from.fighters || []).map((fighter, index) =>
+ blendNumbers(fighter, to.fighters?.[index], blendFighterKeys, t));
+ const blendBall = (ball, other) =>
+ ball ? blendNumbers(ball, other, blendPointKeys, t) : ball;
+ if (from.balls)
+ state.balls = from.balls.map((ball, index) =>
+ blendBall(ball, to.balls?.[index]));
+ if (from.ball) state.ball = blendBall(from.ball, to.ball);
+ if (from.camera) {
+ state.camera = blendNumbers(from.camera, to.camera, blendCameraKeys, t);
+ for (const part of ["position", "target"])
+ if (from.camera[part]) state.camera[part] =
+ blendNumbers(from.camera[part], to.camera?.[part], blendPointKeys, t);
+ }
+ // Projectiles have no identity on the wire, so they are carried forward on
+ // their own reported velocity rather than matched against the next frame's
+ // rows — a wrong match teleports a streak across the arena. A shot flies
+ // straight, so this is not an approximation of the host's path, it IS it.
+ if (from.shots) state.shots = from.shots.map((row) => {
+ const [x, y, z, previousX, previousY, vx, vy, owner, flags] = row;
+ return [x + vx * seconds, y + vy * seconds, z,
+ previousX + vx * seconds, previousY + vy * seconds, vx, vy, owner, flags];
+ });
+ if (from.lobs) state.lobs = from.lobs.map((row, index) => {
+ const [x, y, z, vx, vy, owner, flags, blastRadius, fuseMs] = row;
+ const next = to.lobs?.[index];
+ // A blast's ring grows over a fifth of a second, which is six wire
+ // frames — steppy enough to see. It blends when the next frame plainly
+ // holds the same blast; otherwise the radius stands.
+ const radius = next && next[6] === flags && next[5] === owner
+ ? blastRadius + (next[7] - blastRadius) * t : blastRadius;
+ const moving = flags & 2 ? 0 : seconds;
+ return [x + vx * moving, y + vy * moving, z, vx, vy, owner, flags,
+ radius, fuseMs];
+ });
+ return state;
}
// The buttons a challenger's pad may carry up the wire — the fight's own
@@ -3596,6 +4030,9 @@ function updateRoundViewer(now, dt) {
// toggle the host's debug overlay or send their game back to the title.
const versusInputButtons = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"A", "B", "X", "Y", "LeftShoulder", "RightShoulder"];
+// The buttons of the last frame the wire actually took, held apart from the
+// whole frame so a press can be told from a stick twitch.
+let versusInputLastDown = "[]";
// The challenger's half of the versus wire: sample the local pad, ship it on
// change, and heartbeat while idle so silence can mean absence. Sends pace
@@ -3611,11 +4048,24 @@ function sendChallengerInput(now) {
const round2 = (value) => Math.round((Number(value) || 0) * 100) / 100;
const frame = { down, leftX: round2(pad.leftX), leftY: round2(pad.leftY) };
const worn = JSON.stringify(frame);
- if (worn !== versusInputLastSent) {
- if (now < versusInputMinNextAt) return;
- } else if (now < versusInputNextAt) return;
+ // A button edge leaves at once. The 33 ms floor was written to pace a
+ // stick, which drifts continuously and would otherwise send every tick —
+ // but a press is a discrete event, and holding one back for up to two
+ // frames put a third of a display's lag into the one thing a player feels
+ // most. Only the analog half still waits.
+ const pressed = JSON.stringify(down);
+ const edge = pressed !== versusInputLastDown;
+ if (!edge) {
+ if (worn !== versusInputLastSent) {
+ if (now < versusInputMinNextAt) return;
+ } else if (now < versusInputNextAt) return;
+ }
versusInputMinNextAt = now + 33000;
- versusInputNextAt = now + 250000;
+ // An unchanged frame is repeated once soon after a change, then settles
+ // into the idle heartbeat. The relay drops what arrives too fast and says
+ // nothing about it, so a lost edge used to be invisible for a quarter of a
+ // second; one cheap echo closes that window without adding a protocol.
+ versusInputNextAt = now + (edge ? 50000 : 250000);
const identity = acFeed?.player;
const colors = (Array.isArray(identity?.colors) ? identity.colors : [])
.map((entry) => Array.isArray(entry)
@@ -3625,7 +4075,10 @@ function sendChallengerInput(now) {
.slice(0, 4);
if (roundViewer.sendInput({ seq: versusInputSeq++, ...frame,
name: identity?.handle ? String(identity.handle).toUpperCase() : "",
- colors })) versusInputLastSent = worn;
+ colors })) {
+ versusInputLastSent = worn;
+ versusInputLastDown = pressed;
+ }
}
// ---------------------------------------------------------------------------
@@ -3709,6 +4162,7 @@ function netSimScalars() {
roundCause,
deathCinematic,
impactHitboxesUntil,
+ impactSequence,
fightOpponent,
survivalStartedAt,
survivalLavaY,
@@ -3760,6 +4214,7 @@ function netRestoreScalars(saved) {
roundCause,
deathCinematic,
impactHitboxesUntil,
+ impactSequence,
fightOpponent,
survivalStartedAt,
survivalLavaY,
@@ -3840,7 +4295,16 @@ function netStateHash() {
player.heldBall, player.removedParts, player.dashUntil]);
view.push(balls.map((item) => [item.active, item.x, item.y, item.z,
item.vx, item.vy, item.heldBy]));
- view.push(bullets.length, grenades.length, roundResult, roundElapsedUs,
+ // Projectiles by position, not just by count. A round whose flight differs
+ // between the two machines kills different fighters, and hashing only the
+ // length let that drift silently: the count matched right up to the frame
+ // one seat's bullet connected and the other's missed.
+ view.push(bullets.map((shot) => [shot.x, shot.y, shot.z, shot.vx, shot.vy,
+ shot.owner, shot.life]));
+ view.push(grenades.map((lob) => [lob.x, lob.y, lob.z, lob.vx, lob.vy,
+ lob.owner, lob.fuse, lob.alive, lob.exploding, lob.blastRadius,
+ lob.hitPlayers]));
+ view.push(roundResult, roundElapsedUs,
matchOver, roundStartedAt, roundOverAt);
const text = JSON.stringify(view);
let hash = 0x811c9dc5;
@@ -3928,8 +4392,16 @@ function netBegin(deal, seat, send) {
remoteFrame: -1, remoteSimFrame: 0, lastRemoteMask: 0, ackFrame: -1,
confirmed: -1, hashes: new Map(), peerHashes: new Map(),
lastPacketAt: Date.now(), startedAt: Date.now(), ticks: 0,
+ // peerStamp is the newest wall-clock mark heard from the rival, echoed
+ // back on our next packet so they can read their own round trip; pingMs is
+ // ours, read from the echoes coming the other way.
+ peerStamp: 0,
stats: { rollbacks: 0, rolledFrames: 0, maxRollback: 0, stalls: 0,
- waits: 0, sent: 0, received: 0, desyncs: 0, snapshotMs: 0, resimMs: 0 } };
+ waits: 0, sent: 0, received: 0, desyncs: 0, snapshotMs: 0, resimMs: 0,
+ // `pings` counts measurements, not milliseconds: a round trip under half
+ // a millisecond rounds to zero, and a meter that reads "no measurement
+ // yet" on a LAN would be wrong at exactly the moment it is best.
+ pings: 0, pingMs: 0 } };
for (let frame = 0; frame < deal.delay; frame++) session.local.set(frame, 0);
netSession = session;
netClockUs = deal.origin;
@@ -3959,6 +4431,16 @@ function netLeave(reason) {
function netEnd(reason) {
if (!netSession) return;
const stats = netSession.stats;
+ // A seat in a rollback fight sends net packets, not pads, so the moment
+ // this lane closes the host has heard nothing on the pad wire for as long
+ // as the fight lasted. Read literally that is an empty chair, and an empty
+ // chair resets the match — which would turn a repaired desync into a lost
+ // score. The grace covers the changeover; a rival who has genuinely gone
+ // still times out, just three seconds later.
+ if (reason === "desync" || reason === "desync-peer")
+ versusSeatGraceUntil = Date.now() + 3000;
+ // The stream this seat is about to start watching again is a new timeline.
+ if (roundViewer) resetRoundViewerPlayout();
// Time never runs backwards: the local clock picks up where the fight's
// clock stood, so every deadline the fight left behind is still meaningful.
scaledClockUs = Math.max(scaledClockUs,
@@ -4054,11 +4536,17 @@ function netPrune(session) {
function netDrainInbox(session) {
const remoteSeat = session.seat === 0 ? 1 : 0;
- for (const packet of netInbox) {
+ // Taken as a batch: a hash mismatch inside this loop ends the session and
+ // empties the inbox underneath it.
+ const batch = netInbox.splice(0, netInbox.length);
+ for (const packet of batch) {
if (!packet || typeof packet !== "object") continue;
session.lastPacketAt = Date.now();
session.stats.received++;
if (packet.t === "bye") { session.peerLeft = true; continue; }
+ // The rival noticed the divergence first. Same repair from this side, and
+ // no answering packet: a desync that echoed would ping-pong.
+ if (packet.t === "desync") { session.peerDesync = true; continue; }
if (packet.t === "i" && Array.isArray(packet.m) &&
Number.isInteger(packet.f)) {
// m[k] is the pad for frame f + k; the newest is the last.
@@ -4074,11 +4562,25 @@ function netDrainInbox(session) {
}
if (Number.isInteger(packet.a)) session.ackFrame = Math.max(session.ackFrame, packet.a);
if (Number.isInteger(packet.s)) session.remoteSimFrame = Math.max(session.remoteSimFrame, packet.s);
+ // Ping, by echo. `w` is the rival's own mark, which we hold and send
+ // back; `e` is one of ours coming home, so the round trip is a
+ // subtraction in OUR clock and the two machines need no shared time.
+ // It reads up to one frame long, because the rival holds our mark until
+ // their next tick — an honest part of what a press actually costs.
+ if (Number.isInteger(packet.w)) session.peerStamp = packet.w;
+ if (Number.isInteger(packet.e) && packet.e > 0) {
+ const trip = Date.now() - packet.e;
+ if (trip >= 0 && trip < 4000) {
+ session.stats.pingMs = session.stats.pings
+ ? session.stats.pingMs + (trip - session.stats.pingMs) * .15 : trip;
+ session.stats.pings++;
+ }
+ }
if (Array.isArray(packet.h) && packet.h.length === 2)
netNotePeerHash(session, packet.h[0], packet.h[1], remoteSeat);
+ if (netSession !== session) return;
}
}
- netInbox.length = 0;
}
function netNotePeerHash(session, frame, hash) {
@@ -4088,9 +4590,38 @@ function netNotePeerHash(session, frame, hash) {
if (mine !== hash) {
session.stats.desyncs++;
telemetry("NET_DESYNC", "frame " + frame + " mine " + mine + " theirs " + hash);
+ netDesyncRepair(session, frame);
}
}
+// A desync used to be counted and then lived with: two machines that are no
+// longer playing the same fight went on playing their different ones, and the
+// two people found out when one of them died on a screen where they had not
+// been hit.
+//
+// The repair is to stop pretending there are two authorities. The host has
+// one, so both seats fall back to the lane where the host IS the authority:
+// the challenger goes back to watching the host's stream with its pads going
+// up the wire, and the host resumes the streamed versus fight it was already
+// publishing for the grandstand. The fight does not stop, it does not rewind,
+// and nobody has to re-adopt a state that will not fit through a 2 KiB
+// channel. What it costs is the rollback lane's latency, which is the honest
+// price of one machine having been wrong.
+//
+// Both seats have to agree, or the host sits waiting for packets that are not
+// coming — so the seat that notices says so, and both then refuse to open a
+// rollback session again for a while. Without that cooldown the challenger's
+// next hello would deal a fresh session into the same divergence, one second
+// later, forever.
+const NET_DESYNC_COOLDOWN_MS = 60000;
+let netLaneBlockedUntil = 0;
+
+function netDesyncRepair(session, frame) {
+ netLaneBlockedUntil = Date.now() + NET_DESYNC_COOLDOWN_MS;
+ try { session.send({ t: "desync", f: frame }); } catch (_) {}
+ netEnd("desync");
+}
+
// Our state hash at the newest frame both pads are known for — a frame that
// will never be rolled back again, so both seats hash the same thing if
// they are the same game.
@@ -4128,7 +4659,8 @@ function netSendInputs(session) {
for (let frame = first; frame <= newest; frame++)
masks.push(session.local.get(frame) ?? 0);
const packet = { t: "i", f: first, m: masks, a: session.remoteFrame,
- s: session.frame };
+ s: session.frame, w: Date.now() };
+ if (session.peerStamp) packet.e = session.peerStamp;
if (session.pendingHash) { packet.h = session.pendingHash; session.pendingHash = null; }
if (session.send(packet)) session.stats.sent++;
}
@@ -4140,6 +4672,15 @@ function netSendInputs(session) {
function netTick() {
const session = netSession;
netDrainInbox(session);
+ if (!netSession) return;
+ // The rival told us the two fights came apart. Drop to the streamed lane,
+ // where there is only one authority, and stay out of the rollback lane long
+ // enough that the next hello is not just the same divergence again.
+ if (session.peerDesync) {
+ netLaneBlockedUntil = Date.now() + NET_DESYNC_COOLDOWN_MS;
+ netEnd("desync-peer");
+ return;
+ }
if (session.peerLeft || Date.now() - session.lastPacketAt > NET_PEER_LOST_MS) {
const seat = session.seat;
netEnd(session.peerLeft ? "rival-left" : "peer-lost");
@@ -4175,12 +4716,15 @@ function netTick() {
session.frame++;
netPrune(session);
netExchangeHash(session);
+ // The hash exchange can find the mismatch that ends this session.
+ if (netSession !== session) return;
// A window into the lane for the debug overlay, the agent tools and the
// browser harness: what the fight is costing and whether it is one game.
globalThis.__oskiewarNetStats = { seat: session.seat, frame: session.frame,
confirmed: session.confirmed, remoteFrame: session.remoteFrame,
hash: session.hashes.get(session.confirmed) ?? 0,
- hashFrame: session.confirmed, ...session.stats };
+ hashFrame: session.confirmed, ...session.stats,
+ behind: Math.max(0, session.frame - 1 - session.remoteFrame) };
}
// The challenger announces itself on the net channel until the host answers
@@ -4190,6 +4734,7 @@ function netChallengerHello(now) {
if (netSession || roundViewer?.seat !== "challenger" ||
typeof roundViewer.sendNet !== "function") return;
const at = Date.now();
+ if (at < netLaneBlockedUntil) return;
if (at - netHelloSentAt < NET_HELLO_INTERVAL_MS) return;
netHelloSentAt = at;
roundViewer.sendNet({ t: "hello", v: 1, ...netLocalIdentity() });
@@ -4200,6 +4745,9 @@ function netChallengerHello(now) {
// in the inbox for the session to drain.
function netHandlePreSession(packet) {
if (!packet || typeof packet !== "object") return;
+ // A packet from the lane we have just fallen out of. Both ends are watching
+ // the same cooldown, so this is a straggler in flight, not an offer.
+ if (Date.now() < netLaneBlockedUntil) return;
if (packet.t === "hello" && !roundViewer) {
netPeerHello = { name: packet.name, colors: packet.colors, at: Date.now() };
return;
@@ -4266,6 +4814,7 @@ function updateVersusClaim(now) {
roundViewerMode = "";
roundViewerDemo = null;
roundViewerStatus = "CONNECTING";
+ resetRoundViewerPlayout();
versusClaimArmedAt = 0;
versusRoomName = name;
globalThis.__oskiewarRemotePad = null;
@@ -4329,6 +4878,11 @@ function gameBoot() {
selecting = false;
roundResult = "";
matchOver = false;
+ // Watching a fight IS gameplay for the person doing it. This was false on
+ // every watcher, and the whole match HUD hangs off it: no round clock, no
+ // VS or WAITING FOR HOST label, no "you are ..." seat line, no status
+ // tray. That is the "no state updates on screen" @jeffrey reported.
+ gameplayStarted = true;
matchName = roundViewer.name || "";
spectatorQr = typeof qrcode === "function"
? spectatorCode("https://oskiewar.com/" + matchName) : spectatorQr;
@@ -5420,7 +5974,7 @@ function updateBullets(dt, now, combat = true) {
bullet.vy += 700 * dt;
bullet.life -= dt * .22;
if (bullet.life <= 0) {
- impacts.push({ x: bullet.x, y: bullet.y, z: bullet.z,
+ spawnImpact({ x: bullet.x, y: bullet.y, z: bullet.z,
life: .16, duration: .16, death: false, explosion: false });
continue;
}
@@ -5455,7 +6009,7 @@ function updateBullets(dt, now, combat = true) {
bullet.life -= .18;
if (Math.abs(bullet.vy) < 90) bullet.life = 0;
if (bullet.life <= 0) {
- impacts.push({ x: bullet.x, y: bullet.y, z: bullet.z,
+ spawnImpact({ x: bullet.x, y: bullet.y, z: bullet.z,
life: .16, duration: .16, death: false, explosion: false });
} else playDrum("hat", .3, panAt(bullet.x, bullet.z));
// A splash bounces any bodyless head standing in it — including the
@@ -5489,7 +6043,7 @@ function updateBullets(dt, now, combat = true) {
Math.abs(a.z - b.z) <= 48) {
a.life = 0;
b.life = 0;
- impacts.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2,
+ spawnImpact({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2,
z: (a.z + b.z) / 2, life: .18, duration: .18,
death: false, explosion: false });
playDrum("hat", 1, 0);
@@ -5515,7 +6069,7 @@ function updateBullets(dt, now, combat = true) {
fragment.owner = bullet.owner;
fragment.hitAfter = now + 120000;
bullet.life = 0;
- impacts.push({ x: contact.secondPoint.x, y: contact.secondPoint.y,
+ spawnImpact({ x: contact.secondPoint.x, y: contact.secondPoint.y,
z: contact.secondPoint.z, life: .18, duration: .18,
death: false, explosion: false });
playDrum("hat", .72, panAt(fragment.x1, fragment.z1));
@@ -5551,7 +6105,7 @@ function updateBullets(dt, now, combat = true) {
bullet.y = shield.y + ny * (shield.radius + 25);
bullet.owner = target.pad;
bullet.life = Math.max(bullet.life, .55);
- impacts.push({ x: bullet.x, y: bullet.y, z: bullet.z,
+ spawnImpact({ x: bullet.x, y: bullet.y, z: bullet.z,
life: .18, duration: .18, death: false, explosion: false });
breakShield(target, now);
emitSignal("ballblock", target.pad, 1, 0);
@@ -5574,7 +6128,7 @@ function updateBullets(dt, now, combat = true) {
const segmentIndex = endpointHit && !headshot
? pointContact.segmentIndex : contact.segmentIndex;
bullet.life = 0;
- impacts.push({ x: contact.x, y: contact.y, z: contact.z,
+ spawnImpact({ x: contact.x, y: contact.y, z: contact.z,
life: .2, duration: .2, death: headshot,
explosion: false });
impactHitboxesUntil = Math.max(impactHitboxesUntil, now + 350000);
@@ -5885,7 +6439,7 @@ function returnBall(ball, player, now, shielded, intensity = 1) {
player.vy = Math.min(player.vy + incomingVy * .16, -Math.max(520, speed * .28));
player.grounded = false;
}
- impacts.push({ x: ball.x, y: ball.y, z: ball.z,
+ spawnImpact({ x: ball.x, y: ball.y, z: ball.z,
life: .22, duration: .22, death: false, explosion: false });
impactHitboxesUntil = Math.max(impactHitboxesUntil, now + 350000);
playDrum(shielded ? "block" : "clap", shielded ? 1.1 : 1.25,
@@ -5917,7 +6471,7 @@ function crossWackBall(ball, hitters, now) {
hit.player.lastButton = "CROSS WACK";
hit.player.lastButtonAt = now;
}
- impacts.push({ x: ball.x, y: ball.y, z: ball.z,
+ spawnImpact({ x: ball.x, y: ball.y, z: ball.z,
life: .32, duration: .32, death: false, explosion: true });
impactHitboxesUntil = Math.max(impactHitboxesUntil, now + 350000);
playDrum("clap", 1.55, panAt(ball.x, ball.z));
@@ -5942,7 +6496,7 @@ function bootBall(ball, player, now) {
ball.safePlayers = 1 << player.pad;
player.lastButton = dashing ? "DASH BOOT" : "BOOT";
player.lastButtonAt = now;
- impacts.push({ x: ball.x, y: ball.y, z: ball.z,
+ spawnImpact({ x: ball.x, y: ball.y, z: ball.z,
life: .16, duration: .16, death: false, explosion: false });
impactHitboxesUntil = Math.max(impactHitboxesUntil, now + 300000);
playDrum("kick", 1.12, panAt(ball.x, ball.z));
@@ -5963,7 +6517,7 @@ function bounceBallOffBody(ball, player, now, segmentIndex = -1) {
ball.safePlayers = 1 << player.pad;
applyBodyHit(player, segmentIndex, ball.x - ball.vx,
ball.lastHitBy, now, 760, 110, false);
- impacts.push({ x: ball.x, y: ball.y, z: ball.z,
+ spawnImpact({ x: ball.x, y: ball.y, z: ball.z,
life: .16, duration: .16, death: false, explosion: false });
impactHitboxesUntil = Math.max(impactHitboxesUntil, now + 300000);
playDrum("block", .82, panAt(ball.x, ball.z));
@@ -6260,7 +6814,7 @@ function killPlayer(target, killerPad, now, cause = "KO") {
emitSignal(cause === "BALLED" ? "balled" : "ko",
killerPad, target.pad, players[killerPad]?.score || 0);
roundCause = cause;
- impacts.push({ x: target.x, y: target.y - 120, z: target.z, life: .55,
+ spawnImpact({ x: target.x, y: target.y - 120, z: target.z, life: .55,
duration: .55, death: true, explosion: false });
playDrum("whoosh", 1.15, panPlayer(target));
emitSignal("killcam", killerPad, target.pad, 1);
@@ -6298,7 +6852,7 @@ function groundPound(player, now) {
player.lastButton = "GROUND POUND";
player.lastButtonAt = now;
const terrainY = terrainFloorAt(player.x);
- impacts.push({ x: player.x, y: terrainY, z: player.z,
+ spawnImpact({ x: player.x, y: terrainY, z: player.z,
life: .58, duration: .58, death: false, explosion: true,
blastRadius: radius, power });
impactHitboxesUntil = Math.max(impactHitboxesUntil, now + 350000);
@@ -6329,7 +6883,7 @@ function groundPound(player, now) {
item.vx = 0;
item.vy = 0;
item.serveAt = now + 1200000;
- impacts.push({ x: item.x, y: item.y, z: item.z,
+ spawnImpact({ x: item.x, y: item.y, z: item.z,
life: .22, duration: .22, death: false, explosion: false });
emitSignal("ballblock", player.pad, 1, power);
}
@@ -6366,7 +6920,7 @@ function resolveMelee(now) {
fragment.owner = attacker.pad;
fragment.hitAfter = now + 120000;
attacker.attackHit = true;
- impacts.push({ x: closest.secondPoint.x, y: closest.secondPoint.y,
+ spawnImpact({ x: closest.secondPoint.x, y: closest.secondPoint.y,
z: closest.secondPoint.z, life: .18, duration: .18,
death: false, explosion: false });
playDrum("clap", .82, panPlayer(attacker));
@@ -6386,7 +6940,7 @@ function resolveMelee(now) {
}
for (const { attacker, target, strike, headshot, segmentIndex } of contacts) {
if (!target.alive && contacts.length < 2) continue;
- impacts.push({ x: strike.x, y: strike.y, z: strike.z,
+ spawnImpact({ x: strike.x, y: strike.y, z: strike.z,
life: .2, duration: .2, death: false, explosion: false });
impactHitboxesUntil = Math.max(impactHitboxesUntil, now + 350000);
const away = Math.sign(target.x - attacker.x) || -attacker.facing;
@@ -6468,7 +7022,7 @@ function resolvePogoAttacks(now) {
attacker.vy = Math.min(attacker.vy, -985);
attacker.lastButton = "POGO";
attacker.lastButtonAt = now;
- impacts.push({ x: contact.x, y: contact.y, z: contact.z,
+ spawnImpact({ x: contact.x, y: contact.y, z: contact.z,
life: .2, duration: .2, death: contact.headshot, explosion: false });
if (contact.headshot) killPlayer(target, attacker.pad, now, "POGO");
else applyBodyHit(target, contact.segmentIndex, attacker.x,
@@ -6768,7 +7322,7 @@ function shieldBash(player, now) {
target.blocking = false;
target.knockVx += direction * 420;
}
- impacts.push({ x: (player.x + target.x) / 2, y: player.y - 90,
+ spawnImpact({ x: (player.x + target.x) / 2, y: player.y - 90,
z: (player.z + target.z) / 2, life: .26, duration: .26,
death: false, explosion: true, blastRadius: 180, power: .35 });
playDrum("block", 1.25, panPlayer(player));
@@ -6795,6 +7349,14 @@ function updatePlayer(player, pad, dt, now) {
}
if (!player.alive) {
player.previous = pad.down.slice();
+ // A body that dies off the ground still has to come down. Everything
+ // below this branch is skipped while dead, gravity included, which is
+ // right in a round — the round ends and rebuilds the body, and the
+ // killcam wants the pose held. The waiting room has no round, so a
+ // fighter blasted out of a jump hung in mid-air in its jump pose for the
+ // whole respawn beat. @jeffrey: "it seems possible to die in the waiting
+ // room but keep jumping stilll" — that is what a frozen hop looks like.
+ if (lobbyActive() && now < player.respawnAt) settleCorpse(player, dt);
if (now >= player.respawnAt) {
player.x = player.spawnX;
player.y = terrainFloorAt(player.spawnX);
@@ -7771,7 +8333,7 @@ function finishSurvival(now, result) {
if (result === "LAVA") {
runner.alive = false;
runner.stance = "HIT";
- impacts.push({ x: runner.x, y: survivalLavaY, z: runner.z,
+ spawnImpact({ x: runner.x, y: survivalLavaY, z: runner.z,
life: 1.2, duration: 1.2, death: true, explosion: true,
blastRadius: 240, power: .8 });
playDrum("kick", 1.25, panPlayer(runner));
@@ -9125,8 +9687,11 @@ function runnerContactToPoint(player, t, px, py, pz = 0) {
return { headDistance, bodyDistance, segmentIndex };
}
-function detachPart(player, part, geometry, sourceX, now) {
- if (!hasPart(player, part)) return;
+// The flying half of a dismemberment, separated from the bookkeeping half so
+// that a watcher can spawn the same debris. A watcher is told WHICH limb came
+// off (`removedParts` rides the wire) but not where its pieces went, and the
+// pieces are the part of a limb loss anybody actually sees.
+function spawnDetachedPart(player, part, geometry, sourceX, now) {
const direction = Math.sign(player.x - sourceX) || player.facing || 1;
for (const segment of geometry.segments.filter((item) =>
item.part === part && !item.hitboxOnly)) {
@@ -9139,6 +9704,11 @@ function detachPart(player, part, geometry, sourceX, now) {
spin: direction * (3.5 + detachedParts.length % 4),
life: 2.6, part, owner: player.pad, heldBy: -1, hitAfter: now + 180000 });
}
+}
+
+function detachPart(player, part, geometry, sourceX, now) {
+ if (!hasPart(player, part)) return;
+ spawnDetachedPart(player, part, geometry, sourceX, now);
player.removedParts.push(part);
player.partDamage[part] = Math.max(player.partDamage[part] || 0,
part === "torso" ? 3 : 2);
@@ -9249,7 +9819,11 @@ function applyBodyHit(target, segmentIndex, sourceX, sourcePad, now,
emitSignal("bodyhit", sourcePad, target.pad, segmentIndex);
}
-function updateDetachedParts(dt) {
+// `combat` is false on a watcher: the limbs still tumble, settle and draw,
+// but they do not damage anybody. The fight they belong to is being decided
+// on another machine, and a watcher that scored its own hits would fight the
+// wire for the rest of the round. Same switch `updateBullets` already has.
+function updateDetachedParts(dt, combat = true) {
const now = runtime().monotonicUs;
for (const fragment of detachedParts) {
if (fragment.heldBy >= 0) {
@@ -9299,7 +9873,7 @@ function updateDetachedParts(dt) {
fragment.spin *= .78;
}
const speed = Math.hypot(fragment.vx, fragment.vy);
- if (speed > 650 && now >= (fragment.hitAfter || 0)) {
+ if (combat && speed > 650 && now >= (fragment.hitAfter || 0)) {
for (const target of players) {
if (!target.alive || target.pad === fragment.owner) continue;
const contact = runnerContactToPoint(target,
@@ -9319,8 +9893,11 @@ function updateDetachedParts(dt) {
}
// Dismembered anatomy remains a physical arena object. It can settle,
// be picked up again, thrown, and strike either fighter for the rest of
- // the round rather than evaporating on a cosmetic timer.
- fragment.life = Math.max(fragment.life, 1);
+ // the round rather than evaporating on a cosmetic timer. On a watcher it
+ // is only scenery, and scenery that never expires is a leak: the wire
+ // says which limbs are gone, not which pieces are still worth drawing.
+ if (combat) fragment.life = Math.max(fragment.life, 1);
+ else fragment.life -= dt;
}
for (let index = detachedParts.length - 1; index >= 0; index--) {
if (detachedParts[index].life > 0 || detachedParts[index].heldBy >= 0) continue;
@@ -12355,7 +12932,11 @@ function drawDebugBug(x, y, scale = 1) {
function spectatorQrBox() {
if (typeof capabilities === "function" && capabilities().socialPreview)
return null;
- if (!versusLane())
+ // A watcher is looking at the one thing the code is for. `versusLane()` is a
+ // fact about the fight on THIS machine, and a watcher's machine is not in
+ // one — so an untimed versus round, watched, used to lose its address at
+ // exactly the moment somebody might want to pass it on.
+ if (!versusLane() && !(roundViewer && roundViewerMode === "LIVE"))
if (shellMode === "GAME" && !roundIsTimed()) return null;
if (!spectatorQr || typeof spectatorQr.getModuleCount !== "function")
return null;
@@ -12462,6 +13043,127 @@ function drawAgentLink(x, y, scale = 1, count = 1) {
Math.max(12, Math.round(13 * scale)), ...shell);
}
+// Connection health, as four bars and one number — the thing every online
+// game owes a player and oskiewar had no way to say. @jeffrey: "can we show a
+// connection health like wifi meter, on the top right of the game".
+//
+// The number is whatever this seat can honestly measure, which is not the same
+// on both lanes:
+//
+// A rollback fight knows its round trip exactly (see netSendInputs: our own
+// wall mark comes home on the rival's next packet), so it reports ping.
+// A grandstand or an older-build guest never gets a packet back, so it can
+// only measure the wire it is being served: how irregular the frames are and
+// how many of them the relay ate. It reports jitter, and loss when there is
+// any.
+// A host in the streamed lane hears the rival only through pads, so its one
+// honest reading is how long ago the last one arrived.
+//
+// Nothing is invented. A lane with no rival on it draws nothing at all.
+const netHealthPalette = [[245, 90, 96], [250, 200, 80], [180, 235, 120],
+ [108, 240, 168]];
+
+function netHealth() {
+ if (netSession) {
+ const stats = netSession.stats;
+ const ping = Math.round(stats.pingMs || 0);
+ // Nothing has come home yet; a meter that reads four bars before it has
+ // measured anything is a lie told at exactly the moment somebody is
+ // deciding whether the connection is good.
+ let bars = !stats.pings ? 0
+ : ping < 50 ? 4 : ping < 100 ? 3 : ping < 170 ? 2 : 1;
+ // A desync means the two machines are no longer playing the same fight.
+ // The wire may be fast and the connection is still not healthy.
+ if (bars && stats.desyncs) bars = Math.min(bars, 1);
+ return { bars, label: stats.pings ? ping + "MS" : "···",
+ note: stats.desyncs ? "DESYNC" : "" };
+ }
+ if (roundViewerMode === "LIVE" && roundViewerFrames.length) {
+ const jitter = Math.round(roundViewerJitterMs);
+ const loss = roundViewerLossPct;
+ const bars = jitter < 12 && loss < 3 ? 4
+ : jitter < 30 && loss < 10 ? 3
+ : jitter < 60 && loss < 25 ? 2 : 1;
+ return { bars, label: "±" + jitter + "MS",
+ note: loss ? loss + "% LOST" : "" };
+ }
+ // The host's side of a streamed fight. Only while somebody is actually
+ // sitting in the second chair — an empty room has no connection to rate.
+ if (versusLane() && !roundViewer) {
+ const remote = globalThis.__oskiewarRemotePad;
+ if (!remote || !Number.isFinite(remote.at)) return null;
+ const age = Date.now() - remote.at;
+ if (age > versusChallengerGraceMs) return null;
+ return { bars: age < 150 ? 4 : age < 400 ? 3 : age < 1000 ? 2 : 1,
+ label: age + "MS", note: "" };
+ }
+ return null;
+}
+
+// Where the meter stands, and whether it stands at all. Split out so the rest
+// of the top-right corner can measure around it: with no QR up the meter owns
+// that corner, and a label that assumed the corner was free landed on it.
+function netHealthBox() {
+ if (typeof capabilities === "function" &&
+ (capabilities().socialPreview || capabilities().replayOven === true))
+ return null;
+ if (shellMode !== "GAME") return null;
+ const health = netHealth();
+ if (!health) return null;
+ const safe = hudSafeRect();
+ const qr = spectatorQrBox();
+ const compact = compactLayout();
+ const bar = compact ? 5 : 7;
+ const gap = compact ? 3 : 4;
+ const tall = compact ? 19 : 26;
+ const count = 4;
+ const width = count * bar + (count - 1) * gap;
+ // Under the code, never over it: the QR is the address a friend types and
+ // the meter is a readout about the friend who already did.
+ const top = (qr ? qr.top + qr.size + 8 : safe.top) + 2;
+ // Sized off the bars rather than the HUD's own type scale: the reading is a
+ // caption to the meter, and at hudTypeSize it swamped the thing it labels.
+ const size = Math.max(14, Math.round(tall * .8));
+ const label = health.note ? health.label + " " + health.note : health.label;
+ return { health, bar, gap, tall, count, width, top, base: top + tall,
+ right: safe.right, left: safe.right - Math.max(width,
+ handleWidth(label, size)), size, label };
+}
+
+// The left edge of whatever owns the top-right corner — the round's QR, or
+// the connection meter when no code is up. Anything else that wants to sit up
+// there measures from here instead of from the safe rect, or it draws on top
+// of the corner's furniture.
+function hudTopRightLeft() {
+ const qr = spectatorQrBox();
+ if (qr) return qr.left;
+ return netHealthBox()?.left ?? hudSafeRect().right;
+}
+
+function drawNetHealth(ink) {
+ const box = netHealthBox();
+ if (!box) return;
+ const { health, bar, gap, tall, count, width, base, right, size, label } = box;
+ const lit = netHealthPalette[Math.max(0, health.bars - 1)];
+ const dim = mixColor([58, 64, 78], [196, 201, 212], visualTheme.light);
+ const previousDepth = triangleDepth;
+ triangleDepth = -1.43;
+ for (let index = 0; index < count; index++) {
+ const height = Math.round(tall * (index + 1) / count);
+ const x = right - width + index * (bar + gap);
+ screenRect(x + 2, base - height + 2, bar, height, [24, 26, 34]);
+ screenRect(x, base - height, bar, height,
+ index < health.bars ? lit : dim);
+ }
+ const labelWidth = handleWidth(label, size);
+ const labelY = base + 7;
+ typeWrite(label, right - labelWidth + 2, labelY + 2, size,
+ ...contrastShadow(ink));
+ typeWrite(label, right - labelWidth, labelY, size,
+ ...(health.note ? netHealthPalette[0] : ink));
+ triangleDepth = previousDepth;
+}
+
function drawSpectatorQr(ink, placement = null) {
if (typeof capabilities === "function" && capabilities().socialPreview) return;
const qr = placement || spectatorQrBox();
@@ -12740,8 +13442,13 @@ function gamePaint() {
const viewerLabel = roundViewer.seat === "challenger"
? roundViewerMode === "LIVE" ? "VS" : "WAITING FOR HOST"
: roundViewerMode || roundViewerStatus;
- typeWrite(viewerLabel, hud.right - viewerLabel.length * 18, hud.top + 7,
- 24, ...(roundViewerMode === "LIVE" ? [210, 42, 62] : titleInk));
+ // Left of the corner's furniture, never on it: that corner is the
+ // round's QR, or the connection meter when there is no code up.
+ const labelSize = 24;
+ const labelRight = hudTopRightLeft() - 14;
+ typeWrite(viewerLabel,
+ labelRight - handleWidth(viewerLabel, labelSize), hud.top + 7,
+ labelSize, ...(roundViewerMode === "LIVE" ? [210, 42, 62] : titleInk));
// The chair-holder plays through this feed: say which body is theirs
// while the matchup is still fresh, then get out of the fight's way.
if (roundViewer.seat === "challenger" && roundViewerMode === "LIVE" &&
@@ -12762,9 +13469,7 @@ function gamePaint() {
const label = "update ready";
const size = Math.round(hudTypeSize * .58);
const width = handleWidth(label, size);
- const qrBox = spectatorQrBox();
- const right = qrBox ? qrBox.left - 14 : hud.right;
- const x = right - width;
+ const x = hudTopRightLeft() - 14 - width;
const y = hud.top + hudTypeSize + 12;
typeWrite(label, x + 2, y + 3, size, ...contrastShadow(titleInk));
typeWrite(label, x, y, size, ...titleInk);
@@ -12943,6 +13648,7 @@ function gamePaint() {
// frame, and an instruction painted under it was an instruction hidden.
if (!reelMinimal) drawVersusHud(t, titleInk, run);
drawSpectatorQr(titleInk);
+ if (!reelMinimal) drawNetHealth(titleInk);
drawTouchControls();
}
}
diff --git a/xbox/live/round-room.mjs b/xbox/live/round-room.mjs
index 8c8a45f521..fda42ebd86 100644
--- a/xbox/live/round-room.mjs
+++ b/xbox/live/round-room.mjs
@@ -32,7 +32,13 @@ export class RoundRoom {
this.seatRetryAt = 0;
this.seat = "";
this.socket = null;
- this.timer = null;
+ // Two named slots, not one shared one. A versus room has no stored replay
+ // and 404s forever, so the 1800 ms replay retry below used to hold the
+ // only slot almost all the time — and a socket closing in that window
+ // asked for a reconnect that was silently dropped, which is how a
+ // spectator or a walked-back challenger wedged on "already has a
+ // challenger" and never came back.
+ this.timers = { reconnect: null, replay: null };
this.listener = null;
this.live = false;
this.lastState = null;
@@ -63,6 +69,7 @@ export class RoundRoom {
open() {
if (this.stopped || !this.WebSocketImpl) return;
+ this.clear("reconnect");
const generation = ++this.generation;
const id = `ow-${this.name}`;
const role = this.role === "challenger" ? "&role=challenger" : "";
@@ -137,7 +144,7 @@ export class RoundRoom {
this.live = false;
this.seat = "";
this.loadDemo();
- this.schedule(() => this.open(), 1200);
+ this.schedule("reconnect", () => this.open(), 1200);
};
socket.addEventListener?.("close", closed);
socket.addEventListener?.("error", () => socket.close?.());
@@ -186,17 +193,22 @@ export class RoundRoom {
return;
}
} catch {}
- if (!this.live || force) this.schedule(() => this.loadDemo(force), 1800);
+ if (!this.live || force) this.schedule("replay", () => this.loadDemo(force), 1800);
}
- schedule(action, delay) {
- if (this.timer || this.stopped) return;
- this.timer = setTimeout(() => {
- this.timer = null;
+ schedule(slot, action, delay) {
+ if (this.timers[slot] || this.stopped) return;
+ this.timers[slot] = setTimeout(() => {
+ this.timers[slot] = null;
action();
}, delay);
}
+ clear(slot) {
+ if (slot) { clearTimeout(this.timers[slot]); this.timers[slot] = null; return; }
+ for (const name of Object.keys(this.timers)) this.clear(name);
+ }
+
move(value) {
const name = String(value || "").replace(/^ow-/, "");
if (!ROUND_NAME.test(name) || name === this.name) return;
@@ -205,8 +217,7 @@ export class RoundRoom {
this.live = false;
this.lastState = null;
this.deliverySeen = new Set();
- clearTimeout(this.timer);
- this.timer = null;
+ this.clear();
++this.generation;
this.socket?.close?.(1000, "next round");
this.socket = null;
@@ -219,8 +230,7 @@ export class RoundRoom {
stop() {
this.stopped = true;
++this.generation;
- clearTimeout(this.timer);
- this.timer = null;
+ this.clear();
this.seat = "";
this.socket?.close?.(1000, "leaving");
this.socket = null;
diff --git a/xbox/live/social/manifest.json b/xbox/live/social/manifest.json
index 15323ebbe2..01c0350a6e 100644
--- a/xbox/live/social/manifest.json
+++ b/xbox/live/social/manifest.json
@@ -1,7 +1,7 @@
{
"format": "ac.oskiewar.social-preview",
"version": 2,
- "build": "c5096934c449ae32",
+ "build": "64797fc2f28469ac",
"theme": "light",
"imageWidth": 1200,
"imageHeight": 630,
diff --git a/xbox/live/social/oskiewar-title.jpg b/xbox/live/social/oskiewar-title.jpg
index c537e1ce38092025cb3288134ecc9ba6811426e9..1e2629066adb1440719f294e88b54f0be9a9a98b 100644
GIT binary patch
literal 105103
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