false, 'error' => 'Missing action.'], 400); } switch ($action) { case 'create_room': ensurePostRequest(); createRoom($payload); break; case 'join_room': ensurePostRequest(); joinRoom($payload); break; case 'update_state': ensurePostRequest(); updatePlayerState($payload); break; case 'get_state': getRoomState($payload); break; case 'leave_room': ensurePostRequest(); leaveRoom($payload); break; default: jsonResponse(['success' => false, 'error' => 'Unsupported action.'], 400); } } catch (Throwable $exception) { error_log('Multiplayer API error: ' . $exception->getMessage()); jsonResponse(['success' => false, 'error' => 'Internal server error.'], 500); } function getRequestPayload(): array { $rawBody = file_get_contents('php://input'); $jsonBody = json_decode($rawBody ?: '', true); if (is_array($jsonBody)) { return $jsonBody; } if (!empty($_POST)) { return $_POST; } return $_GET; } function ensurePostRequest(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { jsonResponse(['success' => false, 'error' => 'POST required.'], 405); } } function jsonResponse(array $payload, int $statusCode = 200): void { http_response_code($statusCode); echo json_encode($payload, JSON_UNESCAPED_SLASHES); exit; } function normalizeDisplayName(?string $displayName, int $slot = 0): string { $name = trim((string) $displayName); $name = preg_replace('/\s+/', ' ', $name ?? '') ?? ''; if ($name === '') { return $slot > 0 ? 'Player ' . $slot : 'Player'; } $name = function_exists('mb_substr') ? mb_substr($name, 0, 48) : substr($name, 0, 48); $name = strip_tags($name); return trim($name) !== '' ? $name : ($slot > 0 ? 'Player ' . $slot : 'Player'); } function normalizeRoomCode(?string $roomCode): string { return strtoupper(preg_replace('/[^A-Z0-9]/', '', (string) $roomCode) ?? ''); } function generateRoomCode(PDO $pdo): string { $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $checkStmt = $pdo->prepare('SELECT 1 FROM tetris_rooms WHERE room_code = :room_code LIMIT 1'); for ($attempt = 0; $attempt < 12; $attempt++) { $code = ''; for ($i = 0; $i < 6; $i++) { $code .= $alphabet[random_int(0, strlen($alphabet) - 1)]; } $checkStmt->bindValue(':room_code', $code, PDO::PARAM_STR); $checkStmt->execute(); if (!$checkStmt->fetchColumn()) { return $code; } } throw new RuntimeException('Could not allocate a unique room code.'); } function generatePlayerToken(): string { return bin2hex(random_bytes(24)); } function cleanupMultiplayerState(): void { $pdo = db(); $pdo->exec("UPDATE tetris_room_players SET connection_status = 'disconnected' WHERE last_seen_at < (UTC_TIMESTAMP() - INTERVAL 2 MINUTE)"); $pdo->exec("UPDATE tetris_rooms SET status = 'closed' WHERE updated_at < (UTC_TIMESTAMP() - INTERVAL 12 HOUR)"); } function createRoom(array $payload): void { $pdo = db(); $roomCode = generateRoomCode($pdo); $playerToken = generatePlayerToken(); $displayName = normalizeDisplayName((string) ($payload['display_name'] ?? ''), 1); $pdo->beginTransaction(); try { $insertRoom = $pdo->prepare('INSERT INTO tetris_rooms (room_code, status, expires_at) VALUES (:room_code, :status, (UTC_TIMESTAMP() + INTERVAL 12 HOUR))'); $insertRoom->bindValue(':room_code', $roomCode, PDO::PARAM_STR); $insertRoom->bindValue(':status', 'waiting', PDO::PARAM_STR); $insertRoom->execute(); $roomId = (int) $pdo->lastInsertId(); $insertPlayer = $pdo->prepare( 'INSERT INTO tetris_room_players (room_id, player_token, player_slot, display_name, connection_status, game_status, last_seen_at) ' . 'VALUES (:room_id, :player_token, :player_slot, :display_name, :connection_status, :game_status, UTC_TIMESTAMP())' ); $insertPlayer->bindValue(':room_id', $roomId, PDO::PARAM_INT); $insertPlayer->bindValue(':player_token', $playerToken, PDO::PARAM_STR); $insertPlayer->bindValue(':player_slot', 1, PDO::PARAM_INT); $insertPlayer->bindValue(':display_name', $displayName, PDO::PARAM_STR); $insertPlayer->bindValue(':connection_status', 'connected', PDO::PARAM_STR); $insertPlayer->bindValue(':game_status', 'ready', PDO::PARAM_STR); $insertPlayer->execute(); $playerId = (int) $pdo->lastInsertId(); $updateRoom = $pdo->prepare('UPDATE tetris_rooms SET host_player_id = :host_player_id WHERE id = :room_id'); $updateRoom->bindValue(':host_player_id', $playerId, PDO::PARAM_INT); $updateRoom->bindValue(':room_id', $roomId, PDO::PARAM_INT); $updateRoom->execute(); $pdo->commit(); } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } jsonResponse([ 'success' => true, 'room' => [ 'code' => $roomCode, 'status' => 'waiting', 'player_count' => 1, ], 'self' => [ 'token' => $playerToken, 'slot' => 1, 'display_name' => $displayName, ], ]); } function joinRoom(array $payload): void { $roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? '')); if ($roomCode === '') { jsonResponse(['success' => false, 'error' => 'Room code is required.'], 422); } $pdo = db(); $pdo->beginTransaction(); try { $roomStmt = $pdo->prepare('SELECT * FROM tetris_rooms WHERE room_code = :room_code LIMIT 1 FOR UPDATE'); $roomStmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR); $roomStmt->execute(); $room = $roomStmt->fetch(); if (!$room || $room['status'] === 'closed') { if ($pdo->inTransaction()) { $pdo->rollBack(); } jsonResponse(['success' => false, 'error' => 'Room not found.'], 404); } $countStmt = $pdo->prepare('SELECT COUNT(*) FROM tetris_room_players WHERE room_id = :room_id'); $countStmt->bindValue(':room_id', (int) $room['id'], PDO::PARAM_INT); $countStmt->execute(); $playerCount = (int) $countStmt->fetchColumn(); if ($playerCount >= 2) { if ($pdo->inTransaction()) { $pdo->rollBack(); } jsonResponse(['success' => false, 'error' => 'Room is full.'], 409); } $playerToken = generatePlayerToken(); $displayName = normalizeDisplayName((string) ($payload['display_name'] ?? ''), 2); $insertPlayer = $pdo->prepare( 'INSERT INTO tetris_room_players (room_id, player_token, player_slot, display_name, connection_status, game_status, last_seen_at) ' . 'VALUES (:room_id, :player_token, :player_slot, :display_name, :connection_status, :game_status, UTC_TIMESTAMP())' ); $insertPlayer->bindValue(':room_id', (int) $room['id'], PDO::PARAM_INT); $insertPlayer->bindValue(':player_token', $playerToken, PDO::PARAM_STR); $insertPlayer->bindValue(':player_slot', 2, PDO::PARAM_INT); $insertPlayer->bindValue(':display_name', $displayName, PDO::PARAM_STR); $insertPlayer->bindValue(':connection_status', 'connected', PDO::PARAM_STR); $insertPlayer->bindValue(':game_status', 'ready', PDO::PARAM_STR); $insertPlayer->execute(); $updateRoom = $pdo->prepare('UPDATE tetris_rooms SET status = :status, expires_at = (UTC_TIMESTAMP() + INTERVAL 12 HOUR) WHERE id = :room_id'); $updateRoom->bindValue(':status', 'active', PDO::PARAM_STR); $updateRoom->bindValue(':room_id', (int) $room['id'], PDO::PARAM_INT); $updateRoom->execute(); $pdo->commit(); } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } jsonResponse([ 'success' => true, 'room' => [ 'code' => $roomCode, 'status' => 'active', 'player_count' => 2, ], 'self' => [ 'token' => $playerToken, 'slot' => 2, 'display_name' => $displayName, ], ]); } function updatePlayerState(array $payload): void { $roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? '')); $playerToken = trim((string) ($payload['player_token'] ?? '')); if ($roomCode === '' || $playerToken === '') { jsonResponse(['success' => false, 'error' => 'Room code and player token are required.'], 422); } $boardStateJson = encodeNullableJson($payload['board'] ?? null); $pieceStateJson = encodeNullableJson($payload['piece'] ?? null); $metaJson = encodeNullableJson($payload['meta'] ?? null); $score = max(0, (int) ($payload['score'] ?? 0)); $linesCleared = max(0, (int) ($payload['lines'] ?? 0)); $level = max(1, (int) ($payload['level'] ?? 1)); $gameStatus = normalizeGameStatus((string) ($payload['game_status'] ?? 'ready')); $pdo = db(); $player = fetchPlayerForRoom($pdo, $roomCode, $playerToken); if (!$player) { jsonResponse(['success' => false, 'error' => 'Player or room not found.'], 404); } $updatePlayer = $pdo->prepare( 'UPDATE tetris_room_players SET board_state_json = :board_state_json, piece_state_json = :piece_state_json, meta_json = :meta_json, ' . 'score = :score, lines_cleared = :lines_cleared, level = :level, game_status = :game_status, connection_status = :connection_status, ' . 'last_seen_at = UTC_TIMESTAMP() WHERE id = :player_id' ); $updatePlayer->bindValue(':board_state_json', $boardStateJson, $boardStateJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR); $updatePlayer->bindValue(':piece_state_json', $pieceStateJson, $pieceStateJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR); $updatePlayer->bindValue(':meta_json', $metaJson, $metaJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR); $updatePlayer->bindValue(':score', $score, PDO::PARAM_INT); $updatePlayer->bindValue(':lines_cleared', $linesCleared, PDO::PARAM_INT); $updatePlayer->bindValue(':level', $level, PDO::PARAM_INT); $updatePlayer->bindValue(':game_status', $gameStatus, PDO::PARAM_STR); $updatePlayer->bindValue(':connection_status', 'connected', PDO::PARAM_STR); $updatePlayer->bindValue(':player_id', (int) $player['player_id'], PDO::PARAM_INT); $updatePlayer->execute(); $roomStatus = 'active'; $updateRoom = $pdo->prepare('UPDATE tetris_rooms SET status = :status, expires_at = (UTC_TIMESTAMP() + INTERVAL 12 HOUR) WHERE id = :room_id'); $updateRoom->bindValue(':status', $roomStatus, PDO::PARAM_STR); $updateRoom->bindValue(':room_id', (int) $player['room_id'], PDO::PARAM_INT); $updateRoom->execute(); jsonResponse([ 'success' => true, 'room' => [ 'code' => $roomCode, 'status' => $roomStatus, 'updated_at' => gmdate('c'), ], ]); } function getRoomState(array $payload): void { $roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? '')); $playerToken = trim((string) ($payload['player_token'] ?? '')); if ($roomCode === '' || $playerToken === '') { jsonResponse(['success' => false, 'error' => 'Room code and player token are required.'], 422); } $pdo = db(); $self = fetchPlayerForRoom($pdo, $roomCode, $playerToken); if (!$self) { jsonResponse(['success' => false, 'error' => 'Player or room not found.'], 404); } $touchStmt = $pdo->prepare('UPDATE tetris_room_players SET last_seen_at = UTC_TIMESTAMP(), connection_status = :connection_status WHERE id = :player_id'); $touchStmt->bindValue(':connection_status', 'connected', PDO::PARAM_STR); $touchStmt->bindValue(':player_id', (int) $self['player_id'], PDO::PARAM_INT); $touchStmt->execute(); $playersStmt = $pdo->prepare( 'SELECT p.id, p.player_slot, p.display_name, p.connection_status, p.game_status, p.score, p.lines_cleared, p.level, ' . 'p.board_state_json, p.piece_state_json, p.meta_json, p.last_seen_at, r.room_code, r.status AS room_status, r.updated_at AS room_updated_at ' . 'FROM tetris_room_players p ' . 'INNER JOIN tetris_rooms r ON r.id = p.room_id ' . 'WHERE r.id = :room_id ' . 'ORDER BY p.player_slot ASC' ); $playersStmt->bindValue(':room_id', (int) $self['room_id'], PDO::PARAM_INT); $playersStmt->execute(); $rows = $playersStmt->fetchAll(); $players = []; foreach ($rows as $row) { $players[] = [ 'slot' => (int) $row['player_slot'], 'display_name' => (string) $row['display_name'], 'connection_status' => (string) $row['connection_status'], 'game_status' => (string) $row['game_status'], 'score' => (int) $row['score'], 'lines' => (int) $row['lines_cleared'], 'level' => (int) $row['level'], 'board' => decodeNullableJson($row['board_state_json'] ?? null), 'piece' => decodeNullableJson($row['piece_state_json'] ?? null), 'meta' => decodeNullableJson($row['meta_json'] ?? null), 'last_seen_at' => (string) $row['last_seen_at'], 'is_self' => ((int) ($row['player_slot'] ?? 0)) === ((int) $self['player_slot']), ]; } $playerCount = count($players); $connectedCount = 0; foreach ($players as $player) { if ($player['connection_status'] === 'connected') { $connectedCount++; } } $roomStatus = $playerCount >= 2 ? 'active' : 'waiting'; if ($connectedCount === 0) { $roomStatus = 'closed'; } $roomUpdateStmt = $pdo->prepare('UPDATE tetris_rooms SET status = :status, expires_at = (UTC_TIMESTAMP() + INTERVAL 12 HOUR) WHERE id = :room_id'); $roomUpdateStmt->bindValue(':status', $roomStatus, PDO::PARAM_STR); $roomUpdateStmt->bindValue(':room_id', (int) $self['room_id'], PDO::PARAM_INT); $roomUpdateStmt->execute(); jsonResponse([ 'success' => true, 'room' => [ 'code' => $roomCode, 'status' => $roomStatus, 'player_count' => $playerCount, ], 'self' => [ 'slot' => (int) $self['player_slot'], 'display_name' => (string) $self['display_name'], ], 'players' => $players, ]); } function leaveRoom(array $payload): void { $roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? '')); $playerToken = trim((string) ($payload['player_token'] ?? '')); if ($roomCode === '' || $playerToken === '') { jsonResponse(['success' => false, 'error' => 'Room code and player token are required.'], 422); } $pdo = db(); $player = fetchPlayerForRoom($pdo, $roomCode, $playerToken); if (!$player) { jsonResponse(['success' => false, 'error' => 'Player or room not found.'], 404); } $disconnectStmt = $pdo->prepare('UPDATE tetris_room_players SET connection_status = :connection_status, game_status = :game_status, last_seen_at = UTC_TIMESTAMP() WHERE id = :player_id'); $disconnectStmt->bindValue(':connection_status', 'disconnected', PDO::PARAM_STR); $disconnectStmt->bindValue(':game_status', 'paused', PDO::PARAM_STR); $disconnectStmt->bindValue(':player_id', (int) $player['player_id'], PDO::PARAM_INT); $disconnectStmt->execute(); $countConnectedStmt = $pdo->prepare('SELECT COUNT(*) FROM tetris_room_players WHERE room_id = :room_id AND connection_status = :connection_status'); $countConnectedStmt->bindValue(':room_id', (int) $player['room_id'], PDO::PARAM_INT); $countConnectedStmt->bindValue(':connection_status', 'connected', PDO::PARAM_STR); $countConnectedStmt->execute(); $connectedCount = (int) $countConnectedStmt->fetchColumn(); $roomStatus = $connectedCount > 0 ? 'active' : 'closed'; $updateRoom = $pdo->prepare('UPDATE tetris_rooms SET status = :status WHERE id = :room_id'); $updateRoom->bindValue(':status', $roomStatus, PDO::PARAM_STR); $updateRoom->bindValue(':room_id', (int) $player['room_id'], PDO::PARAM_INT); $updateRoom->execute(); jsonResponse([ 'success' => true, 'room' => [ 'code' => $roomCode, 'status' => $roomStatus, ], ]); } function fetchPlayerForRoom(PDO $pdo, string $roomCode, string $playerToken): ?array { $stmt = $pdo->prepare( 'SELECT p.id AS player_id, p.player_slot, p.display_name, p.room_id ' . 'FROM tetris_room_players p ' . 'INNER JOIN tetris_rooms r ON r.id = p.room_id ' . 'WHERE r.room_code = :room_code AND p.player_token = :player_token LIMIT 1' ); $stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR); $stmt->bindValue(':player_token', $playerToken, PDO::PARAM_STR); $stmt->execute(); $row = $stmt->fetch(); return is_array($row) ? $row : null; } function encodeNullableJson($value): ?string { if ($value === null || $value === '') { return null; } return json_encode($value, JSON_UNESCAPED_SLASHES); } function decodeNullableJson($value) { if ($value === null || $value === '') { return null; } return json_decode((string) $value, true); } function normalizeGameStatus(string $gameStatus): string { $normalized = strtolower(trim($gameStatus)); $allowed = ['ready', 'playing', 'paused', 'game_over']; return in_array($normalized, $allowed, true) ? $normalized : 'ready'; }