Compare commits

...

20 Commits

Author SHA1 Message Date
Vladyslav Doloman
c4a8501635 Fix: indent await send(full, peer) inside STATE_FULL if block to resolve SyntaxError 2025-10-08 01:05:47 +03:00
Vladyslav Doloman
56ac74e916 Mode: implement MODE=net to run both WebTransport (HTTP/3) and QUIC datagram servers concurrently via MultiTransport; add port envs WT_PORT/QUIC_PORT 2025-10-08 01:00:47 +03:00
Vladyslav Doloman
b94aac71f8 Mode: add scaffolding for combined network mode name (net) in help; introduce MultiTransport 2025-10-08 00:53:14 +03:00
Vladyslav Doloman
7337c27898 Transport: add MultiTransport to run WT and QUIC concurrently and route sends 2025-10-08 00:51:35 +03:00
Vladyslav Doloman
921cc42fd4 CLI: add --help to print usage and env-based configuration hints 2025-10-08 00:33:07 +03:00
Vladyslav Doloman
99e818e0fd Fix: define _run_tasks_with_optional_timeout before use; tidy run_webtransport block 2025-10-08 00:29:18 +03:00
Vladyslav Doloman
1bd4d9eac7 Fix: clean up run.py newlines and indentation; add optional RUN_SECONDS timeout helper for server tasks 2025-10-08 00:27:10 +03:00
Vladyslav Doloman
eeabda725e Logging: add informative console logs for startup, ports, connections, JOIN/deny, input, and periodic events
- Configure logging via LOG_LEVEL env (default INFO)
- Log when servers start listening (WT/QUIC/InMemory/HTTPS static)
- Log WT CONNECT accept, QUIC peer connect, datagram traffic at DEBUG
- Log GameServer creation, tick loop start, JOIN accept/deny, config_update broadcasts, and input reception
2025-10-08 00:01:05 +03:00
Vladyslav Doloman
c97c5c4723 Dev: add simple HTTPS static server (serves client/). Enabled in WT mode by default via STATIC=1; uses same cert/key as QUIC/WT 2025-10-07 23:23:29 +03:00
Vladyslav Doloman
0ceea925cd Client: minimal browser WebTransport client (HTML/JS)
- index.html with connect UI, canvas renderer, overlay
- protocol.js with header, QUIC varint, JOIN/INPUT builders, STATE_FULL parser
- client.js to connect, send JOIN, read datagrams, render snakes/apples; basic input handling
2025-10-07 22:49:51 +03:00
Vladyslav Doloman
088c36396b WebTransport: add HTTP/3 WebTransport datagram server using aioquic; run mode switch via MODE=wt|quic|mem 2025-10-07 22:36:06 +03:00
Vladyslav Doloman
1f5ca02ca4 Repo hygiene: restore IDEAS.md; add .gitignore for __pycache__ and pyc; untrack cached bytecode 2025-10-07 20:54:56 +03:00
Vladyslav Doloman
e79c523034 Transport: integrate aioquic QUIC datagram server skeleton (QuicWebTransportServer) and QUIC mode in run.py
- New server/quic_transport.py using aioquic to accept QUIC connections and datagrams
- run.py: QUIC mode when QUIC_CERT/QUIC_KEY provided; else in-memory
- requirements.txt: aioquic + cryptography
2025-10-07 20:53:24 +03:00
Vladyslav Doloman
352da0ef54 Plan: refine networking — hybrid per-snake TLV selection, PART framing details, apples only in first part, and 1200-byte safety budget
- Clarify TLV selection between 2-bit and RLE per snake
- Define PART inner_type for STATE_FULL/STATE_DELTA and merging by update_id
- Apples included only in the first part (full and delta)
- Recommend ~1200-byte post-compression budget (<=1280 hard cap)
2025-10-07 20:46:16 +03:00
Vladyslav Doloman
e555762c64 Protocol/Server: hybrid body TLV (2-bit vs RLE), state_full body builder, and partitioned snapshot on join
- Protocol: RLE packing, TLV chooser, body_2bit_chunk helper, state_full_body builder
- Server: on join, build snapshot body and partition across PART packets if over MTU; include apples only in first part; chunk single oversized snake with 2-bit chunking
2025-10-07 20:36:01 +03:00
Vladyslav Doloman
967784542d Protocol/Server: implement STATE_DELTA + PART partitioning and per-tick minimal changes
- Protocol: SnakeDelta structure; build_state_delta(_body); build_part
- Server: compute per-snake changes (move/grow/blocked-shrink), apples diffs
- Partition large deltas by snake changes with apples in first part; use update_id
- Send STATE_DELTA when under MTU; else PART packets referencing STATE_DELTA
2025-10-07 20:30:48 +03:00
Vladyslav Doloman
991b8f3660 Server: per-tick simulation skeleton (inputs, movement, blocking/shrink, apple growth, wrap behavior) and basic state delta broadcast
- Input buffer rules: enqueue+consume with 180° guard (len>1)
- Movement: wrap per config; allow moving into own tail when it vacates
- Blocking: head holds, tail shrinks to min 1
- Apples: eat= grow; ensure target apples after tick
- Broadcast: send current snakes/apples as delta (placeholder for real deltas)
2025-10-07 20:27:43 +03:00
Vladyslav Doloman
7a5f2d8794 Server: implement JOIN/JOIN_ACK/JOIN_DENY handling, input parsing/relay, spawn logic, apples maintenance; fix InMemoryTransport to address specific peer; add state_full encoder
- Protocol: join parser, join_ack/deny builders, input parser, state_full builder
- Server: on_datagram dispatch, spawn per rules (prefer length 3 else 1), join deny if no cell, immediate input_broadcast relay
- Model: occupancy map and helpers
- Transport: deliver to specified peer in in-memory mode
2025-10-07 20:22:22 +03:00
Vladyslav Doloman
9043ba81c0 Server scaffold: protocol + config + transport abstraction + tick loop skeleton
- Protocol: PacketType, TLV Body types, QUIC varint, header, input_broadcast
  and config_update builders, 2-bit body bitpacking helper
- Config/model: live-config ServerConfig, basic GameState/Snake/Session
- Transport: InMemoryTransport placeholder and QUIC server stub
- Server: asyncio tick loop, periodic config_update broadcast, immediate
  input_broadcast relay; main entry and run.py
2025-10-07 20:02:28 +03:00
Vladyslav Doloman
65bf835b8d Plan: adopt live config updates + spawn/edge/compression/browser decisions
- Tick rate default 10 TPS; live config via config_update, periodic resend
- Spawn policy: prefer length 3 if a straight strip fits; else length 1; deny join if no free cell
- Apples per snake: default 1; min 1, max 12; cap 255 total; live config
- Wrap edges: live-configurable; head-only enforcement on transitions
- Compression: DEFLATE is handshake-only (restart + reconnect)
- Browser targets: latest Firefox required; latest Chrome desirable
- Protocol: join_deny; config_update packet; input_broadcast includes base_tick + rel offsets and apply_at_tick
2025-10-07 19:15:50 +03:00
18 changed files with 2014 additions and 22 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Byte-compiled / cache
__pycache__/
*.py[cod]
# Virtual environments
.venv/
venv/
# Certs (provide via env variables or local path)
*.pem
*.crt
*.key

View File

