multiplayer first pass

This commit is contained in:
Brandon Johnson
2026-02-01 11:58:49 -05:00
parent 1c8382cc26
commit c68ca77404
19 changed files with 2800 additions and 246 deletions

View File

@@ -90,6 +90,30 @@
</div>
<input id="replay-file" class="hidden" type="file" accept=".json" />
<div class="panel-section">
<div class="panel-section-header">
<h2>Multiplayer</h2>
<button id="lobby-refresh" class="ghost compact" type="button">Refresh</button>
</div>
<div class="multiplayer-row">
<button id="lobby-create" class="ghost compact" type="button">Create Room</button>
<label class="checkbox-field">
<input id="lobby-public" type="checkbox" checked />
<span>Public</span>
</label>
</div>
<div class="multiplayer-row">
<input id="lobby-code" class="text-input" type="text" placeholder="Room code" />
<button id="lobby-join" class="ghost compact" type="button">Join</button>
</div>
<div class="multiplayer-row">
<button id="lobby-leave" class="ghost compact hidden" type="button">Leave Room</button>
</div>
<div id="lobby-status" class="pack-status">Lobby: idle</div>
<div id="lobby-players" class="lobby-players hidden"></div>
<div id="lobby-list" class="lobby-list"></div>
</div>
<label id="control-mode-field" class="field hidden">
<span>Control Mode</span>
<div class="control-mode-row">
@@ -336,6 +360,10 @@
}
</script>
<script>
window.LOBBY_URL = "https://webmonkeyball-lobby.sndrec32exe.workers.dev";
</script>
<script type="module" src="./dist/main.js"></script>
</body>
</html>

View File

