Add web client with WebSocket support

Implemented browser-based web client alongside existing pygame desktop client
with dual-protocol server architecture supporting both TCP and WebSocket.

Backend Changes:
- Refactored GameServer for dual-protocol support (TCP + WebSocket)
- Added WebSocketHandler for handling WebSocket connections
- Added HTTPServer using aiohttp for serving web client files
- Updated protocol handling to work with both connection types
- Server tracks clients with protocol metadata (TCP/WebSocket)
- Protocol-agnostic message sending and broadcasting
- Added WebSocket port (8889) and HTTP port (8000) configuration

Web Client:
- Complete HTML5/CSS/JavaScript implementation
- Responsive dark-themed UI
- HTML5 Canvas rendering matching pygame visual style
- WebSocket connection with auto-detected server URL
- Real-time multiplayer gameplay in browser
- Player list with scores and status
- Mobile-friendly responsive design

Deployment Options:
- Development: Built-in HTTP server for local testing
- Production: Disable HTTP server, use nginx/Apache for static files
- Flexible server configuration (--no-http, --no-websocket flags)
- Comprehensive nginx/Apache deployment documentation

New Files:
- src/server/websocket_handler.py - WebSocket connection handler
- src/server/http_server.py - Static file server
- web/index.html - Web client interface
- web/style.css - Responsive styling
- web/protocol.js - Protocol implementation
- web/game.js - Game client with Canvas rendering
- web/README.md - Deployment documentation

Updated Files:
- requirements.txt - Added websockets and aiohttp dependencies
- src/server/game_server.py - Dual-protocol support
- src/shared/constants.py - WebSocket and HTTP port constants
- run_server.py - Server options for web support
- README.md - Web client documentation
- CLAUDE.md - Architecture documentation

