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

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