Implement client-side prediction with input broadcasting

Reduces perceived lag over internet by broadcasting player inputs immediately
and predicting next positions on all clients before server update arrives.

Protocol changes:
- Added PLAYER_INPUT message type for broadcasting inputs
- Server broadcasts player inputs to all clients on every MOVE message
- Includes player_id, current direction, and full input_buffer (max 3)

Desktop client (Python):
- Tracks input buffers and predicted head positions for all players
- On PLAYER_INPUT: predicts next head position using buffered input
- On STATE_UPDATE: clears predictions, uses authoritative state
- Renderer draws predicted positions with darker color (60% brightness)

Web client (JavaScript):
- Same prediction logic as desktop client
- Added darkenColor() helper for visual differentiation
- Predicted heads shown at 60% brightness

Benefits:
- Instant visual feedback for own movements (no round-trip wait)
- See other players' inputs before server tick (better collision avoidance)
- Smooth experience bridging input-to-update gap
- Low bandwidth (only direction tuples, not full state)
- Backward compatible (server authoritative, old clients work)

All 39 tests passing.

🤖 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-04 21:21:49 +03:00
parent ce492b0dc2
commit 4dbbf44638
7 changed files with 129 additions and 9 deletions

View File

@@ -10,6 +10,10 @@ class GameClient {
this.canvas = document.getElementById('game-canvas');
this.ctx = this.canvas.getContext('2d');
// Client-side prediction
this.playerInputBuffers = {}; // player_id -> input_buffer array
this.predictedHeads = {}; // player_id -> [x, y] predicted position
// Game constants (matching Python)
this.GRID_WIDTH = 40;
this.GRID_HEIGHT = 30;
@@ -134,6 +138,8 @@ class GameClient {
case MessageType.STATE_UPDATE:
this.gameState = message.data.game_state;
this.updatePlayersList();
// Clear predictions on authoritative update
this.predictedHeads = {};
if (this.gameState.game_running) {
this.hideOverlay();
}
@@ -164,6 +170,29 @@ class GameClient {
console.error('Server error:', message.data.error);
this.showStatus('Error: ' + message.data.error, 'error');
break;
case MessageType.PLAYER_INPUT:
// Update input buffer and predict next position
const playerId = message.data.player_id;
const direction = message.data.direction;
const inputBuffer = message.data.input_buffer || [];
this.playerInputBuffers[playerId] = inputBuffer;
// Predict next head position
if (this.gameState && this.gameState.snakes) {
const snake = this.gameState.snakes.find(s => s.player_id === playerId);
if (snake && snake.body && snake.body.length > 0) {
// Use first buffered input if available, otherwise current direction
const nextDir = inputBuffer.length > 0 ? inputBuffer[0] : direction;
const head = snake.body[0];
this.predictedHeads[playerId] = [
head[0] + nextDir[0],
head[1] + nextDir[1]
];
}
}
break;
}
}
@@ -245,6 +274,15 @@ class GameClient {
this.drawCell(x, y, color);
}
}
// Draw predicted head position (if available)
if (this.predictedHeads[snake.player_id]) {
const [px, py] = this.predictedHeads[snake.player_id];
const brightColor = this.brightenColor(color, 50);
// Draw with reduced opacity (darker color)
const predictedColor = this.darkenColor(brightColor, 0.6);
this.drawCell(px, py, predictedColor);
}
}
});
}
@@ -307,6 +345,19 @@ class GameClient {
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
}
darkenColor(hex, factor) {
// Convert hex to RGB and multiply by factor
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const newR = Math.floor(r * factor);
const newG = Math.floor(g * factor);
const newB = Math.floor(b * factor);
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
}
updatePlayersList() {
if (!this.gameState || !this.gameState.snakes) {
return;
@@ -314,7 +365,10 @@ class GameClient {
this.playersList.innerHTML = '';
this.gameState.snakes.forEach((snake) => {
// Sort snakes by length descending
const sortedSnakes = [...this.gameState.snakes].sort((a, b) => b.body.length - a.body.length);
sortedSnakes.forEach((snake) => {
const playerItem = document.createElement('div');
playerItem.className = `player-item ${snake.alive ? 'alive' : 'dead'}`;
playerItem.style.borderLeftColor = this.COLOR_SNAKES[snake.color_index % this.COLOR_SNAKES.length];