diff --git a/src/client/game_client.py b/src/client/game_client.py
index b7450b0..4e5b44b 100644
--- a/src/client/game_client.py
+++ b/src/client/game_client.py
@@ -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)
diff --git a/src/client/renderer.py b/src/client/renderer.py
index 57b9dd5..017778f 100644
--- a/src/client/renderer.py
+++ b/src/client/renderer.py
@@ -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
diff --git a/src/server/game_server.py b/src/server/game_server.py
index 86b1098..aa1ca34 100644
--- a/src/server/game_server.py
+++ b/src/server/game_server.py
@@ -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:
diff --git a/src/shared/protocol.py b/src/shared/protocol.py
index 322ca28..7545d1b 100644
--- a/src/shared/protocol.py
+++ b/src/shared/protocol.py
@@ -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
+ })
diff --git a/web/game.js b/web/game.js
index 7a95e13..5f71f8f 100644
--- a/web/game.js
+++ b/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];
diff --git a/web/index.html b/web/index.html
index a198c84..c30bd5f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -59,13 +59,13 @@
diff --git a/web/protocol.js b/web/protocol.js
index 35b33ed..0d4a9f0 100644
--- a/web/protocol.js
+++ b/web/protocol.js
@@ -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 {