Features:
- Web and desktop clients can play together simultaneously
- Same JSON protocol for both client types
- Independent server components (disable what you don't need)
- Production-ready with reverse proxy support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Vladyslav Doloman
2025-10-04 14:39:13 +03:00
parent 0703561446
commit ec8e9cd5fb
13 changed files with 1454 additions and 86 deletions

118
CLAUDE.md
View File

@@ -4,12 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
This is a **network multiplayer Snake game** built with Python 3.11, using asyncio for networking, pygame for graphics, and UDP multicast for automatic server discovery.
This is a **network multiplayer Snake game** built with Python 3.11, featuring both web and desktop clients with dual-protocol support (TCP + WebSocket).
**Project Structure:**
- `src/shared/` - Shared code (models, protocol, constants, discovery utilities)
- `src/server/` - Game server with authoritative game state and multicast beacon
- `src/client/` - Game client with pygame rendering, discovery, and server selection UI
- `src/server/` - Dual-protocol game server (TCP + WebSocket), HTTP server, multicast beacon
- `src/client/` - Pygame desktop client with discovery and server selection UI
- `web/` - Browser-based web client (HTML/CSS/JavaScript)
- `tests/` - Test files using pytest
## Setup Commands
@@ -30,27 +31,29 @@ pip install -r requirements-dev.txt
## Running the Game
```bash
# Start the server (with discovery beacon enabled by default)
python run_server.py --name "My Game" --port 8888
# Web Client (easiest - no installation needed)
python run_server.py --name "My Game"
# Open browser to http://localhost:8000
# Start clients with auto-discovery (no host needed)
# Desktop Client (pygame)
python run_client.py --name Alice
# If multiple servers found, a selection UI appears
# Auto-discovers server on local network
# Manual connection (skip discovery)
python run_client.py 192.168.1.100 --port 8888 --name Bob
# Server Options:
# --host HOST Bind address (default: localhost)
# --port PORT TCP port for desktop clients (default: 8888)
# --ws-port PORT WebSocket port for web clients (default: 8889, 0=disable)
# --http-port PORT HTTP server port (default: 8000, 0=disable)
# --name NAME Server name for discovery
# --no-discovery Disable multicast beacon
# --no-websocket Disable WebSocket server (desktop clients only)
# --no-http Disable HTTP server (use nginx/Apache in production)
# --web-dir PATH Web files directory (default: web)
# Server options:
# --host HOST Bind address (default: localhost)
# --port PORT Port number (default: 8888)
# --name NAME Server name for discovery
# --no-discovery Disable multicast beacon
# Client options:
# [host] Server host (omit for auto-discovery)
# --port PORT Server port
# --name NAME Player name
# --discover Force discovery mode
# Production Deployment:
python run_server.py --no-http --host 0.0.0.0
# Use nginx to serve web/ directory and proxy WebSocket to port 8889
# See web/README.md for nginx configuration examples
# Press SPACE to start the game, arrow keys/WASD to move
```
@@ -76,35 +79,54 @@ mypy src/
## Architecture
**Client-Server Model:**
- Server (`src/server/game_server.py`) runs the authoritative game loop using asyncio
- Clients connect via TCP and send input commands (MOVE, START_GAME, etc.)
- Server broadcasts game state updates to all connected clients at 10 FPS
- Clients render the game state locally at 60 FPS using pygame
**Dual-Protocol Client-Server Model:**
- Server runs authoritative game loop using asyncio
- **Desktop clients** connect via TCP (port 8888) with newline-delimited JSON
- **Web clients** connect via WebSocket (port 8889) with JSON frames
- Server broadcasts game state updates to all clients at 10 FPS
- Desktop clients render at 60 FPS with pygame
- Web clients render at 60 FPS with HTML5 Canvas
**Key Components:**
- `src/shared/protocol.py` - JSON-based message protocol (MessageType enum, Message class)
- `src/shared/models.py` - Data models (Snake, Position, Food, GameState) with serialization
- `src/shared/constants.py` - Game configuration (grid size, colors, tick rate, multicast settings)
- `src/shared/discovery.py` - Multicast discovery utilities (ServerInfo, DiscoveryMessage)
- `src/server/game_logic.py` - Game rules (movement, collision detection, food spawning)
- `src/server/server_beacon.py` - UDP multicast beacon for server discovery
- `src/client/renderer.py` - Pygame rendering of game state
- `src/client/server_discovery.py` - Client-side server discovery
- `src/client/server_selector.py` - Pygame UI for selecting from multiple servers
**Key Backend Components:**
- `src/shared/protocol.py` - JSON-based message protocol (same for TCP and WebSocket)
- `src/shared/models.py` - Data models with serialization (Snake, Position, Food, GameState)
- `src/shared/constants.py` - Configuration (grid, colors, tick rate, ports)
- `src/shared/discovery.py` - Multicast discovery utilities
- `src/server/game_server.py` - Dual-protocol game server (TCP + WebSocket)
- `src/server/game_logic.py` - Game rules (movement, collision, food)
- `src/server/websocket_handler.py` - WebSocket connection handler
- `src/server/http_server.py` - Static file server for web client (aiohttp)
- `src/server/server_beacon.py` - UDP multicast beacon
**Desktop Client Components:**
- `src/client/game_client.py` - pygame client with TCP connection
- `src/client/renderer.py` - Pygame rendering
- `src/client/server_discovery.py` - Multicast discovery
- `src/client/server_selector.py` - Server selection UI
**Web Client Components:**
- `web/index.html` - Main HTML interface
- `web/style.css` - Responsive styling (dark theme)
- `web/protocol.js` - Protocol implementation (JavaScript)
- `web/game.js` - Game client with Canvas rendering and WebSocket connection
**Game Flow:**
1. Server starts on port 8888 and broadcasts presence on multicast group 239.255.0.1:9999
2. Clients send DISCOVER message to multicast group and collect SERVER_ANNOUNCE responses
3. If multiple servers found, client shows selection UI; if one found, auto-connects
4. Clients connect via TCP and receive WELCOME message with player_id
5. Any player can press SPACE to send START_GAME message
6. Server creates snakes for all connected players and spawns food
7. Server runs game loop: update positions → check collisions → broadcast state
8. Game ends when only 0-1 snakes remain alive
1. Server starts TCP (8888), WebSocket (8889), HTTP (8000), and broadcasts on multicast (9999)
2. Desktop clients discover via multicast; web clients use auto-detected WebSocket URL
3. Clients connect and receive WELCOME with player_id
4. Any player presses SPACE to send START_GAME
5. Server creates snakes and spawns food
6. Server loop: update → check collisions → broadcast state (protocol-agnostic)
7. Game ends when ≤1 snake alive
**Discovery Protocol:**
- Multicast group: 239.255.0.1:9999 (local network)
- Client → Multicast: DISCOVER (broadcast request)
- Server → Client: SERVER_ANNOUNCE (direct response with host, port, name, player count)
- Server also periodically broadcasts presence every 2 seconds
**Protocol Handling:**
- Server stores clients as `(connection, ClientType.TCP|WEBSOCKET)`
- `send_to_client()` detects type and sends via appropriate method
- TCP: newline-delimited JSON (`message\n`)
- WebSocket: JSON frames (native WebSocket message)
**Production Deployment:**
- Run server with `--no-http` flag
- Use nginx/Apache to serve `web/` directory
- Proxy WebSocket connections to port 8889
- See `web/README.md` for configuration examples

View File

@@ -5,7 +5,10 @@ A network multiplayer Snake game built with Python, asyncio, and pygame.
## Features
- Real-time multiplayer gameplay with client-server architecture
- **Web client** - Play in your browser without installing anything!
- **Desktop client** - Native pygame client for local play
- **Automatic server discovery** using multicast (zero-configuration LAN play)
- **Dual-protocol support** - TCP for desktop, WebSocket for web
- Support for multiple players simultaneously
- Classic Snake gameplay with collision detection
- Color-coded snakes for each player
@@ -27,40 +30,86 @@ pip install -r requirements-dev.txt
## Running the Game
### Quick Start (Auto-Discovery)
### Web Client (Easiest!)
1. **Start the server** (in one terminal):
1. **Start the server**:
```bash
python run_server.py --name "My Game"
```
2. **Open your browser** and navigate to:
```
http://localhost:8000
```
3. **Enter your name** and click Connect. The WebSocket URL is auto-detected!
4. **Play!** Multiple players can join from different browsers/devices.
### Desktop Client (Pygame)
1. **Start the server** (if not already running):
```bash
python run_server.py
# Optional: python run_server.py --name "My Server" --port 8888
```
2. **Start one or more clients** (in separate terminals):
2. **Start pygame clients** (in separate terminals):
```bash
python run_client.py --name Alice
# Clients will automatically discover servers on the local network
# Auto-discovers the server on your network
```
### Mixed Play
Web and desktop clients can play together! The server supports both protocols simultaneously:
- Desktop clients connect via TCP (port 8888)
- Web clients connect via WebSocket (port 8889)
### Manual Connection
```bash
# Server
python run_server.py --host 0.0.0.0 --port 8888 --name "Game Room"
# Server with all options
python run_server.py --host 0.0.0.0 --port 8888 --ws-port 8889 --name "Game Room"
# Client (specify host directly)
# Desktop client (direct connection)
python run_client.py 192.168.1.100 --port 8888 --name Bob
# Web client: enter ws://192.168.1.100:8889 in the browser
```
### Server Options
```bash
python run_server.py --help
# Network
# --host HOST Host address to bind to (default: localhost)
# --port PORT Port number (default: 8888)
# --port PORT TCP port for desktop clients (default: 8888)
# --ws-port PORT WebSocket port for web clients (default: 8889, 0 to disable)
# --http-port PORT HTTP server port (default: 8000, 0 to disable)
# --name NAME Server name for discovery (default: Snake Server)
# --no-discovery Disable multicast beacon
# Features
# --no-discovery Disable multicast discovery beacon
# --no-websocket Disable WebSocket server (desktop only)
# --no-http Disable HTTP server (use external web server like nginx)
# --web-dir PATH Path to web files (default: web)
```
### Production Deployment
For production with nginx or Apache:
```bash
# Run server without built-in HTTP server
python run_server.py --no-http --host 0.0.0.0
# nginx serves static files from web/ directory
# and proxies WebSocket connections to port 8889
```
See `web/README.md` for detailed nginx/Apache configuration.
### Client Options
```bash

View File

@@ -1,2 +1,4 @@
# Production dependencies
pygame>=2.5.0
websockets>=12.0
aiohttp>=3.9.0

View File

@@ -2,8 +2,10 @@
import asyncio
import argparse
from pathlib import Path
from src.server.game_server import GameServer
from src.shared.constants import DEFAULT_HOST, DEFAULT_PORT
from src.server.http_server import HTTPServer
from src.shared.constants import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_WS_PORT, DEFAULT_HTTP_PORT
async def main() -> None:
@@ -18,7 +20,24 @@ async def main() -> None:
"--port",
type=int,
default=DEFAULT_PORT,
help=f"Port number to bind to (default: {DEFAULT_PORT})",
help=f"TCP port number (default: {DEFAULT_PORT})",
)
parser.add_argument(
"--ws-port",
type=int,
default=DEFAULT_WS_PORT,
help=f"WebSocket port (default: {DEFAULT_WS_PORT}, 0 to disable)",
)
parser.add_argument(
"--http-port",
type=int,
default=DEFAULT_HTTP_PORT,
help=f"HTTP server port (default: {DEFAULT_HTTP_PORT}, 0 to disable)",
)
parser.add_argument(
"--web-dir",
default="web",
help="Directory containing web client files (default: web)",
)
parser.add_argument(
"--name",
@@ -30,17 +49,48 @@ async def main() -> None:
action="store_true",
help="Disable multicast discovery beacon",
)
parser.add_argument(
"--no-websocket",
action="store_true",
help="Disable WebSocket server",
)
parser.add_argument(
"--no-http",
action="store_true",
help="Disable HTTP server (for production with external web server)",
)
args = parser.parse_args()
# Determine WebSocket port
ws_port = None if args.no_websocket or args.ws_port == 0 else args.ws_port
# Create game server
server = GameServer(
host=args.host,
port=args.port,
server_name=args.name,
enable_discovery=not args.no_discovery,
ws_port=ws_port,
)
await server.start()
# Start HTTP server if enabled
http_task = None
if not args.no_http and args.http_port > 0:
web_dir = Path(args.web_dir)
if web_dir.exists():
http_server = HTTPServer(web_dir, args.http_port, "0.0.0.0")
await http_server.start()
http_task = asyncio.create_task(asyncio.Future()) # Keep running
else:
print(f"Warning: Web directory '{web_dir}' not found. HTTP server disabled.")
# Start game server
try:
await server.start()
finally:
if http_task:
http_task.cancel()
if __name__ == "__main__":

View File

@@ -2,7 +2,8 @@
import asyncio
import uuid
from typing import Dict, Set
from typing import Dict, Set, Union, Any
from enum import Enum
from ..shared.protocol import (
Message,
@@ -20,8 +21,14 @@ from .game_logic import GameLogic
from .server_beacon import ServerBeacon
class ClientType(Enum):
"""Type of client connection."""
TCP = "TCP"
WEBSOCKET = "WebSocket"
class GameServer:
"""Multiplayer Snake game server."""
"""Multiplayer Snake game server with dual-protocol support."""
def __init__(
self,
@@ -29,24 +36,32 @@ class GameServer:
port: int = DEFAULT_PORT,
server_name: str = "Snake Server",
enable_discovery: bool = True,
ws_port: int | None = None,
):
"""Initialize the game server.
Args:
host: Host address to bind to
port: Port number to bind to
port: TCP port number to bind to
server_name: Name of the server for discovery
enable_discovery: Enable multicast discovery beacon
ws_port: WebSocket port (None to disable WebSocket)
"""
self.host = host
self.port = port
self.ws_port = ws_port
self.server_name = server_name
self.enable_discovery = enable_discovery
self.clients: Dict[str, asyncio.StreamWriter] = {}
# Store clients with their connection type
# Format: {player_id: (connection, client_type)}
self.clients: Dict[str, tuple[Any, ClientType]] = {}
self.player_names: Dict[str, str] = {}
self.game_logic = GameLogic()
self.game_task: asyncio.Task | None = None
self.beacon_task: asyncio.Task | None = None
self.ws_task: asyncio.Task | None = None
self.beacon: ServerBeacon | None = None
async def handle_client(
@@ -74,10 +89,16 @@ class GameServer:
# Parse message
try:
message = Message.from_json(data.decode().strip())
await self.handle_message(player_id, message, writer)
await self.handle_message(player_id, message, writer, ClientType.TCP)
except Exception as e:
print(f"Error parsing message from {player_id}: {e}")
await self.send_message(writer, create_error_message(str(e)))
error_msg = create_error_message(str(e))
try:
data = error_msg.to_json() + "\n"
writer.write(data.encode())
await writer.drain()
except:
pass
except asyncio.CancelledError:
pass
@@ -92,17 +113,19 @@ class GameServer:
self,
player_id: str,
message: Message,
writer: asyncio.StreamWriter
connection: Any = None,
client_type: ClientType = ClientType.TCP
) -> None:
"""Handle a message from a client.
Args:
player_id: ID of the player who sent the message
message: The message received
writer: Stream writer for responding
connection: Client connection (for JOIN messages)
client_type: Type of client connection
"""
if message.type == MessageType.JOIN:
await self.handle_join(player_id, message, writer)
await self.handle_join(player_id, message, connection, client_type)
elif message.type == MessageType.MOVE:
await self.handle_move(player_id, message)
elif message.type == MessageType.START_GAME:
@@ -114,21 +137,23 @@ class GameServer:
self,
player_id: str,
message: Message,
writer: asyncio.StreamWriter
connection: Any,
client_type: ClientType
) -> None:
"""Handle a player joining.
Args:
player_id: ID of the joining player
message: JOIN message with player name
writer: Stream writer for the client
connection: Client connection (writer or websocket)
client_type: Type of client connection
"""
player_name = message.data.get("player_name", f"Player_{player_id[:8]}")
self.clients[player_id] = writer
self.clients[player_id] = (connection, client_type)
self.player_names[player_id] = player_name
# Send welcome message to new player
await self.send_message(writer, create_welcome_message(player_id))
await self.send_to_client(player_id, create_welcome_message(player_id))
# Notify all clients about new player
await self.broadcast(create_player_joined_message(player_id, player_name))
@@ -138,7 +163,7 @@ class GameServer:
snake = self.game_logic.create_snake(player_id)
self.game_logic.state.snakes.append(snake)
print(f"Player {player_name} ({player_id}) joined. Total players: {len(self.clients)}")
print(f"Player {player_name} ({player_id}) joined via {client_type.value}. Total players: {len(self.clients)}")
async def handle_move(self, player_id: str, message: Message) -> None:
"""Handle a player movement command.
@@ -220,19 +245,29 @@ class GameServer:
# Wait for next tick
await asyncio.sleep(TICK_RATE)
async def send_message(self, writer: asyncio.StreamWriter, message: Message) -> None:
"""Send a message to a client.
async def send_to_client(self, player_id: str, message: Message) -> None:
"""Send a message to a specific client.
Args:
writer: Stream writer for the client
player_id: Player ID to send to
message: Message to send
"""
if player_id not in self.clients:
return
connection, client_type = self.clients[player_id]
try:
data = message.to_json() + "\n"
writer.write(data.encode())
await writer.drain()
if client_type == ClientType.TCP:
# TCP: newline-delimited JSON
data = message.to_json() + "\n"
connection.write(data.encode())
await connection.drain()
elif client_type == ClientType.WEBSOCKET:
# WebSocket: JSON frames
await connection.send(message.to_json())
except Exception as e:
print(f"Error sending message: {e}")
print(f"Error sending message to {player_id} ({client_type.value}): {e}")
async def broadcast(self, message: Message, exclude: Set[str] = None) -> None:
"""Broadcast a message to all connected clients.
@@ -242,9 +277,9 @@ class GameServer:
exclude: Set of player IDs to exclude from broadcast
"""
exclude = exclude or set()
for player_id, writer in list(self.clients.items()):
for player_id in list(self.clients.keys()):
if player_id not in exclude:
await self.send_message(writer, message)
await self.send_to_client(player_id, message)
def get_player_count(self) -> int:
"""Get the current number of connected players.
@@ -254,8 +289,27 @@ class GameServer:
"""
return len(self.clients)
async def start_websocket_server(self) -> None:
"""Start WebSocket server."""
from .websocket_handler import WebSocketHandler, start_websocket_server
async def on_message(player_id: str, message: Message, websocket: Any) -> None:
"""Handle WebSocket message."""
await self.handle_message(player_id, message, websocket, ClientType.WEBSOCKET)
async def on_disconnect(player_id: str) -> None:
"""Handle WebSocket disconnection."""
await self.remove_player(player_id)
handler = WebSocketHandler(
on_message=on_message,
on_disconnect=on_disconnect,
)
await start_websocket_server(self.host, self.ws_port, handler)
async def start(self) -> None:
"""Start the server."""
"""Start the server (TCP and optionally WebSocket)."""
# Start discovery beacon if enabled
if self.enable_discovery:
self.beacon = ServerBeacon(
@@ -265,6 +319,12 @@ class GameServer:
)
self.beacon_task = asyncio.create_task(self.beacon.start())
# Start WebSocket server if enabled
if self.ws_port:
self.ws_task = asyncio.create_task(self.start_websocket_server())
print(f"WebSocket server will start on ws://{self.host}:{self.ws_port}")
# Start TCP server
server = await asyncio.start_server(
self.handle_client,
self.host,
@@ -278,7 +338,7 @@ class GameServer:
async with server:
await server.serve_forever()
finally:
# Clean up beacon on shutdown
# Clean up
if self.beacon:
await self.beacon.stop()
if self.beacon_task:
@@ -287,6 +347,12 @@ class GameServer:
await self.beacon_task
except asyncio.CancelledError:
pass
if self.ws_task:
self.ws_task.cancel()
try:
await self.ws_task
except asyncio.CancelledError:
pass
async def main() -> None:

61
src/server/http_server.py Normal file
View File

@@ -0,0 +1,61 @@
"""HTTP server for serving web client static files."""
import asyncio
from pathlib import Path
from aiohttp import web
from ..shared.constants import DEFAULT_HTTP_PORT
class HTTPServer:
"""Simple HTTP server for serving static web client files."""
def __init__(self, web_dir: str | Path, port: int = DEFAULT_HTTP_PORT, host: str = "0.0.0.0"):
"""Initialize HTTP server.
Args:
web_dir: Directory containing web client files
port: Port to listen on
host: Host address to bind to
"""
self.web_dir = Path(web_dir)
self.port = port
self.host = host
self.app = web.Application()
self.runner = None
# Setup routes
self.app.router.add_static('/', self.web_dir, show_index=True)
async def start(self) -> None:
"""Start the HTTP server."""
self.runner = web.AppRunner(self.app)
await self.runner.setup()
site = web.TCPSite(self.runner, self.host, self.port)
await site.start()
print(f"HTTP server serving {self.web_dir} on http://{self.host}:{self.port}")
async def stop(self) -> None:
"""Stop the HTTP server."""
if self.runner:
await self.runner.cleanup()
async def start_http_server(web_dir: str | Path, port: int = DEFAULT_HTTP_PORT, host: str = "0.0.0.0") -> None:
"""Start HTTP server and run forever.
Args:
web_dir: Directory containing web files
port: Port to listen on
host: Host to bind to
"""
server = HTTPServer(web_dir, port, host)
await server.start()
# Run forever
try:
await asyncio.Future()
except asyncio.CancelledError:
await server.stop()

View File

@@ -0,0 +1,95 @@
"""WebSocket handler for web clients."""
import asyncio
import websockets
from typing import Callable, Awaitable
from websockets.server import WebSocketServerProtocol
from ..shared.protocol import Message
class WebSocketHandler:
"""Handles WebSocket connections for web clients."""
def __init__(
self,
on_message: Callable[[str, Message, WebSocketServerProtocol], Awaitable[None]],
on_disconnect: Callable[[str], Awaitable[None]],
):
"""Initialize WebSocket handler.
Args:
on_message: Callback for received messages (player_id, message, websocket)
on_disconnect: Callback for disconnections (player_id)
"""
self.on_message = on_message
self.on_disconnect = on_disconnect
async def handle_client(
self,
websocket: WebSocketServerProtocol,
path: str,
) -> None:
"""Handle a WebSocket client connection.
Args:
websocket: WebSocket connection
path: Request path (unused)
"""
# Generate player ID (will be replaced when JOIN message received)
import uuid
player_id = str(uuid.uuid4())
remote_addr = websocket.remote_address
print(f"WebSocket connection from {remote_addr}, assigned ID: {player_id}")
try:
# Handle messages
async for message_data in websocket:
try:
# Parse JSON message
message = Message.from_json(message_data)
await self.on_message(player_id, message, websocket)
except Exception as e:
print(f"Error parsing WebSocket message from {player_id}: {e}")
# Send error back to client
from ..shared.protocol import create_error_message
error_msg = create_error_message(str(e))
await websocket.send(error_msg.to_json())
except websockets.exceptions.ConnectionClosed:
print(f"WebSocket client {player_id} disconnected")
except Exception as e:
print(f"Error handling WebSocket client {player_id}: {e}")
finally:
# Notify disconnection
await self.on_disconnect(player_id)
async def start_websocket_server(
host: str,
port: int,
handler: WebSocketHandler,
) -> None:
"""Start the WebSocket server.
Args:
host: Host address to bind to
port: Port number to bind to
handler: WebSocketHandler instance
"""
print(f"Starting WebSocket server on ws://{host}:{port}")
async with websockets.serve(
handler.handle_client,
host,
port,
# Increase max message size for game states
max_size=10 * 1024 * 1024, # 10MB
# Ping interval to keep connections alive
ping_interval=20,
ping_timeout=10,
):
# Run forever
await asyncio.Future()

View File

@@ -3,6 +3,8 @@
# Network settings
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 8888
DEFAULT_WS_PORT = 8889
DEFAULT_HTTP_PORT = 8000
# Multicast discovery settings
MULTICAST_GROUP = "239.255.0.1"

241
web/README.md Normal file
View File

@@ -0,0 +1,241 @@
# Web Client Deployment
This directory contains the web client for the Multiplayer Snake game.
## Development Mode
For local development, use the built-in HTTP server:
```bash
python run_server.py --name "Dev Server"
```
This starts:
- TCP server on port 8888 (for pygame clients)
- WebSocket server on port 8889 (for web clients)
- HTTP server on port 8000 (serving web files)
Access the web client at: http://localhost:8000
## Production Deployment with nginx
For production, disable the built-in HTTP server and use nginx to serve static files and proxy WebSocket connections.
### Step 1: Run server without HTTP
```bash
python run_server.py --no-http --host 0.0.0.0
```
### Step 2: Configure nginx
Create an nginx configuration file (e.g., `/etc/nginx/sites-available/snake-game`):
```nginx
server {
listen 80;
server_name snake.example.com;
# Serve static web files
location / {
root /path/to/multiplayer-snake/web;
try_files $uri $uri/ /index.html;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# Proxy WebSocket connections
location /ws {
proxy_pass http://localhost:8889;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket timeouts
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}
```
Enable the site:
```bash
sudo ln -s /etc/nginx/sites-available/snake-game /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### Step 3: Update web client URL
Users should connect to: `ws://snake.example.com/ws` (or `wss://` for SSL)
## SSL/TLS Support (HTTPS + WSS)
For secure connections with Let's Encrypt:
```nginx
server {
listen 80;
server_name snake.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name snake.example.com;
ssl_certificate /etc/letsencrypt/live/snake.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/snake.example.com/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
# Static files
location / {
root /path/to/multiplayer-snake/web;
try_files $uri $uri/ /index.html;
}
# Secure WebSocket
location /ws {
proxy_pass http://localhost:8889;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}
```
## Apache Configuration
For Apache with mod_proxy_wstunnel:
```apache
<VirtualHost *:80>
ServerName snake.example.com
DocumentRoot /path/to/multiplayer-snake/web
<Directory /path/to/multiplayer-snake/web>
Options -Indexes +FollowSymLinks
AllowOverride None
Require all granted
</Directory>
# WebSocket proxy
ProxyPass /ws ws://localhost:8889
ProxyPassReverse /ws ws://localhost:8889
# Enable WebSocket
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule /ws/(.*) ws://localhost:8889/$1 [P,L]
</VirtualHost>
```
Enable required modules:
```bash
sudo a2enmod proxy proxy_http proxy_wstunnel rewrite
sudo systemctl restart apache2
```
## Caddy Configuration
Caddy makes it even simpler with automatic HTTPS:
```
snake.example.com {
root * /path/to/multiplayer-snake/web
file_server
handle /ws {
reverse_proxy localhost:8889
}
}
```
## Systemd Service
Create `/etc/systemd/system/snake-game.service`:
```ini
[Unit]
Description=Snake Game Server
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/path/to/multiplayer-snake
ExecStart=/path/to/multiplayer-snake/venv/bin/python run_server.py --no-http --host 0.0.0.0
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable snake-game
sudo systemctl start snake-game
sudo systemctl status snake-game
```
## Firewall Configuration
Open required ports:
```bash
# HTTP/HTTPS (nginx/Apache)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Game server TCP (if accessed directly)
sudo ufw allow 8888/tcp
# WebSocket (if not behind proxy)
sudo ufw allow 8889/tcp
```
## Troubleshooting
### WebSocket connection fails
- Check firewall rules
- Verify nginx/Apache WebSocket proxy configuration
- Check browser console for errors
- Test WebSocket endpoint: `wscat -c ws://your-server:8889`
### Static files not loading
- Verify nginx root path is correct
- Check file permissions: `chmod -R 755 /path/to/web`
- Check nginx error logs: `sudo tail -f /var/log/nginx/error.log`
### Game server not starting
- Check if ports are already in use: `sudo netstat -tulpn | grep 8889`
- Review server logs
- Verify Python dependencies are installed

367
web/game.js Normal file
View File

@@ -0,0 +1,367 @@
/**
* Multiplayer Snake Game Web Client
*/
class GameClient {
constructor() {
this.ws = null;
this.playerId = null;
this.gameState = null;
this.canvas = document.getElementById('game-canvas');
this.ctx = this.canvas.getContext('2d');
// Game constants (matching Python)
this.GRID_WIDTH = 40;
this.GRID_HEIGHT = 30;
this.CELL_SIZE = 20;
// Colors (matching Python)
this.COLOR_BACKGROUND = '#000000';
this.COLOR_GRID = '#282828';
this.COLOR_FOOD = '#ff0000';
this.COLOR_SNAKES = [
'#00ff00', // Green - Player 1
'#0000ff', // Blue - Player 2
'#ffff00', // Yellow - Player 3
'#ff00ff' // Magenta - Player 4
];
// Setup canvas
this.canvas.width = this.GRID_WIDTH * this.CELL_SIZE;
this.canvas.height = this.GRID_HEIGHT * this.CELL_SIZE;
// Bind UI elements
this.connectBtn = document.getElementById('connect-btn');
this.disconnectBtn = document.getElementById('disconnect-btn');
this.playerNameInput = document.getElementById('player-name');
this.serverUrlInput = document.getElementById('server-url');
this.connectionStatus = document.getElementById('connection-status');
this.connectionPanel = document.getElementById('connection-panel');
this.gamePanel = document.getElementById('game-panel');
this.gameOverlay = document.getElementById('game-overlay');
this.playersList = document.getElementById('players-list');
// Setup event listeners
this.setupEventListeners();
// Start render loop
this.render();
}
setupEventListeners() {
this.connectBtn.addEventListener('click', () => this.connect());
this.disconnectBtn.addEventListener('click', () => this.disconnect());
// Keyboard controls
document.addEventListener('keydown', (e) => this.handleKeyPress(e));
// Enter key in inputs triggers connect
this.playerNameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.connect();
});
this.serverUrlInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.connect();
});
}
connect() {
const serverUrl = this.serverUrlInput.value.trim();
const playerName = this.playerNameInput.value.trim() || 'Player';
if (!serverUrl) {
this.showStatus('Please enter a server URL', 'error');
return;
}
this.showStatus('Connecting...', 'info');
try {
this.ws = new WebSocket(serverUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.showStatus('Connected! Joining game...', 'success');
// Send JOIN message
const joinMsg = createJoinMessage(playerName);
this.ws.send(joinMsg.toJSON());
};
this.ws.onmessage = (event) => {
try {
const message = Message.fromJSON(event.data);
this.handleMessage(message);
} catch (error) {
console.error('Error parsing message:', error);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.showStatus('Connection error', 'error');
};
this.ws.onclose = () => {
console.log('WebSocket closed');
this.showStatus('Disconnected from server', 'error');
this.showConnectionPanel();
};
} catch (error) {
console.error('Connection error:', error);
this.showStatus('Failed to connect: ' + error.message, 'error');
}
}
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.gameState = null;
this.playerId = null;
this.showConnectionPanel();
}
handleMessage(message) {
switch (message.type) {
case MessageType.WELCOME:
this.playerId = message.data.player_id;
console.log('Assigned player ID:', this.playerId);
this.showGamePanel();
break;
case MessageType.STATE_UPDATE:
this.gameState = message.data.game_state;
this.updatePlayersList();
if (this.gameState.game_running) {
this.hideOverlay();
}
break;
case MessageType.PLAYER_JOINED:
console.log('Player joined:', message.data.player_name);
break;
case MessageType.PLAYER_LEFT:
console.log('Player left:', message.data.player_id);
break;
case MessageType.GAME_STARTED:
console.log('Game started!');
this.hideOverlay();
break;
case MessageType.GAME_OVER:
console.log('Game over! Winner:', message.data.winner_id);
this.showOverlay('Game Over!',
message.data.winner_id ?
`Winner: ${message.data.winner_id}` :
'No winner');
break;
case MessageType.ERROR:
console.error('Server error:', message.data.error);
this.showStatus('Error: ' + message.data.error, 'error');
break;
}
}
handleKeyPress(event) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
let direction = null;
switch (event.key.toLowerCase()) {
case 'arrowup':
case 'w':
direction = Direction.UP;
event.preventDefault();
break;
case 'arrowdown':
case 's':
direction = Direction.DOWN;
event.preventDefault();
break;
case 'arrowleft':
case 'a':
direction = Direction.LEFT;
event.preventDefault();
break;
case 'arrowright':
case 'd':
direction = Direction.RIGHT;
event.preventDefault();
break;
case ' ':
// Start game
const startMsg = createStartGameMessage();
this.ws.send(startMsg.toJSON());
event.preventDefault();
break;
}
if (direction) {
const moveMsg = createMoveMessage(direction);
this.ws.send(moveMsg.toJSON());
}
}
render() {
// Clear canvas
this.ctx.fillStyle = this.COLOR_BACKGROUND;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Draw grid
this.drawGrid();
if (this.gameState) {
// Draw food
if (this.gameState.food) {
this.ctx.fillStyle = this.COLOR_FOOD;
for (const food of this.gameState.food) {
const [x, y] = food.position;
this.drawCell(x, y, this.COLOR_FOOD);
}
}
// Draw snakes
if (this.gameState.snakes) {
this.gameState.snakes.forEach((snake, index) => {
const color = this.COLOR_SNAKES[index % this.COLOR_SNAKES.length];
if (snake.body && snake.alive) {
// Draw body
for (let i = 0; i < snake.body.length; i++) {
const [x, y] = snake.body[i];
// Make head brighter
if (i === 0) {
const brightColor = this.brightenColor(color, 50);
this.drawCell(x, y, brightColor);
} else {
this.drawCell(x, y, color);
}
}
}
});
}
}
requestAnimationFrame(() => this.render());
}
drawGrid() {
this.ctx.strokeStyle = this.COLOR_GRID;
this.ctx.lineWidth = 1;
for (let x = 0; x <= this.GRID_WIDTH; x++) {
this.ctx.beginPath();
this.ctx.moveTo(x * this.CELL_SIZE, 0);
this.ctx.lineTo(x * this.CELL_SIZE, this.canvas.height);
this.ctx.stroke();
}
for (let y = 0; y <= this.GRID_HEIGHT; y++) {
this.ctx.beginPath();
this.ctx.moveTo(0, y * this.CELL_SIZE);
this.ctx.lineTo(this.canvas.width, y * this.CELL_SIZE);
this.ctx.stroke();
}
}
drawCell(x, y, color) {
this.ctx.fillStyle = color;
this.ctx.fillRect(
x * this.CELL_SIZE,
y * this.CELL_SIZE,
this.CELL_SIZE,
this.CELL_SIZE
);
// Border
this.ctx.strokeStyle = this.COLOR_BACKGROUND;
this.ctx.lineWidth = 1;
this.ctx.strokeRect(
x * this.CELL_SIZE,
y * this.CELL_SIZE,
this.CELL_SIZE,
this.CELL_SIZE
);
}
brightenColor(hex, amount) {
// Convert hex to RGB
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
// Brighten
const newR = Math.min(255, r + amount);
const newG = Math.min(255, g + amount);
const newB = Math.min(255, b + amount);
// Convert back to hex
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
}
updatePlayersList() {
if (!this.gameState || !this.gameState.snakes) {
return;
}
this.playersList.innerHTML = '';
this.gameState.snakes.forEach((snake, index) => {
const playerItem = document.createElement('div');
playerItem.className = `player-item ${snake.alive ? 'alive' : 'dead'}`;
playerItem.style.borderLeftColor = this.COLOR_SNAKES[index % this.COLOR_SNAKES.length];
const nameSpan = document.createElement('span');
nameSpan.className = 'player-name';
nameSpan.textContent = snake.player_id === this.playerId ?
`You (${snake.player_id.substring(0, 8)})` :
snake.player_id.substring(0, 8);
const scoreSpan = document.createElement('span');
scoreSpan.className = 'player-score';
scoreSpan.textContent = snake.score;
playerItem.appendChild(nameSpan);
playerItem.appendChild(scoreSpan);
this.playersList.appendChild(playerItem);
});
}
showStatus(message, type) {
this.connectionStatus.textContent = message;
this.connectionStatus.className = `status ${type}`;
}
showConnectionPanel() {
this.connectionPanel.style.display = 'block';
this.gamePanel.style.display = 'none';
}
showGamePanel() {
this.connectionPanel.style.display = 'none';
this.gamePanel.style.display = 'flex';
}
hideOverlay() {
this.gameOverlay.classList.add('hidden');
}
showOverlay(title, message) {
this.gameOverlay.classList.remove('hidden');
this.gameOverlay.querySelector('h2').textContent = title;
this.gameOverlay.querySelector('p').textContent = message;
}
}
// Initialize game when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
new GameClient();
});

