From ddf68f316e4411728d4c9596498405fb53da782b Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Sun, 8 Mar 2026 20:56:28 +0100 Subject: [PATCH] Improve network stack (less traffic) --- src/game.js | 130 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 115 insertions(+), 15 deletions(-) diff --git a/src/game.js b/src/game.js index b07ef94..e986036 100644 --- a/src/game.js +++ b/src/game.js @@ -55,7 +55,8 @@ const POWERUPS = ["bomb", "flame", "speed"]; const MAX_PLAYERS = 8; const NETWORK_PATH = "/ws"; const PLAYER_NAME_STORAGE_KEY = "gptBomberPlayerName"; -const SNAPSHOT_RATE = 24; +const SNAPSHOT_RATE = 18; +const MAP_RESYNC_SECONDS = 1.2; const canvas = document.getElementById("game"); const ctx = canvas.getContext("2d"); @@ -142,6 +143,8 @@ const network = { lastPingAt: 0, lobbyPhase: "lobby", hasReceivedSnapshot: false, + lastFullMapSnapshotAt: 0, + lastSentMapState: null, }; let audio = null; @@ -351,6 +354,7 @@ function resetRoundSingle() { state.multiplayerRoundKills = {}; state.multiplayerRoundDeathTimes = {}; state.multiplayerRoundTime = 0; + resetHostSnapshotMapState(); closeMenu(); } @@ -384,6 +388,7 @@ function resetRoundMultiplayer(roster) { state.multiplayerRoundKills = {}; state.multiplayerRoundDeathTimes = {}; state.multiplayerRoundTime = 0; + resetHostSnapshotMapState(); closeMenu(); inputState.bombQueued = false; network.remoteBombPrev.clear(); @@ -858,6 +863,7 @@ function leaveLobbyInternals() { network.inputStateAtClient = { dir: null, bomb: false, bombCell: null }; network.lastInputSentAt = 0; network.hasReceivedSnapshot = false; + resetHostSnapshotMapState(); } function leaveMultiplayerToMainMenu() { @@ -878,12 +884,68 @@ function hostStartMatchFromLobby() { sendSocketMessage("lobby_start"); } -function serializeGameState() { +function resetHostSnapshotMapState() { + network.lastSentMapState = null; + network.lastFullMapSnapshotAt = 0; +} + +function serializeMapTile(tile) { + return { type: tile.type, powerup: tile.powerup || null }; +} + +function mapTileSignature(tile) { + return tile.type + "|" + (tile.powerup || ""); +} + +function buildMapSignatureState() { + return state.map.map((row) => row.map((tile) => mapTileSignature(tile))); +} + +function serializeMapPatch() { + if (!Array.isArray(network.lastSentMapState) || network.lastSentMapState.length !== state.map.length) { + network.lastSentMapState = buildMapSignatureState(); + return []; + } + + const patch = []; + for (let y = 0; y < state.map.length; y += 1) { + const row = state.map[y]; + const previousRow = network.lastSentMapState[y]; + for (let x = 0; x < row.length; x += 1) { + const tile = row[x]; + const signature = mapTileSignature(tile); + if (!previousRow || previousRow[x] !== signature) { + patch.push({ x, y, type: tile.type, powerup: tile.powerup || null }); + if (previousRow) { + previousRow[x] = signature; + } + } + } + } + + return patch; +} + +function serializeGameState(includeFullMap = false) { + let map; + let mapPatch; + + if (includeFullMap || !Array.isArray(network.lastSentMapState)) { + map = state.map.map((row) => row.map((tile) => serializeMapTile(tile))); + network.lastSentMapState = buildMapSignatureState(); + } else { + const patch = serializeMapPatch(); + if (patch.length > 0) { + mapPatch = patch; + } + } + return { status: state.status, message: state.message, time: state.time, - map: state.map.map((row) => row.map((tile) => ({ type: tile.type, powerup: tile.powerup || null }))), + map, + mapPatch, players: state.players.map((player) => ({ id: player.id, name: player.name, @@ -957,20 +1019,33 @@ function playSnapshotSfx(snapshot) { } let powerupCollected = false; - for (let y = 0; y < state.map.length; y += 1) { - for (let x = 0; x < state.map[y].length; x += 1) { - const prevTile = state.map[y][x]; - const nextTile = snapshot.map[y]?.[x]; - if (!prevTile || !nextTile) { - continue; + if (Array.isArray(snapshot.map)) { + for (let y = 0; y < state.map.length; y += 1) { + for (let x = 0; x < state.map[y].length; x += 1) { + const prevTile = state.map[y][x]; + const nextTile = snapshot.map[y]?.[x]; + if (!prevTile || !nextTile) { + continue; + } + if (prevTile.type === "floor" && prevTile.powerup && nextTile.type === "floor" && !nextTile.powerup) { + powerupCollected = true; + break; + } } - if (prevTile.type === "floor" && prevTile.powerup && nextTile.type === "floor" && !nextTile.powerup) { - powerupCollected = true; + if (powerupCollected) { break; } } - if (powerupCollected) { - break; + } else if (Array.isArray(snapshot.mapPatch)) { + for (const change of snapshot.mapPatch) { + const prevTile = state.map[change.y]?.[change.x]; + if (!prevTile) { + continue; + } + if (prevTile.type === "floor" && prevTile.powerup && change.type === "floor" && !change.powerup) { + powerupCollected = true; + break; + } } } @@ -989,7 +1064,21 @@ function applySnapshot(snapshot) { state.status = snapshot.status; state.message = snapshot.message; state.time = snapshot.time; - state.map = snapshot.map.map((row) => row.map((tile) => ({ type: tile.type, powerup: tile.powerup }))); + + if (Array.isArray(snapshot.map)) { + state.map = snapshot.map.map((row) => row.map((tile) => ({ type: tile.type, powerup: tile.powerup }))); + } else if (Array.isArray(snapshot.mapPatch) && state.map.length > 0) { + for (const change of snapshot.mapPatch) { + if (!inBounds(change.x, change.y)) { + continue; + } + const row = state.map[change.y]; + if (!row || !row[change.x]) { + continue; + } + row[change.x] = { type: change.type, powerup: change.powerup || null }; + } + } state.players = snapshot.players.map((player) => { const key = player.ownerId || `id:${player.id}`; const prev = previousPlayers.get(key); @@ -1031,12 +1120,23 @@ function broadcastSnapshot(force = false) { if (!network.isHost || state.mode !== "multiplayer" || state.screen !== "game") { return; } + const t = now(); if (!force && t - network.lastSnapshotSentAt < 1 / SNAPSHOT_RATE) { return; } + + const includeFullMap = + force || + !Array.isArray(network.lastSentMapState) || + t - network.lastFullMapSnapshotAt >= MAP_RESYNC_SECONDS; + + if (includeFullMap) { + network.lastFullMapSnapshotAt = t; + } + network.lastSnapshotSentAt = t; - sendSocketMessage("game_snapshot", { snapshot: serializeGameState(), ts: t }); + sendSocketMessage("game_snapshot", { snapshot: serializeGameState(includeFullMap), ts: t }); } function updateLobbyNetwork(dt) {