@@ -584,7 +584,7 @@ export class GameplayCamera {
this.lookAt.y = ball.pos.y;
this.lookAt.z = ball.pos.z;
const randYaw = toS16(Math.floor(Math.random() * 0x10000));
const randYaw = toS16(ball.rotY ?? 0);
stack.fromIdentity();
stack.rotateY(randYaw);
tmpVec.x = 3;

View File

@@ -1,4 +1,5 @@
export type QuantizedStick = { x: number; y: number };
export type QuantizedInput = { x: number; y: number; buttons?: number };
export function quantizeStickAxis(value: number): number {
const clamped = Math.max(-1, Math.min(1, value));
@@ -23,6 +24,14 @@ export function quantizeStick(stick: { x: number; y: number }): QuantizedStick {
};
}
export function quantizeInput(stick: { x: number; y: number }, buttons = 0): QuantizedInput {
return {
x: quantizeStickAxis(stick.x),
y: quantizeStickAxis(stick.y),
buttons: buttons | 0,
};
}
export function dequantizeStick(stick: QuantizedStick): { x: number; y: number } {
return {
x: dequantizeStickAxis(stick.x),

File diff suppressed because it is too large Load Diff

View File

@@ -1080,12 +1080,13 @@ export class HudRenderer {
const timeLeftRaw = (game?.stageTimeLimitFrames ?? 0) - (game?.stageTimerFrames ?? 0);
const timeLeft = Math.max(0, timeLeftRaw);
const { seconds, centis } = formatTimerSeconds(timeLeft);
const bananasCollected = game?.ball?.bananas ?? 0;
const localPlayer = game?.getLocalPlayer?.() ?? null;
const bananasCollected = localPlayer?.ball?.bananas ?? 0;
const bananasLeft = game?.bananasLeft ?? 0;
const bananaTotal = bananasCollected + bananasLeft;
const score = String(Math.max(0, Math.trunc(this.scoreDisplay)));
const lives = Math.max(0, Math.trunc(game?.lives ?? 0));
const speedMph = Math.max(0, (game?.ball?.speed ?? 0) * 134.21985);
const speedMph = Math.max(0, (localPlayer?.ball?.speed ?? 0) * 134.21985);
const isBonusStage = game?.isBonusStageActive?.() ?? false;
const floorInfo = game?.course?.getFloorInfo?.();
const floorPrefix = floorInfo?.prefix ?? 'FLOOR';

View File

@@ -494,6 +494,14 @@ export class Input {
return !!button && (button.pressed || button.value > 0.5);
}
getButtonsBitmask() {
let bits = 0;
if (this.isPrimaryActionDown()) {
bits |= 1;
}
return bits;
}
getGamepadStick() {
const gamepads = navigator.getGamepads?.() ?? navigator.webkitGetGamepads?.();
if (!gamepads) {

View File

@@ -28,6 +28,10 @@ import { decompressLZ } from './noclip/SuperMonkeyBall/AVLZ.js';
import * as Nl from './noclip/SuperMonkeyBall/NaomiLib.js';
import * as Gma from './noclip/SuperMonkeyBall/Gma.js';
import { GameplaySyncState, Renderer } from './noclip/Render.js';
import { LobbyClient, HostRelay, ClientPeer, createHostOffer, applyHostSignal } from './netplay.js';
import type { QuantizedInput } from './determinism.js';
import { hashSimState } from './sim_hash.js';
import type { ClientToHostMessage, FrameBundleMessage, HostToClientMessage } from './netcode_protocol.js';
import { parseStagedefLz } from './noclip/SuperMonkeyBall/Stagedef.js';
import { StageId, STAGE_INFO_MAP } from './noclip/SuperMonkeyBall/StageInfo.js';
import type { StageData } from './noclip/SuperMonkeyBall/World.js';
@@ -565,6 +569,7 @@ const syncState: GameplaySyncState = {
bananaCollectedByAnimGroup: null,
animGroupTransforms: null,
ball: null,
balls: null,
goalBags: null,
goalTapes: null,
confetti: null,
@@ -573,6 +578,268 @@ const syncState: GameplaySyncState = {
stageTilt: null,
};
type LobbyRoom = {
roomId: string;
roomCode?: string;
isPublic: boolean;
hostId: number;
courseId: string;
settings: { maxPlayers: number; collisionEnabled: boolean };
};
const lobbyBaseUrl = (window as any).LOBBY_URL ?? "";
const lobbyClient = lobbyBaseUrl ? new LobbyClient(lobbyBaseUrl) : null;
const lobbyRefreshButton = document.getElementById('lobby-refresh') as HTMLButtonElement | null;
const lobbyCreateButton = document.getElementById('lobby-create') as HTMLButtonElement | null;
const lobbyJoinButton = document.getElementById('lobby-join') as HTMLButtonElement | null;
const lobbyPublicCheckbox = document.getElementById('lobby-public') as HTMLInputElement | null;
const lobbyCodeInput = document.getElementById('lobby-code') as HTMLInputElement | null;
const lobbyLeaveButton = document.getElementById('lobby-leave') as HTMLButtonElement | null;
const lobbyStatus = document.getElementById('lobby-status') as HTMLElement | null;
const lobbyList = document.getElementById('lobby-list') as HTMLElement | null;
const lobbyPlayers = document.getElementById('lobby-players') as HTMLElement | null;
let lobbyRoom: LobbyRoom | null = null;
let lobbySignal: { send: (msg: any) => void; close: () => void } | null = null;
let hostRelay: HostRelay | null = null;
let clientPeer: ClientPeer | null = null;
let netplayEnabled = false;
let pendingSnapshot: { frame: number; state: any; stageId?: number; gameSource?: GameSource } | null = null;
let lobbyHeartbeatTimer: number | null = null;
let netplayAccumulator = 0;
const NETPLAY_MAX_FRAME_DELTA = 5;
type NetplayRole = 'host' | 'client';
type NetplayClientState = {
lastAckedHostFrame: number;
lastAckedClientInput: number;
};
type NetplayState = {
role: NetplayRole;
session: ReturnType<Game['ensureRollbackSession']>;
inputHistory: Map<number, Map<number, QuantizedInput>>;
lastInputs: Map<number, QuantizedInput>;
pendingLocalInputs: Map<number, QuantizedInput>;
lastAckedLocalFrame: number;
lastReceivedHostFrame: number;
hostFrameBuffer: Map<number, FrameBundleMessage>;
clientStates: Map<number, NetplayClientState>;
maxRollback: number;
maxResend: number;
hashInterval: number;
hashHistory: Map<number, number>;
expectedHashes: Map<number, number>;
currentCourse: any | null;
currentGameSource: GameSource | null;
awaitingSnapshot: boolean;
};
let netplayState: NetplayState | null = null;
function createNetplayId() {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0] >>> 0;
}
function quantizedEqual(a: QuantizedInput, b: QuantizedInput) {
return a.x === b.x && a.y === b.y && (a.buttons ?? 0) === (b.buttons ?? 0);
}
function ensureNetplayState(role: NetplayRole) {
if (netplayState && netplayState.role === role) {
return netplayState;
}
const session = game.ensureRollbackSession();
session.prime(game.simTick);
netplayState = {
role,
session,
inputHistory: new Map(),
lastInputs: new Map(),
pendingLocalInputs: new Map(),
lastAckedLocalFrame: -1,
lastReceivedHostFrame: game.simTick,
hostFrameBuffer: new Map(),
clientStates: new Map(),
maxRollback: 30,
maxResend: 8,
hashInterval: 15,
hashHistory: new Map(),
expectedHashes: new Map(),
currentCourse: null,
currentGameSource: null,
awaitingSnapshot: false,
};
return netplayState;
}
function resetNetplaySession() {
game.rollbackSession = null;
const session = game.ensureRollbackSession();
session.prime(game.simTick);
if (netplayState) {
netplayState.session = session;
}
}
function getSimHash() {
if (!game.stageRuntime || !game.world) {
return 0;
}
const balls = game.players.map((player) => player.ball);
const worlds = [game.world, ...game.players.map((player) => player.world)];
return hashSimState(balls, worlds, game.stageRuntime);
}
function updateLobbyUi() {
if (!lobbyStatus || !lobbyLeaveButton || !lobbyPlayers) {
return;
}
if (!netplayEnabled || !lobbyRoom) {
lobbyLeaveButton.classList.add('hidden');
lobbyPlayers.classList.add('hidden');
lobbyPlayers.textContent = '';
return;
}
lobbyLeaveButton.classList.remove('hidden');
const role = netplayState?.role ?? 'offline';
const playerIds = game.players.map((player) => player.id);
lobbyPlayers.textContent = `Connected (${role}): ${playerIds.join(', ') || 'none'}`;
lobbyPlayers.classList.remove('hidden');
}
function startLobbyHeartbeat(roomId: string) {
if (!lobbyClient) {
return;
}
if (lobbyHeartbeatTimer !== null) {
window.clearInterval(lobbyHeartbeatTimer);
}
lobbyHeartbeatTimer = window.setInterval(() => {
void lobbyClient.heartbeat(roomId);
}, 15000);
}
function stopLobbyHeartbeat() {
if (lobbyHeartbeatTimer !== null) {
window.clearInterval(lobbyHeartbeatTimer);
lobbyHeartbeatTimer = null;
}
}
function resetNetplayConnections() {
lobbySignal?.close();
lobbySignal = null;
hostRelay?.closeAll();
hostRelay = null;
clientPeer?.close();
clientPeer = null;
netplayEnabled = false;
netplayState = null;
pendingSnapshot = null;
netplayAccumulator = 0;
stopLobbyHeartbeat();
lobbyRoom = null;
updateLobbyUi();
}
function recordInputForFrame(frame: number, playerId: number, input: QuantizedInput) {
if (!netplayState) {
return false;
}
let frameInputs = netplayState.inputHistory.get(frame);
if (!frameInputs) {
frameInputs = new Map();
netplayState.inputHistory.set(frame, frameInputs);
}
const prev = frameInputs.get(playerId);
if (prev && quantizedEqual(prev, input)) {
return false;
}
frameInputs.set(playerId, input);
netplayState.lastInputs.set(playerId, input);
return true;
}
function buildInputsForFrame(frame: number) {
if (!netplayState) {
return new Map<number, QuantizedInput>();
}
let frameInputs = netplayState.inputHistory.get(frame);
if (!frameInputs) {
frameInputs = new Map();
netplayState.inputHistory.set(frame, frameInputs);
}
for (const player of game.players) {
if (!frameInputs.has(player.id)) {
const last = netplayState.lastInputs.get(player.id) ?? { x: 0, y: 0, buttons: 0 };
frameInputs.set(player.id, last);
}
}
return frameInputs;
}
function trimNetplayHistory(frame: number) {
if (!netplayState) {
return;
}
const minFrame = frame - netplayState.maxRollback;
for (const key of Array.from(netplayState.inputHistory.keys())) {
if (key < minFrame) {
netplayState.inputHistory.delete(key);
}
}
for (const key of Array.from(netplayState.hashHistory.keys())) {
if (key < minFrame) {
netplayState.hashHistory.delete(key);
}
}
for (const key of Array.from(netplayState.expectedHashes.keys())) {
if (key < minFrame) {
netplayState.expectedHashes.delete(key);
}
}
}
function rollbackAndResim(startFrame: number) {
if (!netplayState) {
return false;
}
const session = netplayState.session;
const current = session.getFrame();
const rollbackFrame = Math.max(0, startFrame - 1);
if (!session.rollbackTo(rollbackFrame)) {
return false;
}
for (let frame = rollbackFrame + 1; frame <= current; frame += 1) {
const inputs = buildInputsForFrame(frame);
session.advanceTo(frame, inputs);
if (netplayState.hashInterval > 0 && frame % netplayState.hashInterval === 0) {
netplayState.hashHistory.set(frame, getSimHash());
}
trimNetplayHistory(frame);
}
return true;
}
function tryApplyPendingSnapshot(stageId: number) {
if (!pendingSnapshot) {
return;
}
if (pendingSnapshot.stageId !== undefined && pendingSnapshot.stageId !== stageId) {
return;
}
game.loadRollbackState(pendingSnapshot.state);
resetNetplaySession();
if (netplayState) {
netplayState.lastReceivedHostFrame = pendingSnapshot.frame;
netplayState.awaitingSnapshot = false;
}
pendingSnapshot = null;
}
const cameraEye = vec3.create();
function applyGameCamera(alpha = 1) {
@@ -678,6 +945,7 @@ async function handleStageLoaded(stageId: number) {
lastTime = performance.now();
updateMobileMenuButtonVisibility();
maybeStartSmb2LikeStageFade();
tryApplyPendingSnapshot(stageId);
return;
}
@@ -704,6 +972,7 @@ async function handleStageLoaded(stageId: number) {
lastTime = performance.now();
updateMobileMenuButtonVisibility();
maybeStartSmb2LikeStageFade();
tryApplyPendingSnapshot(stageId);
}
function setSelectOptions(select: HTMLSelectElement, values: { value: string; label: string }[]) {
@@ -880,6 +1149,310 @@ async function startStage(
activeGameSource !== GAME_SOURCES.SMB1 && hasSmb2LikeMode(difficulty) ? difficulty.mode : null;
void audio.resume();
await game.start(difficulty);
if (netplayEnabled && netplayState) {
netplayState.inputHistory.clear();
netplayState.lastInputs.clear();
netplayState.pendingLocalInputs.clear();
netplayState.hashHistory.clear();
netplayState.expectedHashes.clear();
netplayState.lastAckedLocalFrame = -1;
netplayState.lastReceivedHostFrame = game.simTick;
resetNetplaySession();
}
}
function requestSnapshot(reason: 'mismatch' | 'lag') {
if (!clientPeer || !netplayState || netplayState.awaitingSnapshot) {
return;
}
netplayState.awaitingSnapshot = true;
clientPeer.send({
type: 'snapshot_request',
frame: netplayState.session.getFrame(),
reason,
});
}
function handleHostMessage(msg: HostToClientMessage) {
const state = netplayState;
if (!state) {
return;
}
if (msg.type === 'frame') {
const frameMsg = msg as FrameBundleMessage;
if (frameMsg.lastAck !== undefined) {
state.lastAckedLocalFrame = Math.max(state.lastAckedLocalFrame, frameMsg.lastAck);
for (const frame of Array.from(state.pendingLocalInputs.keys())) {
if (frame <= state.lastAckedLocalFrame) {
state.pendingLocalInputs.delete(frame);
}
}
}
state.lastReceivedHostFrame = Math.max(state.lastReceivedHostFrame, frameMsg.frame);
let changed = false;
for (const [id, input] of Object.entries(frameMsg.inputs)) {
const playerId = Number(id);
if (recordInputForFrame(frameMsg.frame, playerId, input)) {
changed = true;
}
}
if (frameMsg.hash !== undefined) {
state.expectedHashes.set(frameMsg.frame, frameMsg.hash);
const localHash = state.hashHistory.get(frameMsg.frame);
if (localHash !== undefined && localHash !== frameMsg.hash) {
requestSnapshot('mismatch');
}
}
const currentFrame = state.session.getFrame();
if (changed && frameMsg.frame <= currentFrame) {
if (!rollbackAndResim(frameMsg.frame)) {
requestSnapshot('lag');
}
}
if (state.lastReceivedHostFrame - currentFrame > state.maxRollback) {
requestSnapshot('lag');
}
return;
}
if (msg.type === 'snapshot') {
pendingSnapshot = msg;
if (netplayState) {
netplayState.inputHistory.clear();
netplayState.lastInputs.clear();
netplayState.pendingLocalInputs.clear();
netplayState.hashHistory.clear();
netplayState.expectedHashes.clear();
netplayState.lastAckedLocalFrame = msg.frame;
netplayState.lastReceivedHostFrame = msg.frame;
}
if (!msg.stageId || game.stage?.stageId === msg.stageId) {
tryApplyPendingSnapshot(game.stage?.stageId ?? 0);
}
return;
}
if (msg.type === 'player_join') {
game.addPlayer(msg.playerId, { spectator: msg.spectator });
const player = game.players.find((p) => p.id === msg.playerId);
if (player && msg.pendingSpawn) {
player.pendingSpawn = true;
}
updateLobbyUi();
return;
}
if (msg.type === 'player_leave') {
game.removePlayer(msg.playerId);
updateLobbyUi();
return;
}
if (msg.type === 'room_update') {
game.maxPlayers = msg.room.settings.maxPlayers;
game.playerCollisionEnabled = msg.room.settings.collisionEnabled;
lobbyRoom = msg.room;
updateLobbyUi();
return;
}
if (msg.type === 'start') {
activeGameSource = msg.gameSource;
game.setGameSource(activeGameSource);
game.stageBasePath = msg.stageBasePath ?? getStageBasePath(activeGameSource);
currentSmb2LikeMode = activeGameSource !== GAME_SOURCES.SMB1 && msg.course?.mode ? msg.course.mode : null;
if (netplayState) {
netplayState.currentCourse = msg.course;
netplayState.currentGameSource = msg.gameSource;
}
void startStage(msg.course);
}
}
function handleClientMessage(playerId: number, msg: ClientToHostMessage) {
const state = netplayState;
if (!state) {
return;
}
const clientState = state.clientStates.get(playerId);
if (!clientState) {
return;
}
if (msg.type === 'input') {
if (msg.lastAck !== undefined) {
clientState.lastAckedHostFrame = Math.max(clientState.lastAckedHostFrame, msg.lastAck);
}
clientState.lastAckedClientInput = Math.max(clientState.lastAckedClientInput, msg.frame);
const changed = recordInputForFrame(msg.frame, playerId, msg.input);
const currentFrame = state.session.getFrame();
if (changed && msg.frame <= currentFrame) {
if (!rollbackAndResim(msg.frame)) {
sendSnapshotToClient(playerId);
}
}
return;
}
if (msg.type === 'ack') {
clientState.lastAckedHostFrame = Math.max(clientState.lastAckedHostFrame, msg.frame);
return;
}
if (msg.type === 'snapshot_request') {
sendSnapshotToClient(playerId);
}
}
function sendSnapshotToClient(playerId: number) {
if (!hostRelay || !netplayState) {
return;
}
const state = game.saveRollbackState();
if (!state) {
return;
}
hostRelay.sendTo(playerId, {
type: 'snapshot',
frame: netplayState.session.getFrame(),
state,
stageId: game.stage?.stageId,
gameSource: game.gameSource,
});
}
function hostResendFrames(currentFrame: number) {
if (!hostRelay || !netplayState) {
return;
}
for (const [playerId, clientState] of netplayState.clientStates.entries()) {
const start = Math.max(clientState.lastAckedHostFrame + 1, currentFrame - netplayState.maxResend + 1);
for (let frame = start; frame <= currentFrame; frame += 1) {
const bundle = netplayState.hostFrameBuffer.get(frame);
if (!bundle) {
continue;
}
hostRelay.sendTo(playerId, {
...bundle,
lastAck: clientState.lastAckedClientInput,
});
}
}
}
function clientSendInputBuffer(currentFrame: number) {
if (!clientPeer || !netplayState) {
return;
}
const start = netplayState.lastAckedLocalFrame + 1;
const end = currentFrame;
const minFrame = Math.max(start, end - netplayState.maxResend + 1);
for (let frame = minFrame; frame <= end; frame += 1) {
const input = netplayState.pendingLocalInputs.get(frame);
if (!input) {
continue;
}
clientPeer.send({
type: 'input',
frame,
playerId: game.localPlayerId,
input,
lastAck: netplayState.lastReceivedHostFrame,
});
}
if (start > end) {
clientPeer.send({
type: 'ack',
playerId: game.localPlayerId,
frame: netplayState.lastReceivedHostFrame,
});
}
}
function netplayStep() {
if (!netplayState) {
return;
}
const state = netplayState;
const session = state.session;
const currentFrame = session.getFrame();
let targetFrame = currentFrame;
if (state.role === 'client') {
targetFrame = Math.max(state.lastReceivedHostFrame, currentFrame);
}
const drift = targetFrame - currentFrame;
if (state.role === 'client' && drift < -2) {
clientSendInputBuffer(currentFrame);
return;
}
const frame = session.getFrame() + 1;
const localInput = game.sampleLocalInput();
recordInputForFrame(frame, game.localPlayerId, localInput);
if (state.role === 'client') {
state.pendingLocalInputs.set(frame, localInput);
}
const inputs = buildInputsForFrame(frame);
session.advanceTo(frame, inputs);
let hash: number | undefined;
if (state.hashInterval > 0 && frame % state.hashInterval === 0) {
hash = getSimHash();
state.hashHistory.set(frame, hash);
const expected = state.expectedHashes.get(frame);
if (expected !== undefined && expected !== hash) {
requestSnapshot('mismatch');
}
}
if (state.role === 'host') {
const bundleInputs: Record<number, QuantizedInput> = {};
for (const [playerId, input] of inputs.entries()) {
bundleInputs[playerId] = input;
}
state.hostFrameBuffer.set(frame, {
type: 'frame',
frame,
inputs: bundleInputs,
hash,
});
const minFrame = frame - Math.max(state.maxRollback, state.maxResend);
for (const key of Array.from(state.hostFrameBuffer.keys())) {
if (key < minFrame) {
state.hostFrameBuffer.delete(key);
}
}
}
trimNetplayHistory(frame);
if (state.role === 'host') {
hostResendFrames(session.getFrame());
} else {
clientSendInputBuffer(session.getFrame());
}
}
function netplayTick(dtSeconds: number) {
if (!netplayState) {
return;
}
if (!game.stageRuntime || game.loadingStage) {
game.update(0);
return;
}
netplayAccumulator = Math.min(netplayAccumulator + dtSeconds, game.fixedStep * NETPLAY_MAX_FRAME_DELTA);
const state = netplayState;
const session = state.session;
const currentFrame = session.getFrame();
let targetFrame = currentFrame;
if (state.role === 'client') {
targetFrame = Math.max(state.lastReceivedHostFrame, currentFrame);
}
const drift = targetFrame - currentFrame;
if (state.role === 'client' && drift < -2) {
clientSendInputBuffer(currentFrame);
return;
}
let ticks = Math.floor(netplayAccumulator / game.fixedStep);
if (ticks <= 0 && drift > 2) {
ticks = 1;
}
if (drift > 4) {
ticks = Math.min(3, Math.max(1, ticks + 1));
}
for (let i = 0; i < ticks; i += 1) {
netplayStep();
netplayAccumulator -= game.fixedStep;
}
game.accumulator = Math.max(0, Math.min(game.fixedStep, netplayAccumulator));
}
function setReplayStatus(text: string) {
@@ -906,11 +1479,12 @@ async function startReplay(replay: ReplayData) {
game.course = null;
void audio.resume();
await game.loadStage(replay.stageId);
if (game.ball && game.stage) {
const localPlayer = game.getLocalPlayer?.() ?? null;
if (localPlayer?.ball && game.stage) {
const startTick = Math.max(0, replay.inputStartTick ?? 0);
game.introTotalFrames = startTick;
game.introTimerFrames = startTick;
game.cameraController?.initForStage(game.ball, game.ball.startRotY, game.stageRuntime);
game.cameraController?.initForStage(localPlayer.ball, localPlayer.ball.startRotY, game.stageRuntime);
}
game.replayInputStartTick = Math.max(0, replay.inputStartTick ?? 0);
game.setInputFeed(replay.inputs);
@@ -937,6 +1511,208 @@ function downloadReplay(replay: ReplayData) {
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function refreshLobbyList() {
if (!lobbyClient || !lobbyList || !lobbyStatus) {
return;
}
lobbyStatus.textContent = 'Lobby: loading...';
try {
const rooms = await lobbyClient.listRooms();
lobbyList.innerHTML = '';
for (const room of rooms) {
const item = document.createElement('div');
item.className = 'lobby-item';
const label = document.createElement('span');
label.textContent = `${room.courseId} • host ${room.hostId}`;
const join = document.createElement('button');
join.className = 'ghost compact';
join.type = 'button';
join.textContent = 'Join';
join.addEventListener('click', async () => {
await joinRoom(room.roomId);
});
item.append(label, join);
lobbyList.appendChild(item);
}
lobbyStatus.textContent = `Lobby: ${rooms.length} room(s)`;
} catch (err) {
console.error(err);
lobbyStatus.textContent = 'Lobby: failed';
}
}
async function createRoom() {
if (!lobbyClient || !lobbyStatus) {
return;
}
const isPublic = lobbyPublicCheckbox?.checked ?? true;
const hostId = game.localPlayerId ?? 0;
const room = await lobbyClient.createRoom({
isPublic,
hostId,
courseId: 'smb1-main',
settings: { maxPlayers: 8, collisionEnabled: true },
});
lobbyRoom = room;
lobbyStatus.textContent = `Lobby: hosting ${room.roomCode ?? room.roomId}`;
startHost(room);
}
async function joinRoom(roomId: string) {
if (!lobbyClient || !lobbyStatus) {
return;
}
const room = await lobbyClient.joinRoom({ roomId });
lobbyRoom = room;
lobbyStatus.textContent = `Lobby: joining ${room.roomCode ?? room.roomId}`;
startClient(room);
}
async function joinRoomByCode() {
if (!lobbyClient || !lobbyStatus) {
return;
}
const code = lobbyCodeInput?.value?.trim();
if (!code) {
lobbyStatus.textContent = 'Lobby: enter a room code';
return;
}
const room = await lobbyClient.joinRoom({ roomCode: code });
lobbyRoom = room;
lobbyStatus.textContent = `Lobby: joining ${room.roomCode ?? room.roomId}`;
startClient(room);
}
async function leaveRoom() {
if (!lobbyClient) {
resetNetplayConnections();
return;
}
const roomId = lobbyRoom?.roomId;
const wasHost = netplayState?.role === 'host';
resetNetplayConnections();
if (roomId && wasHost) {
try {
await lobbyClient.closeRoom(roomId);
} catch {
// Ignore.
}
}
if (lobbyStatus) {
lobbyStatus.textContent = 'Lobby: idle';
}
}
function startHost(room: LobbyRoom) {
if (!lobbyClient) {
return;
}
netplayEnabled = true;
ensureNetplayState('host');
game.setLocalPlayerId(room.hostId);
game.maxPlayers = room.settings.maxPlayers;
game.playerCollisionEnabled = room.settings.collisionEnabled;
hostRelay = new HostRelay((playerId, msg) => {
handleClientMessage(playerId, msg);
});
hostRelay.hostId = room.hostId;
hostRelay.onConnect = (playerId) => {
const state = netplayState;
if (!state) {
return;
}
if (!state.clientStates.has(playerId)) {
state.clientStates.set(playerId, { lastAckedHostFrame: -1, lastAckedClientInput: -1 });
}
game.addPlayer(playerId, { spectator: false });
const player = game.players.find((p) => p.id === playerId);
const pendingSpawn = !!player?.pendingSpawn;
for (const existing of game.players) {
hostRelay?.sendTo(playerId, {
type: 'player_join',
playerId: existing.id,
spectator: existing.isSpectator,
pendingSpawn: existing.pendingSpawn,
});
}
hostRelay?.broadcast({ type: 'player_join', playerId, spectator: false, pendingSpawn });
hostRelay?.sendTo(playerId, { type: 'room_update', room });
if (state.currentCourse && state.currentGameSource) {
hostRelay?.sendTo(playerId, {
type: 'start',
gameSource: state.currentGameSource,
course: state.currentCourse,
stageBasePath: getStageBasePath(state.currentGameSource),
});
}
sendSnapshotToClient(playerId);
updateLobbyUi();
};
hostRelay.onDisconnect = (playerId) => {
game.removePlayer(playerId);
netplayState?.clientStates.delete(playerId);
hostRelay?.broadcast({ type: 'player_leave', playerId });
updateLobbyUi();
};
lobbySignal?.close();
lobbySignal = lobbyClient.openSignal(room.roomId, room.hostId, async (msg) => {
if (msg.to !== room.hostId) {
return;
}
if (msg.payload?.join) {
const offer = await createHostOffer(hostRelay!, msg.from);
hostRelay?.onSignal?.({ type: 'signal', from: room.hostId, to: msg.from, payload: { sdp: offer } });
return;
}
await applyHostSignal(hostRelay!, msg.from, msg.payload);
}, () => {
lobbyStatus!.textContent = 'Lobby: disconnected';
resetNetplayConnections();
});
hostRelay.onSignal = (signal) => lobbySignal?.send(signal);
startLobbyHeartbeat(room.roomId);
lobbyStatus!.textContent = `Lobby: hosting ${room.roomCode ?? room.roomId}`;
updateLobbyUi();
}
async function startClient(room: LobbyRoom) {
if (!lobbyClient) {
return;
}
netplayEnabled = true;
ensureNetplayState('client');
const playerId = createNetplayId();
game.setLocalPlayerId(playerId);
game.maxPlayers = room.settings.maxPlayers;
game.playerCollisionEnabled = room.settings.collisionEnabled;
game.addPlayer(room.hostId, { spectator: false });
clientPeer = new ClientPeer((msg) => {
handleHostMessage(msg);
});
clientPeer.playerId = playerId;
clientPeer.hostId = room.hostId;
await clientPeer.createConnection();
lobbySignal?.close();
lobbySignal = lobbyClient.openSignal(room.roomId, playerId, async (msg) => {
if (msg.to !== playerId) {
return;
}
await clientPeer?.handleSignal(msg.payload);
}, () => {
lobbyStatus!.textContent = 'Lobby: disconnected';
resetNetplayConnections();
});
clientPeer.onSignal = (signal) => lobbySignal?.send(signal);
clientPeer.onDisconnect = () => {
lobbyStatus!.textContent = 'Lobby: disconnected';
resetNetplayConnections();
};
lobbySignal.send({ type: 'signal', from: playerId, to: room.hostId, payload: { join: true } });
startLobbyHeartbeat(room.roomId);
lobbyStatus!.textContent = `Lobby: connected ${room.roomCode ?? room.roomId}`;
updateLobbyUi();
}
function bindVolumeControl(
input: HTMLInputElement | null,
output: HTMLOutputElement | null,
@@ -1244,7 +2020,11 @@ function renderFrame(now: number) {
viewerInput.deltaTime = 0;
}
game.update(dtSeconds);
if (netplayEnabled) {
netplayTick(dtSeconds);
} else {
game.update(dtSeconds);
}
const shouldRender = interpolationEnabled || (now - lastRenderTime) >= RENDER_FRAME_MS;
if (!shouldRender) {
@@ -1292,6 +2072,7 @@ function renderFrame(now: number) {
syncState.bananaCollectedByAnimGroup = null;
syncState.animGroupTransforms = game.getAnimGroupTransforms(interpolationAlpha);
syncState.ball = game.getBallRenderState(interpolationAlpha);
syncState.balls = game.getBallRenderStates(interpolationAlpha);
syncState.goalBags = game.getGoalBagRenderState(interpolationAlpha);
syncState.goalTapes = game.getGoalTapeRenderState(interpolationAlpha);
syncState.confetti = game.getConfettiRenderState(interpolationAlpha);
@@ -1583,6 +2364,12 @@ if (interpolationToggle) {
}
startButton.addEventListener('click', () => {
if (netplayEnabled && netplayState?.role === 'client') {
if (hudStatus) {
hudStatus.textContent = 'Waiting for host to start...';
}
return;
}
const resolved = resolveSelectedGameSource();
activeGameSource = resolved.gameSource;
const difficulty = activeGameSource === GAME_SOURCES.SMB2
@@ -1590,6 +2377,16 @@ startButton.addEventListener('click', () => {
: activeGameSource === GAME_SOURCES.MB2WS
? buildMb2wsCourseConfig()
: buildSmb1CourseConfig();
if (netplayEnabled && netplayState?.role === 'host') {
netplayState.currentCourse = difficulty;
netplayState.currentGameSource = activeGameSource;
hostRelay?.broadcast({
type: 'start',
gameSource: activeGameSource,
course: difficulty,
stageBasePath: getStageBasePath(activeGameSource),
});
}
startStage(difficulty).catch((error) => {
if (hudStatus) {
hudStatus.textContent = 'Failed to load stage.';
@@ -1620,4 +2417,28 @@ window.addEventListener('keydown', (event) => {
}
});
if (lobbyRefreshButton) {
lobbyRefreshButton.addEventListener('click', () => {
void refreshLobbyList();
});
}
if (lobbyCreateButton) {
lobbyCreateButton.addEventListener('click', () => {
void createRoom();
});
}
if (lobbyJoinButton) {
lobbyJoinButton.addEventListener('click', () => {
void joinRoomByCode();
});
}
if (lobbyLeaveButton) {
lobbyLeaveButton.addEventListener('click', () => {
void leaveRoom();
});
}
if (lobbyClient) {
void refreshLobbyList();
}
requestAnimationFrame(renderFrame);

95
src/netcode_protocol.ts Normal file
View File

@@ -0,0 +1,95 @@
import type { QuantizedInput } from './determinism.js';
import type { GameSource } from './constants.js';
export type PlayerId = number;
export type RoomSettings = {
maxPlayers: number;
collisionEnabled: boolean;
};
export type RoomInfo = {
roomId: string;
roomCode?: string;
isPublic: boolean;
hostId: PlayerId;
courseId: string;
settings: RoomSettings;
};
export type InputFrameMessage = {
type: 'input';
frame: number;
playerId: PlayerId;
input: QuantizedInput;
lastAck?: number;
};
export type InputAckMessage = {
type: 'ack';
playerId: PlayerId;
frame: number;
};
export type FrameBundleMessage = {
type: 'frame';
frame: number;
inputs: Record<number, QuantizedInput>;
lastAck?: number;
hash?: number;
};
export type SnapshotMessage = {
type: 'snapshot';
frame: number;
state: any;
stageId?: number;
gameSource?: GameSource;
};
export type SnapshotRequestMessage = {
type: 'snapshot_request';
frame: number;
reason: 'mismatch' | 'lag';
};
export type PlayerJoinMessage = {
type: 'player_join';
playerId: PlayerId;
spectator: boolean;
pendingSpawn?: boolean;
};
export type PlayerLeaveMessage = {
type: 'player_leave';
playerId: PlayerId;
};
export type RoomUpdateMessage = {
type: 'room_update';
room: RoomInfo;
};
export type StartMatchMessage = {
type: 'start';
gameSource: GameSource;
course: any;
stageBasePath?: string;
};
export type HostToClientMessage =
| InputFrameMessage
| InputAckMessage
| FrameBundleMessage
| SnapshotMessage
| StartMatchMessage
| PlayerJoinMessage
| PlayerLeaveMessage
| RoomUpdateMessage;
export type ClientToHostMessage =
| InputFrameMessage
| InputAckMessage
| SnapshotRequestMessage
| PlayerJoinMessage
| PlayerLeaveMessage;

261
src/netplay.ts Normal file
View File

@@ -0,0 +1,261 @@
import type {
ClientToHostMessage,
HostToClientMessage,
RoomInfo,
} from './netcode_protocol.js';
export type SignalMessage = {
type: 'signal';
from: number;
to: number | null;
payload: any;
};
const DEFAULT_STUN = [{ urls: 'stun:stun.l.google.com:19302' }];
export class LobbyClient {
constructor(private baseUrl: string) {}
async listRooms(): Promise<RoomInfo[]> {
const res = await fetch(`${this.baseUrl}/rooms`);
const data = await res.json();
return data.rooms ?? [];
}
async createRoom(room: Partial<RoomInfo>): Promise<RoomInfo> {
const res = await fetch(`${this.baseUrl}/rooms`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(room),
});
const data = await res.json();
return data.room;
}
async joinRoom(roomIdOrCode: { roomId?: string; roomCode?: string }): Promise<RoomInfo> {
const res = await fetch(`${this.baseUrl}/rooms/join`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(roomIdOrCode),
});
const data = await res.json();
return data.room;
}
async heartbeat(roomId: string): Promise<void> {
await fetch(`${this.baseUrl}/rooms/heartbeat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ roomId }),
});
}
async closeRoom(roomId: string): Promise<void> {
await fetch(`${this.baseUrl}/rooms/close`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ roomId }),
});
}
openSignal(roomId: string, playerId: number, onMessage: (msg: SignalMessage) => void, onClose: () => void) {
const ws = new WebSocket(`${this.baseUrl.replace('http', 'ws')}/room/${roomId}?playerId=${playerId}`);
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data) as SignalMessage;
if (msg?.type === 'signal') {
onMessage(msg);
}
} catch {
// Ignore malformed.
}
});
ws.addEventListener('close', () => onClose());
return {
send: (msg: SignalMessage) => ws.readyState === WebSocket.OPEN && ws.send(JSON.stringify(msg)),
close: () => ws.close(),
};
}
}
export class HostRelay {
private peers = new Map<number, RTCPeerConnection>();
private channels = new Map<number, RTCDataChannel>();
constructor(private onMessage: (playerId: number, msg: ClientToHostMessage) => void) {}
createPeer(playerId: number): RTCPeerConnection {
const pc = new RTCPeerConnection({ iceServers: DEFAULT_STUN });
pc.addEventListener('icecandidate', (ev) => {
if (ev.candidate) {
this.onSignal?.({ type: 'signal', from: this.hostId, to: playerId, payload: { ice: ev.candidate } });
}
});
pc.addEventListener('datachannel', (ev) => {
this.attachChannel(playerId, ev.channel);
});
this.peers.set(playerId, pc);
return pc;
}
attachChannel(playerId: number, channel: RTCDataChannel) {
this.channels.set(playerId, channel);
channel.binaryType = 'arraybuffer';
channel.addEventListener('open', () => {
this.onConnect?.(playerId);
});
channel.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data) as ClientToHostMessage;
if (msg?.type) {
this.onMessage(playerId, msg);
}
} catch {
// Ignore malformed.
}
});
channel.addEventListener('close', () => {
this.onDisconnect?.(playerId);
});
}
broadcast(msg: HostToClientMessage) {
const payload = JSON.stringify(msg);
for (const channel of this.channels.values()) {
if (channel.readyState === 'open') {
channel.send(payload);
}
}
}
sendTo(playerId: number, msg: HostToClientMessage) {
const channel = this.channels.get(playerId);
if (!channel || channel.readyState !== 'open') {
return;
}
channel.send(JSON.stringify(msg));
}
closeAll() {
for (const channel of this.channels.values()) {
try {
channel.close();
} catch {
// Ignore.
}
}
for (const peer of this.peers.values()) {
try {
peer.close();
} catch {
// Ignore.
}
}
this.channels.clear();
this.peers.clear();
}
hostId = 0;
onSignal?: (msg: SignalMessage) => void;
onConnect?: (playerId: number) => void;
onDisconnect?: (playerId: number) => void;
}
export class ClientPeer {
private pc: RTCPeerConnection | null = null;
private channel: RTCDataChannel | null = null;
constructor(private onMessage: (msg: HostToClientMessage) => void) {}
private attachChannel(channel: RTCDataChannel) {
this.channel = channel;
channel.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data) as HostToClientMessage;
this.onMessage(msg);
} catch {
// Ignore malformed.
}
});
channel.addEventListener('close', () => {
this.onDisconnect?.();
});
}
async createConnection(): Promise<RTCPeerConnection> {
const pc = new RTCPeerConnection({ iceServers: DEFAULT_STUN });
pc.addEventListener('icecandidate', (ev) => {
if (ev.candidate) {
this.onSignal?.({ type: 'signal', from: this.playerId, to: this.hostId, payload: { ice: ev.candidate } });
}
});
pc.addEventListener('datachannel', (ev) => {
this.attachChannel(ev.channel);
});
this.pc = pc;
return pc;
}
send(msg: ClientToHostMessage) {
if (this.channel?.readyState === 'open') {
this.channel.send(JSON.stringify(msg));
}
}
close() {
try {
this.channel?.close();
} catch {
// Ignore.
}
try {
this.pc?.close();
} catch {
// Ignore.
}
this.channel = null;
this.pc = null;
}
async handleSignal(payload: any) {
if (!this.pc) {
return;
}
if (payload?.sdp) {
await this.pc.setRemoteDescription(payload.sdp);
if (payload.sdp.type === 'offer') {
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.onSignal?.({ type: 'signal', from: this.playerId, to: this.hostId, payload: { sdp: this.pc.localDescription } });
}
} else if (payload?.ice) {
await this.pc.addIceCandidate(payload.ice);
}
}
playerId = 0;
hostId = 0;
onSignal?: (msg: SignalMessage) => void;
onDisconnect?: () => void;
}
export async function createHostOffer(host: HostRelay, playerId: number) {
const pc = host.createPeer(playerId);
const channel = pc.createDataChannel('game');
host.attachChannel(playerId, channel);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
return pc.localDescription;
}
export async function applyHostSignal(host: HostRelay, playerId: number, payload: any) {
const pc = host.createPeer(playerId);
if (payload?.sdp) {
await pc.setRemoteDescription(payload.sdp);
if (payload.sdp.type === 'answer') {
return;
}
} else if (payload?.ice) {
await pc.addIceCandidate(payload.ice);
}
}

View File

@@ -140,6 +140,7 @@ export type GameplaySyncState = {
jamabars?: JamabarRenderState[] | null;
animGroupTransforms?: Float32Array[] | null;
ball?: BallRenderState | null;
balls?: BallRenderState[] | null;
goalBags?: GoalBagRenderState[] | null;
goalTapes?: GoalTapeRenderState[] | null;
confetti?: ConfettiRenderState[] | null;
@@ -582,7 +583,10 @@ export class Renderer {
if (state.bananaCollectedByAnimGroup) {
this.world.setBananaCollectedByAnimGroup(state.bananaCollectedByAnimGroup);
}
if (state.ball !== undefined) {
const hasBalls = state.balls !== undefined;
if (hasBalls) {
this.world.setBallsState(state.balls ?? null);
} else if (state.ball !== undefined) {
this.world.setBallState(state.ball ?? null);
}
if (state.goalBags !== undefined) {

View File

@@ -651,6 +651,7 @@ export class World {
private background: Background;
private fgObjects: BgObjectInst[] = [];
private ball: BallInst;
private balls: BallInst[] = [];
private ballPos = vec3.create();
private ballRadius = 0;
private ballVisible = false;
@@ -1083,6 +1084,7 @@ export class World {
this.background = new stageData.stageInfo.bgInfo.bgConstructor(this.worldState, bgObjects);
this.fgObjects = fgObjects;
this.ball = new BallInst(this.worldState.modelCache, stageData);
this.balls = [this.ball];
this.shadowProgram = createShadowProgram(renderCache);
this.shadowMegaState.depthWrite = false;
this.streakProgram = createStreakProgram(renderCache);
@@ -1195,6 +1197,51 @@ export class World {
}
}
public setBallsState(states: BallRenderState[] | null): void {
if (!states || states.length === 0) {
this.balls = [this.ball];
this.ball.setState(null);
this.hasBallPosForTilt = false;
this.hasBallPosForTiltPrev = false;
this.ballVisible = false;
this.ballRadius = 0;
return;
}
if (this.balls.length !== states.length) {
this.balls = new Array(states.length);
for (let i = 0; i < states.length; i++) {
this.balls[i] = new BallInst(this.worldState.modelCache, this.stageData);
}
}
let primary: BallRenderState | null = null;
for (let i = 0; i < states.length; i++) {
const state = states[i];
this.balls[i].setState(state);
if (!primary && state.visible) {
primary = state;
}
}
if (!primary) {
primary = states[0];
}
if (primary) {
if (this.hasBallPosForTilt) {
vec3.copy(this.ballPosForTiltPrev, this.ballPosForTilt);
this.hasBallPosForTiltPrev = true;
}
this.ballVisible = primary.visible;
this.ballRadius = primary.radius;
vec3.set(this.ballPos, primary.pos.x, primary.pos.y, primary.pos.z);
vec3.set(this.ballPosForTilt, primary.pos.x, primary.pos.y, primary.pos.z);
this.hasBallPosForTilt = true;
} else {
this.hasBallPosForTilt = false;
this.hasBallPosForTiltPrev = false;
this.ballVisible = false;
this.ballRadius = 0;
}
}
public update(viewerInput: Viewer.ViewerRenderInput): void {
if (this.externalTimeFrames !== null) {
this.worldState.time.overrideTimeFrames(this.externalTimeFrames, this.externalDeltaFrames);
@@ -1346,7 +1393,9 @@ export class World {
this.fgObjects[i].prepareToRenderWithViewMatrix(this.worldState, stageCtx, viewFromWorldTilted);
}
this.background.prepareToRender(this.worldState, bgCtx);
this.ball.prepareToRender(this.worldState, stageCtx);
for (let i = 0; i < this.balls.length; i++) {
this.balls[i].prepareToRender(this.worldState, stageCtx);
}
}
public getMirrorMode(): MirrorMode {

View File

@@ -295,6 +295,45 @@ export function startBallDrop(ball, frames = 24) {
ball.prevTransform.set(ball.transform);
}
export function resolveBallBallCollision(ballA, ballB) {
const dx = ballB.pos.x - ballA.pos.x;
const dy = ballB.pos.y - ballA.pos.y;
const dz = ballB.pos.z - ballA.pos.z;
const minDist = (ballA.currRadius ?? 0.5) + (ballB.currRadius ?? 0.5);
const distSq = dx * dx + dy * dy + dz * dz;
if (distSq <= FLT_EPSILON || distSq >= minDist * minDist) {
return;
}
const dist = sqrt(distSq);
const nx = dx / dist;
const ny = dy / dist;
const nz = dz / dist;
const overlap = minDist - dist;
const correction = overlap * 0.5;
ballA.pos.x -= nx * correction;
ballA.pos.y -= ny * correction;
ballA.pos.z -= nz * correction;
ballB.pos.x += nx * correction;
ballB.pos.y += ny * correction;
ballB.pos.z += nz * correction;
const rvx = ballB.vel.x - ballA.vel.x;
const rvy = ballB.vel.y - ballA.vel.y;
const rvz = ballB.vel.z - ballA.vel.z;
const relVel = rvx * nx + rvy * ny + rvz * nz;
if (relVel >= 0) {
return;
}
const restitution = (ballA.restitution + ballB.restitution) * 0.5;
const impulse = -((1 + restitution) * relVel) * 0.5;
ballA.vel.x -= impulse * nx;
ballA.vel.y -= impulse * ny;
ballA.vel.z -= impulse * nz;
ballB.vel.x += impulse * nx;
ballB.vel.y += impulse * ny;
ballB.vel.z += impulse * nz;
}
function updateBallCameraSteerYaw(ball) {
const speed = sqrt(sumSq2(ball.vel.x, ball.vel.z));
let velYaw = ball.apeYaw;

74
src/rollback.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { QuantizedInput } from './determinism.js';
export type FrameInputs = Map<number, QuantizedInput>;
export type RollbackCallbacks<T> = {
saveState: () => T;
loadState: (state: T) => void;
advanceFrame: (inputs: FrameInputs) => void;
};
export class RollbackSession<T> {
private callbacks: RollbackCallbacks<T>;
private maxRollbackFrames: number;
private stateHistory = new Map<number, T>();
private inputHistory = new Map<number, FrameInputs>();
private lastFrame = 0;
constructor(callbacks: RollbackCallbacks<T>, maxRollbackFrames = 30) {
this.callbacks = callbacks;
this.maxRollbackFrames = Math.max(1, maxRollbackFrames | 0);
}
getFrame() {
return this.lastFrame;
}
prime(frame: number) {
const target = frame | 0;
this.lastFrame = target;
this.stateHistory.set(target, this.callbacks.saveState());
this.inputHistory.set(target, new Map());
this.trimHistory(target);
}
pushLocalFrame(frame: number, inputs: FrameInputs) {
this.inputHistory.set(frame, inputs);
}
advanceTo(frame: number, inputs: FrameInputs) {
this.inputHistory.set(frame, inputs);
this.callbacks.advanceFrame(inputs);
this.lastFrame = frame;
this.stateHistory.set(frame, this.callbacks.saveState());
this.trimHistory(frame);
}
rollbackTo(frame: number) {
const state = this.stateHistory.get(frame);
if (!state) {
return false;
}
this.callbacks.loadState(state);
this.lastFrame = frame;
return true;
}
getInputs(frame: number) {
return this.inputHistory.get(frame) ?? null;
}
private trimHistory(frame: number) {
const minFrame = frame - this.maxRollbackFrames;
for (const key of this.stateHistory.keys()) {
if (key < minFrame) {
this.stateHistory.delete(key);
}
}
for (const key of this.inputHistory.keys()) {
if (key < minFrame) {
this.inputHistory.delete(key);
}
}
}
}

View File

@@ -23,34 +23,48 @@ function hashS16(hash, value) {
return hashU32(hash, value & 0xffff);
}
export function hashSimState(ball, world, stageRuntime, { includeVisual = false } = {}) {
export function hashSimState(ballOrBalls, worldOrWorlds, stageRuntime, { includeVisual = false } = {}) {
let h = 0x811c9dc5;
if (!ball || !world || !stageRuntime) {
if (!ballOrBalls || !worldOrWorlds || !stageRuntime) {
return h >>> 0;
}
h = hashVec3(h, ball.pos);
h = hashVec3(h, ball.vel);
h = hashS16(h, ball.rotX);
h = hashS16(h, ball.rotY);
h = hashS16(h, ball.rotZ);
h = hashU32(h, ball.state | 0);
h = hashU32(h, ball.flags | 0);
h = hashF32(h, ball.currRadius ?? 0);
h = hashF32(h, ball.speed ?? 0);
h = hashU32(h, ball.animGroupId | 0);
h = hashS16(h, ball.apeYaw ?? 0);
if (ball.orientation) {
h = hashF32(h, ball.orientation.x);
h = hashF32(h, ball.orientation.y);
h = hashF32(h, ball.orientation.z);
h = hashF32(h, ball.orientation.w);
const balls = Array.isArray(ballOrBalls) ? ballOrBalls : [ballOrBalls];
h = hashU32(h, balls.length | 0);
for (const ball of balls) {
if (!ball) {
continue;
}
h = hashVec3(h, ball.pos);
h = hashVec3(h, ball.vel);
h = hashS16(h, ball.rotX);
h = hashS16(h, ball.rotY);
h = hashS16(h, ball.rotZ);
h = hashU32(h, ball.state | 0);
h = hashU32(h, ball.flags | 0);
h = hashF32(h, ball.currRadius ?? 0);
h = hashF32(h, ball.speed ?? 0);
h = hashU32(h, ball.animGroupId | 0);
h = hashS16(h, ball.apeYaw ?? 0);
if (ball.orientation) {
h = hashF32(h, ball.orientation.x);
h = hashF32(h, ball.orientation.y);
h = hashF32(h, ball.orientation.z);
h = hashF32(h, ball.orientation.w);
}
}
h = hashS16(h, world.xrot ?? 0);
h = hashS16(h, world.zrot ?? 0);
if (world.gravity) {
h = hashVec3(h, world.gravity);
const worlds = Array.isArray(worldOrWorlds) ? worldOrWorlds : [worldOrWorlds];
h = hashU32(h, worlds.length | 0);
for (const world of worlds) {
if (!world) {
continue;
}
h = hashS16(h, world.xrot ?? 0);
h = hashS16(h, world.zrot ?? 0);
if (world.gravity) {
h = hashVec3(h, world.gravity);
}
}
h = hashU32(h, stageRuntime.timerFrames | 0);
@@ -76,6 +90,10 @@ export function hashSimState(ball, world, stageRuntime, { includeVisual = false
if (stageRuntime.bananas) {
h = hashU32(h, stageRuntime.bananas.length | 0);
for (const banana of stageRuntime.bananas) {
if (banana.collected || banana.state === 8) {
h = hashU32(h, 0);
continue;
}
h = hashU32(h, banana.state | 0);
h = hashVec3(h, banana.localPos);
h = hashVec3(h, banana.vel ?? { x: 0, y: 0, z: 0 });

View File

@@ -5,7 +5,9 @@ export function runDeterminismTest(game, tickCount, inputFeed = null, { includeV
const hashes = [];
for (let i = 0; i < tickCount; i += 1) {
game.update(game.fixedStep);
hashes.push(hashSimState(game.ball, game.world, game.stageRuntime, { includeVisual }));
const balls = game.players ? game.players.map((player) => player.ball) : game.ball;
const worlds = game.players ? [game.world, ...game.players.map((player) => player.world)] : game.world;
hashes.push(hashSimState(balls, worlds, game.stageRuntime, { includeVisual }));
}
return hashes;
}

View File

@@ -1542,6 +1542,87 @@ export class StageRuntime {
}
}
getState() {
return {
timerFrames: this.timerFrames,
animGroups: structuredClone(this.animGroups),
bumpers: structuredClone(this.bumpers),
jamabars: structuredClone(this.jamabars),
goals: structuredClone(this.goals),
goalBags: structuredClone(this.goalBags),
goalTapes: structuredClone(this.goalTapes),
bananas: structuredClone(this.bananas),
confetti: structuredClone(this.confetti),
effects: structuredClone(this.effects),
switches: structuredClone(this.switches),
switchPressCount: this.switchPressCount ?? 0,
wormholes: structuredClone(this.wormholes),
seesaws: structuredClone(this.seesaws),
boundSphere: structuredClone(this.boundSphere),
goalHoldOpen: this.goalHoldOpen,
switchesEnabled: this.switchesEnabled,
simRngState: this.simRng?.state ?? 0,
visualRngState: this.visualRng?.state ?? 0,
};
}
setState(state) {
if (!state) {
return;
}
this.timerFrames = state.timerFrames ?? 0;
this.animGroups = structuredClone(state.animGroups ?? []);
this.bumpers = structuredClone(state.bumpers ?? []);
this.jamabars = structuredClone(state.jamabars ?? []);
this.goals = structuredClone(state.goals ?? []);
this.goalBags = structuredClone(state.goalBags ?? []);
this.goalTapes = structuredClone(state.goalTapes ?? []);
this.bananas = structuredClone(state.bananas ?? []);
this.confetti = structuredClone(state.confetti ?? []);
this.effects = structuredClone(state.effects ?? []);
this.switches = structuredClone(state.switches ?? []);
this.switchPressCount = state.switchPressCount ?? 0;
this.wormholes = structuredClone(state.wormholes ?? []);
this.seesaws = structuredClone(state.seesaws ?? []);
this.boundSphere = structuredClone(state.boundSphere ?? this.boundSphere);
this.goalHoldOpen = !!state.goalHoldOpen;
this.switchesEnabled = !!state.switchesEnabled;
if (this.simRng) {
this.simRng.state = state.simRngState ?? this.simRng.state;
}
if (this.visualRng) {
this.visualRng.state = state.visualRngState ?? this.visualRng.state;
}
const count = this.stage.animGroupCount ?? 0;
this.goalBagsByGroup.length = count;
this.goalTapesByGroup.length = count;
this.switchesByGroup.length = count;
for (let i = 0; i < count; i += 1) {
this.goalBagsByGroup[i] = [];
this.goalTapesByGroup[i] = [];
this.switchesByGroup[i] = [];
}
for (const bag of this.goalBags) {
const group = bag.animGroupId ?? 0;
if (this.goalBagsByGroup[group]) {
this.goalBagsByGroup[group].push(bag);
}
}
for (const tape of this.goalTapes) {
const group = tape.animGroupId ?? 0;
if (this.goalTapesByGroup[group]) {
this.goalTapesByGroup[group].push(tape);
}
}
for (const sw of this.switches) {
const group = sw.animGroupIndex ?? sw.animGroupId ?? 0;
if (this.switchesByGroup[group]) {
this.switchesByGroup[group].push(sw);
}
}
}
updateSwitchesSmb2PreAnim() {
const stack = this.matrixStack;
for (const stageSwitch of this.switches) {
@@ -1949,7 +2030,8 @@ export class StageRuntime {
stack.rigidInvTfPoint(flyTargetLocal, flyTargetLocal);
}
}
updateBanana(banana, ballLocalPos, flyTargetLocal);
const allowFlyToHud = !!flyTargetLocal;
updateBanana(banana, ballLocalPos, flyTargetLocal, allowFlyToHud);
}
for (const stageSwitch of this.switches) {
if (stageSwitch.cooldown > 0) {
@@ -2615,7 +2697,7 @@ function updateGoalBag(bag, animGroups, gravity, holdOpen, stack, rng) {
updateGoalBagTransform(bag, stack);
}
function updateBanana(banana, ballLocalPos, flyTargetLocal) {
function updateBanana(banana, ballLocalPos, flyTargetLocal, allowFlyToHud) {
if (banana.state === 0) {
return;
}
@@ -2656,8 +2738,14 @@ function updateBanana(banana, ballLocalPos, flyTargetLocal) {
return;
}
if (banana.state === BANANA_STATE_FLY) {
if (!flyTargetLocal) {
banana.state = 6;
if (!allowFlyToHud) {
banana.localPos.y += 0.02;
banana.scale -= 0.01;
if (banana.scale <= 0) {
banana.scale = 0;
banana.state = 0;
banana.collected = true;
}
return;
}
banana.flyTimer -= 1;

View File

@@ -216,6 +216,12 @@ body.gameplay-active {
letter-spacing: 0.3px;
}
.panel h2 {
margin: 0;
font-size: 18px;
letter-spacing: 0.2px;
}
.panel p {
margin: 0 0 16px;
color: var(--muted);
@@ -305,6 +311,70 @@ body.gameplay-active {
margin: 0 0 16px;
}
.panel-section {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.panel-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.multiplayer-row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.text-input {
flex: 1 1 auto;
padding: 10px 12px;
background: rgba(12, 12, 18, 0.6);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
color: var(--text);
font-size: 14px;
}
.lobby-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.lobby-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(12, 12, 18, 0.55);
font-size: 13px;
}
.lobby-item span {
color: var(--muted);
}
.lobby-players {
margin-top: 8px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(12, 12, 18, 0.35);
font-size: 12px;
color: var(--muted);
line-height: 1.4;
}
.control-mode-block {
margin-bottom: 12px;
}

281
worker/src/index.ts Normal file
View File

@@ -0,0 +1,281 @@
export interface Env {
LOBBY: DurableObjectNamespace;
ROOM: DurableObjectNamespace;
}
type RoomSettings = {
maxPlayers: number;
collisionEnabled: boolean;
};
type RoomRecord = {
roomId: string;
roomCode?: string;
isPublic: boolean;
hostId: number;
courseId: string;
settings: RoomSettings;
createdAt: number;
lastActiveAt: number;
};
type LobbyState = {
rooms: Record<string, RoomRecord>;
codes: Record<string, string>;
};
const ROOM_TTL_MS = 1000 * 60;
const CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
function jsonResponse(data: any, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
"content-type": "application/json",
"access-control-allow-origin": "*",
},
});
}
function parseJson<T>(req: Request): Promise<T> {
return req.json() as Promise<T>;
}
function randomCode(length = 6): string {
let out = "";
for (let i = 0; i < length; i += 1) {
out += CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)];
}
return out;
}
function nowMs() {
return Date.now();
}
export class Lobby implements DurableObject {
private state: DurableObjectState;
private env: Env;
private data: LobbyState = { rooms: {}, codes: {} };
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
}
private async load(): Promise<void> {
const stored = await this.state.storage.get<LobbyState>("lobby");
if (stored) {
this.data = stored;
}
}
private async save(): Promise<void> {
await this.state.storage.put("lobby", this.data);
}
private cleanupExpired(): void {
const now = nowMs();
for (const roomId of Object.keys(this.data.rooms)) {
const room = this.data.rooms[roomId];
if (!room) {
continue;
}
if (now - room.lastActiveAt > ROOM_TTL_MS) {
delete this.data.rooms[roomId];
if (room.roomCode) {
delete this.data.codes[room.roomCode];
}
}
}
}
async fetch(request: Request): Promise<Response> {
await this.load();
this.cleanupExpired();
const url = new URL(request.url);
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET,POST,OPTIONS",
"access-control-allow-headers": "content-type",
},
});
}
if (request.method === "GET" && url.pathname === "/rooms") {
const list = Object.values(this.data.rooms).filter((room) => room.isPublic);
return jsonResponse({ rooms: list });
}
if (request.method === "POST" && url.pathname === "/rooms") {
const body = await parseJson<Partial<RoomRecord>>(request);
const roomId = this.env.ROOM.newUniqueId().toString();
const isPublic = !!body.isPublic;
let roomCode: string | undefined;
if (!isPublic) {
for (let i = 0; i < 10; i += 1) {
const code = randomCode();
if (!this.data.codes[code]) {
roomCode = code;
this.data.codes[code] = roomId;
break;
}
}
}
const createdAt = nowMs();
const record: RoomRecord = {
roomId,
roomCode,
isPublic,
hostId: body.hostId ?? 0,
courseId: body.courseId ?? "smb1-main",
settings: body.settings ?? { maxPlayers: 8, collisionEnabled: true },
createdAt,
lastActiveAt: createdAt,
};
this.data.rooms[roomId] = record;
await this.save();
return jsonResponse({ room: record });
}
if (request.method === "POST" && url.pathname === "/rooms/join") {
const body = await parseJson<{ roomCode?: string; roomId?: string }>(request);
const roomId = body.roomId ?? (body.roomCode ? this.data.codes[body.roomCode] : null);
if (!roomId || !this.data.rooms[roomId]) {
return jsonResponse({ error: "room_not_found" }, 404);
}
const room = this.data.rooms[roomId];
room.lastActiveAt = nowMs();
this.data.rooms[roomId] = room;
await this.save();
return jsonResponse({ room });
}
if (request.method === "POST" && url.pathname === "/rooms/heartbeat") {
const body = await parseJson<{ roomId?: string }>(request);
const roomId = body.roomId ?? null;
if (!roomId || !this.data.rooms[roomId]) {
return jsonResponse({ ok: false, error: "room_not_found" }, 404);
}
const room = this.data.rooms[roomId];
room.lastActiveAt = nowMs();
this.data.rooms[roomId] = room;
await this.save();
return jsonResponse({ ok: true });
}
if (request.method === "POST" && url.pathname === "/rooms/close") {
const body = await parseJson<{ roomId?: string }>(request);
const roomId = body.roomId ?? null;
if (!roomId || !this.data.rooms[roomId]) {
return jsonResponse({ ok: false, error: "room_not_found" }, 404);
}
const room = this.data.rooms[roomId];
delete this.data.rooms[roomId];
if (room.roomCode) {
delete this.data.codes[room.roomCode];
}
await this.save();
return jsonResponse({ ok: true });
}
return jsonResponse({ error: "not_found" }, 404);
}
}
type Connection = {
socket: WebSocket;
playerId: number;
};
export class Room implements DurableObject {
private state: DurableObjectState;
private env: Env;
private connections = new Map<string, Connection>();
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
}
private async heartbeat(roomId: string): Promise<void> {
const id = this.env.LOBBY.idFromName("lobby");
const stub = this.env.LOBBY.get(id);
await stub.fetch("https://lobby.internal/rooms/heartbeat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ roomId }),
});
}
private async closeRoom(roomId: string): Promise<void> {
const id = this.env.LOBBY.idFromName("lobby");
const stub = this.env.LOBBY.get(id);
await stub.fetch("https://lobby.internal/rooms/close", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ roomId }),
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.headers.get("upgrade") !== "websocket") {
return new Response("Expected websocket", { status: 400 });
}
const playerId = Number(url.searchParams.get("playerId") ?? "0");
const [client, server] = new WebSocketPair();
server.accept();
const connId = crypto.randomUUID();
this.connections.set(connId, { socket: server, playerId });
const roomId = this.state.id.toString();
void this.heartbeat(roomId);
server.addEventListener("message", (event) => {
const payload = event.data;
void this.heartbeat(roomId);
for (const [id, conn] of this.connections.entries()) {
if (id === connId) {
continue;
}
conn.socket.send(payload);
}
});
server.addEventListener("close", () => {
this.connections.delete(connId);
if (this.connections.size === 0) {
this.state.storage.deleteAll();
void this.closeRoom(roomId);
}
});
return new Response(null, { status: 101, webSocket: client });
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith("/room/")) {
const roomId = url.pathname.slice("/room/".length);
const id = env.ROOM.idFromString(roomId);
const stub = env.ROOM.get(id);
return stub.fetch(request);
}
if (url.pathname.startsWith("/rooms")) {
const id = env.LOBBY.idFromName("lobby");
const stub = env.LOBBY.get(id);
return stub.fetch(request);
}
return new Response("Not found", { status: 404 });
},
};

13
worker/wrangler.toml Normal file
View File

@@ -0,0 +1,13 @@
name = "webmonkeyball-lobby"
main = "src/index.ts"
compatibility_date = "2025-01-01"
[durable_objects]
bindings = [
{ name = "LOBBY", class_name = "Lobby" },
{ name = "ROOM", class_name = "Room" }
]
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Lobby", "Room"]