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:
@@ -11,7 +11,7 @@ from ..shared.protocol import (
|
||||
create_move_message,
|
||||
create_start_game_message,
|
||||
)
|
||||
from ..shared.models import GameState
|
||||
from ..shared.models import GameState, Position
|
||||
from ..shared.constants import (
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_PORT,
|
||||
@@ -51,6 +51,10 @@ class GameClient:
|
||||
self.running = True
|
||||
self.clock = pygame.time.Clock()
|
||||
|
||||
# Client-side prediction
|
||||
self.player_input_buffers: dict[str, list] = {} # player_id -> input_buffer
|
||||
self.predicted_heads: dict[str, Position] = {} # player_id -> predicted head position
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the game server."""
|
||||
try:
|
||||
@@ -125,6 +129,8 @@ class GameClient:
|
||||
elif message.type == MessageType.STATE_UPDATE:
|
||||
state_dict = message.data.get("game_state")
|
||||
self.game_state = GameState.from_dict(state_dict)
|
||||
# Clear predictions on authoritative update
|
||||
self.predicted_heads.clear()
|
||||
|
||||
elif message.type == MessageType.PLAYER_JOINED:
|
||||
player_id = message.data.get("player_id")
|
||||
@@ -146,6 +152,27 @@ class GameClient:
|
||||
error = message.data.get("error")
|
||||
print(f"Error from server: {error}")
|
||||
|
||||
elif message.type == MessageType.PLAYER_INPUT:
|
||||
# Update input buffer and predict next position
|
||||
player_id = message.data.get("player_id")
|
||||
direction = tuple(message.data.get("direction", (1, 0)))
|
||||
input_buffer = [tuple(d) for d in message.data.get("input_buffer", [])]
|
||||
|
||||
self.player_input_buffers[player_id] = input_buffer
|
||||
|
||||
# Predict next head position
|
||||
if self.game_state:
|
||||
snake = next((s for s in self.game_state.snakes if s.player_id == player_id), None)
|
||||
if snake and snake.body:
|
||||
# Use first buffered input if available, otherwise current direction
|
||||
next_dir = input_buffer[0] if input_buffer else direction
|
||||
head = snake.body[0]
|
||||
predicted_head = Position(
|
||||
head.x + next_dir[0],
|
||||
head.y + next_dir[1]
|
||||
)
|
||||
self.predicted_heads[player_id] = predicted_head
|
||||
|
||||
def handle_input(self) -> None:
|
||||
"""Handle pygame input events."""
|
||||
for event in pygame.event.get():
|
||||
@@ -178,8 +205,8 @@ class GameClient:
|
||||
# Handle input
|
||||
self.handle_input()
|
||||
|
||||
# Render current state
|
||||
self.renderer.render(self.game_state, self.player_id)
|
||||
# Render current state with predictions
|
||||
self.renderer.render(self.game_state, self.player_id, self.predicted_heads)
|
||||
|
||||
# Maintain frame rate
|
||||
self.clock.tick(FPS)
|
||||
|
||||
@@ -30,13 +30,15 @@ class Renderer:
|
||||
self.font = pygame.font.Font(None, 36)
|
||||
self.small_font = pygame.font.Font(None, 24)
|
||||
|
||||
def render(self, game_state: Optional[GameState], player_id: Optional[str] = None) -> None:
|
||||
def render(self, game_state: Optional[GameState], player_id: Optional[str] = None, predicted_heads: dict = None) -> None:
|
||||
"""Render the current game state.
|
||||
|
||||
Args:
|
||||
game_state: Current game state to render
|
||||
player_id: ID of the current player (for highlighting)
|
||||
predicted_heads: Dict mapping player_id to predicted head Position
|
||||
"""
|
||||
predicted_heads = predicted_heads or {}
|
||||
# Clear screen
|
||||
self.screen.fill(COLOR_BACKGROUND)
|
||||
|
||||
@@ -65,6 +67,13 @@ class Renderer:
|
||||
head_color = tuple(min(c + 50, 255) for c in color)
|
||||
self.draw_cell(snake.body[0], head_color)
|
||||
|
||||
# Draw predicted head position (if available)
|
||||
if snake.player_id in predicted_heads:
|
||||
predicted_pos = predicted_heads[snake.player_id]
|
||||
# Draw with semi-transparent overlay (darker color)
|
||||
predicted_color = tuple(int(c * 0.6) for c in head_color)
|
||||
self.draw_cell(predicted_pos, predicted_color)
|
||||
|
||||
# Draw scores
|
||||
self.draw_scores(game_state, player_id)
|
||||
|
||||
@@ -105,7 +114,9 @@ class Renderer:
|
||||
player_id: Current player's ID
|
||||
"""
|
||||
y_offset = 10
|
||||
for snake in game_state.snakes:
|
||||
# Sort snakes by length descending
|
||||
sorted_snakes = sorted(game_state.snakes, key=lambda s: len(s.body), reverse=True)
|
||||
for snake in sorted_snakes:
|
||||
color = COLOR_SNAKES[snake.color_index % len(COLOR_SNAKES)]
|
||||
|
||||
# Prepare length text
|
||||
|
||||
@@ -14,6 +14,7 @@ from ..shared.protocol import (
|
||||
create_player_left_message,
|
||||
create_game_started_message,
|
||||
create_error_message,
|
||||
create_player_input_message,
|
||||
)
|
||||
from ..shared.constants import DEFAULT_HOST, DEFAULT_PORT, TICK_RATE, COLOR_SNAKES
|
||||
from .game_logic import GameLogic
|
||||
@@ -184,6 +185,16 @@ class GameServer:
|
||||
direction = tuple(message.data.get("direction", (1, 0)))
|
||||
self.game_logic.update_snake_direction(player_id, direction)
|
||||
|
||||
# Broadcast input to all clients for prediction
|
||||
snake = next((s for s in self.game_logic.state.snakes if s.player_id == player_id), None)
|
||||
if snake:
|
||||
input_msg = create_player_input_message(
|
||||
player_id,
|
||||
snake.direction,
|
||||
snake.input_buffer
|
||||
)
|
||||
await self.broadcast(input_msg)
|
||||
|
||||
async def handle_start_game(self) -> None:
|
||||
"""Start the game."""
|
||||
if self.game_logic.state.game_running:
|
||||
|
||||
@@ -21,6 +21,7 @@ class MessageType(Enum):
|
||||
GAME_STARTED = "GAME_STARTED"
|
||||
GAME_OVER = "GAME_OVER"
|
||||
ERROR = "ERROR"
|
||||
PLAYER_INPUT = "PLAYER_INPUT" # Broadcast player input for prediction
|
||||
|
||||
|
||||
class Message:
|
||||
@@ -114,3 +115,18 @@ def create_game_over_message(winner_id: str = None) -> Message:
|
||||
def create_error_message(error: str) -> Message:
|
||||
"""Create an ERROR message."""
|
||||
return Message(MessageType.ERROR, {"error": error})
|
||||
|
||||
|
||||
def create_player_input_message(player_id: str, direction: tuple, input_buffer: list) -> Message:
|
||||
"""Create a PLAYER_INPUT message for client-side prediction.
|
||||
|
||||
Args:
|
||||
player_id: ID of the player who sent the input
|
||||
direction: Current direction tuple
|
||||
input_buffer: List of buffered direction tuples (max 3)
|
||||
"""
|
||||
return Message(MessageType.PLAYER_INPUT, {
|
||||
"player_id": player_id,
|
||||
"direction": direction,
|
||||
"input_buffer": input_buffer
|
||||
})
|
||||
|
||||
56
web/game.js
56
web/game.js
@@ -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];
|
||||
|
||||
@@ -59,13 +59,13 @@
|
||||
<script src="protocol.js"></script>
|
||||
<script src="game.js"></script>
|
||||
<script>
|
||||
// Auto-detect WebSocket URL
|
||||
// Auto-detect WebSocket URL based on page hostname
|
||||
const wsUrl = document.getElementById('server-url');
|
||||
if (window.location.protocol === 'file:') {
|
||||
wsUrl.value = 'ws://localhost:8889';
|
||||
} else {
|
||||
const host = window.location.hostname;
|
||||
const port = window.location.port ? parseInt(window.location.port) + 889 : 8889;
|
||||
const port = 8889; // Default WebSocket port
|
||||
wsUrl.value = `ws://${host}:${port}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -17,7 +17,8 @@ const MessageType = {
|
||||
PLAYER_LEFT: 'PLAYER_LEFT',
|
||||
GAME_STARTED: 'GAME_STARTED',
|
||||
GAME_OVER: 'GAME_OVER',
|
||||
ERROR: 'ERROR'
|
||||
ERROR: 'ERROR',
|
||||
PLAYER_INPUT: 'PLAYER_INPUT' // Broadcast player input for prediction
|
||||
};
|
||||
|
||||
class Message {
|
||||
|
||||
Reference in New Issue
Block a user