WIP: Add input broadcasting and client-side prediction features
Changes include: - Client: INPUT_BROADCAST packet handling and opponent prediction rendering - Client: Protocol parsing for INPUT_BROADCAST packets - Server: Input broadcasting to all clients except sender 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -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": []
|
||||
}
|
||||
}
|
||||
241
CLAUDE.md
Normal file
241
CLAUDE.md
Normal file
@@ -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/)
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user