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:
Vladyslav Doloman
2025-10-19 15:17:16 +03:00
parent c4a8501635
commit ed5cb14b30
5 changed files with 408 additions and 2 deletions

View 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
View 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/)

View File

@@ -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;

View File

@@ -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 };
}

View File

@@ -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,