@@ -33,15 +33,17 @@
## Server Architecture (Python 3)
- Runtime: Python 3 with asyncio.
- Transport: WebTransport (HTTP/3 datagrams). Candidate library: aioquic for server-side H3 and datagrams.
- Model:
- Authoritative simulation on the server with a fixed tick rate (target 15-20 TPS; start with 15 for network headroom).
- Model:
- Authoritative simulation on the server with a fixed tick rate (default 10 TPS). Tick rate is reconfigurable at runtime by the server operator and changes are broadcast to clients.
- Each tick: process inputs, update snakes, resolve collisions (blocked/shrink), manage apples, generate state deltas.
- Entities:
- Field: width, height, random seed, apple set.
- Snake: id, name (<=16 chars), color id (0-31), deque of cells (head-first), current direction, input buffer, blocked flag, last-move tick, last-known client seq.
- Player session: transport bindings, last-received input seq per player, anti-spam counters.
- Spawn:
- New snake spawns at a random free cell with a random legal starting direction, length 3 by default (configurable).
- Spawn:
- New snake spawns at a random free cell with a random legal starting direction.
- Initial length policy: try to allocate a 3-cell straight strip (head + two body cells) in a legal direction; if not possible, spawn with length 1.
- If the field appears full, temporarily ignore apples as obstacles for the purpose of finding space. If no free single cell exists, deny the join and inform the client to wait.
- On apple eat: grow by 1; spawn a replacement apple at a free cell.
- Disconnect: Immediately remove snake and its length; apples persist. If all disconnect, ensure field contains 3 apples.
- Rate limiting: Cap input datagrams per second and buffer growth per client.
@@ -60,26 +62,31 @@
## Networking & Protocol
Constraints and goals:
- Use datagrams (unreliable, unordered). Include a packet sequence number for late-packet discard with wraparound handling.
- Keep payloads <=1280 bytes to avoid fragmentation; if larger, partition logically.
- Keep payloads <=1280 bytes to avoid fragmentation; prefer a safety budget of ~1200 bytes post-compression when partitioning.
- Support up to 32 concurrent players; names limited to 16 bytes UTF-8.
Header (all packets):
- ver (u8): protocol version.
- type (u8): packet type (join, join_ack, input, input_broadcast, state_delta, state_full, part, ping, pong, error).
- type (u8): packet type (join, join_ack, join_deny, input, input_broadcast, state_delta, state_full, part, config_update, ping, pong, error).
- flags (u8): bit flags (compression, is_last_part, etc.).
- seq (u16): sender's monotonically incrementing sequence number (wraps at 65535). Newer-than logic uses half-range window.
- Optional tick (u16): simulation tick the packet refers to (on server updates).
Handshake (join -> join_ack):
Handshake (join -> join_ack | join_deny):
- Client sends: desired name (<=16), optional preferred color id.
- Server replies: assigned player id (u8), color id (u8), field size (width u8, height u8), tick rate (u8), random seed (u32), palette, and initial full snapshot.
- Server replies (join_ack): assigned player id (u8), color id (u8), field size (width u8, height u8), tick rate (u8), wrap_edges (bool), apples_per_snake (u8), apples_cap (u8 up to 255), compression_mode (enum: none|deflate), random seed (u32), palette, and initial full snapshot.
- Server can reply (join_deny) with a reason (e.g., no free cell; please wait).
Inputs (client -> server):
- Packet input includes: last-acknowledged server seq (u16), local input_seq (u16), one or more direction events with timestamps or relative tick offsets. Client pre-filters per buffer rules.
Input broadcast (server -> clients):
- Upon receiving a player's input, the server immediately relays those input events to all other clients as input_broadcast packets.
- Contents: player_id (u8), the player's input_seq (u16), direction events, and apply_at_tick (u16) assigned by the server (typically current_tick+1) so clients can align predictions.
- Contents:
- player_id (u8), the player's input_seq (u16)
- base_tick (u16): server tick for alignment (typically current_tick)
- events: one or more direction changes, each with rel_tick_offset (u8/varint) from base_tick
- apply_at_tick (u16): optional absolute apply tick for convenience (both provided; clients may use either)
- Purpose: Enable client-side opponent prediction during late or lost state updates. Broadcasts are small; apply rate limits if needed.
State updates (server -> client):
@@ -100,21 +107,25 @@ State updates (server -> client):
- Chunked variants (T=0x10, 0x11): used only when a single snake must be split.
- Prefix V with `start_index` (u16, first direction offset from head) and `dirs_in_chunk` (u16); then the encoding for that range (2-bit or RLE respectively).
- Client buffers chunks by `(update_id, snake_id)` and assembles when the full [0..len-2] range is present; then applies.
- Selection: server chooses per snake whichever TLV (2-bit or RLE) yields fewer bytes.
Compression:
- Primary: domain-specific packing (bits + RLE for segments and apples), then optional DEFLATE flag (flags.compressed=1) if still large.
- Primary: domain-specific packing (bits + RLE for segments and apples).
- Optional global DEFLATE mode (server-configured): when enabled, server may set flags.compressed=1 and apply DEFLATE to payloads; this mode is negotiated in join_ack and is not changed on the fly (server restart required; clients must reconnect).
- Client decompresses if set; otherwise reads packed structs directly.
Packet partitioning (if >1280 bytes after compression):
Packet partitioning (if >~12001280 bytes after compression):
- Apply to state_full (join and periodic recovery) and large state_delta updates.
- Goal: avoid IP fragmentation. Ensure each compressed datagram payload is <1280 bytes.
- Goal: avoid IP fragmentation. Ensure each compressed datagram payload is <1280 bytes (use ~1200 target for headroom).
- Strategy (whole-snake first):
- Sort snakes by estimated compressed size (head + body TLV) descending.
- Greedily pack one or more complete snakes per packet while keeping the compressed payload <1280 bytes.
- If a snake does not fit with others, send it alone if it fits <1280 bytes.
- If a single snake still exceeds 1280 bytes by itself, split that snake into multiple similar-sized chunks using the chunked TLV types.
- Framing:
- Each part carries `update_id` (u16), `part_index` (u8), `parts_total` (u8), and a sequence of per-snake TLVs (body_2bit/body_rle) and/or chunk TLVs (body_2bit_chunk/body_rle_chunk).
- Each PART carries `update_id` (u16), `part_index` (u8), `parts_total` (u8), and `inner_type` (u8: STATE_FULL or STATE_DELTA) followed by the chunk payload.
- For STATE_FULL: the chunk payload is `snakes_count + [per-snake records] + apples`. Include apples only in the first part; clients merge across parts using `update_id`.
- For STATE_DELTA: the chunk payload is a subset of per-snake changes; include `apples_added/removed` only in the first part; clients merge across parts using `update_id`.
- Clients apply complete per-snake TLVs immediately. Chunk TLVs are buffered and assembled by `(update_id, snake_id)` using `start_index` and `dirs_in_chunk` before applying.
Sequence wrap & ordering:
@@ -122,6 +133,11 @@ Sequence wrap & ordering:
- Clients ignore updates older-than last applied per-sender.
- For input_broadcast, deduplicate per (player_id, input_seq) and apply in order per player; if apply_at_tick is in the past, apply immediately up to current tick.
Live config updates (server -> clients):
- Packet type: config_update, sent on change and periodically (low frequency).
- Fields: tick_rate (u8), wrap_edges (bool), apples_per_snake (u8), apples_cap (u8 up to 255).
- Clients apply changes at the next tick boundary. Compression mode is not changed via config_update (handshake-only).
## Simulation Details
- Tick order per tick:
1) Incorporate at most one valid buffered input per snake (with 180-degree rule).
@@ -129,7 +145,7 @@ Sequence wrap & ordering:
3) If blocked=true: shrink tail by 1 (to min length 1). If the blocking cell becomes free (due to own shrink or others moving), the next tick's step in current direction proceeds.
4) If moving into an apple: grow by 1 (do not shrink tail that tick) and respawn an apple.
5) Emit per-snake delta (move, grow, or shrink) and global apple changes.
- Blocking detection: walls (0..width-1, 0..height-1), any occupied snake cell (including heads and tails that are not about to vacate this tick).
- Blocking detection: walls (0..width-1, 0..height-1), any occupied snake cell (including heads and tails that are not about to vacate this tick). Wrap-around rules are applied only to the head; if wrap_edges is turned off while a body segment is mid-edge traversal, allow the body to continue, but prevent the head from crossing walls thereafter.
- 180-degree turn allowance when length == 1 only.
### Client-side Opponent Prediction Heuristics
@@ -147,7 +163,11 @@ Sequence wrap & ordering:
- Players: max 32 (ids 0..31); deny joins beyond capacity.
- Names: UTF-8, truncate to 16 bytes; filter control chars.
- Field: default 60x40; min 3x3; max 255x255; provided by server in join_ack.
- Tick rate: default 15 TPS; configurable 10-30 TPS.
- Tick rate: default 10 TPS; server-controlled, reconfigurable on the fly via config_update (reasonable range 5-30 TPS).
- Apples per snake: default 1; server-controlled, reconfigurable on the fly; min 1/snake, max 12/snake; total apples capped at 255.
- Wrap-around edges: default off (walls are blocking); server-controlled, reconfigurable on the fly; head obeys current rule; legacy body traversal allowed during transitions.
- Compression (DEFLATE): global mode negotiated at join; requires server restart to change; clients must reconnect; when enabled server may set flags.compressed=1.
- Browser targets: must work on latest Firefox; latest Chrome is optional but desirable; others optional.
## Error Handling & Resilience
- Invalid packets: drop and optionally send error with code.
@@ -172,13 +192,9 @@ Sequence wrap & ordering:
8) Polishing: error messages, reconnect, telemetry/logging, Docker/dev scripts.
## Open Questions
- Exact tick rate target and interpolation strategy on client.
- Initial snake length on spawn (3 is suggested) and spawn retry rules in crowded fields.
- Apple count baseline when players are present (fixed count vs proportional to players vs area).
- Whether to allow wrap-around at field edges (currently: walls are blocking).
- Compression choice tradeoffs: custom bitpacking only vs optional DEFLATE.
- Minimum viable browser targets (WebTransport support varies).
- Should input_broadcast include server-assigned apply_at_tick or relative tick offsets only?
- Interpolation/smoothing specifics on client for variable tick rates.
- Any additional anti-cheat measures for input_broadcast misuse.
- Exact UI/UX for join denial when field is full (messaging/timing).
## Next Steps
- Validate the protocol choices and mechanics with stakeholders.

163
client/client.js Normal file
View File

@@ -0,0 +1,163 @@
import { PacketType, Direction, packHeader, parseHeader, buildJoin, buildInput, parseJoinAck, parseStateFullBody } from './protocol.js';
const ui = {
url: document.getElementById('url'),
hash: document.getElementById('hash'),
name: document.getElementById('name'),
connect: document.getElementById('connect'),
status: document.getElementById('status'),
overlay: document.getElementById('overlay'),
canvas: document.getElementById('view'),
};
let transport = null;
let writer = null;
let seq = 0;
let lastServerSeq = 0;
let inputSeq = 0;
let playerId = null;
let colorId = null;
let fieldW = 60, fieldH = 40;
let tickRate = 10;
const snakes = new Map(); // id -> {len, head:[x,y], dirs:[]}
let apples = [];
function setStatus(t) { ui.status.textContent = t; }
function nextSeq() { seq = (seq + 1) & 0xffff; return seq; }
function colorForId(id) {
const palette = [
'#e6194B','#3cb44b','#ffe119','#4363d8','#f58231','#911eb4','#46f0f0','#f032e6',
'#bcf60c','#fabebe','#008080','#e6beff','#9A6324','#fffac8','#800000','#aaffc3',
'#808000','#ffd8b1','#000075','#808080','#ffffff','#000000'
];
return palette[id % palette.length];
}
function decodeSnakeCells(s) {
const cells = [];
let x = s.hx, y = s.hy;
cells.push([x, y]);
for (let d of s.dirs) {
if (d === Direction.UP) y -= 1;
else if (d === Direction.RIGHT) x += 1;
else if (d === Direction.DOWN) y += 1;
else if (d === Direction.LEFT) x -= 1;
cells.push([x, y]);
}
return cells;
}
function render() {
const ctx = ui.canvas.getContext('2d');
const W = ui.canvas.width = window.innerWidth;
const H = ui.canvas.height = window.innerHeight;
ctx.fillStyle = '#111'; ctx.fillRect(0,0,W,H);
const cell = Math.floor(Math.min(W / fieldW, H / fieldH));
const ox = Math.floor((W - cell*fieldW)/2);
const oy = Math.floor((H - cell*fieldH)/2);
// grid (optional)
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
for (let x=0;x<=fieldW;x++){ ctx.beginPath(); ctx.moveTo(ox + x*cell, oy); ctx.lineTo(ox + x*cell, oy + fieldH*cell); ctx.stroke(); }
for (let y=0;y<=fieldH;y++){ ctx.beginPath(); ctx.moveTo(ox, oy + y*cell); ctx.lineTo(ox + fieldW*cell, oy + y*cell); ctx.stroke(); }
// apples
ctx.fillStyle = '#f00';
for (const [ax, ay] of apples) {
ctx.fillRect(ox + ax*cell, oy + ay*cell, cell, cell);
}
// snakes
for (const [sid, s] of snakes) {
const cells = decodeSnakeCells({hx:s.hx, hy:s.hy, dirs:s.dirs});
ctx.fillStyle = colorForId(sid);
for (const [x,y] of cells) ctx.fillRect(ox + x*cell, oy + y*cell, cell, cell);
}
requestAnimationFrame(render);
}
async function readLoop() {
try {
for await (const datagram of transport.datagrams.readable) {
const dv = new DataView(datagram.buffer, datagram.byteOffset, datagram.byteLength);
if (dv.byteLength < 5) continue;
const type = dv.getUint8(1);
lastServerSeq = dv.getUint16(3);
const expectTick = (type === PacketType.STATE_FULL || type === PacketType.STATE_DELTA || type === PacketType.PART || type === PacketType.CONFIG_UPDATE);
const [ver, ptype, flags, seq, tick, off0] = parseHeader(dv, 0, expectTick);
let off = off0;
switch (ptype) {
case PacketType.JOIN_ACK: {
const info = parseJoinAck(dv, off);
playerId = info.playerId; colorId = info.colorId; fieldW = info.width; fieldH = info.height; tickRate = info.tickRate;
ui.overlay.textContent = 'connected — press space to join';
break;
}
case PacketType.JOIN_DENY: {
// read reason
const [len, p] = quicVarintDecode(dv, off); off = p;
const bytes = new Uint8Array(dv.buffer, dv.byteOffset + off, len);
const reason = new TextDecoder().decode(bytes);
setStatus('Join denied: ' + reason);
break;
}
case PacketType.STATE_FULL: {
const { snakes: sn, apples: ap } = parseStateFullBody(dv, off);
snakes.clear();
for (const s of sn) snakes.set(s.id, { len: s.len, hx: s.hx, hy: s.hy, dirs: s.dirs });
apples = ap;
ui.overlay.style.display = snakes.size ? 'none' : 'flex';
break;
}
case PacketType.STATE_DELTA: {
// Minimal: ignore detailed deltas for now; rely on periodic full (good enough for a demo)
break;
}
default: break;
}
}
} catch (e) {
setStatus('Read error: ' + e);
}
}
async function connectWT() {
const url = ui.url.value.trim();
const hashHex = ui.hash.value.trim();
setStatus('connecting...');
const opts = {};
if (hashHex) {
const bytes = new Uint8Array(hashHex.match(/.{1,2}/g).map(b => parseInt(b,16)));
opts.serverCertificateHashes = [{ algorithm: 'sha-256', value: bytes }];
}
const wt = new WebTransport(url, opts);
await wt.ready;
transport = wt;
writer = wt.datagrams.writable.getWriter();
setStatus('connected');
readLoop();
requestAnimationFrame(render);
// Send JOIN immediately (spectator → player upon space handled below)
const pkt = buildJoin(1, nextSeq(), ui.name.value.trim());
await writer.write(pkt);
}
function dirFromKey(e) {
if (e.key === 'ArrowUp' || e.key === 'w') return Direction.UP;
if (e.key === 'ArrowRight' || e.key === 'd') return Direction.RIGHT;
if (e.key === 'ArrowDown' || e.key === 's') return Direction.DOWN;
if (e.key === 'ArrowLeft' || e.key === 'a') return Direction.LEFT;
return null;
}
async function onKey(e) {
const d = dirFromKey(e);
if (d == null) return;
if (!writer) return;
const pkt = buildInput(1, nextSeq(), lastServerSeq, (inputSeq = (inputSeq + 1) & 0xffff), 0, [{ rel: 0, dir: d }]);
try { await writer.write(pkt); } catch (err) { /* ignore */ }
}
ui.connect.onclick = () => { connectWT().catch(e => setStatus('connect failed: ' + e)); };
window.addEventListener('keydown', onKey);
window.addEventListener('resize', render);

