From a66ed11168a61f14d0b97221f0edac0fd7cda88b Mon Sep 17 00:00:00 2001 From: Brandon Johnson Date: Mon, 2 Feb 2026 20:51:48 -0500 Subject: [PATCH] unreliable, wtf were we doing reliable for --- src/game.ts | 4 ++ src/main.ts | 163 ++++++++++++++++++++++++++++++++++++++++++++++++- src/netplay.ts | 136 +++++++++++++++++++++++++++++++++-------- 3 files changed, 276 insertions(+), 27 deletions(-) diff --git a/src/game.ts b/src/game.ts index 64647bc..41775f2 100644 --- a/src/game.ts +++ b/src/game.ts @@ -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; diff --git a/src/main.ts b/src/main.ts index 77e4520..bde75a2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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) { diff --git a/src/netplay.ts b/src/netplay.ts index 47c3eb8..422513f 100644 --- a/src/netplay.ts +++ b/src/netplay.ts @@ -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(); - private channels = new Map(); + private channels = new Map(); + private connected = new Set(); 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;