feat: add configurable speed and expanded grid presets for snake

- Add per-game speed setting with presets (Slow/Normal/Fast/Insane)
- Add speed selector UI in snake lobby
- Expand grid presets with Tiny (15x15) and XL (50x30)
- Auto-calculate cell size based on grid dimensions
- Preserve speed setting in rematch games
This commit is contained in:
Ryan Hamamura
2026-02-04 10:02:40 -10:00
parent f454e0d220
commit e239e948ae
13 changed files with 199 additions and 69 deletions

View File

@@ -5,13 +5,11 @@ import (
)
const (
targetFPS = 60
tickInterval = time.Second / targetFPS
snakeSpeed = 7 // cells per second
moveInterval = time.Second / snakeSpeed
countdownSecondsMultiplayer = 10
countdownSecondsSinglePlayer = 3
inactivityLimit = 60 * time.Second
targetFPS = 60
tickInterval = time.Second / targetFPS
countdownSecondsMultiplayer = 10
countdownSecondsSinglePlayer = 3
inactivityLimit = 60 * time.Second
)
func (si *SnakeGameInstance) startOrResetCountdownLocked() {
@@ -114,18 +112,13 @@ func (si *SnakeGameInstance) gamePhase() {
return
}
// Apply pending directions every tick for responsive input
inputReceived := false
// Track input activity for inactivity timeout
for i := 0; i < 8; i++ {
if si.pendingDir[i] != nil && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil {
si.game.State.Snakes[i].Dir = *si.pendingDir[i]
si.pendingDir[i] = nil
inputReceived = true
if len(si.pendingDirQueue[i]) > 0 {
lastInput = time.Now()
break
}
}
if inputReceived {
lastInput = time.Now()
}
// Inactivity timeout
if time.Since(lastInput) > inactivityLimit {
@@ -138,7 +131,14 @@ func (si *SnakeGameInstance) gamePhase() {
return
}
// Only advance game state at snakeSpeed
// Compute move interval from per-game speed
speed := si.game.Speed
if speed <= 0 {
speed = DefaultSpeed
}
moveInterval := time.Second / time.Duration(speed)
// Only advance game state at game speed
moveAccum += tickInterval
if moveAccum < moveInterval {
si.gameMu.Unlock()
@@ -146,6 +146,14 @@ func (si *SnakeGameInstance) gamePhase() {
}
moveAccum -= moveInterval
// Pop one direction from queue per movement frame
for i := 0; i < 8; i++ {
if len(si.pendingDirQueue[i]) > 0 && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil {
si.game.State.Snakes[i].Dir = si.pendingDirQueue[i][0]
si.pendingDirQueue[i] = si.pendingDirQueue[i][1:]
}
}
state := si.game.State
// Advance snakes

View File

@@ -47,7 +47,10 @@ func (ss *SnakeStore) makeNotify(gameID string) func() {
}
}
func (ss *SnakeStore) Create(width, height int, mode GameMode) *SnakeGameInstance {
func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *SnakeGameInstance {
if speed <= 0 {
speed = DefaultSpeed
}
id := generateID(4)
sg := &SnakeGame{
ID: id,
@@ -58,6 +61,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode) *SnakeGameInstanc
Players: make([]*Player, 8),
Status: StatusWaitingForPlayers,
Mode: mode,
Speed: speed,
}
si := &SnakeGameInstance{
game: sg,
@@ -158,14 +162,14 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame {
}
type SnakeGameInstance struct {
game *SnakeGame
gameMu sync.RWMutex
pendingDir [8]*Direction
notify func()
persister Persister
store *SnakeStore
stopCh chan struct{}
loopOnce sync.Once
game *SnakeGame
gameMu sync.RWMutex
pendingDirQueue [8][]Direction // queued directions per slot (max 3)
notify func()
persister Persister
store *SnakeStore
stopCh chan struct{}
loopOnce sync.Once
}
func (si *SnakeGameInstance) ID() string {
@@ -231,8 +235,8 @@ func (si *SnakeGameInstance) Join(player *Player) bool {
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.
// SetDirection queues a direction change for the given slot.
// Validates against the last queued direction (or current snake dir) to prevent 180° turns.
func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
if slot < 0 || slot >= 8 {
return
@@ -240,13 +244,28 @@ func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
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
}
if si.game.State == nil || slot >= len(si.game.State.Snakes) {
return
}
si.pendingDir[slot] = &dir
s := si.game.State.Snakes[slot]
if s == nil || !s.Alive {
return
}
// Validate against last queued direction, or current snake direction if queue empty
refDir := s.Dir
if len(si.pendingDirQueue[slot]) > 0 {
refDir = si.pendingDirQueue[slot][len(si.pendingDirQueue[slot])-1]
}
if !ValidateDirection(refDir, dir) {
return
}
// Cap queue at 3 to prevent unbounded growth
if len(si.pendingDirQueue[slot]) >= 3 {
return
}
si.pendingDirQueue[slot] = append(si.pendingDirQueue[slot], dir)
}
func (si *SnakeGameInstance) Stop() {
@@ -272,9 +291,10 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
width := si.game.State.Width
height := si.game.State.Height
mode := si.game.Mode
speed := si.game.Speed
si.gameMu.Unlock()
newSI := si.store.Create(width, height, mode)
newSI := si.store.Create(width, height, mode, speed)
newID := newSI.ID()
si.gameMu.Lock()

View File

@@ -97,8 +97,24 @@ type SnakeGame struct {
RematchGameID *string
Mode GameMode // ModeMultiplayer or ModeSinglePlayer
Score int // tracks food eaten in single player
Speed int // cells per second
}
// Speed presets
type SpeedPreset struct {
Name string
Speed int
}
var SpeedPresets = []SpeedPreset{
{Name: "Slow", Speed: 5},
{Name: "Normal", Speed: 7},
{Name: "Fast", Speed: 10},
{Name: "Insane", Speed: 15},
}
const DefaultSpeed = 7
func (sg *SnakeGame) IsFinished() bool {
return sg.Status == StatusFinished
}
@@ -121,9 +137,11 @@ type GridPreset struct {
}
var GridPresets = []GridPreset{
{Name: "Tiny", Width: 15, Height: 15},
{Name: "Small", Width: 20, Height: 20},
{Name: "Medium", Width: 30, Height: 20},
{Name: "Large", Width: 40, Height: 20},
{Name: "XL", Width: 50, Height: 30},
}
// snapshot returns a shallow copy of the game safe for reading outside the lock.