30
client/index.html Normal file
View File

@@ -0,0 +1,30 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Multiplayer Snake (WebTransport)</title>
<style>
html, body { height: 100%; margin: 0; background: #111; color: #ddd; font-family: system-ui, sans-serif; }
#ui { position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.6); padding: 10px; border-radius: 6px; }
label { display: block; margin: 4px 0; font-size: 12px; }
input { width: 360px; }
#overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; color: rgba(255,255,255,0.85); font-size: 24px; font-weight: 600; }
canvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; }
</style>
</head>
<body>
<div id="ui">
<label>Server URL (WebTransport): <input id="url" value="https://localhost:4433/"/></label>
<label>SHA-256 Cert Hash (hex, optional for self-signed): <input id="hash" placeholder="e.g., aabbcc..."/></label>
<label>Name: <input id="name" value="guest"/></label>
<button id="connect">Connect</button>
<span id="status"></span>
</div>
<div id="overlay">press space to join</div>
<canvas id="view"></canvas>
<script src="protocol.js"></script>
<script src="client.js"></script>
</body>
</html>

160
client/protocol.js Normal file
View File

@@ -0,0 +1,160 @@
// Minimal protocol helpers in JS (match server/protocol.py)
export const PacketType = {
JOIN: 0,
JOIN_ACK: 1,
JOIN_DENY: 2,
INPUT: 3,
INPUT_BROADCAST: 4,
STATE_DELTA: 5,
STATE_FULL: 6,
PART: 7,
CONFIG_UPDATE: 8,
PING: 9,
PONG: 10,
ERROR: 11,
};
export const Direction = { UP: 0, RIGHT: 1, DOWN: 2, LEFT: 3 };
export function quicVarintEncode(n) {
if (n <= 0x3f) return new Uint8Array([n & 0x3f]);
if (n <= 0x3fff) {
const v = 0x4000 | n;
return new Uint8Array([(v >> 8) & 0xff, v & 0xff]);
}
if (n <= 0x3fffffff) {
const v = 0x80000000 | n;
return new Uint8Array([(v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff]);
}
throw new Error("varint too large for demo");
}
export function quicVarintDecode(view, offset) {
const first = view.getUint8(offset);
const prefix = first >> 6;
if (prefix === 0) return [first & 0x3f, offset + 1];
if (prefix === 1) return [view.getUint16(offset) & 0x3fff, offset + 2];
if (prefix === 2) return [view.getUint32(offset) & 0x3fffffff, offset + 4];
throw new Error("8-byte varint not supported in demo");
}
export function packHeader(ver, type, flags, seq, tick /* or null */) {
const len = tick == null ? 5 : 7;
const buf = new ArrayBuffer(len);
const dv = new DataView(buf);
dv.setUint8(0, ver & 0xff);
dv.setUint8(1, type & 0xff);
dv.setUint8(2, flags & 0xff);
dv.setUint16(3, seq & 0xffff);
if (tick != null) dv.setUint16(5, tick & 0xffff);
return new Uint8Array(buf);
}
export function parseHeader(dv, offset = 0, expectTick = false) {
if (dv.byteLength - offset < 5) throw new Error("small header");
const ver = dv.getUint8(offset);
const type = dv.getUint8(offset + 1);
const flags = dv.getUint8(offset + 2);
const seq = dv.getUint16(offset + 3);
let tick = null;
let off = offset + 5;
if (expectTick) {
tick = dv.getUint16(off);
off += 2;
}
return [ver, type, flags, seq, tick, off];
}
export function buildJoin(ver, seq, name, preferredColor = null) {
const enc = new TextEncoder();
const nb = enc.encode(name);
const nameLen = quicVarintEncode(nb.length);
const header = packHeader(ver, PacketType.JOIN, 0, seq, null);
const extra = preferredColor == null ? new Uint8Array([]) : new Uint8Array([preferredColor & 0xff]);
const out = new Uint8Array(header.length + nameLen.length + nb.length + extra.length);
out.set(header, 0);
out.set(nameLen, header.length);
out.set(nb, header.length + nameLen.length);
out.set(extra, header.length + nameLen.length + nb.length);
return out;
}
export function buildInput(ver, seq, ackSeq, inputSeq, baseTick, events /* [{rel, dir}] */) {
const header = packHeader(ver, PacketType.INPUT, 0, seq, null);
const evParts = [];
evParts.push(...[ackSeq >> 8, ackSeq & 0xff, inputSeq >> 8, inputSeq & 0xff, baseTick >> 8, baseTick & 0xff]);
const evCount = quicVarintEncode(events.length);
const chunks = [header, new Uint8Array(evParts), evCount];
for (const e of events) {
const off = quicVarintEncode(e.rel);
chunks.push(off, new Uint8Array([e.dir & 0x03]));
}
const size = chunks.reduce((s, a) => s + a.length, 0);
const out = new Uint8Array(size);
let p = 0;
for (const c of chunks) { out.set(c, p); p += c.length; }
return out;
}
export function parseJoinAck(dv, off) {
const playerId = dv.getUint8(off); off += 1;
const colorId = dv.getUint8(off); off += 1;
const width = dv.getUint8(off); off += 1;
const height = dv.getUint8(off); off += 1;
const tickRate = dv.getUint8(off); off += 1;
const wrapEdges = dv.getUint8(off) !== 0; off += 1;
const applesPerSnake = dv.getUint8(off); off += 1;
const applesCap = dv.getUint8(off); off += 1;
const compressionMode = dv.getUint8(off); off += 1;
return { playerId, colorId, width, height, tickRate, wrapEdges, applesPerSnake, applesCap, compressionMode, off };
}
export function parseStateFullBody(dv, off) {
const snakes = [];
// snakes count
let [n, p] = quicVarintDecode(dv, off); off = p;
for (let i = 0; i < n; i++) {
const id = dv.getUint8(off); off += 1;
const len = dv.getUint16(off); off += 2;
const hx = dv.getUint8(off); off += 1;
const hy = dv.getUint8(off); off += 1;
// TLV
let [t, p1] = quicVarintDecode(dv, off); off = p1;
let [L, p2] = quicVarintDecode(dv, off); off = p2;
const tStart = off;
const tEnd = off + L;
let dirs = [];
if (t === 0 /* 2-bit */) {
const needed = Math.max(0, (len - 1));
let bits = 0, acc = 0, di = 0;
for (let iB = off; iB < tEnd && di < needed; iB++) {
acc |= dv.getUint8(iB) << bits;
bits += 8;
while (bits >= 2 && di < needed) {
dirs.push(acc & 0x03);
acc >>= 2; bits -= 2; di++;
}
}
} else if (t === 1 /* RLE */) {
let countAccum = 0;
while (off < tEnd && countAccum < (len - 1)) {
const dir = dv.getUint8(off); off += 1;
let c; [c, off] = quicVarintDecode(dv, off);
for (let k = 0; k < c; k++) dirs.push(dir & 0x03);
countAccum += c;
}
} else {
// chunk types not expected in full body for initial join; skip
off = tEnd;
}
off = tEnd;
snakes.push({ id, len, hx, hy, dirs });
}
// apples
let m; [m, off] = quicVarintDecode(dv, off);
const apples = [];
for (let i = 0; i < m; i++) { apples.push([dv.getUint8(off++), dv.getUint8(off++)]); }
return { snakes, apples, off };
}

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
aioquic>=1.2.0
cryptography>=41.0.0

138
run.py Normal file
View File

@@ -0,0 +1,138 @@
import asyncio
import os
import logging
import sys
from server.server import GameServer
from server.config import ServerConfig
async def _run_tasks_with_optional_timeout(tasks):
"""Await tasks, optionally honoring RUN_SECONDS env var to cancel after a timeout."""
timeout_s = os.environ.get("RUN_SECONDS")
if not timeout_s:
await asyncio.gather(*tasks)
return
try:
await asyncio.wait_for(asyncio.gather(*tasks), timeout=float(timeout_s))
except asyncio.TimeoutError:
logging.info("Timeout reached (RUN_SECONDS=%s); stopping server tasks...", timeout_s)
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def run_in_memory():
from server.transport import InMemoryTransport
cfg = ServerConfig()
server = GameServer(transport=InMemoryTransport(lambda d, p: server.on_datagram(d, p)), config=cfg)
tasks = [asyncio.create_task(server.transport.run()), asyncio.create_task(server.tick_loop())]
await _run_tasks_with_optional_timeout(tasks)
async def run_quic():
from server.quic_transport import QuicWebTransportServer
cfg = ServerConfig()
host = os.environ.get("QUIC_HOST", "0.0.0.0")
port = int(os.environ.get("QUIC_PORT", "4433"))
cert = os.environ["QUIC_CERT"]
key = os.environ["QUIC_KEY"]
server = GameServer(transport=QuicWebTransportServer(host, port, cert, key, lambda d, p: server.on_datagram(d, p)), config=cfg)
tasks = [asyncio.create_task(server.transport.run()), asyncio.create_task(server.tick_loop())]
await _run_tasks_with_optional_timeout(tasks)
async def run_webtransport():
from server.webtransport_server import WebTransportServer
from server.static_server import start_https_static
cfg = ServerConfig()
host = os.environ.get("WT_HOST", os.environ.get("QUIC_HOST", "0.0.0.0"))
port = int(os.environ.get("WT_PORT", os.environ.get("QUIC_PORT", "4433")))
cert = os.environ.get("WT_CERT") or os.environ["QUIC_CERT"]
key = os.environ.get("WT_KEY") or os.environ["QUIC_KEY"]
# Optional static HTTPS server for client assets
static = os.environ.get("STATIC", "1")
static_host = os.environ.get("STATIC_HOST", host)
static_port = int(os.environ.get("STATIC_PORT", "8443"))
static_root = os.environ.get("STATIC_ROOT", "client")
httpd = None
if static == "1":
httpd, _t = start_https_static(static_host, static_port, cert, key, static_root)
print(f"HTTPS static server: https://{static_host}:{static_port}/ serving '{static_root}'")
server = GameServer(transport=WebTransportServer(host, port, cert, key, lambda d, p: server.on_datagram(d, p)), config=cfg)
print(f"WebTransport server: https://{host}:{port}/ (HTTP/3)")
try:
tasks = [asyncio.create_task(server.transport.run()), asyncio.create_task(server.tick_loop())]
await _run_tasks_with_optional_timeout(tasks)
finally:
if httpd is not None:
httpd.shutdown()
if __name__ == "__main__":
try:
if any(a in ("-h", "--help") for a in sys.argv[1:]):
print(
"Usage: python run.py [--mode mem|quic|wt|net] [--log-level LEVEL] [--run-seconds N]\n"
" TLS (for wt/quic): set QUIC_CERT/QUIC_KEY or WT_CERT/WT_KEY env vars\n"
" WT static server (MODE=wt): STATIC=1 [STATIC_HOST/PORT/ROOT]\n"
"Examples:\n MODE=wt QUIC_CERT=cert.pem QUIC_KEY=key.pem python run.py\n MODE=mem python run.py"
)
sys.exit(0)
# Logging setup
level = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(level=getattr(logging, level, logging.INFO), format="[%(asctime)s] %(levelname)s: %(message)s")
mode = os.environ.get("MODE", "mem").lower()
if mode == "wt":
logging.info("Starting in WebTransport mode")
asyncio.run(run_webtransport())
elif mode == "quic":
logging.info("Starting in QUIC datagram mode")
asyncio.run(run_quic())
elif mode == "net":
logging.info("Starting in combined WebTransport+QUIC mode")
from server.webtransport_server import WebTransportServer
from server.quic_transport import QuicWebTransportServer
from server.multi_transport import MultiTransport
cfg = ServerConfig()
host_wt = os.environ.get("WT_HOST", os.environ.get("QUIC_HOST", "0.0.0.0"))
port_wt = int(os.environ.get("WT_PORT", os.environ.get("QUIC_PORT", "4433")))
host_quic = os.environ.get("QUIC_HOST", host_wt)
port_quic = int(os.environ.get("QUIC_PORT", "4443"))
cert = os.environ.get("WT_CERT") or os.environ.get("QUIC_CERT")
key = os.environ.get("WT_KEY") or os.environ.get("QUIC_KEY")
if not cert or not key:
raise SystemExit("WT/QUIC cert/key required: set WT_CERT/WT_KEY or QUIC_CERT/QUIC_KEY")
async def _run_net():
server: GameServer
wt = WebTransportServer(host_wt, port_wt, cert, key, lambda d, p: server.on_datagram(d, p))
qu = QuicWebTransportServer(host_quic, port_quic, cert, key, lambda d, p: server.on_datagram(d, p))
m = MultiTransport(wt, qu)
server = GameServer(transport=m, config=cfg)
await asyncio.gather(m.run(), server.tick_loop())
asyncio.run(_run_net())
else:
logging.info("Starting in in-memory transport mode")
asyncio.run(run_in_memory())
except KeyboardInterrupt:
pass
async def _run_tasks_with_optional_timeout(tasks):
"""Await tasks, optionally honoring RUN_SECONDS env var to cancel after a timeout."""
timeout_s = os.environ.get("RUN_SECONDS")
if not timeout_s:
await asyncio.gather(*tasks)
return
try:
await asyncio.wait_for(asyncio.gather(*tasks), timeout=float(timeout_s))
except asyncio.TimeoutError:
logging.info("Timeout reached (RUN_SECONDS=%s); stopping server tasks...", timeout_s)
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)

