@lobstar/core
Version:
A simple network and lobby manager for multiplayer web games
685 lines (684 loc) • 28.3 kB
JavaScript
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;
}
}