From e239e948aeed36355f09b0bcf11cd3330750fc7d Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:02:40 -1000 Subject: [PATCH] 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 --- db/gen/games.sql.go | 12 ++++-- db/gen/models.go | 1 + db/gen/snake_games.sql.go | 15 ++++--- db/migrations/005_add_snake_speed.sql | 5 +++ db/persister.go | 2 + db/queries/snake_games.sql | 4 +- main.go | 59 +++++++++++++++++++-------- snake/loop.go | 42 +++++++++++-------- snake/store.go | 56 +++++++++++++++++-------- snake/types.go | 18 ++++++++ ui/lobby.go | 4 +- ui/snakeboard.go | 24 +++++++++-- ui/snakelobby.go | 26 +++++++++++- 13 files changed, 199 insertions(+), 69 deletions(-) create mode 100644 db/migrations/005_add_snake_speed.sql diff --git a/db/gen/games.sql.go b/db/gen/games.sql.go index 535c921..6bb412d 100644 --- a/db/gen/games.sql.go +++ b/db/gen/games.sql.go @@ -13,7 +13,7 @@ import ( const createGame = `-- name: CreateGame :one INSERT INTO games (id, board, current_turn, status) VALUES (?, ?, ?, ?) -RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score +RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed ` type CreateGameParams struct { @@ -47,6 +47,7 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e &i.MaxPlayers, &i.GameMode, &i.Score, + &i.SnakeSpeed, ) return i, err } @@ -87,7 +88,7 @@ func (q *Queries) DeleteGame(ctx context.Context, id string) error { } const getActiveGames = `-- name: GetActiveGames :many -SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score FROM games WHERE game_type = 'connect4' AND status < 2 +SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE game_type = 'connect4' AND status < 2 ` func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) { @@ -115,6 +116,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) { &i.MaxPlayers, &i.GameMode, &i.Score, + &i.SnakeSpeed, ); err != nil { return nil, err } @@ -130,7 +132,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) { } const getGame = `-- name: GetGame :one -SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score FROM games WHERE id = ? +SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE id = ? ` func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) { @@ -152,6 +154,7 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) { &i.MaxPlayers, &i.GameMode, &i.Score, + &i.SnakeSpeed, ) return i, err } @@ -192,7 +195,7 @@ func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlay } const getGamesByUserID = `-- name: GetGamesByUserID :many -SELECT g.id, g.board, g.current_turn, g.status, g.winner_user_id, g.winning_cells, g.created_at, g.updated_at, g.rematch_game_id, g.game_type, g.grid_width, g.grid_height, g.max_players, g.game_mode, g.score FROM games g +SELECT g.id, g.board, g.current_turn, g.status, g.winner_user_id, g.winning_cells, g.created_at, g.updated_at, g.rematch_game_id, g.game_type, g.grid_width, g.grid_height, g.max_players, g.game_mode, g.score, g.snake_speed FROM games g JOIN game_players gp ON g.id = gp.game_id WHERE gp.user_id = ? ORDER BY g.updated_at DESC @@ -223,6 +226,7 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) ( &i.MaxPlayers, &i.GameMode, &i.Score, + &i.SnakeSpeed, ); err != nil { return nil, err } diff --git a/db/gen/models.go b/db/gen/models.go index 343e284..b7e8603 100644 --- a/db/gen/models.go +++ b/db/gen/models.go @@ -24,6 +24,7 @@ type Game struct { MaxPlayers int64 GameMode int64 Score int64 + SnakeSpeed int64 } type GamePlayer struct { diff --git a/db/gen/snake_games.sql.go b/db/gen/snake_games.sql.go index 2314308..ec1d547 100644 --- a/db/gen/snake_games.sql.go +++ b/db/gen/snake_games.sql.go @@ -11,9 +11,9 @@ import ( ) const createSnakeGame = `-- name: CreateSnakeGame :one -INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode) -VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?) -RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score +INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed) +VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?) +RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed ` type CreateSnakeGameParams struct { @@ -23,6 +23,7 @@ type CreateSnakeGameParams struct { GridWidth sql.NullInt64 GridHeight sql.NullInt64 GameMode int64 + SnakeSpeed int64 } func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) { @@ -33,6 +34,7 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams arg.GridWidth, arg.GridHeight, arg.GameMode, + arg.SnakeSpeed, ) var i Game err := row.Scan( @@ -51,6 +53,7 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams &i.MaxPlayers, &i.GameMode, &i.Score, + &i.SnakeSpeed, ) return i, err } @@ -91,7 +94,7 @@ func (q *Queries) DeleteSnakeGame(ctx context.Context, id string) error { } const getActiveSnakeGames = `-- name: GetActiveSnakeGames :many -SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0 +SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0 ` func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) { @@ -119,6 +122,7 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) { &i.MaxPlayers, &i.GameMode, &i.Score, + &i.SnakeSpeed, ); err != nil { return nil, err } @@ -134,7 +138,7 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) { } const getSnakeGame = `-- name: GetSnakeGame :one -SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score FROM games WHERE id = ? AND game_type = 'snake' +SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE id = ? AND game_type = 'snake' ` func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) { @@ -156,6 +160,7 @@ func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) { &i.MaxPlayers, &i.GameMode, &i.Score, + &i.SnakeSpeed, ) return i, err } diff --git a/db/migrations/005_add_snake_speed.sql b/db/migrations/005_add_snake_speed.sql new file mode 100644 index 0000000..cbb148c --- /dev/null +++ b/db/migrations/005_add_snake_speed.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE games ADD COLUMN snake_speed INTEGER NOT NULL DEFAULT 7; + +-- +goose Down +ALTER TABLE games DROP COLUMN snake_speed; diff --git a/db/persister.go b/db/persister.go index 891ba39..3fb16c0 100644 --- a/db/persister.go +++ b/db/persister.go @@ -172,6 +172,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error { GridWidth: gridWidth, GridHeight: gridHeight, GameMode: int64(sg.Mode), + SnakeSpeed: int64(sg.Speed), }) return err } @@ -224,6 +225,7 @@ func (p *SnakePersister) LoadSnakeGame(id string) (*snake.SnakeGame, error) { Status: snake.Status(row.Status), Mode: snake.GameMode(row.GameMode), Score: int(row.Score), + Speed: int(row.SnakeSpeed), } if row.RematchGameID.Valid { diff --git a/db/queries/snake_games.sql b/db/queries/snake_games.sql index 3a8d5cc..b5356c3 100644 --- a/db/queries/snake_games.sql +++ b/db/queries/snake_games.sql @@ -1,6 +1,6 @@ -- name: CreateSnakeGame :one -INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode) -VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?) +INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed) +VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?) RETURNING *; -- name: GetSnakeGame :one diff --git a/main.go b/main.go index 1278aa6..0485231 100644 --- a/main.go +++ b/main.go @@ -152,6 +152,19 @@ func main() { snakeNickname = c.Signal(username) } + // Speed selection signal (index into SpeedPresets, default to Normal which is index 1) + selectedSpeedIndex := c.Signal(1) + + // Speed selector actions + var speedSelectClicks []h.H + for i := range snake.SpeedPresets { + idx := i + speedSelectClicks = append(speedSelectClicks, c.Action(func() { + selectedSpeedIndex.SetValue(idx) + c.Sync() + }).OnClick()) + } + // Snake create game actions — one per preset for solo and multiplayer var snakeSoloClicks []h.H var snakeMultiClicks []h.H @@ -163,7 +176,12 @@ func main() { return } c.Session().Set("nickname", name) - si := snakeStore.Create(w, ht, snake.ModeSinglePlayer) + speedIdx := selectedSpeedIndex.Int() + speed := snake.DefaultSpeed + if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) { + speed = snake.SpeedPresets[speedIdx].Speed + } + si := snakeStore.Create(w, ht, snake.ModeSinglePlayer, speed) c.Redirectf("/snake/%s", si.ID()) }).OnClick()) snakeMultiClicks = append(snakeMultiClicks, c.Action(func() { @@ -172,28 +190,35 @@ func main() { return } c.Session().Set("nickname", name) - si := snakeStore.Create(w, ht, snake.ModeMultiplayer) + speedIdx := selectedSpeedIndex.Int() + speed := snake.DefaultSpeed + if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) { + speed = snake.SpeedPresets[speedIdx].Speed + } + si := snakeStore.Create(w, ht, snake.ModeMultiplayer, speed) c.Redirectf("/snake/%s", si.ID()) }).OnClick()) } c.View(func() h.H { return ui.LobbyView(ui.LobbyProps{ - NicknameBind: nickname.Bind(), - CreateGameKeyDown: createGame.OnKeyDown("Enter"), - CreateGameClick: createGame.OnClick(), - IsLoggedIn: isLoggedIn, - Username: username, - LogoutClick: logout.OnClick(), - UserGames: userGames, - DeleteGameClick: deleteGame, - ActiveTab: activeTab.String(), - TabClickConnect4: tabClickConnect4.OnClick(), - TabClickSnake: tabClickSnake.OnClick(), - SnakeNicknameBind: snakeNickname.Bind(), - SnakeSoloClicks: snakeSoloClicks, - SnakeMultiClicks: snakeMultiClicks, - ActiveSnakeGames: snakeStore.ActiveGames(), + NicknameBind: nickname.Bind(), + CreateGameKeyDown: createGame.OnKeyDown("Enter"), + CreateGameClick: createGame.OnClick(), + IsLoggedIn: isLoggedIn, + Username: username, + LogoutClick: logout.OnClick(), + UserGames: userGames, + DeleteGameClick: deleteGame, + ActiveTab: activeTab.String(), + TabClickConnect4: tabClickConnect4.OnClick(), + TabClickSnake: tabClickSnake.OnClick(), + SnakeNicknameBind: snakeNickname.Bind(), + SnakeSoloClicks: snakeSoloClicks, + SnakeMultiClicks: snakeMultiClicks, + ActiveSnakeGames: snakeStore.ActiveGames(), + SelectedSpeedIndex: selectedSpeedIndex.Int(), + SpeedSelectClicks: speedSelectClicks, }) }) }) diff --git a/snake/loop.go b/snake/loop.go index 82fbd03..9d46c09 100644 --- a/snake/loop.go +++ b/snake/loop.go @@ -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 diff --git a/snake/store.go b/snake/store.go index b36c2ca..ea90557 100644 --- a/snake/store.go +++ b/snake/store.go @@ -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() diff --git a/snake/types.go b/snake/types.go index 4e0b9eb..5bb21a9 100644 --- a/snake/types.go +++ b/snake/types.go @@ -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. diff --git a/ui/lobby.go b/ui/lobby.go index 1f0e8a5..6911759 100644 --- a/ui/lobby.go +++ b/ui/lobby.go @@ -21,6 +21,8 @@ type LobbyProps struct { SnakeSoloClicks []h.H SnakeMultiClicks []h.H ActiveSnakeGames []*snake.SnakeGame + SelectedSpeedIndex int + SpeedSelectClicks []h.H } func LobbyView(p LobbyProps) h.H { @@ -41,7 +43,7 @@ func LobbyView(p LobbyProps) h.H { var tabContent h.H if p.ActiveTab == "snake" { - tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakeSoloClicks, p.SnakeMultiClicks, p.ActiveSnakeGames) + tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakeSoloClicks, p.SnakeMultiClicks, p.ActiveSnakeGames, p.SelectedSpeedIndex, p.SpeedSelectClicks) } else { tabContent = connect4LobbyContent(p) } diff --git a/ui/snakeboard.go b/ui/snakeboard.go index 56c2f7a..15097b8 100644 --- a/ui/snakeboard.go +++ b/ui/snakeboard.go @@ -45,10 +45,7 @@ func SnakeBoard(sg *snake.SnakeGame) h.H { } // Cell size scales with grid dimensions - cellSize := 20 - if state.Width <= 20 { - cellSize = 24 - } + cellSize := cellSizeForGrid(state.Width, state.Height) var rows []h.H for y := 0; y < state.Height; y++ { @@ -94,3 +91,22 @@ func SnakeBoard(sg *snake.SnakeGame) h.H { attrs = append(attrs, rows...) return h.Div(attrs...) } + +func cellSizeForGrid(width, height int) int { + maxDim := width + if height > maxDim { + maxDim = height + } + switch { + case maxDim <= 15: + return 28 + case maxDim <= 20: + return 24 + case maxDim <= 30: + return 20 + case maxDim <= 40: + return 16 + default: + return 14 + } +} diff --git a/ui/snakelobby.go b/ui/snakelobby.go index d35e59d..e8b12e5 100644 --- a/ui/snakelobby.go +++ b/ui/snakelobby.go @@ -7,7 +7,7 @@ import ( "github.com/ryanhamamura/via/h" ) -func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames []*snake.SnakeGame) h.H { +func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames []*snake.SnakeGame, selectedSpeedIndex int, speedSelectClicks []h.H) h.H { // Solo play buttons var soloButtons []h.H for i, preset := range snake.GridPresets { @@ -56,6 +56,29 @@ func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames ), ) + // Speed selector + var speedButtons []h.H + for i, preset := range snake.SpeedPresets { + btnClass := "btn btn-sm" + if i == selectedSpeedIndex { + btnClass += " btn-active" + } + var click h.H + if i < len(speedSelectClicks) { + click = speedSelectClicks[i] + } + speedButtons = append(speedButtons, h.Button( + h.Class(btnClass), + h.Type("button"), + h.Text(preset.Name), + click, + )) + } + speedSelector := h.Div(h.Class("mb-4"), + h.Label(h.Class("label"), h.Text("Speed")), + h.Div(append([]h.H{h.Class("btn-group")}, speedButtons...)...), + ) + soloSection := h.Div(h.Class("mb-6"), h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Play Solo")), h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, soloButtons...)...), @@ -93,6 +116,7 @@ func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames return h.Div( nicknameField, + speedSelector, soloSection, multiSection, gameListEl,