Add input buffering, auto-start, and gameplay improvements

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 <noreply@anthropic.com>
This commit is contained in:
Vladyslav Doloman
2025-10-04 19:11:20 +03:00
parent 97d6df1896
commit ce492b0dc2
5 changed files with 167 additions and 15 deletions

View File

@@ -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.")

View File

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

View File

@@ -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())

View File

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

View File

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