73
web/index.html Normal file
View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multiplayer Snake Game</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>🐍 Multiplayer Snake</h1>
</header>
<div id="connection-panel" class="panel">
<h2>Connect to Server</h2>
<div class="form-group">
<label for="player-name">Your Name:</label>
<input type="text" id="player-name" placeholder="Enter your name" value="Player" maxlength="20">
</div>
<div class="form-group">
<label for="server-url">Server URL:</label>
<input type="text" id="server-url" placeholder="ws://localhost:8889" value="">
</div>
<button id="connect-btn" class="btn btn-primary">Connect</button>
<div id="connection-status" class="status"></div>
</div>
<div id="game-panel" class="panel" style="display: none;">
<div class="game-area">
<canvas id="game-canvas"></canvas>
<div id="game-overlay" class="overlay">
<div class="overlay-content">
<h2>Waiting for game to start...</h2>
<p>Press <kbd>SPACE</kbd> to start the game</p>
<p>Use <kbd></kbd> <kbd></kbd> <kbd></kbd> <kbd></kbd> or <kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd> to move</p>
</div>
</div>
</div>
<aside class="sidebar">
<div class="score-panel">
<h3>Players</h3>
<div id="players-list"></div>
</div>
<div class="controls-panel">
<h3>Controls</h3>
<div class="control-info">
<p><kbd></kbd> <kbd></kbd> <kbd></kbd> <kbd></kbd> Move</p>
<p><kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd> Move</p>
<p><kbd>SPACE</kbd> Start Game</p>
</div>
<button id="disconnect-btn" class="btn btn-secondary">Disconnect</button>
</div>
</aside>
</div>
</div>
<script src="protocol.js"></script>
<script src="game.js"></script>
<script>
// Auto-detect WebSocket URL
const wsUrl = document.getElementById('server-url');
if (window.location.protocol === 'file:') {
wsUrl.value = 'ws://localhost:8889';
} else {
const host = window.location.hostname;
const port = window.location.port ? parseInt(window.location.port) + 889 : 8889;
wsUrl.value = `ws://${host}:${port}`;
}
</script>
</body>
</html>