2
server/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
__all__ = []

28
server/config.py Normal file
View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class ServerConfig:
width: int = 60
height: int = 40
tick_rate: int = 10 # TPS, server-controlled, live-configurable
wrap_edges: bool = False # default off
apples_per_snake: int = 1 # min 1, max 12
apples_cap: int = 255 # absolute cap
compression_mode: str = "none" # "none" | "deflate" (handshake-only)
players_max: int = 32
def validate_runtime(self) -> None:
if not (3 <= self.width <= 255 and 3 <= self.height <= 255):
raise ValueError("field size must be within 3..255")
if not (5 <= self.tick_rate <= 30):
raise ValueError("tick_rate must be 5..30 TPS")
if not (1 <= self.apples_per_snake <= 12):
raise ValueError("apples_per_snake must be 1..12")
if not (0 <= self.apples_cap <= 255):
raise ValueError("apples_cap must be 0..255")
if self.compression_mode not in ("none", "deflate"):
raise ValueError("compression_mode must be 'none' or 'deflate'")

77
server/model.py Normal file
View File

@@ -0,0 +1,77 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Deque, Dict, List, Optional, Tuple
from collections import deque
from .protocol import Direction
Coord = Tuple[int, int]
@dataclass
class Snake:
snake_id: int
head: Coord
direction: Direction
body: Deque[Coord] = field(default_factory=deque) # includes head at index 0
input_buf: Deque[Direction] = field(default_factory=deque)
blocked: bool = False
@property
def length(self) -> int:
return len(self.body)
def enqueue_direction(self, new_dir: Direction, capacity: int = 3) -> None:
"""Apply input buffer rules: size<=capacity, replace last on overflow/opposite, drop duplicates."""
last_dir = self.input_buf[-1] if self.input_buf else self.direction
# Drop duplicates
if int(new_dir) == int(last_dir):
return
# Opposite of last? replace last
if (int(new_dir) ^ int(last_dir)) == 2: # 0^2,1^3,2^0,3^1 are opposites
if self.input_buf:
self.input_buf[-1] = new_dir
else:
# No buffered inputs; just add new_dir (consumption will handle 180° rule)
self.input_buf.append(new_dir)
return
# Normal append with overflow replacement
if len(self.input_buf) >= capacity:
self.input_buf[-1] = new_dir
else:
self.input_buf.append(new_dir)
@dataclass
class PlayerSession:
player_id: int
name: str
color_id: int
peer: object # transport-specific handle
input_seq: int = 0
@dataclass
class GameState:
width: int
height: int
snakes: Dict[int, Snake] = field(default_factory=dict)
apples: List[Coord] = field(default_factory=list)
tick: int = 0
occupancy: Dict[Coord, Tuple[int, int]] = field(default_factory=dict) # cell -> (snake_id, index)
def in_bounds(self, x: int, y: int) -> bool:
return 0 <= x < self.width and 0 <= y < self.height
def cell_free(self, x: int, y: int) -> bool:
return (x, y) not in self.occupancy
def occupy_snake(self, snake: Snake) -> None:
for idx, (cx, cy) in enumerate(snake.body):
self.occupancy[(cx, cy)] = (snake.snake_id, idx)
def clear_snake(self, snake: Snake) -> None:
for (cx, cy) in list(snake.body):
self.occupancy.pop((cx, cy), None)

26
server/multi_transport.py Normal file
View File

@@ -0,0 +1,26 @@
from __future__ import annotations
import asyncio
from .transport import DatagramServerTransport, TransportPeer
class MultiTransport(DatagramServerTransport):
"""Run both WebTransport and QUIC transports and route sends.
Inbound datagrams share the same on_datagram callback from GameServer,
so GameServer sees a unified source of datagrams.
"""
def __init__(self, wt_transport: DatagramServerTransport, quic_transport: DatagramServerTransport):
self._wt = wt_transport
self._quic = quic_transport
async def send(self, data: bytes, peer: TransportPeer) -> None:
# WebTransport peers are tuples (proto, flow_id); QUIC uses protocol instance
if isinstance(peer.addr, tuple) and len(peer.addr) == 2:
await self._wt.send(data, peer)
else:
await self._quic.send(data, peer)
async def run(self) -> None:
await asyncio.gather(self._wt.run(), self._quic.run())

428
server/protocol.py Normal file
View File

