// Multiplayer Client Manager
class MultiplayerClient {
constructor(game) {
this.game = game;
this.socket = null;
this.playerId = null;
this.username = null;
this.currentGameId = null;
this.isMultiplayer = false;
this.activePlayers = [];
this.pendingChallenges = new Map();
this.selectedBoardSize = 15; // Default board size for multiplayer
this.opponent = null; // Track opponent for rematch
this.opponentId = null;
this.lastGameResult = null;
}
// Connect to server
async connect() {
if (typeof io === 'undefined') {
this.showMessage('Socket.io library not loaded. Please check your internet connection.', 'error');
return;
}
// Show username modal immediately if not saved
const savedUsername = await window.gameStorage.getItem('connect5_username');
if (!savedUsername) {
this.showUsernameModal();
} else {
this.username = savedUsername; // Pre-load username
}
// Dynamically construct proxy URLs based on current origin
const targetUrl = window.location.origin;
const servers = [
targetUrl, // Primary (Production/Local)
'http://localhost:3000' // Failover for local dev
];
let connected = false;
const loadingEl = document.querySelector('.loading');
for (const serverUrl of servers) {
if (connected) break;
try {
if (loadingEl) loadingEl.textContent = `Connecting to ${serverUrl}...`;
console.log(`Attempting connection to: ${serverUrl}`);
await this.tryConnect(serverUrl);
connected = true;
console.log(`✅ Successfully connected to: ${serverUrl}`);
if (loadingEl) loadingEl.textContent = 'Connected! verifying functionality...';
} catch (error) {
console.warn(`❌ Failed to connect to ${serverUrl}:`, error);
}
}
if (!connected) {
this.showMessage('Failed to connect to any multiplayer server. Please try again later.', 'error');
const loading = document.querySelector('.loading');
if (loading) loading.textContent = 'Connection failed.';
}
// Setup board size selector buttons
document.querySelectorAll('.size-btn-mp').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.size-btn-mp').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
this.selectedBoardSize = parseInt(e.target.dataset.size);
});
});
}
tryConnect(url) {
return new Promise((resolve, reject) => {
const socketOptions = {
reconnection: false, // We handle reconnection manually for failover
timeout: 5000,
transports: ['websocket', 'polling']
};
const tempSocket = io(url, socketOptions);
// Set up ALL listeners BEFORE the connection completes
// This prevents race conditions
this.setupSocketListeners(tempSocket);
const timeout = setTimeout(() => {
if (!tempSocket.connected) {
tempSocket.close();
reject(new Error('Connection timed out'));
}
}, 5000);
tempSocket.on('connect', async () => {
clearTimeout(timeout);
this.socket = tempSocket;
console.log('✅ Socket connected, listeners ready');
// Now that listeners are set up, handle auto-registration
const savedUsername = await window.gameStorage.getItem('connect5_username') || this.username;
if (savedUsername) {
console.log('Auto-registering with saved username:', savedUsername);
this.registerPlayer(savedUsername);
}
resolve();
});
tempSocket.on('connect_error', (err) => {
clearTimeout(timeout);
tempSocket.close();
reject(err);
});
});
}
setupSocketListeners(socket) {
if (!socket) return;
// Safety timeout: If we are connected but don't get a player list or login prompt within 5 seconds, warn the user.
setTimeout(() => {
const loading = document.querySelector('.loading');
if (loading && loading.textContent.includes('Connecting')) {
loading.textContent = 'Connection successful, but server response is slow...';
} else if (loading && loading.textContent === 'Loading players...') {
loading.innerHTML = 'Server not responding. Retry';
}
}, 5000);
socket.on('disconnect', () => {
console.log('❌ Disconnected from server');
this.handleDisconnect();
});
socket.on('registration_result', (data) => {
console.log('📥 Received registration_result:', data);
this.handleRegistration(data);
});
socket.on('active_players_update', (players) => {
this.updateActivePlayers(players);
});
socket.on('challenge_received', (data) => {
this.showChallengeNotification(data);
});
socket.on('challenge_result', (data) => {
this.handleChallengeResult(data);
});
socket.on('challenge_declined', (data) => {
this.showMessage(`${data.by} declined your challenge`, 'error');
});
socket.on('game_started', (data) => {
this.startMultiplayerGame(data);
});
socket.on('opponent_move', (data) => {
this.handleOpponentMove(data);
});
socket.on('move_result', (data) => {
this.handleMoveResult(data);
});
socket.on('game_ended', (data) => {
this.handleGameEnded(data);
});
socket.on('rematch_request', (data) => {
this.handleRematchRequest(data);
});
socket.on('rematch_accepted', (data) => {
this.startMultiplayerGame(data);
});
socket.on('rematch_declined', (data) => {
this.showMessage(`${data.by} declined the rematch`, 'error');
document.getElementById('gameOverModal').classList.remove('active');
this.returnToLobby();
});
socket.on('opponent_disconnected', (data) => {
this.showMessage(data.message + '. Waiting for reconnection...', 'warning');
});
socket.on('opponent_reconnected', (data) => {
this.showMessage(data.message, 'success');
});
// Send heartbeat every 30 seconds
setInterval(() => {
if (this.socket && this.socket.connected) {
this.socket.emit('heartbeat');
}
}, 30000);
}
// Show username input modal
showUsernameModal() {
const modal = document.getElementById('usernameModal');
if (modal) {
modal.classList.add('active');
}
}
// Register player
async registerPlayer(username) {
this.username = username;
await window.gameStorage.setItem('connect5_username', username);
// Hide username modal immediately for better UX
const modal = document.getElementById('usernameModal');
if (modal) {
modal.classList.remove('active');
}
// Show loading state
const loading = document.querySelector('.loading');
if (loading) {
loading.textContent = 'Registering...';
}
if (!this.socket || !this.socket.connected) {
console.log('Socket not ready yet, username saved and will be sent on connect.');
if (loading) {
loading.textContent = 'Connecting to server...';
}
return;
}
console.log('Emitting register_player for:', username);
this.socket.emit('register_player', { username });
}
// Handle registration response
async handleRegistration(data) {
if (data.success) {
this.playerId = data.player.id;
this.username = data.player.username;
await window.gameStorage.setItem('connect5_username', this.username);
console.log('Username saved to IndexedDB');
// Hide username modal (if visible)
const modal = document.getElementById('usernameModal');
if (modal) {
modal.classList.remove('active');
}
// Show multiplayer panel
const multiplayerPanel = document.getElementById('multiplayerPanel');
if (multiplayerPanel) {
multiplayerPanel.style.display = 'block';
}
// Update player stats display
document.getElementById('playerUsername').textContent = this.username;
document.getElementById('playerWins').textContent = data.player.stats.wins;
document.getElementById('playerLosses').textContent = data.player.stats.losses;
document.getElementById('playerDraws').textContent = data.player.stats.draws;
this.showMessage(`Welcome back, ${this.username}!`, 'success');
// Request active players
this.socket.emit('request_active_players');
// Check for active game restoration
if (data.activeGame) {
console.log(' restoring active game:', data.activeGame);
this.startMultiplayerGame(data.activeGame);
}
} else {
// Registration failed - clear saved username and show modal
localStorage.removeItem('connect5_username');
this.showMessage(data.error, 'error');
this.showUsernameModal();
}
}
// Update active players list
updateActivePlayers(players) {
this.activePlayers = players.filter(p => p.username !== this.username);
const container = document.getElementById('activePlayersList');
if (!container) return;
if (this.activePlayers.length === 0) {
container.innerHTML = '
No other players online
';
return;
}
container.innerHTML = this.activePlayers.map(player => `
${player.username}
${player.total_wins}W - ${player.total_losses}L - ${player.total_draws}D
`).join('');
}
// Send challenge
sendChallenge(targetUsername) {
this.socket.emit('send_challenge', {
targetUsername,
boardSize: this.selectedBoardSize
});
this.showMessage(`Challenge sent to ${targetUsername} (${this.selectedBoardSize}×${this.selectedBoardSize})`, 'info');
}
// Handle challenge result
handleChallengeResult(data) {
if (!data.success) {
this.showMessage(data.error, 'error');
}
}
// Show challenge notification
showChallengeNotification(data) {
this.pendingChallenges.set(data.challengeId, data);
const notification = document.createElement('div');
notification.className = 'challenge-notification';
notification.innerHTML = `
Challenge Received!
${data.from} wants to play
Board size: ${data.boardSize}×${data.boardSize}
`;
document.body.appendChild(notification);
setTimeout(() => notification.classList.add('active'), 10);
}
// Accept challenge
acceptChallenge(challengeId) {
this.socket.emit('accept_challenge', { challengeId });
this.pendingChallenges.delete(challengeId);
// Remove notification
const notifications = document.querySelectorAll('.challenge-notification');
notifications.forEach(n => n.remove());
}
// Decline challenge
declineChallenge(challengeId) {
this.socket.emit('decline_challenge', { challengeId });
this.pendingChallenges.delete(challengeId);
// Remove notification
const notifications = document.querySelectorAll('.challenge-notification');
notifications.forEach(n => n.remove());
}
// Start multiplayer game
startMultiplayerGame(data) {
console.log('🎮 Starting multiplayer game:', data);
this.isMultiplayer = true;
this.currentGameId = data.gameId;
this.mySymbol = data.yourSymbol;
this.opponent = data.opponent;
this.opponentId = data.opponentId; // Store for rematch
this.myTurn = data.yourTurn;
// Show surrender button
const surrenderBtn = document.getElementById('surrenderBtn');
if (surrenderBtn) {
surrenderBtn.style.display = 'flex';
}
// Update UI - hide multiplayer lobby, show game board
document.getElementById('multiplayerPanel').style.display = 'none';
document.getElementById('gameControls').style.display = 'grid';
document.querySelector('.board-wrapper').style.display = 'flex';
// Status bar is part of gameControls
// Set board size
document.querySelectorAll('.size-btn').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.size) === data.boardSize);
});
// Reset game board
this.game.boardSize = data.boardSize;
this.game.currentPlayer = this.mySymbol; // Set to our symbol
this.game.gameActive = true;
this.game.initializeBoard();
// Update player identity display (Right Side)
const identitySection = document.getElementById('playerIdentitySection');
const identityMarker = document.getElementById('playerIdentity');
if (identitySection && identityMarker) {
identitySection.style.display = 'flex';
identityMarker.textContent = this.mySymbol;
// Set color for identity marker
if (this.mySymbol === 'O') {
identityMarker.style.color = 'hsl(195, 70%, 55%)';
identityMarker.parentElement.style.borderColor = 'hsl(195, 70%, 55%)';
} else {
identityMarker.style.color = 'hsl(270, 70%, 60%)';
identityMarker.parentElement.style.borderColor = 'hsl(270, 70%, 60%)';
}
}
// Ensure Turn label is correct (Left Side)
const turnLabel = document.getElementById('turnLabel');
if (turnLabel) turnLabel.textContent = 'Current Turn:';
// Set initial color for Current Turn display (will be updated by game.js but good to init)
const currentPlayerDisplay = document.getElementById('currentPlayer');
// Update status text (Left Side generic message)
const statusMessage = document.getElementById('statusMessage');
if (this.myTurn) {
statusMessage.textContent = `VS ${this.opponent} | YOUR TURN`;
statusMessage.className = 'status-text-small success';
} else {
statusMessage.textContent = `VS ${this.opponent} | Waiting for opponent`;
statusMessage.className = 'status-text-small info';
}
console.log(`✅ Game started! You are ${this.mySymbol}, ${this.myTurn ? 'your turn' : 'waiting'}`);
// Restore board state if provided (reconnection)
if (data.board) {
this.game.board = data.board;
// Re-render board
const cells = this.game.boardElement.children;
for (let r = 0; r < data.boardSize; r++) {
for (let c = 0; c < data.boardSize; c++) {
const symbol = data.board[r][c];
if (symbol) {
const index = r * data.boardSize + c;
const cell = cells[index];
if (cell) {
cell.classList.add('occupied', symbol.toLowerCase());
cell.textContent = ''; // CSS handles the X/O appearance
}
}
}
}
// Update current player for game logic
if (data.currentTurnSymbol) {
this.game.currentPlayer = data.currentTurnSymbol;
const currentPlayerDisplay = document.getElementById('currentPlayer');
if (currentPlayerDisplay) {
currentPlayerDisplay.textContent = this.game.currentPlayer;
}
}
}
}
// Make move in multiplayer game
makeMove(row, col) {
if (!this.isMultiplayer || !this.myTurn) return false;
this.socket.emit('make_move', {
gameId: this.currentGameId,
row: row,
col: col
});
return true;
}
// Handle move result from server
handleMoveResult(data) {
if (data.success) {
this.myTurn = false;
if (!data.gameOver) {
const statusMessage = document.getElementById('statusMessage');
if (statusMessage) {
statusMessage.textContent = `Playing against ${this.opponent} - Waiting for opponent...`;
statusMessage.className = 'status-text-small info';
}
}
} else {
this.showMessage(data.error, 'error');
}
}
// Handle opponent move
handleOpponentMove(data) {
// Place opponent's piece on board
this.game.currentPlayer = data.symbol;
// Update turn indicator immediately to show X or O
const currentPlayerDisplay = document.getElementById('currentPlayer');
if (currentPlayerDisplay) {
currentPlayerDisplay.textContent = data.symbol;
}
const cellIndex = data.row * this.game.boardSize + data.col;
const cell = this.game.boardElement.children[cellIndex];
if (cell) {
cell.classList.add('occupied', data.symbol.toLowerCase());
this.game.board[data.row][data.col] = data.symbol;
}
// It's now my turn
this.myTurn = true;
this.game.currentPlayer = this.mySymbol;
const statusMessage = document.getElementById('statusMessage');
if (statusMessage) {
statusMessage.textContent = `Playing against ${this.opponent} - Your turn`;
statusMessage.className = 'status-text-small success';
}
}
// Handle game ended
handleGameEnded(data) {
this.isMultiplayer = false;
this.currentGameId = null;
let message = '';
if (data.reason === 'win') {
message = '🎉 You won!';
} else if (data.reason === 'loss') {
message = '😔 You lost!';
} else if (data.reason === 'draw') {
message = '🤝 It\'s a draw!';
} else if (data.reason === 'opponent_abandoned') {
message = '🏆 You won! Opponent disconnected';
}
// Update stats
if (data.stats) {
document.getElementById('playerWins').textContent = data.stats.wins;
document.getElementById('playerLosses').textContent = data.stats.losses;
document.getElementById('playerDraws').textContent = data.stats.draws;
}
// Hide surrender button
const surrenderBtn = document.getElementById('surrenderBtn');
if (surrenderBtn) {
surrenderBtn.style.display = 'none';
}
// Show game-over modal with stats
const modal = document.getElementById('gameOverModal');
const icon = document.getElementById('gameOverIcon');
const title = document.getElementById('gameOverTitle');
const subtitle = document.getElementById('gameOverMessage');
if (modal && icon && title && subtitle) {
// Set icon and title based on result
if (data.reason === 'win' || data.reason === 'opponent_abandoned') {
icon.textContent = '🏆';
title.textContent = 'Victory!';
subtitle.textContent = data.reason === 'opponent_abandoned' ? 'Opponent disconnected' : 'Great game!';
} else if (data.reason === 'loss' || data.reason === 'surrender') {
icon.textContent = '😔';
title.textContent = 'Defeat';
subtitle.textContent = data.reason === 'surrender' ? 'You surrendered' : 'Better luck next time!';
} else {
icon.textContent = '🤝';
title.textContent = "It's a Draw!";
subtitle.textContent = 'Well played!';
}
// Update stats in modal
if (data.stats) {
document.getElementById('gameOverWins').textContent = data.stats.wins;
document.getElementById('gameOverLosses').textContent = data.stats.losses;
document.getElementById('gameOverDraws').textContent = data.stats.draws;
}
// Show modal
modal.classList.add('active');
}
}
// Handle disconnect
handleDisconnect() {
this.showMessage('Disconnected from server. Reconnecting...', 'error');
// Try to reconnect
setTimeout(() => {
if (!this.socket || !this.socket.connected) {
this.connect();
}
}, 2000);
}
// Return to lobby
returnToLobby() {
this.isMultiplayer = false;
this.currentGameId = null;
document.getElementById('multiplayerPanel').style.display = 'block';
document.getElementById('gameControls').style.display = 'block'; // Or grid/none depending on logic, but block/grid usually toggled by logic
// Hide identity section in local play
const identitySection = document.getElementById('playerIdentitySection');
if (identitySection) identitySection.style.display = 'none';
this.socket.emit('request_active_players');
}
// Show message to user
showMessage(text, type = 'info') {
const messageEl = document.getElementById('statusMessage');
if (messageEl) {
messageEl.textContent = text;
messageEl.className = `status-text-small ${type}`;
}
}
// Handle rematch request from opponent
handleRematchRequest(data) {
const notification = document.createElement('div');
notification.className = 'challenge-notification';
notification.innerHTML = `
Rematch Request!
${data.from} wants a rematch
Board size: ${data.boardSize}×${data.boardSize}
`;
document.body.appendChild(notification);
setTimeout(() => notification.classList.add('active'), 10);
}
}
// Global helper functions for rematch from notifications
function acceptRematchFromNotification(rematchId) {
if (window.multiplayerClient && window.multiplayerClient.socket) {
window.multiplayerClient.socket.emit('accept_rematch', { rematchId });
// Hide game over modal if visible
const modal = document.getElementById('gameOverModal');
if (modal) {
modal.classList.remove('active');
}
// Remove notification
const notifications = document.querySelectorAll('.challenge-notification');
notifications.forEach(n => n.remove());
}
}
function declineRematchFromNotification(rematchId) {
if (window.multiplayerClient && window.multiplayerClient.socket) {
window.multiplayerClient.socket.emit('decline_rematch', { rematchId });
// Remove notification
const notifications = document.querySelectorAll('.challenge-notification');
notifications.forEach(n => n.remove());
}
}
// Initialize multiplayer client (will be used by game.js)
// Initialize multiplayer client (will be used by game.js)
window.multiplayerClient = null;