65
web/protocol.js Normal file
View File

@@ -0,0 +1,65 @@
/**
* Network protocol for client-server communication
* Matches the Python protocol implementation
*/
const MessageType = {
// Client -> Server
JOIN: 'JOIN',
MOVE: 'MOVE',
START_GAME: 'START_GAME',
LEAVE: 'LEAVE',
// Server -> Client
WELCOME: 'WELCOME',
STATE_UPDATE: 'STATE_UPDATE',
PLAYER_JOINED: 'PLAYER_JOINED',
PLAYER_LEFT: 'PLAYER_LEFT',
GAME_STARTED: 'GAME_STARTED',
GAME_OVER: 'GAME_OVER',
ERROR: 'ERROR'
};
class Message {
constructor(type, data = {}) {
this.type = type;
this.data = data;
}
toJSON() {
return JSON.stringify({
type: this.type,
data: this.data
});
}
static fromJSON(jsonStr) {
const obj = JSON.parse(jsonStr);
return new Message(obj.type, obj.data || {});
}
}
// Helper functions for creating messages
function createJoinMessage(playerName) {
return new Message(MessageType.JOIN, { player_name: playerName });
}
function createMoveMessage(direction) {
return new Message(MessageType.MOVE, { direction: direction });
}
function createStartGameMessage() {
return new Message(MessageType.START_GAME);
}
function createLeaveMessage() {
return new Message(MessageType.LEAVE);
}
// Direction constants (matching Python)
const Direction = {
UP: [0, -1],
DOWN: [0, 1],
LEFT: [-1, 0],
RIGHT: [1, 0]
};