@@ -0,0 +1,428 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum
from typing import Iterable, List, Sequence, Tuple
class PacketType(IntEnum):
JOIN = 0
JOIN_ACK = 1
JOIN_DENY = 2
INPUT = 3
INPUT_BROADCAST = 4
STATE_DELTA = 5
STATE_FULL = 6
PART = 7
CONFIG_UPDATE = 8
PING = 9
PONG = 10
ERROR = 11
class BodyTLV(IntEnum):
BODY_2BIT = 0x00
BODY_RLE = 0x01
BODY_2BIT_CHUNK = 0x10
BODY_RLE_CHUNK = 0x11
class Direction(IntEnum):
UP = 0
RIGHT = 1
DOWN = 2
LEFT = 3
def quic_varint_encode(value: int) -> bytes:
"""Encode QUIC varint (RFC 9000)."""
if value < 0:
raise ValueError("varint must be non-negative")
if value <= 0x3F: # 6 bits, 1 byte, 00xx xxxx
return bytes([value & 0x3F])
if value <= 0x3FFF: # 14 bits, 2 bytes, 01xx xxxx
v = 0x4000 | value
return v.to_bytes(2, "big")
if value <= 0x3FFFFFFF: # 30 bits, 4 bytes, 10xx xxxx
v = 0x80000000 | value
return v.to_bytes(4, "big")
if value <= 0x3FFFFFFFFFFFFFFF: # 62 bits, 8 bytes, 11xx xxxx
v = 0xC000000000000000 | value
return v.to_bytes(8, "big")
raise ValueError("varint too large")
def quic_varint_decode(buf: bytes, offset: int = 0) -> Tuple[int, int]:
"""Decode QUIC varint starting at offset. Returns (value, next_offset)."""
first = buf[offset]
prefix = first >> 6
if prefix == 0:
return (first & 0x3F, offset + 1)
if prefix == 1:
v = int.from_bytes(buf[offset : offset + 2], "big") & 0x3FFF
return (v, offset + 2)
if prefix == 2:
v = int.from_bytes(buf[offset : offset + 4], "big") & 0x3FFFFFFF
return (v, offset + 4)
v = int.from_bytes(buf[offset : offset + 8], "big") & 0x3FFFFFFFFFFFFFFF
return (v, offset + 8)
def pack_header(version: int, ptype: PacketType, flags: int, seq: int, tick: int | None) -> bytes:
"""Pack common header fields.
Layout:
- ver: u8
- type: u8
- flags: u8
- seq: u16 (network order)
- tick: optional u16
"""
if not (0 <= version <= 255):
raise ValueError("version out of range")
if not (0 <= flags <= 255):
raise ValueError("flags out of range")
if not (0 <= seq <= 0xFFFF):
raise ValueError("seq out of range")
parts = bytearray()
parts.append(version & 0xFF)
parts.append(int(ptype) & 0xFF)
parts.append(flags & 0xFF)
parts.extend(seq.to_bytes(2, "big"))
if tick is not None:
if not (0 <= tick <= 0xFFFF):
raise ValueError("tick out of range")
parts.extend(tick.to_bytes(2, "big"))
return bytes(parts)
def unpack_header(buf: bytes, expect_tick: bool) -> Tuple[int, PacketType, int, int, int | None, int]:
"""Unpack header; returns (ver, type, flags, seq, tick, next_offset)."""
if len(buf) < 5:
raise ValueError("buffer too small for header")
ver = buf[0]
ptype = PacketType(buf[1])
flags = buf[2]
seq = int.from_bytes(buf[3:5], "big")
off = 5
tick = None
if expect_tick:
if len(buf) < 7:
raise ValueError("buffer too small for tick")
tick = int.from_bytes(buf[5:7], "big")
off = 7
return ver, ptype, flags, seq, tick, off
# Message builders/parsers (subset)
def build_config_update(
*,
version: int,
seq: int,
tick: int,
tick_rate: int,
wrap_edges: bool,
apples_per_snake: int,
apples_cap: int,
) -> bytes:
header = pack_header(version, PacketType.CONFIG_UPDATE, 0, seq, tick)
body = bytearray()
body.append(tick_rate & 0xFF) # u8
body.append(1 if wrap_edges else 0) # bool u8
body.append(apples_per_snake & 0xFF) # u8
body.append(apples_cap & 0xFF) # u8
return header + bytes(body)
@dataclass
class InputEvent:
rel_tick_offset: int # relative to base_tick
direction: Direction
def build_input_broadcast(
*,
version: int,
seq: int,
tick: int,
player_id: int,
input_seq: int,
base_tick: int,
events: Sequence[InputEvent],
apply_at_tick: int | None = None,
) -> bytes:
header = pack_header(version, PacketType.INPUT_BROADCAST, 0, seq, tick)
body = bytearray()
body.append(player_id & 0xFF)
body.extend(int(input_seq & 0xFFFF).to_bytes(2, "big"))
body.extend(int(base_tick & 0xFFFF).to_bytes(2, "big"))
# number of events as QUIC varint
body.extend(quic_varint_encode(len(events)))
for ev in events:
# rel offset as QUIC varint, direction as u8 (low 2 bits)
body.extend(quic_varint_encode(int(ev.rel_tick_offset)))
body.append(int(ev.direction) & 0x03)
# Optional absolute apply_at_tick presence flag + value
if apply_at_tick is None:
body.append(0)
else:
body.append(1)
body.extend(int(apply_at_tick & 0xFFFF).to_bytes(2, "big"))
return header + bytes(body)
def pack_body_tlv(t: BodyTLV, payload: bytes) -> bytes:
return quic_varint_encode(int(t)) + quic_varint_encode(len(payload)) + payload
def bitpack_2bit_directions(directions: Iterable[Direction]) -> bytes:
out = bytearray()
acc = 0
bits = 0
for d in directions:
acc |= (int(d) & 0x03) << bits
bits += 2
if bits >= 8:
out.append(acc & 0xFF)
acc = acc >> 8
bits -= 8
if bits:
out.append(acc & 0xFF) # zero-padded high bits
return bytes(out)
def rle_pack_directions(directions: Sequence[Direction]) -> bytes:
"""Pack directions as runs: dir (u8) + count (QUIC varint >=1)."""
if not directions:
return b""
out = bytearray()
run_dir = directions[0]
run_len = 1
for d in directions[1:]:
if d == run_dir:
run_len += 1
else:
out.append(int(run_dir) & 0xFF)
out.extend(quic_varint_encode(run_len))
run_dir = d
run_len = 1
out.append(int(run_dir) & 0xFF)
out.extend(quic_varint_encode(run_len))
return bytes(out)
def build_body_tlv_for_dirs(directions: Sequence[Direction]) -> bytes:
"""Choose the more compact of 2-bit stream vs RLE for given directions and return its TLV."""
# 2-bit body size
two_bit_payload = bitpack_2bit_directions(directions)
two_bit = pack_body_tlv(BodyTLV.BODY_2BIT, two_bit_payload)
# RLE
rle_payload = rle_pack_directions(directions)
rle = pack_body_tlv(BodyTLV.BODY_RLE, rle_payload)
return two_bit if len(two_bit) <= len(rle) else rle
# Join / Ack / Deny
def build_join(name_utf8: bytes, preferred_color_id: int | None = None) -> bytes:
# Client-side helper (not used in server)
raise NotImplementedError
def parse_join(buf: bytes, offset: int) -> Tuple[str, int | None, int]:
name_len, off = quic_varint_decode(buf, offset)
name_b = buf[off : off + name_len]
off += name_len
preferred = None
if off < len(buf):
preferred = buf[off]
off += 1
name = name_b.decode("utf-8", errors="ignore")
return name, preferred, off
def build_join_ack(
*,
version: int,
seq: int,
player_id: int,
color_id: int,
width: int,
height: int,
tick_rate: int,
wrap_edges: bool,
apples_per_snake: int,
apples_cap: int,
compression_mode: int,
) -> bytes:
header = pack_header(version, PacketType.JOIN_ACK, 0, seq, None)
body = bytearray()
body.append(player_id & 0xFF)
body.append(color_id & 0xFF)
body.append(width & 0xFF)
body.append(height & 0xFF)
body.append(tick_rate & 0xFF)
body.append(1 if wrap_edges else 0)
body.append(apples_per_snake & 0xFF)
body.append(apples_cap & 0xFF)
body.append(compression_mode & 0xFF)
return header + bytes(body)
def build_join_deny(*, version: int, seq: int, reason: str) -> bytes:
header = pack_header(version, PacketType.JOIN_DENY, 0, seq, None)
rb = reason.encode("utf-8")[:64]
return header + quic_varint_encode(len(rb)) + rb
# Input (client -> server)
def parse_input(buf: bytes, offset: int) -> Tuple[int, int, int, List[InputEvent], int]:
ack_seq = int.from_bytes(buf[offset : offset + 2], "big")
offset += 2
input_seq = int.from_bytes(buf[offset : offset + 2], "big")
offset += 2
base_tick = int.from_bytes(buf[offset : offset + 2], "big")
offset += 2
n_ev, offset = quic_varint_decode(buf, offset)
events: List[InputEvent] = []
for _ in range(n_ev):
rel, offset = quic_varint_decode(buf, offset)
d = Direction(buf[offset] & 0x03)
offset += 1
events.append(InputEvent(rel_tick_offset=int(rel), direction=d))
return ack_seq, input_seq, base_tick, events, offset
# State snapshot (server -> client)
def build_state_full(
*,
version: int,
seq: int,
tick: int,
snakes: Sequence[Tuple[int, int, int, int, Sequence[Direction]]],
apples: Sequence[Tuple[int, int]],
) -> bytes:
"""Build a minimal state_full: per-snake header + BODY_2BIT TLV; apples list.
snakes: sequence of (snake_id, len, head_x, head_y, body_dirs_from_head)
apples: sequence of (x, y)
"""
header = pack_header(version, PacketType.STATE_FULL, 0, seq, tick & 0xFFFF)
body = build_state_full_body(snakes=snakes, apples=apples)
return header + body
def build_state_full_body(
*, snakes: Sequence[Tuple[int, int, int, int, Sequence[Direction]]], apples: Sequence[Tuple[int, int]]
) -> bytes:
body = bytearray()
# snakes count
body.extend(quic_varint_encode(len(snakes)))
for sid, slen, hx, hy, dirs in snakes:
body.append(sid & 0xFF)
body.extend(int(slen & 0xFFFF).to_bytes(2, "big"))
body.append(hx & 0xFF)
body.append(hy & 0xFF)
tlv = build_body_tlv_for_dirs(dirs)
body.extend(tlv)
# apples
body.extend(quic_varint_encode(len(apples)))
for ax, ay in apples:
body.append(ax & 0xFF)
body.append(ay & 0xFF)
return bytes(body)
def build_body_2bit_chunk(directions: Sequence[Direction], start_index: int, dirs_in_chunk: int) -> bytes:
"""Build chunk TLV for a sub-range of directions (2-bit)."""
sub = directions[start_index : start_index + dirs_in_chunk]
payload = bytearray()
payload.extend(int(start_index & 0xFFFF).to_bytes(2, "big"))
payload.extend(int(dirs_in_chunk & 0xFFFF).to_bytes(2, "big"))
payload.extend(bitpack_2bit_directions(sub))
return pack_body_tlv(BodyTLV.BODY_2BIT_CHUNK, bytes(payload))
def estimate_body_2bit_bytes(snake_len: int) -> int:
used_bits = max(0, (snake_len - 1) * 2)
return (used_bits + 7) // 8
# --- State Delta ---
@dataclass
class SnakeDelta:
snake_id: int
head_moved: bool
tail_removed: bool
grew: bool
blocked: bool
new_head_x: int = 0
new_head_y: int = 0
direction: Direction = Direction.RIGHT
def build_state_delta_body(
*, update_id: int, changes: Sequence[SnakeDelta], apples_added: Sequence[Tuple[int, int]], apples_removed: Sequence[Tuple[int, int]]
) -> bytes:
body = bytearray()
body.extend(int(update_id & 0xFFFF).to_bytes(2, "big"))
# snakes
body.extend(quic_varint_encode(len(changes)))
for ch in changes:
body.append(ch.snake_id & 0xFF)
flags = (
(1 if ch.head_moved else 0)
| ((1 if ch.tail_removed else 0) << 1)
| ((1 if ch.grew else 0) << 2)
| ((1 if ch.blocked else 0) << 3)
)
body.append(flags & 0xFF)
body.append(int(ch.direction) & 0x03)
if ch.head_moved:
body.append(ch.new_head_x & 0xFF)
body.append(ch.new_head_y & 0xFF)
# apples added
body.extend(quic_varint_encode(len(apples_added)))
for ax, ay in apples_added:
body.append(ax & 0xFF)
body.append(ay & 0xFF)
# apples removed
body.extend(quic_varint_encode(len(apples_removed)))
for rx, ry in apples_removed:
body.append(rx & 0xFF)
body.append(ry & 0xFF)
return bytes(body)
def build_state_delta(
*, version: int, seq: int, tick: int, update_id: int, changes: Sequence[SnakeDelta], apples_added: Sequence[Tuple[int, int]], apples_removed: Sequence[Tuple[int, int]]
) -> bytes:
header = pack_header(version, PacketType.STATE_DELTA, 0, seq, tick & 0xFFFF)
body = build_state_delta_body(update_id=update_id, changes=changes, apples_added=apples_added, apples_removed=apples_removed)
return header + body
def build_part(
*,
version: int,
seq: int,
tick: int,
update_id: int,
part_index: int,
parts_total: int,
inner_type: PacketType,
chunk_payload: bytes,
) -> bytes:
header = pack_header(version, PacketType.PART, 0, seq, tick & 0xFFFF)
body = bytearray()
body.extend(int(update_id & 0xFFFF).to_bytes(2, "big"))
body.append(part_index & 0xFF)
body.append(parts_total & 0xFF)
body.append(int(inner_type) & 0xFF)
# include the chunk payload bytes
body.extend(chunk_payload)
return header + bytes(body)

