const CONFIG = { cols: 15, rows: 13, tileSize: 48, crateChance: 0.56, powerupChance: 0.3, bombFuse: 2.2, explosionDuration: 0.55, baseSpeed: 3.2, }; const DIRECTIONS = [ { key: "up", dx: 0, dy: -1, rot: 0 }, { key: "right", dx: 1, dy: 0, rot: Math.PI / 2 }, { key: "down", dx: 0, dy: 1, rot: Math.PI }, { key: "left", dx: -1, dy: 0, rot: -Math.PI / 2 }, ]; const ASSET_PATHS = { floor: "assets/tiles/floor.svg", wall: "assets/tiles/wall.svg", crate: "assets/tiles/crate.svg", bomb: "assets/tiles/bomb.svg", flameCenter: "assets/tiles/flame-center.svg", flameStraight: "assets/tiles/flame-straight.svg", flameEnd: "assets/tiles/flame-end.svg", powerBomb: "assets/powerups/bomb-up.svg", powerFlame: "assets/powerups/flame-up.svg", powerSpeed: "assets/powerups/speed-up.svg", playerBlue: "assets/players/player-blue.svg", playerRed: "assets/players/player-red.svg", playerGreen: "assets/players/player-green.svg", playerYellow: "assets/players/player-yellow.svg", }; const PLAYER_DEFS = [ { name: "You", skin: "playerBlue", isHuman: true, color: "#7ac5ff" }, { name: "Nova", skin: "playerRed", isHuman: false, color: "#ff8d7f" }, { name: "Moss", skin: "playerGreen", isHuman: false, color: "#8af0ad" }, { name: "Volt", skin: "playerYellow", isHuman: false, color: "#ffe184" }, ]; const SPAWN_POINTS = [ { x: 1, y: 1 }, { x: CONFIG.cols - 2, y: 1 }, { x: 1, y: CONFIG.rows - 2 }, { x: CONFIG.cols - 2, y: CONFIG.rows - 2 }, { x: Math.floor(CONFIG.cols / 2), y: 1 }, { x: Math.floor(CONFIG.cols / 2), y: CONFIG.rows - 2 }, { x: 1, y: Math.floor(CONFIG.rows / 2) }, { x: CONFIG.cols - 2, y: Math.floor(CONFIG.rows / 2) }, ]; const POWERUPS = ["bomb", "flame", "speed"]; const MAX_PLAYERS = 8; const NETWORK_PATH = "/ws"; const PLAYER_NAME_STORAGE_KEY = "gptBomberPlayerName"; const SNAPSHOT_RATE = 18; const MAP_RESYNC_SECONDS = 1.2; const canvas = document.getElementById("game"); const ctx = canvas.getContext("2d"); canvas.width = CONFIG.cols * CONFIG.tileSize; canvas.height = CONFIG.rows * CONFIG.tileSize; const state = { map: [], players: [], bombs: [], explosions: [], fireLookup: new Set(), status: "loading", message: "Loading...", time: 0, nextBombId: 1, pendingDetonations: [], pendingDetonationSet: new Set(), lastFrame: 0, outcomePlayed: false, multiplayerRoundKills: {}, multiplayerRoundDeathTimes: {}, multiplayerRoundTime: 0, menu: { open: false, selectedIndex: 0, }, screen: "mainMenu", mode: "single", mainMenu: { selectedIndex: 0, mode: "single", }, lobbyMenu: { selectedIndex: 0, }, notification: { text: "", expiresAt: 0, }, }; const images = {}; const inputState = { held: new Set(), movementOrder: [], bombQueued: false, bombHeld: false, gamepadPrev: { up: false, down: false, left: false, right: false, bomb: false, start: false, }, }; const network = { clientId: typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `client-${Date.now()}-${Math.floor(Math.random() * 100000)}`, socket: null, connected: false, inLobby: false, isHost: false, hostId: null, localName: "", lobbyPlayers: [], highscores: [], remoteInputs: new Map(), remoteBombPrev: new Map(), lastSnapshotSentAt: 0, lastSnapshotAt: 0, inputStateAtClient: { dir: null, bomb: false, bombCell: null, }, lastInputSentAt: 0, activeRoster: [], lastPingAt: 0, lobbyPhase: "lobby", hasReceivedSnapshot: false, lastFullMapSnapshotAt: 0, lastSentMapState: null, }; let audio = null; network.localName = loadSavedPlayerName() || `P-${shortId(network.clientId)}`; function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function tileKey(x, y) { return `${x},${y}`; } function inBounds(x, y) { return x >= 0 && y >= 0 && x < CONFIG.cols && y < CONFIG.rows; } function shuffle(array) { for (let i = array.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } return array; } function lerp(a, b, t) { return a + (b - a) * t; } function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); } function shortId(id) { return id.slice(0, 4).toUpperCase(); } function normalizePlayerName(value) { if (typeof value !== "string") { return ""; } return value .replace(/\s+/g, " ") .trim() .slice(0, 20); } function loadSavedPlayerName() { try { return normalizePlayerName(window.localStorage.getItem(PLAYER_NAME_STORAGE_KEY) || ""); } catch { return ""; } } function savePlayerName(name) { try { window.localStorage.setItem(PLAYER_NAME_STORAGE_KEY, name); } catch { // Ignore storage failures. } } function menuNameLabel(name) { const text = normalizePlayerName(name) || `P-${shortId(network.clientId)}`; if (text.length <= 12) { return text; } return `${text.slice(0, 9)}...`; } function now() { return performance.now() / 1000; } function showMatchNotification(text, duration = 3) { if (typeof text !== "string" || !text.trim()) { return; } state.notification.text = text.trim(); state.notification.expiresAt = now() + duration; } function loadImage(path) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error(`Failed to load ${path}`)); img.src = path; }); } async function loadAssets() { const entries = Object.entries(ASSET_PATHS); const loaded = await Promise.all(entries.map(([, path]) => loadImage(path))); for (let i = 0; i < entries.length; i += 1) { images[entries[i][0]] = loaded[i]; } } function makeTile(type, powerup = null) { return { type, powerup }; } function randomPowerup() { return POWERUPS[Math.floor(Math.random() * POWERUPS.length)]; } function clearSpawnArea(map, x, y) { const offsets = [ [0, 0], [1, 0], [0, 1], [2, 0], [0, 2], [-1, 0], [0, -1], ]; for (const [ox, oy] of offsets) { const tx = x + ox; const ty = y + oy; if (inBounds(tx, ty)) { map[ty][tx] = makeTile("floor", null); } } } function generateMap() { const map = []; for (let y = 0; y < CONFIG.rows; y += 1) { const row = []; for (let x = 0; x < CONFIG.cols; x += 1) { const border = x === 0 || y === 0 || x === CONFIG.cols - 1 || y === CONFIG.rows - 1; const pillar = x % 2 === 0 && y % 2 === 0; if (border || pillar) { row.push(makeTile("wall", null)); } else { const makeCrate = Math.random() < CONFIG.crateChance; if (makeCrate) { const powerup = Math.random() < CONFIG.powerupChance ? randomPowerup() : null; row.push(makeTile("crate", powerup)); } else { row.push(makeTile("floor", null)); } } } map.push(row); } for (const spawn of SPAWN_POINTS) { clearSpawnArea(map, spawn.x, spawn.y); } return map; } function createPlayer(id, def, spawn, overrides = {}) { return { id, name: def.name, skin: def.skin, color: def.color, isHuman: def.isHuman, control: overrides.control || (def.isHuman ? "local" : "bot"), ownerId: overrides.ownerId || null, showName: overrides.showName || !def.isHuman, alive: true, x: spawn.x, y: spawn.y, renderX: spawn.x, renderY: spawn.y, moveFromX: spawn.x, moveFromY: spawn.y, moveToX: spawn.x, moveToY: spawn.y, moveProgress: 1, speed: CONFIG.baseSpeed, bombCapacity: 1, bombsPlaced: 0, flameRange: 2, ai: { thinkTimer: 0, desiredDir: null, bombCooldown: 0, }, }; } function resetRoundSingle() { state.map = generateMap(); state.players = PLAYER_DEFS.map((def, i) => createPlayer(i, def, SPAWN_POINTS[i], { control: i === 0 ? "local" : "bot", ownerId: i === 0 ? network.clientId : null, showName: i !== 0, }), ); state.bombs = []; state.explosions = []; state.fireLookup.clear(); state.pendingDetonations = []; state.pendingDetonationSet.clear(); state.status = "running"; state.message = "Fight"; state.outcomePlayed = false; state.multiplayerRoundKills = {}; state.multiplayerRoundDeathTimes = {}; state.multiplayerRoundTime = 0; resetHostSnapshotMapState(); closeMenu(); } function resetRoundMultiplayer(roster) { state.map = generateMap(); state.players = roster.slice(0, MAX_PLAYERS).map((entry, i) => createPlayer( i, { name: entry.name || `P${i + 1}`, skin: PLAYER_DEFS[i % PLAYER_DEFS.length].skin, color: PLAYER_DEFS[i % PLAYER_DEFS.length].color, isHuman: true, }, SPAWN_POINTS[i], { control: "network", ownerId: entry.id, showName: true, }, ), ); state.bombs = []; state.explosions = []; state.fireLookup.clear(); state.pendingDetonations = []; state.pendingDetonationSet.clear(); state.status = "running"; state.message = "Multiplayer match live"; state.outcomePlayed = false; state.multiplayerRoundKills = {}; state.multiplayerRoundDeathTimes = {}; state.multiplayerRoundTime = 0; resetHostSnapshotMapState(); closeMenu(); inputState.bombQueued = false; network.remoteBombPrev.clear(); } function clearMovementInput() { setMovementHeld("up", false); setMovementHeld("down", false); setMovementHeld("left", false); setMovementHeld("right", false); } function getMenuItems() { if (state.mode === "multiplayer") { if (state.status === "ended") { if (network.isHost) { return [ { id: "nextRound", label: "Start Next Round" }, { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, { id: "exitLobby", label: "Exit To Lobby" }, ]; } return [ { id: "waitHost", label: "Waiting For Host Restart" }, { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, { id: "leaveMatch", label: "Leave Match" }, ]; } return [ { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, { id: "exitLobby", label: "Exit To Lobby" }, { id: "close", label: "Close" }, ]; } if (state.status === "ended") { return [ { id: "playAgain", label: "Play Again" }, { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, ]; } return [ { id: "resume", label: "Resume Match" }, { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, { id: "restart", label: "Restart Round" }, { id: "exitSingle", label: "Exit To Main Menu" }, ]; } function openMenu() { state.menu.open = true; state.menu.selectedIndex = 0; clearMovementInput(); } function closeMenu() { state.menu.open = false; state.menu.selectedIndex = 0; clearMovementInput(); } function moveMenuSelection(delta) { const items = getMenuItems(); const count = items.length; if (count === 0) { state.menu.selectedIndex = 0; return; } state.menu.selectedIndex = (state.menu.selectedIndex + delta + count) % count; } function restartMultiplayerRoundAsHost() { if (state.mode !== "multiplayer" || state.screen !== "game" || !network.isHost) { return; } sendSocketMessage("lobby_next_round"); state.message = "Starting next round..."; } function activateMenuSelection() { const items = getMenuItems(); if (items.length === 0) { return; } const item = items[state.menu.selectedIndex] || items[0]; if (!item) { return; } if (item.id === "resume") { closeMenu(); return; } if (item.id === "close") { closeMenu(); return; } if (item.id === "music") { toggleMusicPreference(); return; } if (item.id === "waitHost") { return; } if (item.id === "nextRound") { restartMultiplayerRoundAsHost(); return; } if (item.id === "restart" || item.id === "playAgain") { resetRoundSingle(); return; } if (item.id === "exitSingle") { leaveSinglePlayerToMainMenu(); return; } if (item.id === "exitLobby") { closeMenu(); leaveMultiplayerToLobby(); return; } if (item.id === "leaveMatch") { closeMenu(); leaveMultiplayerToMainMenu(); return; } } function getMainMenuItems() { return [ { id: "mode", label: `Mode: ${state.mainMenu.mode === "single" ? "Single Player" : "Multiplayer"}`, }, { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, { id: "name", label: `Name: ${menuNameLabel(network.localName)}` }, { id: "start", label: state.mainMenu.mode === "single" ? "Start Single Player" : "Enter Lobby" }, ]; } function getLobbyMenuItems() { const enoughPlayers = network.lobbyPlayers.length >= 2; const canStart = network.lobbyPhase === "lobby" && network.isHost && enoughPlayers; const startLabel = network.lobbyPhase === "game" ? "Game In Progress" : network.isHost ? enoughPlayers ? "Start Match" : "Need 2 Players" : "Waiting For Host"; return [ { id: "start", label: startLabel, disabled: !canStart, }, { id: "music", label: `Music: ${audio && audio.isMusicEnabled() ? "On" : "Off"}` }, { id: "name", label: `Name: ${menuNameLabel(network.localName)}` }, { id: "leave", label: "Leave Lobby" }, ]; } function toggleMusicPreference() { if (!audio.hasContext()) { audio.unlock().then(() => { if (audio.isMusicEnabled()) { audio.toggleMusic(); } }); return; } audio.toggleMusic(); } function setLocalPlayerName(name) { const normalized = normalizePlayerName(name); if (!normalized) { state.message = "Name must be 1-20 characters."; return false; } network.localName = normalized; savePlayerName(normalized); sendSocketMessage("set_name", { name: normalized }); if (network.inLobby || state.screen === "lobby") { sendSocketMessage("lobby_join"); } state.message = `Name set: ${normalized}`; return true; } function promptForPlayerName() { const current = normalizePlayerName(network.localName) || `P-${shortId(network.clientId)}`; const raw = window.prompt("Enter multiplayer name (1-20 chars):", current); if (raw === null) { return; } setLocalPlayerName(raw); } function normalizeLobbyPlayers(players) { return [...players] .slice(0, MAX_PLAYERS) .sort((a, b) => a.joinedAt - b.joinedAt) .map((player) => ({ id: player.id, name: player.name, joinedAt: player.joinedAt, lastSeenAt: player.lastSeenAt || now(), })); } function normalizeHighscores(entries) { if (!Array.isArray(entries)) { return []; } return [...entries] .filter((entry) => entry && typeof entry.name === "string") .map((entry) => ({ name: normalizePlayerName(entry.name) || "Unknown", wins: Math.max(0, Math.floor(Number(entry.wins) || 0)), kills: Math.max(0, Math.floor(Number(entry.kills) || 0)), longestAlive: Math.max(0, Number(entry.longestAlive) || 0), })) .sort( (a, b) => b.wins - a.wins || b.kills - a.kills || b.longestAlive - a.longestAlive || a.name.localeCompare(b.name), ) .slice(0, 8) .map((entry) => ({ ...entry, longestAlive: Math.round(entry.longestAlive * 10) / 10, })); } function formatAliveSeconds(value) { const seconds = Math.max(0, Number(value) || 0); return String(Math.round(seconds)) + "s"; } function sendSocketMessage(type, payload = {}) { if (!network.socket || network.socket.readyState !== WebSocket.OPEN) { return; } network.socket.send(JSON.stringify({ type, ...payload })); } function ensureSocketConnection() { if (typeof WebSocket === "undefined") { return; } if ( network.socket && (network.socket.readyState === WebSocket.OPEN || network.socket.readyState === WebSocket.CONNECTING) ) { return; } const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const socketUrl = `${protocol}//${window.location.host}${NETWORK_PATH}`; network.socket = new WebSocket(socketUrl); network.socket.addEventListener("open", () => { network.connected = true; sendSocketMessage("hello", { clientId: network.clientId, name: network.localName }); if (state.screen === "lobby") { sendSocketMessage("lobby_join"); network.inLobby = true; network.lastPingAt = now(); } }); network.socket.addEventListener("message", (event) => { handleSocketMessage(event.data); }); network.socket.addEventListener("close", () => { network.connected = false; network.socket = null; network.isHost = false; network.hostId = null; network.lobbyPlayers = []; network.lobbyPhase = "lobby"; if (state.screen === "lobby") { network.inLobby = true; state.message = "Disconnected. Reconnecting..."; } else { network.inLobby = false; } if (state.screen === "game" && state.mode === "multiplayer" && state.status === "running") { state.status = "ended"; state.message = "Disconnected from server."; openMenu(); } }); network.socket.addEventListener("error", () => { state.message = "Network error."; }); } function handleSocketMessage(raw) { let msg; try { msg = JSON.parse(raw); } catch { return; } if (!msg || typeof msg !== "object") { return; } if (msg.type === "welcome") { if (msg.clientId && typeof msg.clientId === "string") { network.clientId = msg.clientId; } if (msg.name && typeof msg.name === "string") { network.localName = normalizePlayerName(msg.name) || `P-${shortId(network.clientId)}`; savePlayerName(network.localName); } else if (!network.localName) { network.localName = `P-${shortId(network.clientId)}`; } if (Array.isArray(msg.highscores)) { network.highscores = normalizeHighscores(msg.highscores); } if (state.screen === "lobby") { sendSocketMessage("lobby_join"); network.inLobby = true; } return; } if (msg.type === "error") { if (msg.message) { state.message = msg.message; } return; } if (msg.type === "lobby_state") { const previousLobbyPlayers = network.lobbyPlayers.slice(); const nextLobbyPlayers = normalizeLobbyPlayers(msg.players || []); network.hostId = msg.hostId || null; network.isHost = network.hostId === network.clientId; network.lobbyPhase = msg.phase === "game" ? "game" : "lobby"; network.lobbyPlayers = nextLobbyPlayers; if (Array.isArray(msg.highscores)) { network.highscores = normalizeHighscores(msg.highscores); } if (state.mode === "multiplayer" && state.screen === "game" && msg.phase === "game") { const activeMatchIds = new Set(network.activeRoster.map((player) => player.id)); const previousIds = new Set(previousLobbyPlayers.map((player) => player.id)); const newlyWaiting = nextLobbyPlayers.filter( (player) => !previousIds.has(player.id) && !activeMatchIds.has(player.id), ); if (newlyWaiting.length === 1) { showMatchNotification(newlyWaiting[0].name + " is waiting in lobby."); } else if (newlyWaiting.length > 1) { showMatchNotification(String(newlyWaiting.length) + " players are waiting in lobby."); } } if (state.screen === "lobby" && network.lobbyPhase === "game") { state.message = "Match running. You will join next round."; } if (state.mode === "multiplayer" && state.screen === "game" && msg.phase === "lobby") { state.screen = "lobby"; state.status = "idle"; state.message = msg.reason || "Back to lobby."; network.inLobby = true; } return; } if (msg.type === "lobby_start") { network.hostId = msg.hostId; network.isHost = msg.hostId === network.clientId; network.lobbyPhase = "game"; network.activeRoster = normalizeLobbyPlayers(msg.roster || []); if (Array.isArray(msg.highscores)) { network.highscores = normalizeHighscores(msg.highscores); } startMultiplayerMatch(network.activeRoster); return; } if (msg.type === "highscore_update") { if (Array.isArray(msg.highscores)) { network.highscores = normalizeHighscores(msg.highscores); } return; } if (msg.type === "game_player_left") { if (typeof msg.playerId !== "string") { return; } network.activeRoster = network.activeRoster.filter((player) => player.id !== msg.playerId); network.remoteInputs.delete(msg.playerId); network.remoteBombPrev.delete(msg.playerId); if (state.mode === "multiplayer" && state.screen === "game") { const player = state.players.find((entry) => entry.ownerId === msg.playerId) || null; if (player && player.alive) { killPlayer(player, null); showMatchNotification(player.name + " left the round."); } else { showMatchNotification("A player left the round."); } } return; } if (msg.type === "player_input") { if (!network.isHost || state.mode !== "multiplayer" || state.screen !== "game") { return; } network.remoteInputs.set(msg.playerId, msg.input || { dir: null, bomb: false, bombCell: null }); return; } if (msg.type === "game_snapshot") { if (network.isHost || state.mode !== "multiplayer" || state.screen !== "game") { return; } network.lastSnapshotAt = now(); applySnapshot(msg.snapshot); } } function startSinglePlayerFromMenu() { leaveLobbyInternals(); state.mode = "single"; state.screen = "game"; resetRoundSingle(); } function startMultiplayerMatch(roster) { network.activeRoster = roster.slice(0, MAX_PLAYERS); network.inLobby = false; network.lobbyPhase = "game"; network.lastSnapshotAt = now(); network.lastInputSentAt = 0; network.hasReceivedSnapshot = network.isHost; state.mode = "multiplayer"; state.screen = "game"; resetRoundMultiplayer(network.activeRoster); network.remoteInputs.clear(); network.remoteBombPrev.clear(); if (!network.isHost) { sendLocalMultiplayerInput(true); } } function enterLobbyScreen() { state.screen = "lobby"; state.mode = "multiplayer"; state.lobbyMenu.selectedIndex = 0; state.menu.open = false; state.message = "Connecting..."; network.inLobby = true; network.isHost = false; network.hostId = null; network.lobbyPhase = "lobby"; network.lobbyPlayers = []; network.remoteInputs.clear(); network.remoteBombPrev.clear(); ensureSocketConnection(); if (network.connected) { sendSocketMessage("lobby_join"); } } function leaveLobbyInternals() { network.inLobby = false; network.isHost = false; network.hostId = null; network.lobbyPhase = "lobby"; network.lobbyPlayers = []; network.remoteInputs.clear(); network.remoteBombPrev.clear(); network.activeRoster = []; state.notification.text = ""; state.notification.expiresAt = 0; state.multiplayerRoundKills = {}; state.multiplayerRoundDeathTimes = {}; state.multiplayerRoundTime = 0; network.inputStateAtClient = { dir: null, bomb: false, bombCell: null }; network.lastInputSentAt = 0; network.hasReceivedSnapshot = false; resetHostSnapshotMapState(); } function leaveMultiplayerToMainMenu() { if (state.mode === "multiplayer") { sendSocketMessage("lobby_leave"); } leaveLobbyInternals(); state.mode = "single"; state.screen = "mainMenu"; state.status = "idle"; state.message = "Select mode"; } function leaveMultiplayerToLobby() { sendSocketMessage("lobby_leave"); leaveLobbyInternals(); enterLobbyScreen(); state.message = "Returned to lobby."; } function leaveSinglePlayerToMainMenu() { closeMenu(); inputState.bombQueued = false; inputState.bombHeld = false; state.mode = "single"; state.screen = "mainMenu"; state.status = "idle"; state.message = "Select mode"; } function hostStartMatchFromLobby() { if (!network.isHost || network.lobbyPhase !== "lobby" || network.lobbyPlayers.length < 2) { return; } sendSocketMessage("lobby_start"); } 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, mapPatch, players: state.players.map((player) => ({ id: player.id, name: player.name, skin: player.skin, color: player.color, control: player.control, ownerId: player.ownerId, showName: player.showName, isHuman: player.isHuman, alive: player.alive, x: player.x, y: player.y, renderX: player.renderX, renderY: player.renderY, moveFromX: player.moveFromX, moveFromY: player.moveFromY, moveToX: player.moveToX, moveToY: player.moveToY, moveProgress: player.moveProgress, speed: player.speed, bombCapacity: player.bombCapacity, bombsPlaced: player.bombsPlaced, flameRange: player.flameRange, })), bombs: state.bombs.map((bomb) => ({ id: bomb.id, x: bomb.x, y: bomb.y, timer: bomb.timer, range: bomb.range, ownerId: bomb.ownerId, })), explosions: state.explosions.map((explosion) => ({ timer: explosion.timer, ownerId: explosion.ownerId, cells: explosion.cells.map((cell) => ({ x: cell.x, y: cell.y, type: cell.type, rot: cell.rot })), })), nextBombId: state.nextBombId, outcomePlayed: state.outcomePlayed, }; } function playSnapshotSfx(snapshot) { const localEntityIds = new Set( snapshot.players.filter((player) => player.ownerId === network.clientId).map((player) => player.id), ); const previousBombIds = new Set(state.bombs.map((bomb) => bomb.id)); let placedByOther = false; for (const bomb of snapshot.bombs) { if (!previousBombIds.has(bomb.id) && !localEntityIds.has(bomb.ownerId)) { placedByOther = true; break; } } if (placedByOther) { audio.playPlaceBomb(); } const nextBombIds = new Set(snapshot.bombs.map((bomb) => bomb.id)); let bombDetonated = false; for (const bomb of state.bombs) { if (!nextBombIds.has(bomb.id)) { bombDetonated = true; break; } } if (bombDetonated && snapshot.explosions.length > 0) { audio.playExplosion(); } let powerupCollected = false; 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 (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; } } } if (powerupCollected) { audio.playPowerup(); } } function applySnapshot(snapshot) { const previousPlayers = new Map( state.players.map((player) => [player.ownerId || `id:${player.id}`, { x: player.renderX, y: player.renderY }]), ); if (network.hasReceivedSnapshot) { playSnapshotSfx(snapshot); } state.status = snapshot.status; state.message = snapshot.message; state.time = snapshot.time; 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); let renderX = player.renderX; let renderY = player.renderY; if (prev) { const dx = player.renderX - prev.x; const dy = player.renderY - prev.y; const dist = Math.hypot(dx, dy); if (dist < 2) { renderX = lerp(prev.x, player.renderX, 0.55); renderY = lerp(prev.y, player.renderY, 0.55); } } return { ...player, renderX, renderY, ai: { thinkTimer: 0, desiredDir: null, bombCooldown: 0 }, }; }); state.bombs = snapshot.bombs.map((bomb) => ({ ...bomb, passThrough: new Set(), })); state.explosions = snapshot.explosions.map((explosion) => ({ timer: explosion.timer, ownerId: explosion.ownerId, cells: explosion.cells.map((cell) => ({ ...cell })), })); state.nextBombId = snapshot.nextBombId; state.outcomePlayed = snapshot.outcomePlayed; network.hasReceivedSnapshot = true; rebuildFireLookup(); } 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(includeFullMap), ts: t }); } function updateLobbyNetwork(dt) { void dt; if (!network.inLobby || state.screen !== "lobby") { return; } ensureSocketConnection(); const t = now(); if (network.connected && t - network.lastPingAt > 2) { network.lastPingAt = t; sendSocketMessage("ping"); sendSocketMessage("lobby_join"); } } function sameBombCell(a, b) { if (!a && !b) { return true; } if (!a || !b) { return false; } return a.x === b.x && a.y === b.y; } function resolveBombDropCell(player, preferredCell = null) { const candidates = []; if (player.moveProgress < 1) { candidates.push({ x: player.moveFromX, y: player.moveFromY }); if (player.moveToX !== player.moveFromX || player.moveToY !== player.moveFromY) { candidates.push({ x: player.moveToX, y: player.moveToY }); } } else { candidates.push({ x: player.x, y: player.y }); } const canPlaceAt = (x, y) => { if (!inBounds(x, y)) { return false; } const tile = state.map[y]?.[x]; if (!tile || tile.type !== "floor") { return false; } return !getBombAt(x, y); }; const isCandidateCell = (cell) => candidates.some((candidate) => candidate.x === cell.x && candidate.y === cell.y); if (preferredCell && Number.isInteger(preferredCell.x) && Number.isInteger(preferredCell.y)) { if (isCandidateCell(preferredCell) && canPlaceAt(preferredCell.x, preferredCell.y)) { return { x: preferredCell.x, y: preferredCell.y }; } } const primary = player.moveProgress < 1 && player.moveProgress >= 0.5 ? { x: player.moveToX, y: player.moveToY } : candidates[0]; if (primary && canPlaceAt(primary.x, primary.y)) { return { x: primary.x, y: primary.y }; } for (const candidate of candidates) { if (canPlaceAt(candidate.x, candidate.y)) { return { x: candidate.x, y: candidate.y }; } } return null; } function sendLocalMultiplayerInput(force = false) { if (state.mode !== "multiplayer" || state.screen !== "game" || network.isHost) { return; } const t = now(); const localPlayer = state.players.find((player) => player.ownerId === network.clientId) || null; const nextInput = { dir: currentHumanDirection(), bomb: inputState.bombHeld, bombCell: null, }; if (nextInput.bomb && localPlayer) { nextInput.bombCell = resolveBombDropCell(localPlayer); } const changed = nextInput.dir !== network.inputStateAtClient.dir || nextInput.bomb !== network.inputStateAtClient.bomb || !sameBombCell(nextInput.bombCell, network.inputStateAtClient.bombCell); const dueHeartbeat = t - network.lastInputSentAt > 0.12; if (!changed && !force && !dueHeartbeat) { return; } network.inputStateAtClient = { dir: nextInput.dir, bomb: nextInput.bomb, bombCell: nextInput.bombCell ? { ...nextInput.bombCell } : null, }; network.lastInputSentAt = t; sendSocketMessage("player_input", { input: nextInput, ts: t }); } function getBombAt(x, y) { return state.bombs.find((bomb) => bomb.x === x && bomb.y === y) || null; } function isBombBlockingTile(x, y, player = null) { const bomb = getBombAt(x, y); if (!bomb) { return false; } if (player && bomb.passThrough.has(player.id)) { return false; } return true; } function canEnterTile(x, y, player = null) { if (!inBounds(x, y)) { return false; } const tile = state.map[y][x]; if (tile.type !== "floor") { return false; } if (isBombBlockingTile(x, y, player)) { return false; } return true; } function queueDetonation(bombId) { if (state.pendingDetonationSet.has(bombId)) { return; } state.pendingDetonationSet.add(bombId); state.pendingDetonations.push(bombId); } function dropBomb(player, preferredCell = null) { if (!player.alive || player.bombsPlaced >= player.bombCapacity) { return false; } const dropCell = resolveBombDropCell(player, preferredCell); if (!dropCell) { return false; } const bomb = { id: state.nextBombId, x: dropCell.x, y: dropCell.y, timer: CONFIG.bombFuse, range: player.flameRange, ownerId: player.id, passThrough: new Set([player.id]), }; state.nextBombId += 1; player.bombsPlaced += 1; state.bombs.push(bomb); audio.playPlaceBomb(); return true; } function hasHardBlockBetween(ax, ay, bx, by) { if (ax !== bx && ay !== by) { return true; } if (ax === bx) { const minY = Math.min(ay, by); const maxY = Math.max(ay, by); for (let y = minY + 1; y < maxY; y += 1) { const t = state.map[y][ax]; if (t.type === "wall" || t.type === "crate") { return true; } } } else { const minX = Math.min(ax, bx); const maxX = Math.max(ax, bx); for (let x = minX + 1; x < maxX; x += 1) { const t = state.map[ay][x]; if (t.type === "wall" || t.type === "crate") { return true; } } } return false; } function bombCanHitCell(bomb, x, y) { if (bomb.x !== x && bomb.y !== y) { return false; } const distance = Math.abs(bomb.x - x) + Math.abs(bomb.y - y); if (distance > bomb.range) { return false; } return !hasHardBlockBetween(bomb.x, bomb.y, x, y); } function estimateDangerAt(x, y) { let danger = state.fireLookup.has(tileKey(x, y)) ? 100 : 0; for (const bomb of state.bombs) { if (bombCanHitCell(bomb, x, y)) { const intensity = clamp(6 - bomb.timer * 3, 0, 6); danger = Math.max(danger, intensity); } } return danger; } function computeVirtualBlastSet(originX, originY, range) { const blast = new Set([tileKey(originX, originY)]); for (const dir of DIRECTIONS) { for (let step = 1; step <= range; step += 1) { const nx = originX + dir.dx * step; const ny = originY + dir.dy * step; if (!inBounds(nx, ny)) { break; } const tile = state.map[ny][nx]; if (tile.type === "wall") { break; } blast.add(tileKey(nx, ny)); if (tile.type === "crate") { break; } } } return blast; } function hasEscapeRouteAfterBomb(player) { const dangerBlast = computeVirtualBlastSet(player.x, player.y, player.flameRange); const queue = [{ x: player.x, y: player.y, depth: 0 }]; const visited = new Set([tileKey(player.x, player.y)]); const maxDepth = 6; while (queue.length > 0) { const node = queue.shift(); const key = tileKey(node.x, node.y); const safeTile = !dangerBlast.has(key) && estimateDangerAt(node.x, node.y) < 1; if (node.depth > 0 && safeTile) { return true; } if (node.depth >= maxDepth) { continue; } for (const dir of DIRECTIONS) { const nx = node.x + dir.dx; const ny = node.y + dir.dy; const nextKey = tileKey(nx, ny); if (visited.has(nextKey)) { continue; } if (!inBounds(nx, ny)) { continue; } if (state.map[ny][nx].type !== "floor") { continue; } const bomb = getBombAt(nx, ny); if (bomb && (nx !== player.x || ny !== player.y)) { continue; } visited.add(nextKey); queue.push({ x: nx, y: ny, depth: node.depth + 1 }); } } return false; } function adjacentCrate(x, y) { for (const dir of DIRECTIONS) { const nx = x + dir.dx; const ny = y + dir.dy; if (inBounds(nx, ny) && state.map[ny][nx].type === "crate") { return true; } } return false; } function countAdjacentCrates(x, y) { let count = 0; for (const dir of DIRECTIONS) { const nx = x + dir.dx; const ny = y + dir.dy; if (inBounds(nx, ny) && state.map[ny][nx].type === "crate") { count += 1; } } return count; } function threatenedEnemiesFromCell(player, x, y) { let count = 0; for (const other of state.players) { if (!other.alive || other.id === player.id) { continue; } if (x !== other.x && y !== other.y) { continue; } const distance = Math.abs(x - other.x) + Math.abs(y - other.y); if (distance > player.flameRange) { continue; } if (!hasHardBlockBetween(x, y, other.x, other.y)) { count += 1; } } return count; } function nearestEnemyDistance(x, y, playerId) { let best = Infinity; for (const other of state.players) { if (!other.alive || other.id === playerId) { continue; } const distance = Math.abs(x - other.x) + Math.abs(y - other.y); if (distance < best) { best = distance; } } return best; } function botFindDirection(player, evaluator, maxDepth = 8) { const visited = new Set([tileKey(player.x, player.y)]); const queue = []; for (const dir of DIRECTIONS) { const nx = player.x + dir.dx; const ny = player.y + dir.dy; if (!canEnterTile(nx, ny, player)) { continue; } visited.add(tileKey(nx, ny)); queue.push({ x: nx, y: ny, depth: 1, firstDir: dir.key }); } let best = null; while (queue.length > 0) { const node = queue.shift(); const score = evaluator(node.x, node.y, node.depth); if (Number.isFinite(score)) { if (!best || score > best.score || (score === best.score && node.depth < best.depth)) { best = { score, depth: node.depth, dir: node.firstDir }; } } if (node.depth >= maxDepth) { continue; } for (const dir of DIRECTIONS) { const nx = node.x + dir.dx; const ny = node.y + dir.dy; const key = tileKey(nx, ny); if (visited.has(key)) { continue; } if (!canEnterTile(nx, ny, player)) { continue; } visited.add(key); queue.push({ x: nx, y: ny, depth: node.depth + 1, firstDir: node.firstDir }); } } return best ? best.dir : null; } function pickEscapeDirection(player) { return botFindDirection( player, (x, y, depth) => { const danger = estimateDangerAt(x, y); let score = -danger * 3 - depth * 0.55; if (danger < 0.6) { score += 3.5; } if (!state.fireLookup.has(tileKey(x, y))) { score += 0.6; } const tile = state.map[y][x]; if (tile && tile.powerup) { score += 0.8; } return score; }, 9, ); } function enemyInBlastPotential(player) { return threatenedEnemiesFromCell(player, player.x, player.y) > 0; } function updateBot(player, dt) { if (!player.alive || player.moveProgress < 1) { return; } player.ai.thinkTimer -= dt; player.ai.bombCooldown -= dt; if (player.ai.thinkTimer > 0) { return; } player.ai.thinkTimer = 0.08 + Math.random() * 0.12; const currentDanger = estimateDangerAt(player.x, player.y); const currentKey = tileKey(player.x, player.y); const targetEnemy = state.players .filter((p) => p.alive && p.id !== player.id) .sort( (a, b) => Math.abs(a.x - player.x) + Math.abs(a.y - player.y) - (Math.abs(b.x - player.x) + Math.abs(b.y - player.y)), )[0]; if (state.fireLookup.has(currentKey) || currentDanger >= 1.2) { player.ai.desiredDir = pickEscapeDirection(player); return; } const cratePressure = countAdjacentCrates(player.x, player.y); const enemyPressure = threatenedEnemiesFromCell(player, player.x, player.y); if ( player.bombsPlaced < player.bombCapacity && player.ai.bombCooldown <= 0 && currentDanger < 0.8 && hasEscapeRouteAfterBomb(player) && (enemyPressure > 0 || cratePressure >= 2 || (cratePressure >= 1 && Math.random() < 0.45)) ) { if (dropBomb(player)) { player.ai.bombCooldown = enemyPressure > 0 ? 0.8 + Math.random() * 0.45 : 1.1 + Math.random() * 0.55; } } let desiredDir = botFindDirection( player, (x, y, depth) => { const tile = state.map[y][x]; if (!tile || !tile.powerup) { return Number.NEGATIVE_INFINITY; } return 8 - depth - estimateDangerAt(x, y) * 1.8; }, 7, ); if (!desiredDir && targetEnemy) { desiredDir = botFindDirection( player, (x, y, depth) => { const danger = estimateDangerAt(x, y); if (danger > 4.2) { return Number.NEGATIVE_INFINITY; } const dist = Math.abs(targetEnemy.x - x) + Math.abs(targetEnemy.y - y); let score = 6 - dist * 1.1 - depth * 0.4 - danger * 1.3; if (countAdjacentCrates(x, y) > 0) { score += 0.35; } return score; }, 8, ); } if (!desiredDir) { desiredDir = botFindDirection( player, (x, y, depth) => { const crates = countAdjacentCrates(x, y); if (crates <= 0) { return Number.NEGATIVE_INFINITY; } return crates * 2.2 - depth * 0.55 - estimateDangerAt(x, y) * 1.4; }, 8, ); } if (!desiredDir) { const options = []; for (const dir of shuffle([...DIRECTIONS])) { const nx = player.x + dir.dx; const ny = player.y + dir.dy; if (!canEnterTile(nx, ny, player)) { continue; } const danger = estimateDangerAt(nx, ny); const nearestEnemy = nearestEnemyDistance(nx, ny, player.id); let score = Math.random() * 0.18 - danger * 1.8 - nearestEnemy * 0.16; if (danger < currentDanger) { score += 1.6; } if (adjacentCrate(nx, ny)) { score += 0.25; } const tile = state.map[ny][nx]; if (tile.powerup) { score += 1.4; } options.push({ dir: dir.key, score }); } options.sort((a, b) => b.score - a.score); desiredDir = options.length > 0 ? options[0].dir : null; } player.ai.desiredDir = desiredDir; } function registerMultiplayerKill(killerPlayerId, victimPlayerId) { if (state.mode !== "multiplayer" || !network.isHost) { return; } const killer = state.players.find((player) => player.id === killerPlayerId); const victim = state.players.find((player) => player.id === victimPlayerId); if (!killer || !victim || !killer.ownerId || !victim.ownerId) { return; } if (killer.ownerId === victim.ownerId) { return; } const previous = state.multiplayerRoundKills[killer.ownerId] || 0; state.multiplayerRoundKills[killer.ownerId] = previous + 1; } function findKillerForHit(tiles, victimPlayerId) { for (const explosion of state.explosions) { const ownerId = Number.isInteger(explosion.ownerId) ? explosion.ownerId : null; if (ownerId === null || ownerId === victimPlayerId) { continue; } for (const cell of explosion.cells) { if (tiles.includes(tileKey(cell.x, cell.y))) { return ownerId; } } } return null; } function killPlayer(player, killerPlayerId = null) { if (!player.alive) { return false; } player.alive = false; player.ai.desiredDir = null; if (state.mode === "multiplayer" && network.isHost && player.ownerId) { if (!Object.prototype.hasOwnProperty.call(state.multiplayerRoundDeathTimes, player.ownerId)) { state.multiplayerRoundDeathTimes[player.ownerId] = state.multiplayerRoundTime; } } if (Number.isInteger(killerPlayerId) && killerPlayerId !== player.id) { registerMultiplayerKill(killerPlayerId, player.id); } return true; } function collectPowerup(player) { if (player.moveProgress < 1 || !player.alive) { return; } const tile = state.map[player.y][player.x]; if (!tile.powerup) { return; } if (tile.powerup === "bomb") { player.bombCapacity = clamp(player.bombCapacity + 1, 1, 8); } else if (tile.powerup === "flame") { player.flameRange = clamp(player.flameRange + 1, 2, 10); } else if (tile.powerup === "speed") { player.speed = clamp(player.speed + 0.42, CONFIG.baseSpeed, 7); } tile.powerup = null; audio.playPowerup(); } function startMove(player, dirKey) { const dir = DIRECTIONS.find((d) => d.key === dirKey); if (!dir) { return false; } const nx = player.x + dir.dx; const ny = player.y + dir.dy; if (!canEnterTile(nx, ny, player)) { return false; } player.moveFromX = player.x; player.moveFromY = player.y; player.moveToX = nx; player.moveToY = ny; player.moveProgress = 0; return true; } function occupiedTiles(player) { if (player.moveProgress < 1) { const from = tileKey(player.moveFromX, player.moveFromY); const to = tileKey(player.moveToX, player.moveToY); if (from === to) { return [from]; } return [from, to]; } return [tileKey(player.x, player.y)]; } function getNetworkInput(ownerId) { return network.remoteInputs.get(ownerId) || { dir: null, bomb: false, bombCell: null }; } function getDesiredDirection(player) { if (player.control === "bot") { return player.ai.desiredDir; } if (player.control === "network") { if (player.ownerId === network.clientId) { return currentHumanDirection(); } return getNetworkInput(player.ownerId).dir; } return currentHumanDirection(); } function updatePlayerMovement(player, dt) { if (!player.alive) { return; } if (player.moveProgress < 1) { player.moveProgress = clamp(player.moveProgress + dt * player.speed, 0, 1); const t = easeOutQuad(player.moveProgress); player.renderX = lerp(player.moveFromX, player.moveToX, t); player.renderY = lerp(player.moveFromY, player.moveToY, t); if (player.moveProgress >= 1) { player.x = player.moveToX; player.y = player.moveToY; player.renderX = player.x; player.renderY = player.y; } } else { const dir = getDesiredDirection(player); if (dir) { startMove(player, dir); } player.renderX = player.x; player.renderY = player.y; } const occupied = new Set(occupiedTiles(player)); for (const bomb of state.bombs) { if (bomb.passThrough.has(player.id) && !occupied.has(tileKey(bomb.x, bomb.y))) { bomb.passThrough.delete(player.id); } } } function detonateBomb(bomb) { const bombIndex = state.bombs.findIndex((entry) => entry.id === bomb.id); if (bombIndex === -1) { return; } state.bombs.splice(bombIndex, 1); const owner = state.players.find((player) => player.id === bomb.ownerId); if (owner) { owner.bombsPlaced = Math.max(0, owner.bombsPlaced - 1); } const cells = [{ x: bomb.x, y: bomb.y, type: "center", rot: 0 }]; for (const dir of DIRECTIONS) { for (let step = 1; step <= bomb.range; step += 1) { const nx = bomb.x + dir.dx * step; const ny = bomb.y + dir.dy * step; if (!inBounds(nx, ny)) { break; } const tile = state.map[ny][nx]; if (tile.type === "wall") { break; } const hitCrate = tile.type === "crate"; const atEnd = step === bomb.range || hitCrate; const segmentType = atEnd ? "end" : "straight"; cells.push({ x: nx, y: ny, type: segmentType, rot: dir.rot }); const linkedBomb = getBombAt(nx, ny); if (linkedBomb) { queueDetonation(linkedBomb.id); } if (hitCrate) { tile.type = "floor"; break; } } } state.explosions.push({ cells, timer: CONFIG.explosionDuration, ownerId: bomb.ownerId }); audio.playExplosion(); } function resolveDetonations() { while (state.pendingDetonations.length > 0) { const id = state.pendingDetonations.shift(); state.pendingDetonationSet.delete(id); const bomb = state.bombs.find((entry) => entry.id === id); if (bomb) { detonateBomb(bomb); } } } function rebuildFireLookup() { state.fireLookup.clear(); for (const explosion of state.explosions) { for (const cell of explosion.cells) { state.fireLookup.add(tileKey(cell.x, cell.y)); } } } function checkDeaths() { for (const player of state.players) { if (!player.alive) { continue; } const tiles = occupiedTiles(player); if (tiles.some((key) => state.fireLookup.has(key))) { const killerPlayerId = findKillerForHit(tiles, player.id); killPlayer(player, killerPlayerId); } } } function evaluateOutcome() { if (state.status !== "running") { return false; } let endedThisFrame = false; const alivePlayers = state.players.filter((player) => player.alive); if (state.mode === "single") { const human = state.players[0]; if (!human.alive) { state.status = "ended"; state.message = "You were vaporized. Restart to try again."; if (!state.outcomePlayed) { audio.playDefeat(); state.outcomePlayed = true; } } else if (alivePlayers.length === 1 && alivePlayers[0].id === human.id) { state.status = "ended"; state.message = "Victory. You cleared the arena."; if (!state.outcomePlayed) { audio.playVictory(); state.outcomePlayed = true; } } else if (alivePlayers.length === 0) { state.status = "ended"; state.message = "Everyone exploded. Restart for a rematch."; } } else if (alivePlayers.length <= 1) { state.status = "ended"; if (alivePlayers.length === 0) { state.message = "Multiplayer draw: everyone exploded."; } else { state.message = `${alivePlayers[0].name} wins the round.`; } } if (state.status === "ended") { endedThisFrame = true; openMenu(); } return endedThisFrame; } function reportRoundKillsToServer() { if (state.mode !== "multiplayer" || !network.isHost) { return; } const kills = {}; let hasKills = false; for (const [ownerId, count] of Object.entries(state.multiplayerRoundKills)) { const cleanCount = Math.floor(Number(count)); if (!ownerId || !Number.isFinite(cleanCount) || cleanCount <= 0) { continue; } kills[ownerId] = cleanCount; hasKills = true; } const roundDuration = Math.max(0, state.multiplayerRoundTime); const aliveTimes = {}; let hasAliveTimes = false; for (const player of state.players) { if (!player.ownerId) { continue; } const deathAt = state.multiplayerRoundDeathTimes[player.ownerId]; const aliveSeconds = Number.isFinite(deathAt) ? clamp(deathAt, 0, roundDuration) : roundDuration; aliveTimes[player.ownerId] = Math.round(aliveSeconds * 10) / 10; hasAliveTimes = true; } const alivePlayers = state.players.filter((player) => player.alive && player.ownerId); const winnerId = alivePlayers.length === 1 ? alivePlayers[0].ownerId : null; if (!hasKills && !hasAliveTimes && !winnerId) { return; } sendSocketMessage("lobby_kill_report", { kills, winnerId, aliveTimes }); state.multiplayerRoundKills = {}; state.multiplayerRoundDeathTimes = {}; state.multiplayerRoundTime = 0; } function updateNonHostClient(dt) { for (const player of state.players) { if (!player.alive) { continue; } if (player.ownerId === network.clientId) { updatePlayerMovement(player, dt); continue; } if (player.moveProgress < 1) { player.moveProgress = clamp(player.moveProgress + dt * player.speed, 0, 1); const t = easeOutQuad(player.moveProgress); player.renderX = lerp(player.moveFromX, player.moveToX, t); player.renderY = lerp(player.moveFromY, player.moveToY, t); if (player.moveProgress >= 1) { player.x = player.moveToX; player.y = player.moveToY; player.renderX = player.x; player.renderY = player.y; } } } const localPlayer = state.players.find((player) => player.ownerId === network.clientId); if (inputState.bombQueued && localPlayer && localPlayer.alive) { dropBomb(localPlayer); } inputState.bombQueued = false; for (const bomb of state.bombs) { bomb.timer = Math.max(0, bomb.timer - dt); } for (const explosion of state.explosions) { explosion.timer -= dt; } state.explosions = state.explosions.filter((explosion) => explosion.timer > 0); } function updateGame(dt) { if (state.status !== "running") { return; } if (state.mode === "single" && state.menu.open) { return; } if (state.mode === "multiplayer") { state.multiplayerRoundTime += dt; } if (state.mode === "multiplayer" && !network.isHost) { updateNonHostClient(dt); return; } if (state.mode === "single") { for (let i = 1; i < state.players.length; i += 1) { updateBot(state.players[i], dt); } } for (const player of state.players) { updatePlayerMovement(player, dt); } if (state.mode === "single") { const human = state.players[0]; if (inputState.bombQueued && human && human.alive) { dropBomb(human); } } else { for (const player of state.players) { if (!player.alive) { continue; } if (player.ownerId === network.clientId) { if (inputState.bombQueued) { dropBomb(player); } continue; } const remote = getNetworkInput(player.ownerId); const prevBomb = network.remoteBombPrev.get(player.ownerId) || false; if (remote.bomb && !prevBomb) { dropBomb(player, remote.bombCell || null); } network.remoteBombPrev.set(player.ownerId, remote.bomb); } } inputState.bombQueued = false; for (const bomb of state.bombs) { bomb.timer -= dt; if (bomb.timer <= 0) { queueDetonation(bomb.id); } } resolveDetonations(); for (const explosion of state.explosions) { explosion.timer -= dt; } state.explosions = state.explosions.filter((explosion) => explosion.timer > 0); rebuildFireLookup(); checkDeaths(); for (const player of state.players) { collectPowerup(player); } const endedThisFrame = evaluateOutcome(); if (state.mode === "multiplayer" && network.isHost) { if (endedThisFrame) { reportRoundKillsToServer(); } broadcastSnapshot(endedThisFrame); } } function drawImageRotated(image, x, y, size, rotation, scale = 1) { const half = size / 2; ctx.save(); ctx.translate(x + half, y + half); ctx.rotate(rotation); ctx.scale(scale, scale); ctx.drawImage(image, -half, -half, size, size); ctx.restore(); } function drawMap() { const tileSize = CONFIG.tileSize; for (let y = 0; y < CONFIG.rows; y += 1) { for (let x = 0; x < CONFIG.cols; x += 1) { const px = x * tileSize; const py = y * tileSize; const tile = state.map[y][x]; ctx.drawImage(images.floor, px, py, tileSize, tileSize); if (tile.type === "wall") { ctx.drawImage(images.wall, px, py, tileSize, tileSize); } else if (tile.type === "crate") { ctx.drawImage(images.crate, px, py, tileSize, tileSize); } } } } function drawPowerups() { const tileSize = CONFIG.tileSize; const pulse = 0.9 + Math.sin(state.time * 5.2) * 0.08; for (let y = 0; y < CONFIG.rows; y += 1) { for (let x = 0; x < CONFIG.cols; x += 1) { const tile = state.map[y][x]; if (tile.type !== "floor" || !tile.powerup) { continue; } const px = x * tileSize; const py = y * tileSize; const icon = tile.powerup === "bomb" ? images.powerBomb : tile.powerup === "flame" ? images.powerFlame : images.powerSpeed; drawImageRotated(icon, px + 8, py + 8, tileSize - 16, 0, pulse); } } } function drawBombs() { const tileSize = CONFIG.tileSize; for (const bomb of state.bombs) { const px = bomb.x * tileSize; const py = bomb.y * tileSize; const pulse = 1 + Math.sin((CONFIG.bombFuse - bomb.timer) * 15) * 0.06; drawImageRotated(images.bomb, px + 3, py + 3, tileSize - 6, 0, pulse); } } function drawExplosions() { const tileSize = CONFIG.tileSize; for (const explosion of state.explosions) { ctx.save(); ctx.globalAlpha = clamp(explosion.timer / CONFIG.explosionDuration + 0.2, 0.35, 1); for (const cell of explosion.cells) { const px = cell.x * tileSize; const py = cell.y * tileSize; if (cell.type === "center") { drawImageRotated(images.flameCenter, px + 2, py + 2, tileSize - 4, 0, 1); } else if (cell.type === "straight") { const rotate = Math.abs(cell.rot) === Math.PI / 2 ? Math.PI / 2 : 0; drawImageRotated(images.flameStraight, px + 2, py + 2, tileSize - 4, rotate, 1); } else { drawImageRotated(images.flameEnd, px + 2, py + 2, tileSize - 4, cell.rot, 1); } } ctx.restore(); } } function drawPlayers() { const tileSize = CONFIG.tileSize; const sorted = [...state.players].sort((a, b) => a.renderY - b.renderY); for (const player of sorted) { const px = player.renderX * tileSize; const py = player.renderY * tileSize; if (!player.alive) { ctx.save(); ctx.globalAlpha = 0.45; ctx.fillStyle = "#140f14"; ctx.beginPath(); ctx.ellipse(px + tileSize / 2, py + tileSize / 2 + 8, 16, 8, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); continue; } const bob = player.moveProgress < 1 ? Math.sin(state.time * 16 + player.id * 0.8) * 1.8 : 0; ctx.drawImage(images[player.skin], px + 4, py + 4 + bob, tileSize - 8, tileSize - 8); if (player.showName) { ctx.fillStyle = player.color; ctx.font = "bold 12px Trebuchet MS"; ctx.textAlign = "center"; ctx.fillText(player.name, px + tileSize / 2, py - 2); } } } function drawMenu() { if (!state.menu.open) { return; } const items = getMenuItems(); const hasOutcome = state.status === "ended"; const panelWidth = 340; const itemsStartOffset = hasOutcome ? 102 : 90; const itemStep = 34; const footerOffset = itemsStartOffset + items.length * itemStep + 14; const panelHeight = Math.max(220, footerOffset + 24); const panelX = (canvas.width - panelWidth) / 2; const panelY = (canvas.height - panelHeight) / 2; ctx.save(); ctx.fillStyle = "#041321bf"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#10283bd8"; ctx.strokeStyle = "#8acfff88"; ctx.lineWidth = 2; ctx.fillRect(panelX, panelY, panelWidth, panelHeight); ctx.strokeRect(panelX, panelY, panelWidth, panelHeight); ctx.textAlign = "center"; ctx.fillStyle = "#ffe8a8"; ctx.font = "bold 30px Trebuchet MS"; ctx.fillText( state.status === "ended" ? "Round Over" : state.mode === "single" ? "Pause Menu" : "Match Menu", canvas.width / 2, panelY + 44, ); if (state.status === "ended") { ctx.fillStyle = "#cfe8ff"; ctx.font = "bold 15px Trebuchet MS"; ctx.fillText(state.message, canvas.width / 2, panelY + 70); } const baseY = panelY + itemsStartOffset; items.forEach((item, index) => { const y = baseY + index * itemStep; const selected = index === state.menu.selectedIndex; if (selected) { ctx.fillStyle = "#ffd166"; ctx.fillRect(panelX + 36, y - 18, panelWidth - 72, 26); } ctx.fillStyle = selected ? "#1a2a3f" : "#e7f4ff"; ctx.font = "bold 18px Trebuchet MS"; ctx.fillText(item.label, canvas.width / 2, y); }); ctx.fillStyle = "#9ecdf0"; ctx.font = "bold 13px Trebuchet MS"; ctx.fillText("Up/Down + Enter | D-pad Up/Down + A", canvas.width / 2, panelY + footerOffset); ctx.restore(); } function drawStaticBackdrop() { ctx.fillStyle = "#0a1622"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = "#8acfff1c"; for (let x = 0; x <= CONFIG.cols; x += 1) { ctx.beginPath(); ctx.moveTo(x * CONFIG.tileSize, 0); ctx.lineTo(x * CONFIG.tileSize, canvas.height); ctx.stroke(); } for (let y = 0; y <= CONFIG.rows; y += 1) { ctx.beginPath(); ctx.moveTo(0, y * CONFIG.tileSize); ctx.lineTo(canvas.width, y * CONFIG.tileSize); ctx.stroke(); } } function drawMainMenu() { drawStaticBackdrop(); const panelWidth = 420; const panelHeight = 340; const panelX = (canvas.width - panelWidth) / 2; const panelY = (canvas.height - panelHeight) / 2; const items = getMainMenuItems(); ctx.fillStyle = "#041321c9"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#10283bf2"; ctx.strokeStyle = "#8acfff88"; ctx.lineWidth = 2; ctx.fillRect(panelX, panelY, panelWidth, panelHeight); ctx.strokeRect(panelX, panelY, panelWidth, panelHeight); ctx.textAlign = "center"; ctx.fillStyle = "#ffe8a8"; ctx.font = "bold 42px Trebuchet MS"; ctx.fillText("GPT BOMBER", canvas.width / 2, panelY + 58); ctx.font = "bold 15px Trebuchet MS"; ctx.fillStyle = "#b6ddff"; ctx.fillText("Choose mode and start", canvas.width / 2, panelY + 82); if (state.message && state.message !== "Select mode") { ctx.fillStyle = "#ffcf9f"; ctx.font = "bold 13px Trebuchet MS"; ctx.fillText(state.message, canvas.width / 2, panelY + 100); } items.forEach((item, index) => { const y = panelY + 146 + index * 46; const selected = index === state.mainMenu.selectedIndex; if (selected) { ctx.fillStyle = "#ffd166"; ctx.fillRect(panelX + 36, y - 23, panelWidth - 72, 32); } ctx.fillStyle = selected ? "#1a2a3f" : "#e7f4ff"; ctx.font = "bold 22px Trebuchet MS"; ctx.fillText(item.label, canvas.width / 2, y); }); } function drawLobby() { drawStaticBackdrop(); const panelWidth = 560; const panelHeight = 430; const panelX = (canvas.width - panelWidth) / 2; const panelY = (canvas.height - panelHeight) / 2; ctx.fillStyle = "#041321d4"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#10283bf5"; ctx.strokeStyle = "#8acfff88"; ctx.lineWidth = 2; ctx.fillRect(panelX, panelY, panelWidth, panelHeight); ctx.strokeRect(panelX, panelY, panelWidth, panelHeight); ctx.textAlign = "center"; ctx.fillStyle = "#ffe8a8"; ctx.font = "bold 34px Trebuchet MS"; ctx.fillText("Multiplayer Lobby", canvas.width / 2, panelY + 48); ctx.fillStyle = "#b6ddff"; ctx.font = "bold 14px Trebuchet MS"; const lobbyHint = network.lobbyPhase === "game" ? "Match currently running. You will join next round." : "Host can start with 2-8 players."; ctx.fillText(lobbyHint, canvas.width / 2, panelY + 74); const splitX = panelX + panelWidth / 2; const playerListX = panelX + 34; const highscoreX = splitX + 24; const listStartY = panelY + 130; const fitLine = (text, maxWidth) => { const limit = Math.max(10, maxWidth); let trimmed = text; while (ctx.measureText(trimmed).width > limit && trimmed.length > 4) { trimmed = trimmed.slice(0, -4) + "..."; } return trimmed; }; ctx.textAlign = "left"; ctx.font = "bold 15px Trebuchet MS"; ctx.fillStyle = "#ffdca3"; ctx.fillText("Lobby Players", playerListX, panelY + 102); const playerMaxWidth = splitX - playerListX - 18; for (let i = 0; i < MAX_PLAYERS; i += 1) { const player = network.lobbyPlayers[i]; const y = listStartY + i * 24; const rawLine = player ? String(i + 1) + ". " + player.name + (player.id === network.hostId ? " (Host)" : "") : String(i + 1) + ". Waiting..."; const line = fitLine(rawLine, playerMaxWidth); ctx.fillStyle = player ? "#e9f6ff" : "#8fb6d4"; ctx.fillText(line, playerListX, y); } ctx.strokeStyle = "#8acfff44"; ctx.beginPath(); ctx.moveTo(splitX, panelY + 98); ctx.lineTo(splitX, panelY + 318); ctx.stroke(); ctx.fillStyle = "#ffdca3"; ctx.font = "bold 17px Trebuchet MS"; ctx.fillText("Server Highscores", highscoreX, panelY + 102); const highscoreMaxWidth = panelX + panelWidth - highscoreX - 20; if (network.highscores.length === 0) { ctx.fillStyle = "#8fb6d4"; ctx.font = "bold 15px Trebuchet MS"; ctx.fillText("No rounds recorded yet.", highscoreX, listStartY); } else { ctx.font = "bold 14px Trebuchet MS"; network.highscores.forEach((entry, index) => { const y = listStartY + index * 20; const rank = String(index + 1).padStart(2, "0"); const rawLine = rank + ". " + entry.name + " - " + entry.kills + " (" + formatAliveSeconds(entry.longestAlive) + ")"; const line = fitLine(rawLine, highscoreMaxWidth); ctx.fillStyle = "#e9f6ff"; ctx.fillText(line, highscoreX, y); }); } const items = getLobbyMenuItems(); items.forEach((item, index) => { const y = panelY + 326 + index * 30; const selected = index === state.lobbyMenu.selectedIndex; if (selected && !item.disabled) { ctx.fillStyle = "#ffd166"; ctx.fillRect(panelX + 34, y - 18, panelWidth - 68, 26); } ctx.fillStyle = item.disabled ? "#7f9bb0" : selected ? "#1a2a3f" : "#e7f4ff"; ctx.textAlign = "center"; ctx.font = "bold 18px Trebuchet MS"; ctx.fillText(item.label, canvas.width / 2, y); }); } function drawMatchNotification() { if (state.screen !== "game" || !state.notification.text) { return; } const remaining = state.notification.expiresAt - now(); if (remaining <= 0) { state.notification.text = ""; state.notification.expiresAt = 0; return; } const fade = clamp(remaining < 0.35 ? remaining / 0.35 : 1, 0, 1); const panelWidth = Math.min(canvas.width - 36, 520); const panelHeight = 34; const panelX = (canvas.width - panelWidth) / 2; const panelY = 14; ctx.save(); ctx.globalAlpha = 0.96 * fade; ctx.fillStyle = "#071421db"; ctx.strokeStyle = "#8acfff99"; ctx.lineWidth = 2; ctx.fillRect(panelX, panelY, panelWidth, panelHeight); ctx.strokeRect(panelX, panelY, panelWidth, panelHeight); ctx.fillStyle = "#e7f4ff"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = "bold 16px Trebuchet MS"; ctx.fillText(state.notification.text, canvas.width / 2, panelY + panelHeight / 2 + 1); ctx.restore(); } function drawFrame() { if (state.screen === "mainMenu") { drawMainMenu(); return; } if (state.screen === "lobby") { drawLobby(); return; } ctx.clearRect(0, 0, canvas.width, canvas.height); drawMap(); drawPowerups(); drawBombs(); drawExplosions(); drawPlayers(); drawMatchNotification(); drawMenu(); ctx.strokeStyle = "#86cef91f"; for (let x = 0; x <= CONFIG.cols; x += 1) { ctx.beginPath(); ctx.moveTo(x * CONFIG.tileSize, 0); ctx.lineTo(x * CONFIG.tileSize, canvas.height); ctx.stroke(); } for (let y = 0; y <= CONFIG.rows; y += 1) { ctx.beginPath(); ctx.moveTo(0, y * CONFIG.tileSize); ctx.lineTo(canvas.width, y * CONFIG.tileSize); ctx.stroke(); } } function currentHumanDirection() { if (inputState.movementOrder.length === 0) { return null; } return inputState.movementOrder[inputState.movementOrder.length - 1]; } function setMovementHeld(direction, held) { if (!held) { inputState.held.delete(direction); inputState.movementOrder = inputState.movementOrder.filter((entry) => entry !== direction); return; } if (!inputState.held.has(direction)) { inputState.held.add(direction); inputState.movementOrder = inputState.movementOrder.filter((entry) => entry !== direction); inputState.movementOrder.push(direction); } } function keyToAction(code) { switch (code) { case "ArrowUp": case "KeyW": return { type: "move", value: "up" }; case "ArrowDown": case "KeyS": return { type: "move", value: "down" }; case "ArrowLeft": case "KeyA": return { type: "move", value: "left" }; case "ArrowRight": case "KeyD": return { type: "move", value: "right" }; case "Space": case "Enter": return { type: "bomb" }; case "Escape": case "KeyP": return { type: "menu" }; default: return null; } } function moveMainMenuSelection(delta) { const count = getMainMenuItems().length; state.mainMenu.selectedIndex = (state.mainMenu.selectedIndex + delta + count) % count; } function activateMainMenuSelection() { const items = getMainMenuItems(); const item = items[state.mainMenu.selectedIndex]; if (!item) { return; } if (item.id === "mode") { state.mainMenu.mode = state.mainMenu.mode === "single" ? "multiplayer" : "single"; return; } if (item.id === "music") { toggleMusicPreference(); return; } if (item.id === "name") { promptForPlayerName(); return; } if (item.id === "start") { if (state.mainMenu.mode === "single") { startSinglePlayerFromMenu(); } else { enterLobbyScreen(); } } } function moveLobbySelection(delta) { const count = getLobbyMenuItems().length; state.lobbyMenu.selectedIndex = (state.lobbyMenu.selectedIndex + delta + count) % count; } function activateLobbySelection() { const items = getLobbyMenuItems(); const item = items[state.lobbyMenu.selectedIndex]; if (!item || item.disabled) { return; } if (item.id === "start") { hostStartMatchFromLobby(); return; } if (item.id === "music") { toggleMusicPreference(); return; } if (item.id === "name") { promptForPlayerName(); return; } if (item.id === "leave") { leaveMultiplayerToMainMenu(); } } function onActionDown(action, isRepeat = false) { if (state.screen === "mainMenu") { if (action.type === "move" && !isRepeat) { if (action.value === "up") { moveMainMenuSelection(-1); } else if (action.value === "down") { moveMainMenuSelection(1); } else if (action.value === "left" || action.value === "right") { activateMainMenuSelection(); } } else if (action.type === "bomb" && !isRepeat) { activateMainMenuSelection(); } return; } if (state.screen === "lobby") { if (action.type === "move" && !isRepeat) { if (action.value === "up") { moveLobbySelection(-1); } else if (action.value === "down") { moveLobbySelection(1); } } else if (action.type === "bomb" && !isRepeat) { activateLobbySelection(); } else if (action.type === "menu" && !isRepeat) { leaveMultiplayerToMainMenu(); } return; } if (action.type === "menu" && !isRepeat) { if (state.menu.open) { closeMenu(); } else { openMenu(); } if (state.mode === "multiplayer" && !network.isHost) { sendLocalMultiplayerInput(true); } return; } if (state.menu.open) { if (action.type === "move" && !isRepeat) { if (action.value === "up") { moveMenuSelection(-1); } else if (action.value === "down") { moveMenuSelection(1); } } else if (action.type === "bomb" && !isRepeat) { activateMenuSelection(); } return; } if (action.type === "move") { setMovementHeld(action.value, true); if (state.mode === "multiplayer" && !network.isHost) { sendLocalMultiplayerInput(true); } } else if (action.type === "bomb" && !isRepeat) { inputState.bombQueued = true; inputState.bombHeld = true; if (state.mode === "multiplayer" && !network.isHost) { sendLocalMultiplayerInput(true); } } } function onActionUp(action) { if (state.screen !== "game") { return; } if (state.menu.open && state.mode === "single") { return; } if (action.type === "move") { setMovementHeld(action.value, false); if (state.mode === "multiplayer" && !network.isHost) { sendLocalMultiplayerInput(true); } } if (action.type === "bomb") { inputState.bombHeld = false; if (state.mode === "multiplayer" && !network.isHost) { sendLocalMultiplayerInput(true); } } } function setupInput() { window.addEventListener("keydown", (event) => { const action = keyToAction(event.code); if (!action) { return; } event.preventDefault(); if (action.type !== "menu") { audio.unlock(); } onActionDown(action, event.repeat); }); window.addEventListener("keyup", (event) => { const action = keyToAction(event.code); if (!action) { return; } event.preventDefault(); onActionUp(action); }); window.addEventListener("beforeunload", () => { if (network.inLobby || state.mode === "multiplayer") { sendSocketMessage("lobby_leave"); } if (network.socket) { network.socket.close(); network.socket = null; } }); } function buttonPressed(button) { if (!button) { return false; } return typeof button === "object" ? button.pressed : button === 1; } function pollGamepadInput() { if (!navigator.getGamepads) { return; } const pads = navigator.getGamepads(); const pad = Array.from(pads).find((entry) => entry && entry.connected); if (!pad) { for (const dir of ["up", "down", "left", "right"]) { if (inputState.gamepadPrev[dir]) { onActionUp({ type: "move", value: dir }); } } if (inputState.gamepadPrev.bomb) { onActionUp({ type: "bomb" }); } inputState.gamepadPrev = { up: false, down: false, left: false, right: false, bomb: false, start: false, }; return; } const axisX = pad.axes[0] || 0; const axisY = pad.axes[1] || 0; const current = { up: buttonPressed(pad.buttons[12]) || axisY < -0.6, down: buttonPressed(pad.buttons[13]) || axisY > 0.6, left: buttonPressed(pad.buttons[14]) || axisX < -0.6, right: buttonPressed(pad.buttons[15]) || axisX > 0.6, bomb: buttonPressed(pad.buttons[0]), start: buttonPressed(pad.buttons[9]), }; const prev = inputState.gamepadPrev; if (current.start && !prev.start) { onActionDown({ type: "menu" }, false); } for (const dir of ["up", "down", "left", "right"]) { if (current[dir] && !prev[dir]) { onActionDown({ type: "move", value: dir }, false); } if (!current[dir] && prev[dir]) { onActionUp({ type: "move", value: dir }); } } if (current.bomb && !prev.bomb) { audio.unlock(); onActionDown({ type: "bomb" }, false); } if (!current.bomb && prev.bomb) { onActionUp({ type: "bomb" }); } inputState.gamepadPrev = current; } function updateNetworking(dt) { if (state.screen === "lobby") { updateLobbyNetwork(dt); return; } if (state.screen === "game" && state.mode === "multiplayer") { if (!network.isHost) { sendLocalMultiplayerInput(); if (now() - network.lastSnapshotAt > 4 && state.status === "running") { state.status = "ended"; state.message = "Connection to host lost."; openMenu(); } } } } function gameLoop(timestamp) { if (state.lastFrame === 0) { state.lastFrame = timestamp; } const dt = clamp((timestamp - state.lastFrame) / 1000, 0, 0.05); state.lastFrame = timestamp; state.time += dt; pollGamepadInput(); updateNetworking(dt); updateGame(dt); drawFrame(); requestAnimationFrame(gameLoop); } async function boot() { try { await loadAssets(); setupInput(); state.screen = "mainMenu"; state.mode = "single"; state.status = "idle"; state.message = "Select mode"; requestAnimationFrame(gameLoop); } catch (error) { state.status = "error"; state.message = "Asset load failed. Check console for details."; console.error(error); } } class AudioSystem { constructor() { this.ctx = null; this.masterGain = null; this.musicGain = null; this.sfxGain = null; this.noiseBuffer = null; this.scheduler = null; this.musicEnabled = true; this.stepIndex = 0; this.nextStepTime = 0; this.music = { bpm: 130, stepsPerBar: 16, progression: [ { root: 45, chord: [0, 3, 7, 10] }, { root: 50, chord: [0, 3, 7, 10] }, { root: 43, chord: [0, 4, 7, 11] }, { root: 48, chord: [0, 4, 7, 11] }, ], bassPattern: [0, null, 0, null, 7, null, 10, null, 0, null, 7, null, 3, null, 5, null], arpPattern: [12, 19, 22, 19, 15, 19, 22, 19, 12, 19, 22, 24, 15, 19, 22, 19], leadPatternA: [null, 24, null, 26, null, 27, 26, null, null, 31, null, 29, 27, null, 26, null], leadPatternB: [null, 24, null, 24, null, 26, 27, null, null, 29, null, 31, 29, null, 27, null], kick: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0], snare: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0], hat: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], openHat: [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], }; } hasContext() { return Boolean(this.ctx); } isMusicEnabled() { return this.musicEnabled; } async unlock() { if (this.ctx) { if (this.ctx.state === "suspended") { await this.ctx.resume(); } return; } const AudioCtx = window.AudioContext || window.webkitAudioContext; if (!AudioCtx) { return; } this.ctx = new AudioCtx(); this.masterGain = this.ctx.createGain(); this.masterGain.gain.value = 0.75; this.masterGain.connect(this.ctx.destination); this.musicGain = this.ctx.createGain(); this.musicGain.gain.value = 0.24; this.musicGain.connect(this.masterGain); this.sfxGain = this.ctx.createGain(); this.sfxGain.gain.value = 0.88; this.sfxGain.connect(this.masterGain); this.noiseBuffer = this.createNoiseBuffer(); this.nextStepTime = this.ctx.currentTime + 0.05; this.scheduler = window.setInterval(() => this.scheduleMusic(), 90); } toggleMusic() { if (!this.ctx) { return; } this.musicEnabled = !this.musicEnabled; this.musicGain.gain.setTargetAtTime(this.musicEnabled ? 0.24 : 0, this.ctx.currentTime, 0.05); if (this.musicEnabled) { this.nextStepTime = this.ctx.currentTime + 0.03; } } midiToFreq(note) { return 440 * 2 ** ((note - 69) / 12); } scheduleMusic() { if (!this.ctx || !this.musicEnabled) { return; } const stepDuration = (60 / this.music.bpm) / 2; const totalSteps = this.music.stepsPerBar * this.music.progression.length; while (this.nextStepTime < this.ctx.currentTime + 0.35) { this.playMusicStep(this.stepIndex, this.nextStepTime); this.stepIndex = (this.stepIndex + 1) % totalSteps; this.nextStepTime += stepDuration; } } playMusicStep(index, time) { const step = index % this.music.stepsPerBar; const barIndex = Math.floor(index / this.music.stepsPerBar) % this.music.progression.length; const bar = this.music.progression[barIndex]; const leadPattern = barIndex % 2 === 0 ? this.music.leadPatternA : this.music.leadPatternB; const swingTime = step % 2 === 1 ? time + 0.008 : time; const bassInterval = this.music.bassPattern[step]; if (bassInterval !== null) { this.playTone(this.midiToFreq(bar.root + bassInterval), 0.19, "triangle", 0.17, swingTime, this.musicGain); if (step % 4 === 0) { this.playTone(this.midiToFreq(bar.root + bassInterval - 12), 0.1, "sine", 0.08, swingTime, this.musicGain); } } const arpInterval = this.music.arpPattern[(step + barIndex * 2) % this.music.arpPattern.length]; if (arpInterval !== null) { this.playTone(this.midiToFreq(bar.root + arpInterval), 0.07, "sawtooth", 0.05, swingTime, this.musicGain); } const leadInterval = leadPattern[step]; if (leadInterval !== null) { const accent = step % 4 === 1 ? 1.15 : 1; this.playTone( this.midiToFreq(bar.root + leadInterval), 0.12, "square", 0.095 * accent, swingTime, this.musicGain, 0.004, 0.07, ); } if (step % 8 === 0) { this.playTone(this.midiToFreq(bar.root + bar.chord[0] + 12), 0.42, "triangle", 0.045, time, this.musicGain); this.playTone(this.midiToFreq(bar.root + bar.chord[1] + 12), 0.42, "triangle", 0.037, time, this.musicGain); this.playTone(this.midiToFreq(bar.root + bar.chord[2] + 12), 0.42, "triangle", 0.04, time, this.musicGain); } if (this.music.kick[step]) { this.playTone(58, 0.05, "sine", 0.16, time, this.musicGain, 0.0015, 0.04); this.playTone(42, 0.08, "triangle", 0.11, time, this.musicGain, 0.0015, 0.05); } if (this.music.snare[step]) { this.playNoise(0.06, 0.04, time, this.musicGain); this.playTone(188, 0.045, "triangle", 0.07, time, this.musicGain, 0.002, 0.035); } if (this.music.hat[step]) { this.playNoise(0.018, 0.014, swingTime, this.musicGain); } if (this.music.openHat[step]) { this.playNoise(0.025, 0.06, swingTime, this.musicGain); } } playTone(freq, duration, type, volume, time, targetGain, attack = 0.005, release = 0.08) { if (!this.ctx) { return; } const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, time); gain.gain.setValueAtTime(0.0001, time); gain.gain.linearRampToValueAtTime(volume, time + attack); gain.gain.exponentialRampToValueAtTime(0.0001, time + duration + release); osc.connect(gain); gain.connect(targetGain); osc.start(time); osc.stop(time + duration + release + 0.01); } playNoise(volume, duration, time, targetGain) { if (!this.ctx || !this.noiseBuffer) { return; } const source = this.ctx.createBufferSource(); const filter = this.ctx.createBiquadFilter(); const gain = this.ctx.createGain(); source.buffer = this.noiseBuffer; filter.type = "bandpass"; filter.frequency.setValueAtTime(1800, time); filter.Q.setValueAtTime(0.6, time); gain.gain.setValueAtTime(volume, time); gain.gain.exponentialRampToValueAtTime(0.0001, time + duration); source.connect(filter); filter.connect(gain); gain.connect(targetGain); source.start(time); source.stop(time + duration + 0.02); } createNoiseBuffer() { const length = this.ctx.sampleRate * 0.5; const buffer = this.ctx.createBuffer(1, length, this.ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < length; i += 1) { data[i] = Math.random() * 2 - 1; } return buffer; } playPlaceBomb() { if (!this.ctx) { return; } const t = this.ctx.currentTime; this.playTone(320, 0.06, "square", 0.12, t, this.sfxGain, 0.001, 0.03); this.playTone(250, 0.06, "triangle", 0.08, t + 0.01, this.sfxGain, 0.001, 0.03); } playExplosion() { if (!this.ctx) { return; } const t = this.ctx.currentTime; this.playTone(140, 0.08, "sawtooth", 0.25, t, this.sfxGain, 0.002, 0.06); this.playTone(70, 0.18, "triangle", 0.18, t, this.sfxGain, 0.002, 0.1); this.playNoise(0.22, 0.14, t, this.sfxGain); } playPowerup() { if (!this.ctx) { return; } const t = this.ctx.currentTime; this.playTone(660, 0.08, "triangle", 0.14, t, this.sfxGain, 0.002, 0.05); this.playTone(830, 0.08, "triangle", 0.13, t + 0.07, this.sfxGain, 0.002, 0.05); this.playTone(990, 0.12, "triangle", 0.12, t + 0.14, this.sfxGain, 0.002, 0.07); } playDefeat() { if (!this.ctx) { return; } const t = this.ctx.currentTime; this.playTone(260, 0.12, "square", 0.14, t, this.sfxGain, 0.003, 0.1); this.playTone(206, 0.18, "square", 0.14, t + 0.14, this.sfxGain, 0.003, 0.1); this.playTone(174, 0.24, "triangle", 0.14, t + 0.32, this.sfxGain, 0.003, 0.12); } playVictory() { if (!this.ctx) { return; } const t = this.ctx.currentTime; this.playTone(523, 0.14, "triangle", 0.13, t, this.sfxGain, 0.002, 0.08); this.playTone(659, 0.14, "triangle", 0.13, t + 0.13, this.sfxGain, 0.002, 0.08); this.playTone(784, 0.22, "triangle", 0.16, t + 0.28, this.sfxGain, 0.002, 0.12); } } audio = new AudioSystem(); boot();