From ce492b0dc2d6ce7f6f7904a7ff553216bbc6deb7 Mon Sep 17 00:00:00 2001 From: Vladyslav Doloman Date: Sat, 4 Oct 2025 19:11:20 +0300 Subject: [PATCH] Add input buffering, auto-start, and gameplay improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input buffer system: - Added 3-slot direction input buffer to handle rapid key presses - Buffer ignores duplicate inputs (same key pressed multiple times) - Opposite direction replaces last buffered input (e.g., LEFT→RIGHT replaces LEFT) - Buffer overflow replaces last slot when full - Multi-segment snakes skip invalid 180° turns when consuming buffer - Head-only snakes (length=1) can turn 180° for flexibility Gameplay improvements: - Desktop client auto-starts game on connect (no SPACE needed) - Field populates with 3 apples when no players connected - HTTP server now binds to 0.0.0.0 for network access (matches game server) Testing: - Added 7 new tests for input buffer functionality - Added test for zero-player apple spawning - All 19 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- run_server.py | 3 +- src/client/game_client.py | 4 ++ src/server/game_logic.py | 44 ++++++++++--- src/shared/models.py | 3 + tests/test_game_logic.py | 128 ++++++++++++++++++++++++++++++++++++-- 5 files changed, 167 insertions(+), 15 deletions(-) diff --git a/run_server.py b/run_server.py index eb34613..ddd8cad 100644 --- a/run_server.py +++ b/run_server.py @@ -80,8 +80,7 @@ async def main() -> None: web_dir = Path(args.web_dir) if web_dir.exists(): # Use same host as game server for HTTP - http_host = args.host if args.host != "0.0.0.0" else "localhost" - http_server = HTTPServer(web_dir, args.http_port, http_host) + http_server = HTTPServer(web_dir, args.http_port, args.host) await http_server.start() else: print(f"Warning: Web directory '{web_dir}' not found. HTTP server disabled.") diff --git a/src/client/game_client.py b/src/client/game_client.py index e29d553..b7450b0 100644 --- a/src/client/game_client.py +++ b/src/client/game_client.py @@ -62,6 +62,10 @@ class GameClient: # Send JOIN message await self.send_message(create_join_message(self.player_name)) + # Automatically start the game + await self.send_message(create_start_game_message()) + print("Starting game...") + except Exception as e: print(f"Failed to connect to server: {e}") raise diff --git a/src/server/game_logic.py b/src/server/game_logic.py index cae8347..06273f7 100644 --- a/src/server/game_logic.py +++ b/src/server/game_logic.py @@ -104,7 +104,7 @@ class GameLogic: return False def update_snake_direction(self, player_id: str, direction: Tuple[int, int]) -> None: - """Update a snake's direction if valid. + """Update a snake's direction by adding to input buffer. Args: player_id: Player whose snake to update @@ -112,9 +112,19 @@ class GameLogic: """ for snake in self.state.snakes: if snake.player_id == player_id and snake.alive: - # Prevent 180-degree turns - if direction != OPPOSITE_DIRECTIONS.get(snake.direction): - snake.direction = direction + # Don't add duplicate inputs (same as last in buffer) + if snake.input_buffer and snake.input_buffer[-1] == direction: + break + + # If opposite to last in buffer, replace it + if snake.input_buffer and direction == OPPOSITE_DIRECTIONS.get(snake.input_buffer[-1]): + snake.input_buffer[-1] = direction + # If buffer not full, append + elif len(snake.input_buffer) < 3: + snake.input_buffer.append(direction) + # Buffer full, replace last slot + else: + snake.input_buffer[-1] = direction break def move_snakes(self) -> None: @@ -123,6 +133,18 @@ class GameLogic: if not snake.alive: # Skip disconnected players continue + # Consume direction from input buffer if available + while snake.input_buffer: + buffered_direction = snake.input_buffer.pop(0) + + # Skip 180-degree turns for multi-segment snakes + if len(snake.body) > 1 and buffered_direction == OPPOSITE_DIRECTIONS.get(snake.direction): + continue # Skip this buffered input, try next + + # Valid direction from buffer + snake.direction = buffered_direction + break + # Calculate next position based on current direction next_position = snake.get_head() + snake.direction @@ -160,6 +182,14 @@ class GameLogic: """Perform one game tick: move snakes and spawn food.""" self.move_snakes() - # Spawn food if needed - if len(self.state.food) < len([s for s in self.state.snakes if s.alive]): - self.state.food.append(self.spawn_food()) + # Spawn food based on player count + alive_snakes = [s for s in self.state.snakes if s.alive] + + if len(alive_snakes) == 0: + # No players - populate field with 3 apples + while len(self.state.food) < 3: + self.state.food.append(self.spawn_food()) + else: + # Normal game - 1 food per alive snake + if len(self.state.food) < len(alive_snakes): + self.state.food.append(self.spawn_food()) diff --git a/src/shared/models.py b/src/shared/models.py index 4a2f9d2..8dc4139 100644 --- a/src/shared/models.py +++ b/src/shared/models.py @@ -35,6 +35,7 @@ class Snake: stuck: bool = False # True when snake is blocked and shrinking color_index: int = 0 # Index in COLOR_SNAKES array for persistent color player_name: str = "" # Human-readable player name + input_buffer: List[Tuple[int, int]] = field(default_factory=list) # Buffer for pending direction changes (max 3) def get_head(self) -> Position: """Get the head position of the snake.""" @@ -51,6 +52,7 @@ class Snake: "stuck": self.stuck, "color_index": self.color_index, "player_name": self.player_name, + "input_buffer": self.input_buffer, } @classmethod @@ -64,6 +66,7 @@ class Snake: snake.stuck = data.get("stuck", False) # Default to False for backward compatibility snake.color_index = data.get("color_index", 0) # Default to 0 for backward compatibility snake.player_name = data.get("player_name", "") # Default to empty string for backward compatibility + snake.input_buffer = [tuple(d) for d in data.get("input_buffer", [])] # Default to empty list for backward compatibility return snake diff --git a/tests/test_game_logic.py b/tests/test_game_logic.py index 22edfbe..ffb0f09 100644 --- a/tests/test_game_logic.py +++ b/tests/test_game_logic.py @@ -29,18 +29,20 @@ class TestGameLogic: assert 0 <= food.position.y < GRID_HEIGHT def test_update_snake_direction(self) -> None: - """Test updating snake direction.""" + """Test updating snake direction via input buffer.""" logic = GameLogic() snake = logic.create_snake("player1") logic.state.snakes.append(snake) - # Valid direction change + # Direction changes go into buffer first logic.update_snake_direction("player1", UP) - assert snake.direction == UP + assert snake.input_buffer == [UP] + assert snake.direction == RIGHT # Original direction unchanged - # Invalid 180-degree turn (should be ignored) - logic.update_snake_direction("player1", DOWN) - assert snake.direction == UP # Should remain UP + # Moving consumes from buffer + logic.move_snakes() + assert snake.direction == UP # Now changed after movement + assert len(snake.input_buffer) == 0 def test_move_snakes(self) -> None: """Test snake movement.""" @@ -252,3 +254,117 @@ class TestGameLogic: assert len(snake_a.body) == 1 assert len(snake_b.body) == 1 + def test_input_buffer_fills_to_three(self) -> None: + """Test input buffer fills up to 3 directions.""" + logic = GameLogic() + snake = logic.create_snake("player1") + logic.state.snakes.append(snake) + + # Add 3 different directions + logic.update_snake_direction("player1", UP) + logic.update_snake_direction("player1", LEFT) + logic.update_snake_direction("player1", DOWN) + + assert len(snake.input_buffer) == 3 + assert snake.input_buffer == [UP, LEFT, DOWN] + + def test_input_buffer_ignores_duplicates(self) -> None: + """Test input buffer ignores duplicate inputs.""" + logic = GameLogic() + snake = logic.create_snake("player1") + logic.state.snakes.append(snake) + + logic.update_snake_direction("player1", UP) + logic.update_snake_direction("player1", UP) # Duplicate + + assert len(snake.input_buffer) == 1 + assert snake.input_buffer == [UP] + + def test_input_buffer_opposite_replacement(self) -> None: + """Test opposite direction replaces last in buffer.""" + logic = GameLogic() + snake = logic.create_snake("player1") + logic.state.snakes.append(snake) + + logic.update_snake_direction("player1", UP) + logic.update_snake_direction("player1", DOWN) # Opposite to UP + + # DOWN should replace UP + assert len(snake.input_buffer) == 1 + assert snake.input_buffer == [DOWN] + + def test_input_buffer_overflow_replacement(self) -> None: + """Test 4th input replaces last slot when buffer is full.""" + logic = GameLogic() + snake = logic.create_snake("player1") + logic.state.snakes.append(snake) + + # Fill buffer with 3 directions + logic.update_snake_direction("player1", UP) + logic.update_snake_direction("player1", LEFT) + logic.update_snake_direction("player1", DOWN) + + # 4th input should replace last slot + logic.update_snake_direction("player1", RIGHT) + + assert len(snake.input_buffer) == 3 + assert snake.input_buffer == [UP, LEFT, RIGHT] # DOWN replaced by RIGHT + + def test_input_buffer_consumption(self) -> None: + """Test buffer is consumed during movement.""" + logic = GameLogic() + snake = Snake(player_id="player1", body=[ + Position(5, 5), + Position(4, 5), + Position(3, 5), + ], direction=RIGHT) + logic.state.snakes.append(snake) + + # Add direction to buffer + snake.input_buffer = [UP] + + logic.move_snakes() + + # Buffer should be consumed and direction applied + assert len(snake.input_buffer) == 0 + assert snake.direction == UP + assert snake.get_head().y == 4 # Moved up + + def test_input_buffer_skips_180_turn(self) -> None: + """Test buffer skips 180-degree turns for multi-segment snakes.""" + logic = GameLogic() + snake = Snake(player_id="player1", body=[ + Position(5, 5), + Position(4, 5), + Position(3, 5), + ], direction=RIGHT) + logic.state.snakes.append(snake) + + # Buffer has opposite direction then valid direction + snake.input_buffer = [LEFT, UP] # LEFT is 180° from RIGHT + + logic.move_snakes() + + # LEFT should be skipped, UP should be applied + assert len(snake.input_buffer) == 0 + assert snake.direction == UP + assert snake.get_head().y == 4 # Moved up + + def test_zero_players_spawns_three_apples(self) -> None: + """Test field populates with 3 apples when no players.""" + logic = GameLogic() + logic.state.game_running = True + + # Start with no snakes and no food + logic.state.snakes = [] + logic.state.food = [] + + # Update should populate 3 apples + logic.update() + + assert len(logic.state.food) == 3 + + # Subsequent updates should maintain 3 apples + logic.update() + assert len(logic.state.food) == 3 +