UNPKG

@lobstar/core

Version:

A simple network and lobby manager for multiplayer web games

685 lines (684 loc) 28.3 kB
import mitt from "mitt"; import { NetworkManager } from "./network"; export const SESSION_STATES = { DISCONNECTED: "DISCONNECTED", CONNECTING: "CONNECTING", LOBBY: "LOBBY", PLAYING: "PLAYING", GAME_OVER: "GAME_OVER", ERROR: "ERROR", }; export const ERROR_CODES = { CONNECTION_FAILED: "CONNECTION_FAILED", HOST_DISCONNECTED: "HOST_DISCONNECTED", PEER_DISCONNECTED: "PEER_DISCONNECTED", NOT_HOST: "NOT_HOST", ALREADY_HOST: "ALREADY_HOST", INVALID_STATE: "INVALID_STATE", SESSION_FULL: "SESSION_FULL", INVALID_MESSAGE: "INVALID_MESSAGE", MESSAGE_SEND_FAILED: "MESSAGE_SEND_FAILED", PLAYER_NOT_FOUND: "PLAYER_NOT_FOUND", SELF_ACTION_INVALID: "SELF_ACTION_INVALID", NOT_ALL_PLAYERS_READY: "NOT_ALL_PLAYERS_READY", NOT_ENOUGH_PLAYERS: "NOT_ENOUGH_PLAYERS", UNKNOWN: "UNKNOWN", }; var MessageType; (function (MessageType) { MessageType["PLAYER_INFO"] = "PLAYER_INFO"; MessageType["LOBBY_UPDATE"] = "LOBBY_UPDATE"; MessageType["LOBBY_FULL"] = "LOBBY_FULL"; MessageType["PLAYER_READY"] = "PLAYER_READY"; MessageType["GAME_START"] = "GAME_START"; MessageType["GAME_OVER"] = "GAME_OVER"; MessageType["KICK_PLAYER"] = "KICK_PLAYER"; MessageType["CUSTOM_MESSAGE"] = "CUSTOM_MESSAGE"; })(MessageType || (MessageType = {})); export class GameSessionManager { constructor(options = {}) { this.networkManager = new NetworkManager({ debug: options.debug, peerOptions: options.peerOptions, }); this.emitter = mitt(); this.currentState = SESSION_STATES.DISCONNECTED; this.players = {}; this.selfId = null; this.hostId = null; this.maxPlayers = options.maxPlayers || 8; this.requireReadyBeforeStart = options.requireReadyBeforeStart !== false; this.debug = options.debug || false; this.pendingPlayers = new Map(); this.playerJoinTimeoutMs = options.playerJoinTimeoutMs || 10000; this.setupNetworkListeners(); } log(message) { if (this.debug) { console.log(`[GameSession] ${message}`); } } setState(newState) { const previousState = this.currentState; if (newState !== previousState) { this.currentState = newState; this.log(`State changed from ${previousState} to ${newState}`); this.emitter.emit("stateChange", { state: newState, previousState }); } } emitError(code, message, context, originalError, isRecoverable = true) { this.log(`ERROR [${code}]: ${message}${context ? ` (${context})` : ""}`); if (originalError) { console.error(originalError); } this.emitter.emit("error", { code, message, context, originalError }); if (!isRecoverable && this.currentState !== SESSION_STATES.ERROR) { this.setState(SESSION_STATES.ERROR); } } setupNetworkListeners() { this.networkManager.on("message", ({ data, peerId }) => { this.log(`Received message of type ${data.type} from ${peerId}`); this.handleMessage(data, peerId); }); this.networkManager.on("connection", ({ peerId }) => { this.log(`New connection from ${peerId}`); if (this.isHost()) { if (Object.keys(this.players).length + this.pendingPlayers.size >= this.maxPlayers) { this.log(`Rejecting connection from ${peerId}, lobby is full (players: ${Object.keys(this.players).length}, pending: ${this.pendingPlayers.size}, max: ${this.maxPlayers})`); // Attempt to send LOBBY_FULL before disconnecting // Note: Connection might not be fully ready, this is best-effort this.networkManager.sendToPeer(peerId, { type: MessageType.LOBBY_FULL, }); setTimeout(() => this.networkManager.disconnectPeer(peerId), 100); return; } this.log(`Waiting for player info from ${peerId}, setting timeout (${this.playerJoinTimeoutMs}ms)`); const timeoutId = setTimeout(() => { if (this.pendingPlayers.has(peerId)) { this.log(`Player info timeout for ${peerId}. Disconnecting.`); this.networkManager.disconnectPeer(peerId); } }, this.playerJoinTimeoutMs); this.pendingPlayers.set(peerId, timeoutId); } if (!this.isHost() && peerId !== this.hostId) { this.log(`Rejecting connection request from non-host ${peerId}`); this.networkManager.disconnectPeer(peerId); } }); this.networkManager.on("disconnection", ({ peerId }) => { this.log(`Disconnection: ${peerId} (current host: ${this.hostId}, self: ${this.selfId})`); if (peerId === this.hostId && !this.isHost()) { this.log(`Host disconnected (${peerId}), terminating session`); this.emitError(ERROR_CODES.HOST_DISCONNECTED, "Host disconnected", "host_disconnect", undefined, false); this.leave(); return; } if (this.players[peerId]) { const player = this.players[peerId]; this.log(`Removing player ${player.name} (${peerId}) from players list`); delete this.players[peerId]; this.emitter.emit("playersUpdate", { players: { ...this.players } }); if (this.isHost()) { this.log(`Broadcasting updated lobby after ${peerId} disconnected`); this.broadcastLobbyUpdate(); } } }); this.networkManager.on("error", ({ error, context }) => { this.log(`Network error: ${String(error)} (${context})`); this.emitError(ERROR_CODES.CONNECTION_FAILED, "Network error occurred", context, error, false); }); } handleMessage(message, peerId) { try { switch (message.type) { case MessageType.PLAYER_INFO: this.handlePlayerInfo(message, peerId); break; case MessageType.LOBBY_UPDATE: this.handleLobbyUpdate(message); break; case MessageType.LOBBY_FULL: this.handleLobbyFull(); break; case MessageType.PLAYER_READY: this.handlePlayerReady(message); break; case MessageType.GAME_START: this.handleGameStart(); break; case MessageType.GAME_OVER: this.handleGameOver(); break; case MessageType.KICK_PLAYER: this.handleKickPlayer(message); break; case MessageType.CUSTOM_MESSAGE: this.handleCustomMessage(message, peerId); break; default: this.log(`Unknown message type: ${message.type}`); } } catch (error) { this.emitError(ERROR_CODES.INVALID_MESSAGE, "Error handling message", message.type, error); } } handlePlayerInfo(message, peerId) { if (!this.isHost()) { this.log("Ignoring player info message, not host"); return; } if (this.pendingPlayers.has(peerId)) { clearTimeout(this.pendingPlayers.get(peerId)); this.pendingPlayers.delete(peerId); this.log(`Received player info from ${peerId}, cleared timeout.`); } else { // This could happen if the message arrives extremely quickly, or if the peer // was previously disconnected after timeout but managed to send the info before // the disconnection completed network-wise. this.log(`WARN: Received player info from ${peerId} which was not in the pending list.`); if (Object.keys(this.players).length >= this.maxPlayers) { // User may have not received the LOBBY_FULL message, so we'll disconnect them. this.log(`Lobby is full, disconnecting ${peerId}`); setTimeout(() => this.networkManager.disconnectPeer(peerId), 100); return; } // Otherwise, it's generally safe to proceed, but worth noting. } this.log(`Received player info from ${peerId}`); const { player } = message; if (player.id !== peerId) { this.log(`Player ID mismatch: ${player.id} vs ${peerId}`); player.id = peerId; } player.isHost = false; this.addPlayer(player); } handleLobbyUpdate(message) { if (this.isHost()) { this.log("Ignoring lobby update, we are the host"); return; } const prevPlayerIds = Object.keys(this.players); const newPlayerIds = Object.keys(message.players); this.log(`Received lobby update: ${newPlayerIds.length} players (previously had ${prevPlayerIds.length})`); // Preserve self info if it's not in the update (might happen during join process) const selfPlayer = this.players[this.selfId]; this.players = { ...message.players }; if (selfPlayer && !this.players[this.selfId]) { this.log(`Self not found in lobby update, preserving self info: ${selfPlayer.name} (${selfPlayer.id})`); this.players[this.selfId] = selfPlayer; } // Log player diffs for debugging const added = newPlayerIds.filter((id) => !prevPlayerIds.includes(id)); const removed = prevPlayerIds.filter((id) => !newPlayerIds.includes(id) && id !== this.selfId); if (added.length > 0) { this.log(`New players in update: ${added.join(", ")}`); } if (removed.length > 0) { this.log(`Removed players in update: ${removed.join(", ")}`); } this.emitter.emit("playersUpdate", { players: { ...this.players } }); } handleLobbyFull() { this.emitError(ERROR_CODES.SESSION_FULL, "Lobby is full", "lobby_full"); } handlePlayerReady(message) { const { playerId, isReady } = message; if (!this.players[playerId]) { this.log(`Player ${playerId} not found`); return; } this.players[playerId].isReady = isReady; this.emitter.emit("playersUpdate", { players: { ...this.players } }); if (this.isHost()) { this.broadcastLobbyUpdate(); } } handleGameStart() { if (this.currentState !== SESSION_STATES.LOBBY) { this.log(`Ignoring game start, not in LOBBY state (current: ${this.currentState})`); return; } this.setState(SESSION_STATES.PLAYING); } handleGameOver() { if (this.currentState !== SESSION_STATES.PLAYING) { this.log(`Ignoring game over, not in PLAYING state (current: ${this.currentState})`); return; } this.setState(SESSION_STATES.GAME_OVER); } handleKickPlayer(message) { const { playerId } = message; if (this.isHost()) { this.log(`Ignoring kick player message, we are the host`); return; } if (playerId === this.selfId) { this.log(`We have been kicked from the game (our ID: ${this.selfId})`); // Emit kicked event before leaving to allow listeners to handle the event this.emitter.emit("kicked", undefined); // Make sure to fully reset our state when kicked this.log("Performing full cleanup after being kicked"); // Short timeout to ensure the kicked event is processed before leave setTimeout(() => { this.leave(); }, 10); return; } } handleCustomMessage(message, peerId) { this.log(`Received custom message from ${peerId}`); this.emitter.emit("customMessage", { data: message.data, peerId }); } addPlayer(player) { if (Object.keys(this.players).length >= this.maxPlayers) { this.log(`Cannot add player ${player.name}, lobby is full`); this.emitError(ERROR_CODES.SESSION_FULL, `Lobby is full (max: ${this.maxPlayers})`, "player_join"); this.networkManager.sendToPeer(player.id, { type: MessageType.LOBBY_FULL, }); if (this.isHost()) { this.networkManager.disconnectPeer(player.id); } return; } this.players[player.id] = player; this.emitter.emit("playersUpdate", { players: { ...this.players } }); if (this.isHost()) { this.broadcastLobbyUpdate(); } } broadcastLobbyUpdate() { const message = { type: MessageType.LOBBY_UPDATE, players: this.players, }; this.networkManager.broadcast(message); } // Public API /** * Host a new game session * @param playerName Display name for the host * @param lobbyId Optional specific ID to request for the lobby * @returns The actual lobby ID assigned */ async host(playerName, lobbyId) { if (this.currentState !== SESSION_STATES.DISCONNECTED) { throw new Error("Cannot host: already in a session"); } this.setState(SESSION_STATES.CONNECTING); try { await this.networkManager.initialize(lobbyId); this.selfId = this.networkManager.getPeerId(); this.hostId = this.selfId; if (!this.selfId) { throw new Error("Failed to initialize peer connection"); } const hostPlayer = { id: this.selfId, name: playerName, isReady: true, // Host is always ready isHost: true, }; this.players = { [this.selfId]: hostPlayer }; this.setState(SESSION_STATES.LOBBY); this.emitter.emit("playersUpdate", { players: { ...this.players } }); return this.selfId; } catch (error) { this.setState(SESSION_STATES.ERROR); this.emitError(ERROR_CODES.CONNECTION_FAILED, "Failed to host game", "host_creation", error, false); throw error; } } /** * Join an existing game session * @param lobbyId The ID of the lobby host to connect to * @param playerName Display name for the joining player */ async join(lobbyId, playerName) { if (this.currentState !== SESSION_STATES.DISCONNECTED) { throw new Error("Cannot join: already in a session"); } this.setState(SESSION_STATES.CONNECTING); this.hostId = lobbyId; try { await this.networkManager.initialize(); this.selfId = this.networkManager.getPeerId(); if (!this.selfId) { throw new Error("Failed to initialize peer connection"); } const selfPlayer = { id: this.selfId, name: playerName, isReady: false, isHost: false, }; this.players = { [this.selfId]: selfPlayer }; // Wait for connection with timeout await this.connectWithTimeout(lobbyId); // Ensure we're in a consistent state before proceeding if (this.currentState !== SESSION_STATES.CONNECTING) { this.log(`State changed during connection to ${this.currentState}, aborting join`); throw new Error(`Cannot join: state changed to ${this.currentState}`); } // Send player info after connection is established const message = { type: MessageType.PLAYER_INFO, player: selfPlayer, }; this.log(`Sending player info to ${lobbyId}`); this.networkManager.sendToPeer(lobbyId, message); // Add a timeout to wait for the host to respond with a lobby update const lobbyUpdatePromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("Lobby update timeout - disconnecting")); }, 2000); const unsubError = this.on("error", ({ code }) => { if (code === ERROR_CODES.SESSION_FULL) { clearTimeout(timeout); unsubError(); unsubLobbyUpdate(); reject(new Error("Lobby is full")); } }); const unsubLobbyUpdate = this.on("playersUpdate", () => { this.log("Received initial lobby update, continuing join"); clearTimeout(timeout); unsubError(); unsubLobbyUpdate(); resolve(); }); }); // Wait for lobby update with a timeout await lobbyUpdatePromise; this.setState(SESSION_STATES.LOBBY); } catch (error) { this.setState(SESSION_STATES.ERROR); this.emitError(ERROR_CODES.CONNECTION_FAILED, "Failed to join game", "join_lobby", error, false); throw error; } } /** * Connect to a peer with a timeout * @param peerId ID of the peer to connect to * @param timeoutMs Timeout in milliseconds (default: 10000) * @returns Promise that resolves when connection is established or rejects on timeout/error */ async connectWithTimeout(peerId, timeoutMs = 10000) { return new Promise((resolve, reject) => { let timeoutId; const onConnection = ({ peerId: connectedPeerId, }) => { if (connectedPeerId === peerId) { clearTimeout(timeoutId); this.networkManager.off("connection", onConnection); resolve(); } }; this.networkManager.on("connection", onConnection); this.networkManager.connectToPeer(peerId); timeoutId = setTimeout(() => { this.networkManager.off("connection", onConnection); reject(new Error(`Connection timeout: Failed to connect to ${peerId} after ${timeoutMs}ms`)); }, timeoutMs); }); } leave() { this.log(`Leaving session. Current state: ${this.currentState}, players: ${Object.keys(this.players).length}, selfId: ${this.selfId}, hostId: ${this.hostId}`); // Disconnect from all peers first if (this.networkManager) { this.log("Destroying network manager"); this.networkManager.destroy(); } // Clear any pending player timeouts this.pendingPlayers.forEach((timeoutId) => clearTimeout(timeoutId)); this.pendingPlayers.clear(); if (this.isHost()) { this.log(`Cleared ${this.pendingPlayers.size} pending player timeouts`); } // Clear state const oldPlayers = { ...this.players }; this.players = {}; this.log(`Cleared players (had ${Object.keys(oldPlayers).length} players)`); // Reset all state variables this.selfId = null; this.hostId = null; // Emit events this.emitter.emit("playersUpdate", { players: {} }); this.setState(SESSION_STATES.DISCONNECTED); this.log("Session is now fully disconnected"); } /** * Set the ready state for the current player * @param isReady True if ready, false otherwise */ setReady(isReady) { if (this.currentState !== SESSION_STATES.LOBBY) { this.log(`Cannot set ready state: not in LOBBY state (current: ${this.currentState})`); this.emitError(ERROR_CODES.INVALID_STATE, `Cannot set ready state in ${this.currentState} state`, "set_ready"); return; } if (!this.selfId || !this.players[this.selfId]) { this.log("Cannot set ready state: player not found"); this.emitError(ERROR_CODES.PLAYER_NOT_FOUND, "Cannot set ready state: player not found", "set_ready"); return; } if (this.isHost() && !isReady) { this.log("Host cannot set ready to false"); this.emitError(ERROR_CODES.SELF_ACTION_INVALID, "Host cannot set ready to false", "set_ready"); return; } this.players[this.selfId].isReady = isReady; const message = { type: MessageType.PLAYER_READY, playerId: this.selfId, isReady, }; if (this.isHost()) { this.networkManager.broadcast(message); this.broadcastLobbyUpdate(); } else { this.networkManager.sendToPeer(this.hostId, message); } this.emitter.emit("playersUpdate", { players: { ...this.players } }); } /** * Start the game (host only) */ startGame() { if (!this.isHost()) { this.log("Cannot start game: not the host"); this.emitError(ERROR_CODES.NOT_HOST, "Only the host can start the game", "start_game"); return; } if (this.currentState !== SESSION_STATES.LOBBY) { this.log(`Cannot start game: not in LOBBY state (current: ${this.currentState})`); this.emitError(ERROR_CODES.INVALID_STATE, `Cannot start game in ${this.currentState} state`, "start_game"); return; } if (Object.keys(this.players).length < 2) { this.log("Cannot start game: not enough players"); this.emitError(ERROR_CODES.NOT_ENOUGH_PLAYERS, "Cannot start game: Not enough players", "start_game"); return; } if (this.requireReadyBeforeStart && !this.areAllPlayersReady()) { this.log("Cannot start game: not all players are ready"); this.emitError(ERROR_CODES.NOT_ALL_PLAYERS_READY, "Cannot start game: Not all players are ready", "start_game"); return; } const message = { type: MessageType.GAME_START, }; this.networkManager.broadcast(message); this.setState(SESSION_STATES.PLAYING); } /** * End the game (host only) */ endGame() { if (!this.isHost()) { this.log("Cannot end game: not the host"); return; } if (this.currentState !== SESSION_STATES.PLAYING) { this.log(`Cannot end game: not in PLAYING state (current: ${this.currentState})`); return; } const message = { type: MessageType.GAME_OVER, }; this.networkManager.broadcast(message); this.setState(SESSION_STATES.GAME_OVER); } /** * Kick a player from the game (host only) * @param playerId ID of the player to kick */ kickPlayer(playerId) { if (!this.isHost()) { this.log(`Cannot kick player: not the host (self: ${this.selfId}, host: ${this.hostId})`); return; } if (!this.players[playerId]) { this.log(`Cannot kick player: player ${playerId} not found in players list`); return; } if (playerId === this.selfId) { this.log("Cannot kick yourself"); return; } this.log(`Kicking player ${playerId} (${this.players[playerId].name})`); const message = { type: MessageType.KICK_PLAYER, playerId, }; this.log(`Sending KICK_PLAYER message to ${playerId}`); this.networkManager.sendToPeer(playerId, message); setTimeout(() => { this.log(`Disconnecting peer ${playerId} after kick message`); this.networkManager.disconnectPeer(playerId); }, 100); delete this.players[playerId]; this.log(`Removed kicked player ${playerId} from players list`); this.emitter.emit("playersUpdate", { players: { ...this.players } }); this.log("Broadcasting updated lobby after kick"); this.broadcastLobbyUpdate(); } /** * Send a custom message to a specific player * Only the host can send messages to other players. * Clients can only send messages to the host with this. * @param peerId ID of the recipient * @param data Custom data to send */ sendMessage(peerId, data) { if (!this.networkManager || !this.selfId || (this.currentState !== SESSION_STATES.LOBBY && this.currentState !== SESSION_STATES.PLAYING && this.currentState !== SESSION_STATES.GAME_OVER)) { this.log(`Cannot send message: invalid state (${this.currentState}) or network not ready.`); return; } const message = { type: MessageType.CUSTOM_MESSAGE, data, }; this.networkManager.sendToPeer(peerId, message); } /** * Send a custom message directly to the host. Does nothing if called by the host. * @param data Custom data to send to the host */ sendMessageToHost(data) { if (this.isHost()) { this.log("sendMessageToHost called by the host. Ignoring."); return; } if (!this.hostId) { this.log("Cannot send message to host: host ID is unknown."); return; } this.sendMessage(this.hostId, data); } /** * Broadcast a custom message to all players (optionally excluding self). * @param data Custom data to send * @param excludeSelf Whether to exclude the sender (default: true) */ broadcastMessage(data, excludeSelf = true) { if (!this.isHost()) { this.emitError(ERROR_CODES.NOT_HOST, "Cannot broadcast message: not the host", "broadcast_message"); return; } if (!this.networkManager || !this.selfId || (this.currentState !== SESSION_STATES.LOBBY && this.currentState !== SESSION_STATES.PLAYING && this.currentState !== SESSION_STATES.GAME_OVER)) { this.log(`Cannot broadcast message: invalid state (${this.currentState}) or network not ready.`); return; } const message = { type: MessageType.CUSTOM_MESSAGE, data, }; this.networkManager.broadcast(message, excludeSelf); } /** * Register an event listener * @param event Event to listen for * @param callback Event handler function * @returns Unsubscribe function */ on(event, callback) { this.emitter.on(event, callback); return () => this.emitter.off(event, callback); } /** * Remove an event listener * @param event Event to remove listener from * @param callback Event handler to remove */ off(event, callback) { this.emitter.off(event, callback); } // Utility methods areAllPlayersReady() { if (Object.keys(this.players).length <= 1) { return false; } return Object.values(this.players).every((player) => player.isReady || player.isHost); } isHost() { return this.selfId !== null && this.selfId === this.hostId; } getState() { return this.currentState; } getPlayers() { return { ...this.players }; } getPlayer(id) { return this.players[id] || null; } getSelf() { return this.selfId ? this.players[this.selfId] || null : null; } getHost() { return this.hostId ? this.players[this.hostId] || null : null; } getMaxPlayers() { return this.maxPlayers; } }