diff --git a/CLAUDE.md b/CLAUDE.md index abc6f19..6982a74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index d465e54..d98b779 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/requirements.txt b/requirements.txt index 58cb28d..f3705fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ # Production dependencies pygame>=2.5.0 +websockets>=12.0 +aiohttp>=3.9.0 diff --git a/run_server.py b/run_server.py index c3eb726..9d13c3a 100644 --- a/run_server.py +++ b/run_server.py @@ -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__": diff --git a/src/server/game_server.py b/src/server/game_server.py index 0f7c237..e4229f4 100644 --- a/src/server/game_server.py +++ b/src/server/game_server.py @@ -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: diff --git a/src/server/http_server.py b/src/server/http_server.py new file mode 100644 index 0000000..9a0572e --- /dev/null +++ b/src/server/http_server.py @@ -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() diff --git a/src/server/websocket_handler.py b/src/server/websocket_handler.py new file mode 100644 index 0000000..32f7bca --- /dev/null +++ b/src/server/websocket_handler.py @@ -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() diff --git a/src/shared/constants.py b/src/shared/constants.py index f4d4054..60bcd7d 100644 --- a/src/shared/constants.py +++ b/src/shared/constants.py @@ -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" diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..802f0df --- /dev/null +++ b/web/README.md @@ -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 + + ServerName snake.example.com + DocumentRoot /path/to/multiplayer-snake/web + + + Options -Indexes +FollowSymLinks + AllowOverride None + Require all granted + + + # 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] + +``` + +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 diff --git a/web/game.js b/web/game.js new file mode 100644 index 0000000..d25b3ea --- /dev/null +++ b/web/game.js @@ -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(); +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..a198c84 --- /dev/null +++ b/web/index.html @@ -0,0 +1,73 @@ + + + + + + Multiplayer Snake Game + + + +
+
+

🐍 Multiplayer Snake

+
+ +
+

Connect to Server

+
+ + +
+
+ + +
+ +
+
+ + +
+ + + + + + diff --git a/web/protocol.js b/web/protocol.js new file mode 100644 index 0000000..35b33ed --- /dev/null +++ b/web/protocol.js @@ -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] +}; diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..a45c8e4 --- /dev/null +++ b/web/style.css @@ -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; + } +}