79
server/quic_transport.py Normal file
View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Awaitable, Callable, Dict, Optional
from .transport import DatagramServerTransport, OnDatagram, TransportPeer
import logging
try:
from aioquic.asyncio import QuicConnectionProtocol, serve
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import DatagramFrameReceived, ProtocolNegotiated
except Exception: # pragma: no cover - optional dependency not installed in skeleton
QuicConnectionProtocol = object # type: ignore
QuicConfiguration = object # type: ignore
serve = None # type: ignore
DatagramFrameReceived = object # type: ignore
ProtocolNegotiated = object # type: ignore
class GameQuicProtocol(QuicConnectionProtocol): # type: ignore[misc]
def __init__(self, *args, on_datagram: OnDatagram, peers: Dict[int, "GameQuicProtocol"], **kwargs):
super().__init__(*args, **kwargs)
self._on_datagram = on_datagram
self._peers = peers
self._peer_id: Optional[int] = None
def quic_event_received(self, event) -> None: # type: ignore[override]
if isinstance(event, ProtocolNegotiated):
# Register by connection id
self._peer_id = int(self._quic.connection_id) # type: ignore[attr-defined]
self._peers[self._peer_id] = self
logging.info("QUIC peer connected: id=%s", self._peer_id)
elif isinstance(event, DatagramFrameReceived):
# Schedule async callback
if self._peer_id is None:
return
peer = TransportPeer(addr=self)
logging.debug("QUIC datagram received from id=%s, %d bytes", self._peer_id, len(event.data))
asyncio.ensure_future(self._on_datagram(bytes(event.data), peer))
async def send_datagram(self, data: bytes) -> None:
logging.debug("QUIC send datagram: %d bytes", len(data))
self._quic.send_datagram_frame(data) # type: ignore[attr-defined]
await self._loop.run_in_executor(None, self.transmit) # type: ignore[attr-defined]
class QuicWebTransportServer(DatagramServerTransport):
def __init__(self, host: str, port: int, certfile: str, keyfile: str, on_datagram: OnDatagram):
if serve is None:
raise RuntimeError("aioquic is not installed. Please `pip install aioquic`.")
self.host = host
self.port = port
self.certfile = certfile
self.keyfile = keyfile
self._on_datagram = on_datagram
self._server = None
self._peers: Dict[int, GameQuicProtocol] = {}
async def send(self, data: bytes, peer: TransportPeer) -> None:
proto = peer.addr # expected GameQuicProtocol
if isinstance(proto, GameQuicProtocol):
await proto.send_datagram(data)
async def run(self) -> None:
configuration = QuicConfiguration(is_client=False, alpn_protocols=["h3"])
configuration.load_cert_chain(self.certfile, self.keyfile)
async def _create_protocol(*args, **kwargs):
return GameQuicProtocol(*args, on_datagram=self._on_datagram, peers=self._peers, **kwargs)
logging.info("QUIC datagram server listening on %s:%d", self.host, self.port)
self._server = await serve(self.host, self.port, configuration=configuration, create_protocol=_create_protocol)
try:
await self._server.wait_closed()
finally:
self._server.close()

597
server/server.py Normal file
View File

