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
+