275
web/style.css Normal file
View File

@@ -0,0 +1,275 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e0e0e0;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
width: 100%;
max-width: 1400px;
}
header {
text-align: center;
margin-bottom: 30px;
}
header h1 {
font-size: 2.5rem;
color: #00ff88;
text-shadow: 0 0 10px rgba(0, 255, 136, 0.5);
}
.panel {
background: rgba(255, 255, 255, 0.05);
border-radius: 15px;
padding: 30px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
#connection-panel {
max-width: 500px;
margin: 0 auto;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #00ff88;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #00ff88;
box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
}
.btn {
padding: 12px 30px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
width: 100%;
}
.btn-primary {
background: linear-gradient(135deg, #00ff88 0%, #00cc6f 100%);
color: #1a1a2e;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(0, 255, 136, 0.4);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
.status {
margin-top: 15px;
padding: 10px;
border-radius: 6px;
text-align: center;
font-size: 0.9rem;
}
.status.success {
background: rgba(0, 255, 136, 0.2);
color: #00ff88;
}
.status.error {
background: rgba(255, 0, 0, 0.2);
color: #ff6b6b;
}
.status.info {
background: rgba(0, 136, 255, 0.2);
color: #00aaff;
}
#game-panel {
display: flex;
gap: 30px;
}
.game-area {
flex: 1;
position: relative;
}
#game-canvas {
display: block;
background: #0a0a0a;
border: 2px solid #00ff88;
border-radius: 10px;
box-shadow: 0 0 30px rgba(0, 255, 136, 0.3);
width: 100%;
height: auto;
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
border-radius: 10px;
}
.overlay.hidden {
display: none;
}
.overlay-content {
text-align: center;
padding: 30px;
}
.overlay-content h2 {
color: #00ff88;
margin-bottom: 20px;
}
.overlay-content p {
margin: 10px 0;
font-size: 1.1rem;
}
kbd {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
padding: 3px 8px;
font-family: monospace;
font-size: 0.9rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.sidebar {
width: 280px;
display: flex;
flex-direction: column;
gap: 20px;
}
.score-panel,
.controls-panel {
background: rgba(255, 255, 255, 0.05);
padding: 20px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.score-panel h3,
.controls-panel h3 {
color: #00ff88;
margin-bottom: 15px;
font-size: 1.2rem;
}
#players-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.player-item {
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
border-left: 3px solid;
display: flex;
justify-content: space-between;
}
.player-item.alive {
opacity: 1;
}
.player-item.dead {
opacity: 0.5;
text-decoration: line-through;
}
.player-name {
font-weight: 500;
}
.player-score {
color: #00ff88;
font-weight: 600;
}
.control-info p {
margin: 8px 0;
font-size: 0.9rem;
}
/* Responsive design */
@media (max-width: 1024px) {
#game-panel {
flex-direction: column;
}
.sidebar {
width: 100%;
flex-direction: row;
}
.score-panel,
.controls-panel {
flex: 1;
}
}
@media (max-width: 768px) {
header h1 {
font-size: 2rem;
}
.sidebar {
flex-direction: column;
}
}