@@ -0,0 +1,597 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import logging
from typing import Dict, List, Optional, Tuple
from collections import deque
from .config import ServerConfig
from .model import GameState, PlayerSession, Snake, Coord
from .protocol import (
Direction,
InputEvent,
PacketType,
build_join_ack,
build_join_deny,
build_config_update,
build_input_broadcast,
build_state_full,
build_state_full_body,
build_body_tlv_for_dirs,
build_body_2bit_chunk,
build_state_delta,
build_state_delta_body,
build_part,
SnakeDelta,
pack_header,
parse_input,
parse_join,
)
from .transport import DatagramServerTransport, TransportPeer
@dataclass
class ServerRuntime:
config: ServerConfig
state: GameState
seq: int = 0
version: int = 1
update_id: int = 0
def next_seq(self) -> int:
self.seq = (self.seq + 1) & 0xFFFF
return self.seq
def next_update_id(self) -> int:
self.update_id = (self.update_id + 1) & 0xFFFF
return self.update_id
class GameServer:
def __init__(self, transport: DatagramServerTransport, config: ServerConfig):
self.transport = transport
self.runtime = ServerRuntime(config=config, state=GameState(config.width, config.height))
self.sessions: Dict[int, PlayerSession] = {}
self._config_update_interval_ticks = 50 # periodic resend
logging.info("GameServer created: field=%dx%d, tick_rate=%d TPS, wrap=%s", config.width, config.height, config.tick_rate, config.wrap_edges)
async def on_datagram(self, data: bytes, peer: TransportPeer) -> None:
# Minimal header parse
if len(data) < 5:
return
ver = data[0]
ptype = PacketType(data[1])
flags = data[2]
# seq = int.from_bytes(data[3:5], 'big') # currently unused
off = 5
if ptype == PacketType.JOIN:
await self._handle_join(data, off, peer)
elif ptype == PacketType.INPUT:
await self._handle_input(data, off, peer)
else:
# ignore others in skeleton
return
async def broadcast_config_update(self) -> None:
r = self.runtime
payload = build_config_update(
version=r.version,
seq=r.next_seq(),
tick=r.state.tick & 0xFFFF,
tick_rate=r.config.tick_rate,
wrap_edges=r.config.wrap_edges,
apples_per_snake=r.config.apples_per_snake,
apples_cap=r.config.apples_cap,
)
# Broadcast to all sessions (requires real peer handles)
for session in list(self.sessions.values()):
await self.transport.send(payload, TransportPeer(session.peer))
logging.debug("Broadcasted config_update to %d sessions", len(self.sessions))
async def relay_input_broadcast(
self,
*,
from_player_id: int,
input_seq: int,
base_tick: int,
events: List[InputEvent],
apply_at_tick: int | None,
) -> None:
r = self.runtime
payload = build_input_broadcast(
version=r.version,
seq=r.next_seq(),
tick=r.state.tick & 0xFFFF,
player_id=from_player_id,
input_seq=input_seq,
base_tick=base_tick & 0xFFFF,
events=events,
apply_at_tick=apply_at_tick,
)
for session in list(self.sessions.values()):
if session.player_id == from_player_id:
continue
await self.transport.send(payload, TransportPeer(session.peer))
async def tick_loop(self) -> None:
r = self.runtime
tick_duration = 1.0 / max(1, r.config.tick_rate)
next_cfg_resend = self._config_update_interval_ticks
logging.info("Tick loop started at %.2f TPS", 1.0 / tick_duration)
while True:
start = asyncio.get_event_loop().time()
# process inputs, update snakes, collisions, apples, deltas
await self._simulate_tick()
r.state.tick = (r.state.tick + 1) & 0xFFFFFFFF
next_cfg_resend -= 1
if next_cfg_resend <= 0:
await self.broadcast_config_update()
next_cfg_resend = self._config_update_interval_ticks
elapsed = asyncio.get_event_loop().time() - start
await asyncio.sleep(max(0.0, tick_duration - elapsed))
# --- Simulation ---
def _consume_input_for_snake(self, s: Snake) -> None:
# Consume at most one input; skip 180° turns when length>1
while s.input_buf:
nd = s.input_buf[0]
# 180-degree check
if s.length > 1 and ((int(nd) ^ int(s.direction)) == 2):
s.input_buf.popleft()
continue
# Accept
s.direction = nd
s.input_buf.popleft()
break
def _step_from(self, x: int, y: int, d: Direction) -> Tuple[int, int, bool]:
dx = 1 if d == Direction.RIGHT else -1 if d == Direction.LEFT else 0
dy = 1 if d == Direction.DOWN else -1 if d == Direction.UP else 0
nx, ny = x + dx, y + dy
st = self.runtime.state
wrap = self.runtime.config.wrap_edges
wrapped = False
if wrap:
if nx < 0:
nx = st.width - 1; wrapped = True
elif nx >= st.width:
nx = 0; wrapped = True
if ny < 0:
ny = st.height - 1; wrapped = True
elif ny >= st.height:
ny = 0; wrapped = True
return nx, ny, wrapped
async def _simulate_tick(self) -> None:
st = self.runtime.state
cfg = self.runtime.config
apples_eaten: List[Coord] = []
apples_before = set(st.apples)
changes: List[SnakeDelta] = []
# Prepare snapshot of tails to allow moving into own tail when it vacates
tails: Dict[int, Coord] = {}
for sid, s in st.snakes.items():
if s.length > 1:
tails[sid] = s.body[-1]
# Process snakes
for sid, s in st.snakes.items():
# Consume one input if available
self._consume_input_for_snake(s)
hx, hy = s.body[0]
nx, ny, wrapped = self._step_from(hx, hy, s.direction)
# Check wall if no wrap
obstacle = False
if not self.runtime.config.wrap_edges and not st.in_bounds(nx, ny):
obstacle = True
else:
# Bounds correction already handled by _step_from
# Occupancy check
occ = st.occupancy.get((nx, ny))
if occ is not None:
# Allow moving into own tail if it will vacate (not growing)
own_tail_ok = (s.length > 1 and (nx, ny) == tails.get(sid))
if not own_tail_ok:
obstacle = True
if obstacle:
s.blocked = True
# shrink tail by 1 (to min 1)
if s.length > 1:
tx, ty = s.body.pop()
st.occupancy.pop((tx, ty), None)
changes.append(
SnakeDelta(
snake_id=sid,
head_moved=False,
tail_removed=(s.length > 1),
grew=False,
blocked=True,
new_head_x=hx,
new_head_y=hy,
direction=s.direction,
)
)
continue
# Move or grow
s.blocked = False
will_grow = (nx, ny) in st.apples
# Add new head
s.body.appendleft((nx, ny))
st.occupancy[(nx, ny)] = (sid, 0)
if will_grow:
# eat apple; no tail removal this tick
st.apples.remove((nx, ny))
apples_eaten.append((nx, ny))
changes.append(
SnakeDelta(
snake_id=sid,
head_moved=True,
tail_removed=False,
grew=True,
blocked=False,
new_head_x=nx,
new_head_y=ny,
direction=s.direction,
)
)
else:
# normal move: remove tail (unless length==0)
tx, ty = s.body.pop()
if (tx, ty) in st.occupancy:
st.occupancy.pop((tx, ty), None)
changes.append(
SnakeDelta(
snake_id=sid,
head_moved=True,
tail_removed=True,
grew=False,
blocked=False,
new_head_x=nx,
new_head_y=ny,
direction=s.direction,
)
)
# Replenish apples to target
self._ensure_apples()
apples_after = set(st.apples)
apples_added = sorted(list(apples_after - apples_before))
apples_removed = sorted(list(apples_before - apples_after))
# Broadcast a basic delta (currently full snakes + apples as delta)
update_id = self.runtime.next_update_id()
# Serialize full delta body once, then partition if large
full_body = build_state_delta_body(
update_id=update_id,
changes=changes,
apples_added=apples_added,
apples_removed=apples_removed,
)
MTU = 1200 # soft limit for payload to avoid fragmentation
if len(full_body) <= MTU:
packet = pack_header(self.runtime.version, PacketType.STATE_DELTA, 0, self.runtime.next_seq(), st.tick & 0xFFFF) + full_body
for session in list(self.sessions.values()):
await self.transport.send(packet, TransportPeer(session.peer))
else:
# Partition by splitting snake changes across parts; include apples only in the first part
parts: List[bytes] = []
remaining = list(changes)
idx = 0
part_index = 0
while remaining:
chunk: List[SnakeDelta] = []
# greedy pack until size would exceed MTU
# start with apples only for first part
add_ap = apples_added if part_index == 0 else []
rem_ap = apples_removed if part_index == 0 else []
# Try adding changes one by one
for i, ch in enumerate(remaining):
tmp_body = build_state_delta_body(update_id=update_id, changes=chunk + [ch], apples_added=add_ap, apples_removed=rem_ap)
if len(tmp_body) > MTU and chunk:
break
if len(tmp_body) > MTU:
# even a single change + apples doesn't fit; force single
chunk.append(ch)
i += 1
break
chunk.append(ch)
# Remove chunked items
remaining = remaining[len(chunk) :]
# Build chunk body
chunk_body = build_state_delta_body(update_id=update_id, changes=chunk, apples_added=add_ap, apples_removed=rem_ap)
parts.append(chunk_body)
part_index += 1
# Emit PART packets
total = len(parts)
for i, body in enumerate(parts):
pkt = build_part(
version=self.runtime.version,
seq=self.runtime.next_seq(),
tick=st.tick & 0xFFFF,
update_id=update_id,
part_index=i,
parts_total=total,
inner_type=PacketType.STATE_DELTA,
chunk_payload=body,
)
for session in list(self.sessions.values()):
await self.transport.send(pkt, TransportPeer(session.peer))
# --- Join / Spawn ---
def _allocate_player_id(self) -> Optional[int]:
used = {s.player_id for s in self.sessions.values()}
for pid in range(self.runtime.config.players_max):
if pid not in used:
return pid
return None
def _choose_color_id(self) -> int:
used = {s.color_id for s in self.sessions.values()}
for cid in range(32):
if cid not in used:
return cid
return 0
def _neighbors(self, x: int, y: int) -> List[Tuple[Direction, Coord]]:
return [
(Direction.UP, (x, y - 1)),
(Direction.RIGHT, (x + 1, y)),
(Direction.DOWN, (x, y + 1)),
(Direction.LEFT, (x - 1, y)),
]
def _find_spawn(self) -> Optional[Snake]:
st = self.runtime.state
# Try to find a 3-cell straight strip
for y in range(st.height):
for x in range(st.width):
if not st.cell_free(x, y):
continue
for d, (nx, ny) in self._neighbors(x, y):
# check two cells in direction
x2, y2 = nx, ny
x3, y3 = nx + (1 if d == Direction.RIGHT else -1 if d == Direction.LEFT else 0), ny + (1 if d == Direction.DOWN else -1 if d == Direction.UP else 0)
if st.in_bounds(x2, y2) and st.in_bounds(x3, y3) and st.cell_free(x2, y2) and st.cell_free(x3, y3):
body = [
(x, y),
(x2, y2),
(x3, y3),
]
return Snake(snake_id=-1, head=(x, y), direction=d, body=deque(body))
# Fallback: any single free cell
for y in range(st.height):
for x in range(st.width):
if st.cell_free(x, y):
return Snake(snake_id=-1, head=(x, y), direction=Direction.RIGHT, body=deque([(x, y)]))
return None
def _ensure_apples(self) -> None:
st = self.runtime.state
cfg = self.runtime.config
import random
random.seed(0xC0FFEE)
target = 3 if not self.sessions else min(cfg.apples_cap, max(0, len(self.sessions) * cfg.apples_per_snake))
# grow apples up to target
while len(st.apples) < target:
x = random.randrange(st.width)
y = random.randrange(st.height)
if st.cell_free(x, y) and (x, y) not in st.apples:
st.apples.append((x, y))
# shrink if too many
if len(st.apples) > target:
st.apples = st.apples[:target]
async def _handle_join(self, buf: bytes, off: int, peer: TransportPeer) -> None:
name, preferred, off2 = parse_join(buf, off)
# enforce name <=16 bytes utf-8
name = name.encode("utf-8")[:16].decode("utf-8", errors="ignore")
pid = self._allocate_player_id()
if pid is None:
payload = build_join_deny(version=self.runtime.version, seq=self.runtime.next_seq(), reason="Server full")
await self.transport.send(payload, peer)
logging.warning("JOIN denied: server full (name=%s)", name)
return
# Spawn snake
snake = self._find_spawn()
if snake is None:
payload = build_join_deny(version=self.runtime.version, seq=self.runtime.next_seq(), reason="No free cell, please wait")
await self.transport.send(payload, peer)
logging.warning("JOIN denied: no free cell (name=%s)", name)
return
# Register session and snake
color_id = preferred if (preferred is not None) else self._choose_color_id()
session = PlayerSession(player_id=pid, name=name, color_id=color_id, peer=peer.addr)
self.sessions[pid] = session
snake.snake_id = pid
self.runtime.state.snakes[pid] = snake
self.runtime.state.occupy_snake(snake)
self._ensure_apples()
logging.info("JOIN accepted: player_id=%d name=%s color=%d len=%d at=%s", pid, name, color_id, snake.length, snake.body[0])
# Send join_ack
cfg = self.runtime.config
ack = build_join_ack(
version=self.runtime.version,
seq=self.runtime.next_seq(),
player_id=pid,
color_id=color_id,
width=cfg.width,
height=cfg.height,
tick_rate=cfg.tick_rate,
wrap_edges=cfg.wrap_edges,
apples_per_snake=cfg.apples_per_snake,
apples_cap=cfg.apples_cap,
compression_mode=0 if cfg.compression_mode == "none" else 1,
)
await self.transport.send(ack, peer)
# Send initial state_full (partitioned if needed)
snakes_dirs: List[Tuple[int, int, int, int, List[Direction]]] = []
for s in self.runtime.state.snakes.values():
dirs: List[Direction] = []
# build directions from consecutive coords: from head toward tail
coords = list(s.body)
for i in range(len(coords) - 1):
x0, y0 = coords[i]
x1, y1 = coords[i + 1]
if x1 == x0 and y1 == y0 - 1:
dirs.append(Direction.UP)
elif x1 == x0 + 1 and y1 == y0:
dirs.append(Direction.RIGHT)
elif x1 == x0 and y1 == y0 + 1:
dirs.append(Direction.DOWN)
elif x1 == x0 - 1 and y1 == y0:
dirs.append(Direction.LEFT)
hx, hy = coords[0]
snakes_dirs.append((s.snake_id, s.length, hx, hy, dirs))
# Build body and partition across parts if needed
body = build_state_full_body(snakes=snakes_dirs, apples=self.runtime.state.apples)
MTU = 1200
if len(body) <= MTU:
full = pack_header(
self.runtime.version, PacketType.STATE_FULL, 0, self.runtime.next_seq(), self.runtime.state.tick & 0xFFFF
) + body
await self.transport.send(full, peer)
else:
# Partition by packing whole snakes first, apples only in first part; chunk a single oversized snake using 2-bit chunks
update_id = self.runtime.next_update_id()
parts: List[bytes] = []
# Prepare apples buffer for first part
def encode_apples(apples: List[Coord]) -> bytes:
from .protocol import quic_varint_encode
b = bytearray()
b.extend(quic_varint_encode(len(apples)))
for ax, ay in apples:
b.append(ax & 0xFF)
b.append(ay & 0xFF)
return bytes(b)
apples_encoded = encode_apples(self.runtime.state.apples)
# Cursor over snakes, building per part bodies
remaining = list(snakes_dirs)
first = True
while remaining:
# Start part body with snakes count placeholder; we'll rebuild as we pack
part_snakes: List[bytes] = []
packed_snakes = 0
budget = MTU
# Reserve apples size on first part
apples_this = apples_encoded if first else encode_apples([])
budget -= len(apples_this)
# Greedily add snakes
i = 0
while i < len(remaining):
sid, slen, hx, hy, dirs = remaining[i]
# Build this snake record with chosen TLV
tlv = build_body_tlv_for_dirs(dirs)
record = bytearray()
# one snake: id u8, len u16, head x y, TLV
record.append(sid & 0xFF)
record.extend(int(slen & 0xFFFF).to_bytes(2, "big"))
record.append(hx & 0xFF)
record.append(hy & 0xFF)
record.extend(tlv)
if len(record) <= budget:
part_snakes.append(bytes(record))
budget -= len(record)
packed_snakes += 1
i += 1
continue
# If single snake doesn't fit and no snakes packed yet, chunk this snake
if packed_snakes == 0:
# Use 2-bit chunking; compute directions chunk size to fit budget minus fixed headers (id/len/head + TLV type/len + chunk header)
# Fixed snake header = 1+2+1+1 = 5 bytes; TLV type/len (worst-case 2 bytes for small values) + chunk header 4 bytes
overhead = 5 + 2 + 4
dir_capacity_bytes = max(0, budget - overhead)
if dir_capacity_bytes <= 0:
break
# Convert capacity bytes to number of directions (each 2 bits → 4 dirs per byte)
dir_capacity = dir_capacity_bytes * 4
if dir_capacity <= 0:
break
# Emit at least one direction
dirs_in_chunk = max(1, min(dir_capacity, len(dirs)))
tlv_chunk = build_body_2bit_chunk(dirs, start_index=0, dirs_in_chunk=dirs_in_chunk)
record = bytearray()
record.append(sid & 0xFF)
record.extend(int(slen & 0xFFFF).to_bytes(2, "big"))
record.append(hx & 0xFF)
record.append(hy & 0xFF)
record.extend(tlv_chunk)
if len(record) <= budget:
part_snakes.append(bytes(record))
budget -= len(record)
# Replace remaining snake with truncated version (advance dirs)
remaining[i] = (sid, slen, hx, hy, list(dirs)[dirs_in_chunk:])
packed_snakes += 1
else:
break
else:
break
# Build part body: snakes_count + snakes + apples
body_part = bytearray()
from .protocol import quic_varint_encode
body_part.extend(quic_varint_encode(len(part_snakes)))
for rec in part_snakes:
body_part.extend(rec)
body_part.extend(apples_this)
parts.append(bytes(body_part))
# Advance remaining list
remaining = remaining[packed_snakes:]
first = False
# Emit part packets
total = len(parts)
for i, chunk_body in enumerate(parts):
pkt = build_part(
version=self.runtime.version,
seq=self.runtime.next_seq(),
tick=self.runtime.state.tick & 0xFFFF,
update_id=update_id,
part_index=i,
parts_total=total,
inner_type=PacketType.STATE_FULL,
chunk_payload=chunk_body,
)
await self.transport.send(pkt, peer)
async def _handle_input(self, buf: bytes, off: int, peer: TransportPeer) -> None:
try:
ack_seq, input_seq, base_tick, events, off2 = parse_input(buf, off)
except Exception:
return
# Find player by peer
player_id = None
for pid, sess in self.sessions.items():
if sess.peer is peer.addr:
player_id = pid
break
if player_id is None:
return
logging.debug("INPUT from player_id=%d: %d events (base_tick=%d)", player_id, len(events), base_tick)
# Relay to others immediately for prediction
await self.relay_input_broadcast(
from_player_id=player_id,
input_seq=input_seq,
base_tick=base_tick,
events=events,
apply_at_tick=None,
)
async def main() -> None:
from .transport import InMemoryTransport
cfg = ServerConfig()
server = GameServer(transport=InMemoryTransport(lambda d, p: server.on_datagram(d, p)), config=cfg)
# In-memory transport never returns; run tick loop in parallel
await asyncio.gather(server.transport.run(), server.tick_loop())
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass

