diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..d514e26 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Read(//g/Coding/code2/**)", + "Bash(python -m py_compile server/server.py)", + "Bash(node --check client/client.js)", + "Bash(node --check client/protocol.js)" + ], + "deny": [], + "ask": [] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3e7e91a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,241 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a real-time multiplayer Snake game using Python 3 (server) and vanilla JavaScript (web client), communicating via WebTransport datagrams (HTTP/3) for low-latency gameplay. The game features continuous persistent gameplay, unique collision mechanics (head-blocks-and-tail-shrinks instead of death), and sophisticated UDP packet handling with compression and partitioning. + +## Running the Server + +### Dependencies +```bash +# Create and activate virtual environment +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt +``` + +### Server Modes + +The server supports multiple transport modes via `MODE` environment variable: + +```bash +# In-memory mode (testing, no network) +MODE=mem python run.py + +# WebTransport mode (HTTP/3, default for production) +MODE=wt QUIC_CERT=cert.pem QUIC_KEY=key.pem python run.py + +# QUIC datagram mode only +MODE=quic QUIC_CERT=cert.pem QUIC_KEY=key.pem python run.py + +# Combined mode (both WebTransport and QUIC) +MODE=net WT_PORT=4433 QUIC_PORT=4443 QUIC_CERT=cert.pem QUIC_KEY=key.pem python run.py +``` + +### Environment Variables + +- `MODE`: Transport mode (mem|wt|quic|net) +- `QUIC_CERT` / `QUIC_KEY`: TLS certificate and key paths (required for wt/quic/net) +- `WT_CERT` / `WT_KEY`: WebTransport-specific cert/key (falls back to QUIC_* if not set) +- `WT_HOST` / `WT_PORT`: WebTransport server host/port (default: 0.0.0.0:4433) +- `QUIC_HOST` / `QUIC_PORT`: QUIC server host/port (default: 0.0.0.0:4433, or 4443 in net mode) +- `STATIC`: Enable static HTTPS server for client (default: 1 in wt mode) +- `STATIC_HOST` / `STATIC_PORT` / `STATIC_ROOT`: Static server settings (default: same as WT host, port 8443, root "client") +- `LOG_LEVEL`: Logging level (DEBUG|INFO|WARNING|ERROR) +- `RUN_SECONDS`: Optional timeout for server shutdown (testing) + +### Help +```bash +python run.py --help +``` + +## Architecture + +### Server Architecture (Python 3 + asyncio) + +**Core Components:** + +1. **server/server.py** (`GameServer`): Authoritative game server + - Fixed tick rate simulation (default 10 TPS, configurable 5-30) + - Manages player sessions, snakes, apples, collision detection + - Handles join/spawn logic, input processing, state broadcasting + - Automatically partitions large updates across multiple datagrams (<1200 bytes each to avoid IP fragmentation) + +2. **server/model.py**: Game state entities + - `GameState`: Field grid, snakes dict, apples list, occupancy map + - `Snake`: Deque-based body representation, input buffer (capacity 3), blocked flag + - `PlayerSession`: Per-player metadata (id, name, color, peer handle) + +3. **server/protocol.py**: Wire protocol implementation + - Packet types: JOIN, JOIN_ACK, JOIN_DENY, INPUT, INPUT_BROADCAST, STATE_DELTA, STATE_FULL, PART, CONFIG_UPDATE + - TLV body encoding: 2-bit direction streams, RLE compression, chunked variants for oversized snakes + - QUIC varint encoding for efficient integer serialization + - Sequence number handling with wraparound logic (16-bit) + +4. **server/config.py** (`ServerConfig`): Server configuration dataclass with validation + +5. **Transport layer** (pluggable): + - `server/transport.py`: Abstract `DatagramServerTransport` interface + - `server/webtransport_server.py`: WebTransport (HTTP/3) implementation using aioquic + - `server/quic_transport.py`: Raw QUIC datagram implementation + - `server/multi_transport.py`: Multiplexer for running multiple transports concurrently + - `server/static_server.py`: Optional HTTPS static file server for serving client assets + +### Client Architecture (Vanilla JavaScript) + +**Files:** +- `client/index.html`: Main HTML page with canvas and UI controls +- `client/client.js`: Game client, WebTransport connection, input handling, rendering +- `client/protocol.js`: Wire protocol parsing/building (mirrors server protocol) + +**Key Features:** +- WebTransport datagram API for unreliable, unordered messaging +- Canvas-based rendering with auto-scaling grid +- Input buffering (up to 3 direction changes) with 180° turn filtering +- State update handling: STATE_FULL (initial/recovery), STATE_DELTA (per-tick), PART (fragmented) +- Packet sequence tracking to discard late/out-of-order updates + +### Game Mechanics (Unique Collision System) + +**No Death on Collision:** +- When a snake's head hits an obstacle (wall, self, or another snake), the head stays in place (blocked state) +- While blocked, the tail shrinks by 1 cell per tick until the player turns to a free direction +- Minimum length is 1 (head only); snake cannot disappear while player is connected + +**Input Buffer Rules:** +- Client-side buffer holds up to 3 upcoming direction changes +- Opposite direction to last buffered input replaces the last entry (not appended) +- Consecutive duplicates are dropped +- Overflow policy: replace last element +- Server consumption: At each tick, consume at most one input; skip 180° turns when length > 1 + +**Apples & Scoring:** +- Displayed as current snake length (not separate score) +- Eating an apple grows snake by 1 immediately +- Target apple count: max(3, min(apples_cap, connected_players × apples_per_snake)) +- When 0 players remain, pre-populate field with exactly 3 apples + +**Spawning:** +- Try to allocate a 3-cell straight strip in a free direction +- Fallback: spawn length 1 (head only) at any free cell +- Deny join if no free cells available + +### Protocol Details + +**Packet Structure:** +``` +Header (all packets): + ver (u8) | type (u8) | flags (u8) | seq (u16 big-endian) | [tick (u16 optional)] +``` + +**Sequence Numbers:** +- 16-bit wraparound with half-range window comparison: `is_newer(a,b) = ((a-b) & 0xFFFF) < 0x8000` +- Clients discard packets with older seq than last applied per sender + +**State Encoding:** +- **STATE_FULL**: Complete snapshot (on join, periodic recovery); includes all snakes + apples + - Per-snake: id (u8), len (u16), head (x,y), body TLV (BODY_2BIT or BODY_RLE) +- **STATE_DELTA**: Incremental update (per tick); includes only changed snakes + apple diffs + - Per-change: snake_id, flags (head_moved|tail_removed|grew|blocked), direction, new_head coords +- **PART**: Multi-part fragmentation for large updates (>1200 bytes) + - Fields: update_id (u16), part_index, parts_total, inner_type (STATE_FULL or STATE_DELTA), chunk_payload + +**Body TLV Types:** +- `BODY_2BIT (0x00)`: 2-bit direction stream (up=00, right=01, down=10, left=11), packed LSB-first +- `BODY_RLE (0x01)`: Run-length encoding (dir u8, count QUIC varint) for straight segments +- `BODY_2BIT_CHUNK (0x10)` / `BODY_RLE_CHUNK (0x11)`: Chunked variants for splitting oversized snakes across parts + - Chunk header: start_index (u16), dirs_in_chunk (u16) + +**Input Broadcasting:** +- Server relays client inputs immediately to all other clients as INPUT_BROADCAST packets +- Enables client-side opponent prediction during late/lost state updates +- Fields: player_id, input_seq, base_tick, events (rel_tick_offset + direction), optional apply_at_tick + +**Partitioning Strategy:** +- Soft limit: 1200 bytes per datagram (avoid fragmentation; MTU ~1280-1500) +- Whole-snake-first: pack complete snakes greedily; if a single snake exceeds budget, split using chunked TLV +- Apples included only in first part; clients merge across parts using update_id + +## Key Simulation Details + +**Tick Order:** +1. Consume at most one valid input per snake (apply 180° rule for length > 1) +2. Compute intended head step; if blocked (wall/occupied), set `blocked=True` and keep head stationary +3. If blocked: shrink tail by 1 (min length 1) +4. If moving into apple: grow by 1 (don't shrink tail that tick) and respawn apple +5. Emit per-snake delta and global apple changes + +**Collision Detection:** +- Occupancy map tracks all snake cells as (coord) -> (snake_id, index) +- Allow moving into own tail if it will vacate (i.e., not growing) +- Walls block only when `wrap_edges=False`; wrapped coordinates computed by `_step_from()` + +**180° Turn Handling:** +- Checked at consumption time using XOR: `(int(new_dir) ^ int(current_dir)) == 2` +- Allowed only when snake length == 1 (head only) + +## Development Notes + +- **Testing**: No automated test suite currently exists. Manual testing via in-memory mode (`MODE=mem`) or live WebTransport server. +- **Logging**: Use `LOG_LEVEL=DEBUG` for verbose packet-level debugging. +- **Field Size**: Default 60×40; protocol supports 3×3 to 255×255 (negotiated in JOIN_ACK). +- **Players**: Max 32 concurrent (ids 0-31); names truncated to 16 UTF-8 bytes. +- **Colors**: 32-color palette; deterministic mapping from player_id to color_id (0-31). +- **Tick Rate Changes**: Server broadcasts CONFIG_UPDATE every 50 ticks; clients apply at next tick boundary. +- **Compression**: Optional global DEFLATE mode (handshake-only; requires server restart). Currently defaults to "none". +- **Browser Compatibility**: Requires WebTransport API support (latest Firefox/Chrome). + +## Common Patterns + +**Adding a New Packet Type:** +1. Add enum value to `PacketType` in server/protocol.py and client/protocol.js +2. Implement builder function in protocol (e.g., `build_foo()`) and parser (e.g., `parse_foo()`) +3. Handle in server's `on_datagram()` and client's `readLoop()` switch statements +4. Update header packing if tick field is required (pass tick to `pack_header()`) + +**Modifying Simulation Logic:** +1. Edit `_simulate_tick()` in server/server.py for per-tick updates +2. Update `SnakeDelta` dataclass if new change types are needed +3. Adjust `build_state_delta_body()` if encoding changes +4. Mirror changes in client rendering logic (client/client.js) + +**Changing Configuration:** +1. Update `ServerConfig` dataclass in server/config.py +2. Add runtime validation in `validate_runtime()` +3. If live-configurable: broadcast via CONFIG_UPDATE packet; otherwise require server restart +4. Update JOIN_ACK builder/parser if part of handshake + +## File Organization + +``` +G:\Coding\code2\ +├── run.py # Server entrypoint with mode switching +├── requirements.txt # Python dependencies (aioquic, cryptography) +├── PROJECT_PLAN.md # Detailed design spec (authoritative reference) +├── IDEAS.md # Original feature brainstorming +├── server/ +│ ├── server.py # GameServer (tick loop, join/spawn, simulation) +│ ├── model.py # GameState, Snake, PlayerSession entities +│ ├── protocol.py # Wire protocol (packet builders/parsers, TLV encoding) +│ ├── config.py # ServerConfig dataclass +│ ├── transport.py # Abstract DatagramServerTransport interface +│ ├── webtransport_server.py # HTTP/3 WebTransport implementation +│ ├── quic_transport.py # QUIC datagram implementation +│ ├── multi_transport.py # Multi-transport multiplexer +│ ├── static_server.py # Optional HTTPS static file server +│ └── utils.py # Utility functions +└── client/ + ├── index.html # Main HTML page + ├── client.js # Game client (WebTransport, input, rendering) + └── protocol.js # Wire protocol (mirrors server protocol.py) +``` + +## References + +- **PROJECT_PLAN.md**: Comprehensive design document covering protocol, mechanics, limits, testing strategy, and milestones. +- **aioquic**: Python QUIC and HTTP/3 library (https://github.com/aiortc/aioquic) +- **WebTransport**: W3C spec for low-latency client-server messaging over QUIC (https://w3c.github.io/webtransport/) diff --git a/client/client.js b/client/client.js index 7069279..37f073d 100644 --- a/client/client.js +++ b/client/client.js @@ -1,4 +1,4 @@ -import { PacketType, Direction, packHeader, parseHeader, buildJoin, buildInput, parseJoinAck, parseStateFullBody } from './protocol.js'; +import { PacketType, Direction, packHeader, parseHeader, buildJoin, buildInput, parseJoinAck, parseStateFullBody, parseStateDeltaBody } from './protocol.js'; const ui = { url: document.getElementById('url'), @@ -109,7 +109,79 @@ async function readLoop() { break; } case PacketType.STATE_DELTA: { - // Minimal: ignore detailed deltas for now; rely on periodic full (good enough for a demo) + const delta = parseStateDeltaBody(dv, off); + // Apply changes to local snake state + for (const ch of delta.changes) { + let snake = snakes.get(ch.snakeId); + if (!snake) { + // New snake appeared; skip for now (will get it in next full update) + continue; + } + + // Update direction (always present) + // Note: direction is the current direction, not necessarily the new head direction + + if (ch.headMoved) { + // Snake moved: prepend new head position + // Compute direction from old head to new head + const oldHx = snake.hx; + const oldHy = snake.hy; + const newHx = ch.newHeadX; + const newHy = ch.newHeadY; + + // Determine direction of movement + let moveDir = ch.direction; + if (newHx === oldHx && newHy === oldHy - 1) moveDir = Direction.UP; + else if (newHx === oldHx + 1 && newHy === oldHy) moveDir = Direction.RIGHT; + else if (newHx === oldHx && newHy === oldHy + 1) moveDir = Direction.DOWN; + else if (newHx === oldHx - 1 && newHy === oldHy) moveDir = Direction.LEFT; + + // Update head position + snake.hx = newHx; + snake.hy = newHy; + + // If tail was removed (normal move), keep dirs same length by prepending new dir + // If grew (ate apple), prepend without removing tail + if (ch.grew) { + // Growing: add direction to front, don't remove from back + snake.dirs = [moveDir, ...snake.dirs]; + snake.len += 1; + } else if (ch.tailRemoved) { + // Normal move: head advances, tail shrinks + // Keep dirs array same size (represents len-1 directions) + // Actually, we need to be careful here about the representation + // dirs[i] tells us how to get from cell i to cell i+1 + // When head moves and tail shrinks, we add new dir at front, remove old from back + if (snake.dirs.length > 0) { + snake.dirs.pop(); // remove tail direction + } + snake.dirs = [moveDir, ...snake.dirs]; + } else { + // Head moved but tail didn't remove (shouldn't happen in normal gameplay) + snake.dirs = [moveDir, ...snake.dirs]; + snake.len += 1; + } + } else if (ch.blocked) { + // Snake is blocked: head stayed in place, tail shrunk + if (ch.tailRemoved && snake.dirs.length > 0) { + snake.dirs.pop(); // remove last direction (tail shrinks) + snake.len = Math.max(1, snake.len - 1); + } + } + } + + // Apply apple changes + const appleSet = new Set(apples.map(a => `${a[0]},${a[1]}`)); + for (const [x, y] of delta.applesAdded) { + appleSet.add(`${x},${y}`); + } + for (const [x, y] of delta.applesRemoved) { + appleSet.delete(`${x},${y}`); + } + apples = Array.from(appleSet).map(s => { + const [x, y] = s.split(',').map(Number); + return [x, y]; + }); break; } default: break; diff --git a/client/protocol.js b/client/protocol.js index 727bad1..e3322b6 100644 --- a/client/protocol.js +++ b/client/protocol.js @@ -158,3 +158,57 @@ export function parseStateFullBody(dv, off) { return { snakes, apples, off }; } +export function parseStateDeltaBody(dv, off) { + // Parse STATE_DELTA body format (mirrors server/protocol.py build_state_delta_body) + // update_id (u16) + const updateId = dv.getUint16(off); off += 2; + + // changes count (QUIC varint) + let [changesCount, p1] = quicVarintDecode(dv, off); off = p1; + + const changes = []; + for (let i = 0; i < changesCount; i++) { + const snakeId = dv.getUint8(off); off += 1; + const flags = dv.getUint8(off); off += 1; + const direction = dv.getUint8(off) & 0x03; off += 1; + + const headMoved = (flags & 0x01) !== 0; + const tailRemoved = (flags & 0x02) !== 0; + const grew = (flags & 0x04) !== 0; + const blocked = (flags & 0x08) !== 0; + + let newHeadX = 0, newHeadY = 0; + if (headMoved) { + newHeadX = dv.getUint8(off); off += 1; + newHeadY = dv.getUint8(off); off += 1; + } + + changes.push({ + snakeId, + headMoved, + tailRemoved, + grew, + blocked, + direction, + newHeadX, + newHeadY + }); + } + + // apples added (count + coords) + let [applesAddedCount, p2] = quicVarintDecode(dv, off); off = p2; + const applesAdded = []; + for (let i = 0; i < applesAddedCount; i++) { + applesAdded.push([dv.getUint8(off++), dv.getUint8(off++)]); + } + + // apples removed (count + coords) + let [applesRemovedCount, p3] = quicVarintDecode(dv, off); off = p3; + const applesRemoved = []; + for (let i = 0; i < applesRemovedCount; i++) { + applesRemoved.push([dv.getUint8(off++), dv.getUint8(off++)]); + } + + return { updateId, changes, applesAdded, applesRemoved, off }; +} + diff --git a/server/server.py b/server/server.py index 945686e..4647eac 100644 --- a/server/server.py +++ b/server/server.py @@ -571,6 +571,33 @@ class GameServer: if player_id is None: return logging.debug("INPUT from player_id=%d: %d events (base_tick=%d)", player_id, len(events), base_tick) + + # Apply inputs to snake buffer (client-side filtering already applied) + snake = self.runtime.state.snakes.get(player_id) + if snake is not None: + for ev in events: + # Apply input buffer rules from project plan: + # - Max capacity 3 + # - If opposite to last buffered, replace last + # - Drop duplicates + # - Overflow: replace last + if len(snake.input_buf) > 0: + last_dir = snake.input_buf[-1] + # Check if opposite (XOR == 2) + if (int(ev.direction) ^ int(last_dir)) == 2: + # Replace last + snake.input_buf[-1] = ev.direction + continue + # Check if duplicate + if ev.direction == last_dir: + continue + # Add to buffer + if len(snake.input_buf) < 3: + snake.input_buf.append(ev.direction) + else: + # Overflow: replace last + snake.input_buf[-1] = ev.direction + # Relay to others immediately for prediction await self.relay_input_broadcast( from_player_id=player_id,