unreliable, wtf were we doing reliable for

This commit is contained in:
Brandon Johnson
2026-02-02 20:51:48 -05:00
parent 6901578938
commit a66ed11168
3 changed files with 276 additions and 27 deletions

View File

@@ -372,6 +372,8 @@ export class Game {
};
public simTick: number;
public netplayRttMs: number | null;
public netplayDebugLines: string[] | null;
public netplayWarning: string | null;
public suppressVisualEffects: boolean;
public inputFeed: (QuantizedStick | QuantizedInput)[] | null;
public inputFeedIndex: number;
@@ -496,6 +498,8 @@ export class Game {
};
this.simTick = 0;
this.netplayRttMs = null;
this.netplayDebugLines = null;
this.netplayWarning = null;
this.suppressVisualEffects = false;
this.inputFeed = null;
this.inputFeedIndex = 0;

View File

@@ -206,6 +206,26 @@ const gamepadCalibrationOverlay = document.getElementById('gamepad-calibration')
const gamepadCalibrationMap = document.getElementById('gamepad-calibration-map') as HTMLCanvasElement | null;
const gamepadCalibrationButton = document.getElementById('gamepad-calibrate') as HTMLButtonElement | null;
const gamepadCalibrationCtx = gamepadCalibrationMap?.getContext('2d') ?? null;
const netplayDebugWrap = document.createElement('div');
const netplayDebugWarningEl = document.createElement('div');
const netplayDebugInfoEl = document.createElement('div');
netplayDebugWrap.id = 'netplay-debug';
netplayDebugWrap.style.position = 'fixed';
netplayDebugWrap.style.left = '12px';
netplayDebugWrap.style.top = '120px';
netplayDebugWrap.style.zIndex = '10000';
netplayDebugWrap.style.color = '#ffffff';
netplayDebugWrap.style.font = '12px/1.4 system-ui, sans-serif';
netplayDebugWrap.style.whiteSpace = 'pre';
netplayDebugWrap.style.pointerEvents = 'none';
netplayDebugWrap.style.textShadow = '0 1px 2px rgba(0,0,0,0.7)';
netplayDebugWrap.style.display = 'none';
netplayDebugWarningEl.style.color = '#ff6666';
netplayDebugWarningEl.style.fontWeight = '600';
netplayDebugWarningEl.style.marginBottom = '4px';
netplayDebugInfoEl.style.whiteSpace = 'pre';
netplayDebugWrap.append(netplayDebugWarningEl, netplayDebugInfoEl);
document.body.appendChild(netplayDebugWrap);
const startButton = document.getElementById('start') as HTMLButtonElement;
const resumeButton = document.getElementById('resume') as HTMLButtonElement;
const difficultySelect = document.getElementById('difficulty') as HTMLSelectElement;
@@ -660,6 +680,10 @@ const NETPLAY_LAG_FUSE_FRAMES = 24;
const NETPLAY_LAG_FUSE_MS = 500;
const NETPLAY_SNAPSHOT_COOLDOWN_MS = 1000;
const NETPLAY_PING_INTERVAL_MS = 1000;
const NETPLAY_HOST_STALL_MS = 3000;
const NETPLAY_HOST_SNAPSHOT_BEHIND_FRAMES = 120;
const NETPLAY_HOST_SNAPSHOT_COOLDOWN_MS = 1500;
const NETPLAY_DEBUG_STORAGE_KEY = 'smb_netplay_debug';
const LOBBY_HEARTBEAT_INTERVAL_MS = 15000;
const LOBBY_HEARTBEAT_FALLBACK_MS = 12000;
@@ -742,10 +766,34 @@ function recordNetplayPerf(startMs: number, simTicks = 0) {
logNetplayPerf(nowMs);
}
function isNetplayDebugEnabled() {
const globalFlag = (window as any).NETPLAY_DEBUG;
if (globalFlag !== undefined) {
return !!globalFlag;
}
try {
return localStorage.getItem(NETPLAY_DEBUG_STORAGE_KEY) === '1';
} catch {
return false;
}
}
function setNetplayDebugEnabled(enabled: boolean) {
(window as any).NETPLAY_DEBUG = enabled;
try {
localStorage.setItem(NETPLAY_DEBUG_STORAGE_KEY, enabled ? '1' : '0');
} catch {
// Ignore storage issues.
}
}
(window as any).setNetplayDebug = setNetplayDebugEnabled;
type NetplayRole = 'host' | 'client';
type NetplayClientState = {
lastAckedHostFrame: number;
lastAckedClientInput: number;
lastSnapshotMs: number | null;
};
type NetplayState = {
role: NetplayRole;
@@ -878,6 +926,7 @@ function resetNetplayForStage() {
for (const clientState of netplayState.clientStates.values()) {
clientState.lastAckedHostFrame = -1;
clientState.lastAckedClientInput = -1;
clientState.lastSnapshotMs = null;
}
resetNetplaySession();
}
@@ -1831,7 +1880,7 @@ function handleClientMessage(playerId: number, msg: ClientToHostMessage) {
}
let clientState = state.clientStates.get(playerId);
if (!clientState) {
clientState = { lastAckedHostFrame: -1, lastAckedClientInput: -1 };
clientState = { lastAckedHostFrame: -1, lastAckedClientInput: -1, lastSnapshotMs: null };
state.clientStates.set(playerId, clientState);
}
if (!game.players.some((player) => player.id === playerId)) {
@@ -1951,6 +2000,29 @@ function hostResendFrames(currentFrame: number) {
}
}
function hostMaybeSendSnapshots(nowMs: number) {
if (!hostRelay || !netplayState || netplayState.role !== 'host') {
return;
}
const state = netplayState;
const currentFrame = state.session.getFrame();
for (const [playerId, clientState] of state.clientStates.entries()) {
if (clientState.lastAckedHostFrame < 0) {
continue;
}
const behind = currentFrame - clientState.lastAckedHostFrame;
if (behind < NETPLAY_HOST_SNAPSHOT_BEHIND_FRAMES) {
continue;
}
const lastSnap = clientState.lastSnapshotMs;
if (lastSnap !== null && (nowMs - lastSnap) < NETPLAY_HOST_SNAPSHOT_COOLDOWN_MS) {
continue;
}
clientState.lastSnapshotMs = nowMs;
sendSnapshotToClient(playerId, currentFrame);
}
}
function clientSendInputBuffer(currentFrame: number) {
if (!clientPeer || !netplayState) {
return;
@@ -2087,6 +2159,9 @@ function netplayTick(dtSeconds: number) {
return;
}
}
if (netplayAccumulator < 0) {
netplayAccumulator = 0;
}
const session = state.session;
const currentFrame = session.getFrame();
const targetFrame = getNetplayTargetFrame(state, currentFrame);
@@ -2148,10 +2223,93 @@ function netplayTick(dtSeconds: number) {
netplayStep();
netplayAccumulator -= game.fixedStep;
}
if (netplayAccumulator < 0) {
netplayAccumulator = 0;
}
if (state.role === 'host') {
hostMaybeSendSnapshots(nowMs);
}
game.accumulator = Math.max(0, Math.min(game.fixedStep, netplayAccumulator));
recordNetplayPerf(perfStart, ticks);
}
function updateNetplayDebugOverlay(nowMs: number) {
if (!netplayEnabled || !netplayState) {
game.netplayDebugLines = null;
game.netplayWarning = null;
netplayDebugWrap.style.display = 'none';
return;
}
const state = netplayState;
const localPlayer = game.getLocalPlayer?.() ?? null;
let warning: string | null = null;
if (state.role === 'client') {
const hostAge = state.lastHostFrameTimeMs === null ? null : nowMs - state.lastHostFrameTimeMs;
if (hostAge !== null && hostAge > NETPLAY_HOST_STALL_MS) {
warning = `NET: host frames stale ${(hostAge / 1000).toFixed(1)}s`;
} else if (state.awaitingStageSync) {
warning = 'NET: awaiting stage sync';
}
}
if (!warning && localPlayer) {
if (localPlayer.isSpectator) {
warning = 'NET: local spectator';
} else if (localPlayer.pendingSpawn) {
warning = 'NET: local pending spawn';
}
}
game.netplayWarning = warning;
if (!isNetplayDebugEnabled()) {
game.netplayDebugLines = null;
if (!warning) {
netplayDebugWrap.style.display = 'none';
return;
}
netplayDebugWarningEl.textContent = warning;
netplayDebugInfoEl.textContent = '';
netplayDebugWrap.style.display = 'block';
return;
}
const sessionFrame = state.session.getFrame();
const simFrame = sessionFrame + (netplayAccumulator / game.fixedStep);
const targetFrame = getNetplayTargetFrame(state, sessionFrame);
const drift = targetFrame - simFrame;
const lines: string[] = [];
lines.push(`net ${state.role} id=${game.localPlayerId}`);
lines.push(`stage=${state.currentStageId ?? game.stage?.stageId ?? 0} seq=${state.stageSeq}`);
lines.push(`frame=${sessionFrame} host=${state.lastReceivedHostFrame} ack=${state.lastAckedLocalFrame}`);
lines.push(`drift=${drift.toFixed(2)} acc=${netplayAccumulator.toFixed(3)}`);
lines.push(`sync=${state.awaitingStageSync ? 1 : 0} ready=${state.awaitingStageReady ? 1 : 0} snap=${state.awaitingSnapshot ? 1 : 0}`);
if (state.role === 'client') {
const chanState = clientPeer?.getChannelState?.() ?? 'none';
const hostAge = state.lastHostFrameTimeMs === null ? 'n/a' : `${((nowMs - state.lastHostFrameTimeMs) / 1000).toFixed(1)}s`;
lines.push(`peer=${chanState} hostAge=${hostAge}`);
} else {
const peers = hostRelay?.getChannelStates?.() ?? [];
const peerText = peers.length
? peers.map((peer) => `${peer.playerId}:${peer.readyState}`).join(' ')
: 'none';
lines.push(`peers=${peerText}`);
if (state.clientStates.size > 0) {
const currentFrame = state.session.getFrame();
const behind = Array.from(state.clientStates.entries())
.map(([playerId, clientState]) => `${playerId}:${currentFrame - clientState.lastAckedHostFrame}`)
.join(' ');
lines.push(`behind=${behind}`);
}
}
if (localPlayer) {
lines.push(`local spec=${localPlayer.isSpectator ? 1 : 0} spawn=${localPlayer.pendingSpawn ? 1 : 0} state=${localPlayer.ball?.state ?? 0}`);
}
lines.push(`intro=${game.introTimerFrames} timeover=${game.timeoverTimerFrames}`);
game.netplayDebugLines = lines;
netplayDebugWarningEl.textContent = warning ?? '';
netplayDebugInfoEl.textContent = lines.join('\n');
netplayDebugWrap.style.display = 'block';
}
function setReplayStatus(text: string) {
if (replayStatus) {
replayStatus.textContent = text;
@@ -2320,7 +2478,7 @@ function startHost(room: LobbyRoom) {
return;
}
if (!state.clientStates.has(playerId)) {
state.clientStates.set(playerId, { lastAckedHostFrame: -1, lastAckedClientInput: -1 });
state.clientStates.set(playerId, { lastAckedHostFrame: -1, lastAckedClientInput: -1, lastSnapshotMs: null });
}
game.addPlayer(playerId, { spectator: false });
const player = game.players.find((p) => p.id === playerId);
@@ -2727,6 +2885,7 @@ function renderFrame(now: number) {
} else {
game.update(dtSeconds);
}
updateNetplayDebugOverlay(now);
const shouldRender = interpolationEnabled || (now - lastRenderTime) >= RENDER_FRAME_MS;
if (!shouldRender) {

View File

@@ -12,6 +12,15 @@ export type SignalMessage = {
};
const DEFAULT_STUN = [{ urls: 'stun:stun.l.google.com:19302' }];
const FAST_MESSAGE_TYPES = new Set(['frame', 'input', 'ack', 'ping', 'pong']);
function isFastMessage(msg: { type: string }) {
return FAST_MESSAGE_TYPES.has(msg.type);
}
function getChannelRole(label: string) {
return label === 'fast' ? 'fast' : 'ctrl';
}
export class LobbyClient {
constructor(private baseUrl: string) {}
@@ -95,7 +104,8 @@ export class LobbyClient {
export class HostRelay {
private peers = new Map<number, RTCPeerConnection>();
private channels = new Map<number, RTCDataChannel>();
private channels = new Map<number, { ctrl?: RTCDataChannel; fast?: RTCDataChannel }>();
private connected = new Set<number>();
constructor(private onMessage: (playerId: number, msg: ClientToHostMessage) => void) {}
@@ -118,10 +128,16 @@ export class HostRelay {
}
attachChannel(playerId: number, channel: RTCDataChannel) {
this.channels.set(playerId, channel);
const role = getChannelRole(channel.label);
const entry = this.channels.get(playerId) ?? {};
entry[role] = channel;
this.channels.set(playerId, entry);
channel.binaryType = 'arraybuffer';
channel.addEventListener('open', () => {
this.onConnect?.(playerId);
if (role === 'ctrl' && !this.connected.has(playerId)) {
this.connected.add(playerId);
this.onConnect?.(playerId);
}
});
channel.addEventListener('message', (event) => {
try {
@@ -134,33 +150,73 @@ export class HostRelay {
}
});
channel.addEventListener('close', () => {
this.onDisconnect?.(playerId);
const current = this.channels.get(playerId);
const ctrl = current?.ctrl;
if (role === 'ctrl' || !ctrl || ctrl.readyState === 'closed' || ctrl.readyState === 'closing') {
if (this.connected.has(playerId)) {
this.connected.delete(playerId);
this.onDisconnect?.(playerId);
}
}
});
}
private pickChannel(playerId: number, preferFast: boolean) {
const entry = this.channels.get(playerId);
if (!entry) {
return null;
}
const primary = preferFast ? entry.fast : entry.ctrl;
const fallback = preferFast ? entry.ctrl : entry.fast;
if (primary && primary.readyState === 'open') {
return primary;
}
if (fallback && fallback.readyState === 'open') {
return fallback;
}
return null;
}
broadcast(msg: HostToClientMessage) {
const payload = JSON.stringify(msg);
for (const channel of this.channels.values()) {
if (channel.readyState === 'open') {
const preferFast = isFastMessage(msg);
for (const playerId of this.channels.keys()) {
const channel = this.pickChannel(playerId, preferFast);
if (channel) {
channel.send(payload);
}
}
}
getChannelStates() {
const states: Array<{ playerId: number; readyState: string }> = [];
for (const [playerId, entry] of this.channels.entries()) {
const ctrl = entry.ctrl?.readyState ?? 'none';
const fast = entry.fast?.readyState ?? 'none';
states.push({ playerId, readyState: `ctrl=${ctrl} fast=${fast}` });
}
return states;
}
sendTo(playerId: number, msg: HostToClientMessage) {
const channel = this.channels.get(playerId);
if (!channel || channel.readyState !== 'open') {
const channel = this.pickChannel(playerId, isFastMessage(msg));
if (!channel) {
return;
}
channel.send(JSON.stringify(msg));
}
closeAll() {
for (const channel of this.channels.values()) {
try {
channel.close();
} catch {
// Ignore.
for (const entry of this.channels.values()) {
for (const channel of [entry.ctrl, entry.fast]) {
if (!channel) {
continue;
}
try {
channel.close();
} catch {
// Ignore.
}
}
}
for (const peer of this.peers.values()) {
@@ -172,6 +228,7 @@ export class HostRelay {
}
this.channels.clear();
this.peers.clear();
this.connected.clear();
}
hostId = 0;
@@ -182,12 +239,18 @@ export class HostRelay {
export class ClientPeer {
private pc: RTCPeerConnection | null = null;
private channel: RTCDataChannel | null = null;
private ctrlChannel: RTCDataChannel | null = null;
private fastChannel: RTCDataChannel | null = null;
constructor(private onMessage: (msg: HostToClientMessage) => void) {}
private attachChannel(channel: RTCDataChannel) {
this.channel = channel;
const role = getChannelRole(channel.label);
if (role === 'fast') {
this.fastChannel = channel;
} else {
this.ctrlChannel = channel;
}
channel.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data) as HostToClientMessage;
@@ -197,7 +260,9 @@ export class ClientPeer {
}
});
channel.addEventListener('close', () => {
this.onDisconnect?.();
if (role === 'ctrl') {
this.onDisconnect?.();
}
});
}
@@ -216,23 +281,42 @@ export class ClientPeer {
}
send(msg: ClientToHostMessage) {
if (this.channel?.readyState === 'open') {
this.channel.send(JSON.stringify(msg));
const preferFast = isFastMessage(msg);
const primary = preferFast ? this.fastChannel : this.ctrlChannel;
const fallback = preferFast ? this.ctrlChannel : this.fastChannel;
if (primary?.readyState === 'open') {
primary.send(JSON.stringify(msg));
return;
}
if (fallback?.readyState === 'open') {
fallback.send(JSON.stringify(msg));
}
}
getChannelState(): string {
const ctrl = this.ctrlChannel?.readyState ?? 'none';
const fast = this.fastChannel?.readyState ?? 'none';
return `ctrl=${ctrl} fast=${fast}`;
}
close() {
try {
this.channel?.close();
} catch {
// Ignore.
for (const channel of [this.ctrlChannel, this.fastChannel]) {
if (!channel) {
continue;
}
try {
channel.close();
} catch {
// Ignore.
}
}
try {
this.pc?.close();
} catch {
// Ignore.
}
this.channel = null;
this.ctrlChannel = null;
this.fastChannel = null;
this.pc = null;
}
@@ -260,8 +344,10 @@ export class ClientPeer {
export async function createHostOffer(host: HostRelay, playerId: number) {
const pc = host.getPeer(playerId);
const channel = pc.createDataChannel('game');
host.attachChannel(playerId, channel);
const ctrl = pc.createDataChannel('ctrl');
host.attachChannel(playerId, ctrl);
const fast = pc.createDataChannel('fast', { ordered: false, maxRetransmits: 0 });
host.attachChannel(playerId, fast);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
return pc.localDescription;