feat: add single player snake mode

Add solo mode where players survive as long as possible while tracking
score (food eaten). Single player games start with a shorter 3-second
countdown vs 10 seconds for multiplayer, maintain exactly 1 food item
for classic snake feel, and end when the player dies rather than when
one player remains.

- Add GameMode type (ModeMultiplayer/ModeSinglePlayer) and Score field
- Filter single player games from "Join a Game" lobby list
- Show "Ready?" and "Score: X" UI for single player mode
- Hide invite link for single player games
- Preserve game mode on rematch
This commit is contained in:
Ryan Hamamura
2026-02-04 07:33:02 -10:00
parent 7faf94fa6d
commit f454e0d220
14 changed files with 205 additions and 78 deletions

View File

@@ -47,7 +47,7 @@ func (ss *SnakeStore) makeNotify(gameID string) func() {
}
}
func (ss *SnakeStore) Create(width, height int) *SnakeGameInstance {
func (ss *SnakeStore) Create(width, height int, mode GameMode) *SnakeGameInstance {
id := generateID(4)
sg := &SnakeGame{
ID: id,
@@ -57,6 +57,7 @@ func (ss *SnakeStore) Create(width, height int) *SnakeGameInstance {
},
Players: make([]*Player, 8),
Status: StatusWaitingForPlayers,
Mode: mode,
}
si := &SnakeGameInstance{
game: sg,
@@ -134,8 +135,8 @@ func (ss *SnakeStore) Delete(id string) error {
return nil
}
// ActiveGames returns metadata of games that can be joined.
// Copies game data to avoid holding nested locks.
// ActiveGames returns metadata of multiplayer games that can be joined.
// Single player games are excluded. Copies game data to avoid holding nested locks.
func (ss *SnakeStore) ActiveGames() []*SnakeGame {
ss.gamesMu.RLock()
instances := make([]*SnakeGameInstance, 0, len(ss.games))
@@ -148,7 +149,7 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame {
for _, si := range instances {
si.gameMu.RLock()
g := si.game
if g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown {
if g.Mode == ModeMultiplayer && (g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown) {
games = append(games, g)
}
si.gameMu.RUnlock()
@@ -220,7 +221,10 @@ func (si *SnakeGameInstance) Join(player *Player) bool {
si.notify()
if si.game.PlayerCount() >= 2 {
// Single player starts countdown immediately when 1 player joins
if si.game.Mode == ModeSinglePlayer && si.game.PlayerCount() >= 1 {
si.startOrResetCountdownLocked()
} else if si.game.Mode == ModeMultiplayer && si.game.PlayerCount() >= 2 {
si.startOrResetCountdownLocked()
}
@@ -267,9 +271,10 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
// (which acquires gamesMu) to avoid lock ordering deadlock.
width := si.game.State.Width
height := si.game.State.Height
mode := si.game.Mode
si.gameMu.Unlock()
newSI := si.store.Create(width, height)
newSI := si.store.Create(width, height, mode)
newID := newSI.ID()
si.gameMu.Lock()