36
server/static_server.py Normal file
View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import ssl
import logging
import threading
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from typing import Tuple
class _Handler(SimpleHTTPRequestHandler):
# Allow passing a base directory at construction time
def __init__(self, *args, directory: str | None = None, **kwargs):
super().__init__(*args, directory=directory, **kwargs)
def start_https_static(host: str, port: int, certfile: str, keyfile: str, docroot: str) -> Tuple[ThreadingHTTPServer, threading.Thread]:
"""Start a simple HTTPS static file server in a background thread.
Returns the (httpd, thread). Caller is responsible for calling httpd.shutdown()
to stop the server on application exit.
"""
docroot_path = str(Path(docroot).resolve())
def handler(*args, **kwargs):
return _Handler(*args, directory=docroot_path, **kwargs)
httpd = ThreadingHTTPServer((host, port), handler)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(certfile=certfile, keyfile=keyfile)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
t = threading.Thread(target=httpd.serve_forever, name="https-static", daemon=True)
t.start()
logging.info("HTTPS static server listening on https://%s:%d serving '%s'", host, port, docroot_path)
return httpd, t

59
server/transport.py Normal file
View File

@@ -0,0 +1,59 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import logging
from typing import Awaitable, Callable, Optional, Tuple
OnDatagram = Callable[[bytes, object], Awaitable[None]]
@dataclass
class TransportPeer:
addr: object # opaque peer handle (e.g., QUIC session)
class DatagramServerTransport:
async def send(self, data: bytes, peer: TransportPeer) -> None:
raise NotImplementedError
async def run(self) -> None:
raise NotImplementedError
class InMemoryTransport(DatagramServerTransport):
"""A test transport that loops datagrams back to registered peers."""
def __init__(self, on_datagram: OnDatagram):
self._on_datagram = on_datagram
self._peers: list[TransportPeer] = []
def register_peer(self, peer: TransportPeer) -> None:
self._peers.append(peer)
async def send(self, data: bytes, peer: TransportPeer) -> None:
# In-memory: deliver only to the addressed peer
if peer in self._peers:
await self._on_datagram(data, peer)
async def run(self) -> None:
logging.info("InMemory transport started (no network)")
await asyncio.Future()
class QuicWebTransportServer(DatagramServerTransport):
"""Placeholder for a real WebTransport (HTTP/3) datagram server.
Integrate with aioquic or another QUIC library and invoke the provided
on_datagram callback when a datagram arrives.
"""
def __init__(self, on_datagram: OnDatagram):
self._on_datagram = on_datagram
async def send(self, data: bytes, peer: TransportPeer) -> None:
raise NotImplementedError("QUIC server not implemented in skeleton")
async def run(self) -> None:
raise NotImplementedError("QUIC server not implemented in skeleton")

10
server/utils.py Normal file
View File

@@ -0,0 +1,10 @@
from __future__ import annotations
def is_newer(a: int, b: int) -> bool:
"""Return True if 16-bit sequence number a is newer than b (wrap-aware).
Uses half-range window on unsigned 16-bit arithmetic.
"""
return ((a - b) & 0xFFFF) < 0x8000

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Awaitable, Callable, Dict, Optional
from .transport import DatagramServerTransport, OnDatagram, TransportPeer
import logging
try:
from aioquic.asyncio import QuicConnectionProtocol, serve
from aioquic.quic.configuration import QuicConfiguration
from aioquic.h3.connection import H3Connection
from aioquic.h3.events import HeadersReceived
# Datagram event names vary by aioquic version; try both
try:
from aioquic.h3.events import DatagramReceived as H3DatagramReceived # type: ignore
except Exception: # pragma: no cover
H3DatagramReceived = object # type: ignore
except Exception: # pragma: no cover - optional dependency not installed
QuicConnectionProtocol = object # type: ignore
QuicConfiguration = object # type: ignore
H3Connection = object # type: ignore
HeadersReceived = object # type: ignore
H3DatagramReceived = object # type: ignore
serve = None # type: ignore
@dataclass
class WTSession:
flow_id: int # HTTP/3 datagram flow id (usually CONNECT stream id)
proto: "GameWTProtocol"
class GameWTProtocol(QuicConnectionProtocol): # type: ignore[misc]
def __init__(self, *args, on_datagram: OnDatagram, sessions: Dict[int, WTSession], **kwargs):
super().__init__(*args, **kwargs)
self._on_datagram = on_datagram
self._sessions = sessions
self._http: Optional[H3Connection] = None
def http_event_received(self, event) -> None: # type: ignore[override]
# Headers for CONNECT :protocol = webtransport open a session
if isinstance(event, HeadersReceived):
headers = {k.decode().lower(): v.decode() for k, v in event.headers}
method = headers.get(":method")
protocol = headers.get(":protocol") or headers.get("sec-webtransport-protocol")
if method == "CONNECT" and (protocol == "webtransport"):
# In WebTransport over H3, datagrams use the CONNECT stream id as flow_id
flow_id = event.stream_id # type: ignore[attr-defined]
self._sessions[flow_id] = WTSession(flow_id=flow_id, proto=self)
logging.info("WT CONNECT accepted: flow_id=%s", flow_id)
# Send 2xx to accept the session
if self._http is not None:
self._http.send_headers(event.stream_id, [(b":status", b"200")])
elif isinstance(event, H3DatagramReceived): # type: ignore[misc]
# Route datagram to session by flow_id
flow_id = getattr(event, "flow_id", None)
data = getattr(event, "data", None)
if flow_id is None or data is None:
return
sess = self._sessions.get(flow_id)
if not sess:
return
peer = TransportPeer(addr=(self, flow_id))
logging.debug("WT datagram received: flow_id=%s, %d bytes", flow_id, len(data))
asyncio.ensure_future(self._on_datagram(bytes(data), peer))
def quic_event_received(self, event) -> None: # type: ignore[override]
# Lazily create H3 connection wrapper
if self._http is None and hasattr(self, "_quic"):
try:
self._http = H3Connection(self._quic, enable_webtransport=True) # type: ignore[attr-defined]
except Exception:
self._http = None
if self._http is not None:
for http_event in self._http.handle_event(event):
self.http_event_received(http_event)
async def send_h3_datagram(self, flow_id: int, data: bytes) -> None:
if self._http is None:
return
try:
logging.debug("WT send datagram: flow_id=%s, %d bytes", flow_id, len(data))
self._http.send_datagram(flow_id, data)
await self._loop.run_in_executor(None, self.transmit) # type: ignore[attr-defined]
except Exception:
pass
class WebTransportServer(DatagramServerTransport):
"""HTTP/3 WebTransport datagram server using aioquic.
Accepts CONNECT requests with :protocol = webtransport and exchanges
RFC 9297 HTTP/3 datagrams bound to the CONNECT stream id.
"""
def __init__(self, host: str, port: int, certfile: str, keyfile: str, on_datagram: OnDatagram):
if serve is None:
raise RuntimeError("aioquic is not installed. Please `pip install aioquic`.")
self.host = host
self.port = port
self.certfile = certfile
self.keyfile = keyfile
self._on_datagram = on_datagram
self._server = None
self._sessions: Dict[int, WTSession] = {}
async def send(self, data: bytes, peer: TransportPeer) -> None:
# peer.addr is (protocol, flow_id)
if isinstance(peer.addr, tuple) and len(peer.addr) == 2:
proto, flow_id = peer.addr
if isinstance(proto, GameWTProtocol) and isinstance(flow_id, int):
await proto.send_h3_datagram(flow_id, data)
async def run(self) -> None:
configuration = QuicConfiguration(is_client=False, alpn_protocols=["h3"])
configuration.load_cert_chain(self.certfile, self.keyfile)
async def _create_protocol(*args, **kwargs):
return GameWTProtocol(*args, on_datagram=self._on_datagram, sessions=self._sessions, **kwargs)
logging.info("WebTransport (H3) server listening on %s:%d", self.host, self.port)
self._server = await serve(self.host, self.port, configuration=configuration, create_protocol=_create_protocol)
try:
await self._server.wait_closed()
finally:
self._server.close()