Files
games/snake/store.go
Ryan Hamamura f454e0d220 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
2026-02-04 07:33:02 -10:00

302 lines
6.0 KiB
Go

package snake
import (
"crypto/rand"
"encoding/hex"
"sync"
)
type PubSub interface {
Publish(subject string, data []byte) error
}
type Persister interface {
SaveSnakeGame(sg *SnakeGame) error
LoadSnakeGame(id string) (*SnakeGame, error)
SaveSnakePlayer(gameID string, player *Player) error
LoadSnakePlayers(gameID string) ([]*Player, error)
DeleteSnakeGame(id string) error
}
type SnakeStore struct {
games map[string]*SnakeGameInstance
gamesMu sync.RWMutex
persister Persister
pubsub PubSub
}
func NewSnakeStore() *SnakeStore {
return &SnakeStore{
games: make(map[string]*SnakeGameInstance),
}
}
func (ss *SnakeStore) SetPersister(p Persister) {
ss.persister = p
}
func (ss *SnakeStore) SetPubSub(ps PubSub) {
ss.pubsub = ps
}
func (ss *SnakeStore) makeNotify(gameID string) func() {
return func() {
if ss.pubsub != nil {
ss.pubsub.Publish("snake."+gameID, nil)
}
}
}
func (ss *SnakeStore) Create(width, height int, mode GameMode) *SnakeGameInstance {
id := generateID(4)
sg := &SnakeGame{
ID: id,
State: &GameState{
Width: width,
Height: height,
},
Players: make([]*Player, 8),
Status: StatusWaitingForPlayers,
Mode: mode,
}
si := &SnakeGameInstance{
game: sg,
notify: ss.makeNotify(id),
persister: ss.persister,
store: ss,
}
ss.gamesMu.Lock()
ss.games[id] = si
ss.gamesMu.Unlock()
if ss.persister != nil {
ss.persister.SaveSnakeGame(sg)
}
return si
}
func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
ss.gamesMu.RLock()
si, ok := ss.games[id]
ss.gamesMu.RUnlock()
if ok {
return si, true
}
if ss.persister == nil {
return nil, false
}
sg, err := ss.persister.LoadSnakeGame(id)
if err != nil || sg == nil {
return nil, false
}
players, _ := ss.persister.LoadSnakePlayers(id)
if sg.Players == nil {
sg.Players = make([]*Player, 8)
}
for _, p := range players {
if p.Slot >= 0 && p.Slot < 8 {
sg.Players[p.Slot] = p
}
}
si = &SnakeGameInstance{
game: sg,
notify: ss.makeNotify(id),
persister: ss.persister,
store: ss,
}
ss.gamesMu.Lock()
ss.games[id] = si
ss.gamesMu.Unlock()
return si, true
}
func (ss *SnakeStore) Delete(id string) error {
ss.gamesMu.Lock()
si, ok := ss.games[id]
delete(ss.games, id)
ss.gamesMu.Unlock()
if ok && si != nil {
si.Stop()
}
if ss.persister != nil {
return ss.persister.DeleteSnakeGame(id)
}
return nil
}
// 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))
for _, si := range ss.games {
instances = append(instances, si)
}
ss.gamesMu.RUnlock()
var games []*SnakeGame
for _, si := range instances {
si.gameMu.RLock()
g := si.game
if g.Mode == ModeMultiplayer && (g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown) {
games = append(games, g)
}
si.gameMu.RUnlock()
}
return games
}
type SnakeGameInstance struct {
game *SnakeGame
gameMu sync.RWMutex
pendingDir [8]*Direction
notify func()
persister Persister
store *SnakeStore
stopCh chan struct{}
loopOnce sync.Once
}
func (si *SnakeGameInstance) ID() string {
si.gameMu.RLock()
defer si.gameMu.RUnlock()
return si.game.ID
}
// GetGame returns a snapshot of the game state safe for concurrent read.
func (si *SnakeGameInstance) GetGame() *SnakeGame {
si.gameMu.RLock()
defer si.gameMu.RUnlock()
return si.game.snapshot()
}
func (si *SnakeGameInstance) GetPlayerSlot(pid PlayerID) int {
si.gameMu.RLock()
defer si.gameMu.RUnlock()
for i, p := range si.game.Players {
if p != nil && p.ID == pid {
return i
}
}
return -1
}
func (si *SnakeGameInstance) Join(player *Player) bool {
si.gameMu.Lock()
defer si.gameMu.Unlock()
if si.game.Status == StatusInProgress || si.game.Status == StatusFinished {
return false
}
slot := -1
for i, p := range si.game.Players {
if p == nil {
slot = i
break
}
}
if slot == -1 {
return false
}
player.Slot = slot
si.game.Players[slot] = player
if si.persister != nil {
si.persister.SaveSnakePlayer(si.game.ID, player)
si.persister.SaveSnakeGame(si.game)
}
si.notify()
// 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()
}
return true
}
// SetDirection buffers a direction change for the given slot.
// The write happens under the game lock to avoid a data race with the game loop.
func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
if slot < 0 || slot >= 8 {
return
}
si.gameMu.Lock()
defer si.gameMu.Unlock()
if si.game.State != nil && slot < len(si.game.State.Snakes) {
s := si.game.State.Snakes[slot]
if s != nil && s.Alive && !ValidateDirection(s.Dir, dir) {
return
}
}
si.pendingDir[slot] = &dir
}
func (si *SnakeGameInstance) Stop() {
if si.stopCh != nil {
select {
case <-si.stopCh:
default:
close(si.stopCh)
}
}
}
func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
si.gameMu.Lock()
if !si.game.IsFinished() || si.game.RematchGameID != nil {
si.gameMu.Unlock()
return nil
}
// Capture state needed, then release lock before calling store.Create
// (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, mode)
newID := newSI.ID()
si.gameMu.Lock()
// Re-check after reacquiring lock
if si.game.RematchGameID != nil {
si.gameMu.Unlock()
return newSI
}
si.game.RematchGameID = &newID
if si.persister != nil {
si.persister.SaveSnakeGame(si.game)
}
si.gameMu.Unlock()
si.notify()
return newSI
}
func generateID(size int) string {
b := make([]byte, size)
rand.Read(b)
return hex.EncodeToString(b)
}