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

@@ -13,7 +13,7 @@ import (
const createGame = `-- name: CreateGame :one const createGame = `-- name: CreateGame :one
INSERT INTO games (id, board, current_turn, status) INSERT INTO games (id, board, current_turn, status)
VALUES (?, ?, ?, ?) 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 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
` `
type CreateGameParams struct { type CreateGameParams struct {
@@ -45,6 +45,8 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
&i.GridWidth, &i.GridWidth,
&i.GridHeight, &i.GridHeight,
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode,
&i.Score,
) )
return i, err return i, err
} }
@@ -85,7 +87,7 @@ func (q *Queries) DeleteGame(ctx context.Context, id string) error {
} }
const getActiveGames = `-- name: GetActiveGames :many 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 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 FROM games WHERE game_type = 'connect4' AND status < 2
` `
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) { func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
@@ -111,6 +113,8 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
&i.GridWidth, &i.GridWidth,
&i.GridHeight, &i.GridHeight,
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode,
&i.Score,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -126,7 +130,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
} }
const getGame = `-- name: GetGame :one 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 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 FROM games WHERE id = ?
` `
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) { func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
@@ -146,6 +150,8 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
&i.GridWidth, &i.GridWidth,
&i.GridHeight, &i.GridHeight,
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode,
&i.Score,
) )
return i, err return i, err
} }
@@ -186,7 +192,7 @@ func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlay
} }
const getGamesByUserID = `-- name: GetGamesByUserID :many 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 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 FROM games g
JOIN game_players gp ON g.id = gp.game_id JOIN game_players gp ON g.id = gp.game_id
WHERE gp.user_id = ? WHERE gp.user_id = ?
ORDER BY g.updated_at DESC ORDER BY g.updated_at DESC
@@ -215,6 +221,8 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
&i.GridWidth, &i.GridWidth,
&i.GridHeight, &i.GridHeight,
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode,
&i.Score,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View File

@@ -22,6 +22,8 @@ type Game struct {
GridWidth sql.NullInt64 GridWidth sql.NullInt64
GridHeight sql.NullInt64 GridHeight sql.NullInt64
MaxPlayers int64 MaxPlayers int64
GameMode int64
Score int64
} }
type GamePlayer struct { type GamePlayer struct {

View File

@@ -11,9 +11,9 @@ import (
) )
const createSnakeGame = `-- name: CreateSnakeGame :one const createSnakeGame = `-- name: CreateSnakeGame :one
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players) INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode)
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8) 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 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
` `
type CreateSnakeGameParams struct { type CreateSnakeGameParams struct {
@@ -22,6 +22,7 @@ type CreateSnakeGameParams struct {
Status int64 Status int64
GridWidth sql.NullInt64 GridWidth sql.NullInt64
GridHeight sql.NullInt64 GridHeight sql.NullInt64
GameMode int64
} }
func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) { func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) {
@@ -31,6 +32,7 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams
arg.Status, arg.Status,
arg.GridWidth, arg.GridWidth,
arg.GridHeight, arg.GridHeight,
arg.GameMode,
) )
var i Game var i Game
err := row.Scan( err := row.Scan(
@@ -47,6 +49,8 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams
&i.GridWidth, &i.GridWidth,
&i.GridHeight, &i.GridHeight,
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode,
&i.Score,
) )
return i, err return i, err
} }
@@ -87,7 +91,7 @@ func (q *Queries) DeleteSnakeGame(ctx context.Context, id string) error {
} }
const getActiveSnakeGames = `-- name: GetActiveSnakeGames :many 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 FROM games WHERE game_type = 'snake' 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 FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0
` `
func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) { func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
@@ -113,6 +117,8 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
&i.GridWidth, &i.GridWidth,
&i.GridHeight, &i.GridHeight,
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode,
&i.Score,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -128,7 +134,7 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
} }
const getSnakeGame = `-- name: GetSnakeGame :one 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 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 FROM games WHERE id = ? AND game_type = 'snake'
` `
func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) { func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
@@ -148,6 +154,8 @@ func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
&i.GridWidth, &i.GridWidth,
&i.GridHeight, &i.GridHeight,
&i.MaxPlayers, &i.MaxPlayers,
&i.GameMode,
&i.Score,
) )
return i, err return i, err
} }
@@ -239,7 +247,7 @@ func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullSt
const updateSnakeGame = `-- name: UpdateSnakeGame :exec const updateSnakeGame = `-- name: UpdateSnakeGame :exec
UPDATE games UPDATE games
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND game_type = 'snake' WHERE id = ? AND game_type = 'snake'
` `
@@ -248,6 +256,7 @@ type UpdateSnakeGameParams struct {
Status int64 Status int64
WinnerUserID sql.NullString WinnerUserID sql.NullString
RematchGameID sql.NullString RematchGameID sql.NullString
Score int64
ID string ID string
} }
@@ -257,6 +266,7 @@ func (q *Queries) UpdateSnakeGame(ctx context.Context, arg UpdateSnakeGameParams
arg.Status, arg.Status,
arg.WinnerUserID, arg.WinnerUserID,
arg.RematchGameID, arg.RematchGameID,
arg.Score,
arg.ID, arg.ID,
) )
return err return err

View File

@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE games ADD COLUMN game_mode INTEGER NOT NULL DEFAULT 0;
ALTER TABLE games ADD COLUMN score INTEGER NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE games DROP COLUMN score;
ALTER TABLE games DROP COLUMN game_mode;

View File

@@ -171,6 +171,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
Status: int64(sg.Status), Status: int64(sg.Status),
GridWidth: gridWidth, GridWidth: gridWidth,
GridHeight: gridHeight, GridHeight: gridHeight,
GameMode: int64(sg.Mode),
}) })
return err return err
} }
@@ -193,6 +194,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
Status: int64(sg.Status), Status: int64(sg.Status),
WinnerUserID: winnerUserID, WinnerUserID: winnerUserID,
RematchGameID: rematchGameID, RematchGameID: rematchGameID,
Score: int64(sg.Score),
ID: sg.ID, ID: sg.ID,
}) })
} }
@@ -220,6 +222,8 @@ func (p *SnakePersister) LoadSnakeGame(id string) (*snake.SnakeGame, error) {
State: state, State: state,
Players: make([]*snake.Player, 8), Players: make([]*snake.Player, 8),
Status: snake.Status(row.Status), Status: snake.Status(row.Status),
Mode: snake.GameMode(row.GameMode),
Score: int(row.Score),
} }
if row.RematchGameID.Valid { if row.RematchGameID.Valid {

View File

@@ -1,6 +1,6 @@
-- name: CreateSnakeGame :one -- name: CreateSnakeGame :one
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players) INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode)
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8) VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?)
RETURNING *; RETURNING *;
-- name: GetSnakeGame :one -- name: GetSnakeGame :one
@@ -8,14 +8,14 @@ SELECT * FROM games WHERE id = ? AND game_type = 'snake';
-- name: UpdateSnakeGame :exec -- name: UpdateSnakeGame :exec
UPDATE games UPDATE games
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND game_type = 'snake'; WHERE id = ? AND game_type = 'snake';
-- name: DeleteSnakeGame :exec -- name: DeleteSnakeGame :exec
DELETE FROM games WHERE id = ? AND game_type = 'snake'; DELETE FROM games WHERE id = ? AND game_type = 'snake';
-- name: GetActiveSnakeGames :many -- name: GetActiveSnakeGames :many
SELECT * FROM games WHERE game_type = 'snake' AND status < 2; SELECT * FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0;
-- name: CreateSnakePlayer :exec -- name: CreateSnakePlayer :exec
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot) INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)

24
main.go
View File

@@ -152,17 +152,27 @@ func main() {
snakeNickname = c.Signal(username) snakeNickname = c.Signal(username)
} }
// Snake create game actions — one per preset // Snake create game actions — one per preset for solo and multiplayer
var snakePresetClicks []h.H var snakeSoloClicks []h.H
var snakeMultiClicks []h.H
for _, preset := range snake.GridPresets { for _, preset := range snake.GridPresets {
w, ht := preset.Width, preset.Height w, ht := preset.Width, preset.Height
snakePresetClicks = append(snakePresetClicks, c.Action(func() { snakeSoloClicks = append(snakeSoloClicks, c.Action(func() {
name := snakeNickname.String() name := snakeNickname.String()
if name == "" { if name == "" {
return return
} }
c.Session().Set("nickname", name) c.Session().Set("nickname", name)
si := snakeStore.Create(w, ht) si := snakeStore.Create(w, ht, snake.ModeSinglePlayer)
c.Redirectf("/snake/%s", si.ID())
}).OnClick())
snakeMultiClicks = append(snakeMultiClicks, c.Action(func() {
name := snakeNickname.String()
if name == "" {
return
}
c.Session().Set("nickname", name)
si := snakeStore.Create(w, ht, snake.ModeMultiplayer)
c.Redirectf("/snake/%s", si.ID()) c.Redirectf("/snake/%s", si.ID())
}).OnClick()) }).OnClick())
} }
@@ -181,7 +191,8 @@ func main() {
TabClickConnect4: tabClickConnect4.OnClick(), TabClickConnect4: tabClickConnect4.OnClick(),
TabClickSnake: tabClickSnake.OnClick(), TabClickSnake: tabClickSnake.OnClick(),
SnakeNicknameBind: snakeNickname.Bind(), SnakeNicknameBind: snakeNickname.Bind(),
SnakePresetClicks: snakePresetClicks, SnakeSoloClicks: snakeSoloClicks,
SnakeMultiClicks: snakeMultiClicks,
ActiveSnakeGames: snakeStore.ActiveGames(), ActiveSnakeGames: snakeStore.ActiveGames(),
}) })
}) })
@@ -599,7 +610,8 @@ func main() {
content = append(content, ui.SnakeBoard(sg)) content = append(content, ui.SnakeBoard(sg))
} }
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown { // Only show invite link for multiplayer games
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
content = append(content, ui.SnakeInviteLink(sg.ID)) content = append(content, ui.SnakeInviteLink(sg.ID))
} }

View File

@@ -152,8 +152,12 @@ func RemoveFood(state *GameState, indices []int) {
} }
// SpawnFood adds food items to maintain the target count. // SpawnFood adds food items to maintain the target count.
func SpawnFood(state *GameState, playerCount int) { // Single player always maintains exactly 1 food for classic snake feel.
func SpawnFood(state *GameState, playerCount int, mode GameMode) {
target := playerCount/2 + 1 target := playerCount/2 + 1
if mode == ModeSinglePlayer {
target = 1
}
for len(state.Food) < target { for len(state.Food) < target {
p := randomEmptyCell(state) p := randomEmptyCell(state)
if p == nil { if p == nil {

View File

@@ -9,13 +9,19 @@ const (
tickInterval = time.Second / targetFPS tickInterval = time.Second / targetFPS
snakeSpeed = 7 // cells per second snakeSpeed = 7 // cells per second
moveInterval = time.Second / snakeSpeed moveInterval = time.Second / snakeSpeed
countdownSeconds = 10 countdownSecondsMultiplayer = 10
countdownSecondsSinglePlayer = 3
inactivityLimit = 60 * time.Second inactivityLimit = 60 * time.Second
) )
func (si *SnakeGameInstance) startOrResetCountdownLocked() { func (si *SnakeGameInstance) startOrResetCountdownLocked() {
si.game.Status = StatusCountdown si.game.Status = StatusCountdown
si.game.CountdownEnd = time.Now().Add(countdownSeconds * time.Second)
countdown := countdownSecondsMultiplayer
if si.game.Mode == ModeSinglePlayer {
countdown = countdownSecondsSinglePlayer
}
si.game.CountdownEnd = time.Now().Add(time.Duration(countdown) * time.Second)
si.loopOnce.Do(func() { si.loopOnce.Do(func() {
si.stopCh = make(chan struct{}) si.stopCh = make(chan struct{})
@@ -86,7 +92,7 @@ func (si *SnakeGameInstance) initGame() {
state := si.game.State state := si.game.State
state.Snakes = SpawnSnakes(activeSlots, state.Width, state.Height) state.Snakes = SpawnSnakes(activeSlots, state.Width, state.Height)
SpawnFood(state, len(activeSlots)) SpawnFood(state, len(activeSlots), si.game.Mode)
} }
func (si *SnakeGameInstance) gamePhase() { func (si *SnakeGameInstance) gamePhase() {
@@ -152,17 +158,34 @@ func (si *SnakeGameInstance) gamePhase() {
// Check food eaten (only by surviving snakes) // Check food eaten (only by surviving snakes)
eaten := CheckFood(state) eaten := CheckFood(state)
RemoveFood(state, eaten) RemoveFood(state, eaten)
SpawnFood(state, si.game.PlayerCount()) SpawnFood(state, si.game.PlayerCount(), si.game.Mode)
// Track score for single player
si.game.Score += len(eaten)
// Check game over // Check game over
alive := AliveCount(state) alive := AliveCount(state)
gameOver := false
if si.game.Mode == ModeSinglePlayer {
// Single player ends when the player dies (alive == 0)
if alive == 0 {
gameOver = true
// No winner in single player - just final score
}
} else {
// Multiplayer ends when 1 or fewer alive
if alive <= 1 { if alive <= 1 {
si.game.Status = StatusFinished gameOver = true
winnerIdx := LastAlive(state) winnerIdx := LastAlive(state)
if winnerIdx >= 0 && winnerIdx < len(si.game.Players) { if winnerIdx >= 0 && winnerIdx < len(si.game.Players) {
si.game.Winner = si.game.Players[winnerIdx] si.game.Winner = si.game.Players[winnerIdx]
} }
} }
}
if gameOver {
si.game.Status = StatusFinished
}
if si.persister != nil { if si.persister != nil {
si.persister.SaveSnakeGame(si.game) si.persister.SaveSnakeGame(si.game)
@@ -171,7 +194,7 @@ func (si *SnakeGameInstance) gamePhase() {
si.gameMu.Unlock() si.gameMu.Unlock()
si.notify() si.notify()
if alive <= 1 { if gameOver {
return return
} }
} }

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

View File

@@ -14,6 +14,13 @@ const (
DirRight DirRight
) )
type GameMode int
const (
ModeMultiplayer GameMode = iota // Default (0) - backward compatible
ModeSinglePlayer // Single player survival mode
)
// Opposite returns true if a and b are 180-degree reversals. // Opposite returns true if a and b are 180-degree reversals.
func (d Direction) Opposite(other Direction) bool { func (d Direction) Opposite(other Direction) bool {
switch d { switch d {
@@ -88,6 +95,8 @@ type SnakeGame struct {
Winner *Player // nil if draw Winner *Player // nil if draw
CountdownEnd time.Time // when countdown reaches 0 CountdownEnd time.Time // when countdown reaches 0
RematchGameID *string RematchGameID *string
Mode GameMode // ModeMultiplayer or ModeSinglePlayer
Score int // tracks food eaten in single player
} }
func (sg *SnakeGame) IsFinished() bool { func (sg *SnakeGame) IsFinished() bool {
@@ -132,6 +141,7 @@ func (sg *SnakeGame) snapshot() *SnakeGame {
} }
cp.Players = make([]*Player, len(sg.Players)) cp.Players = make([]*Player, len(sg.Players))
copy(cp.Players, sg.Players) copy(cp.Players, sg.Players)
// Mode and Score are value types, already copied by *sg
return &cp return &cp
} }

View File

@@ -18,7 +18,8 @@ type LobbyProps struct {
TabClickConnect4 h.H TabClickConnect4 h.H
TabClickSnake h.H TabClickSnake h.H
SnakeNicknameBind h.H SnakeNicknameBind h.H
SnakePresetClicks []h.H SnakeSoloClicks []h.H
SnakeMultiClicks []h.H
ActiveSnakeGames []*snake.SnakeGame ActiveSnakeGames []*snake.SnakeGame
} }
@@ -40,7 +41,7 @@ func LobbyView(p LobbyProps) h.H {
var tabContent h.H var tabContent h.H
if p.ActiveTab == "snake" { if p.ActiveTab == "snake" {
tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakePresetClicks, p.ActiveSnakeGames) tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakeSoloClicks, p.SnakeMultiClicks, p.ActiveSnakeGames)
} else { } else {
tabContent = connect4LobbyContent(p) tabContent = connect4LobbyContent(p)
} }

View File

@@ -7,14 +7,32 @@ import (
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.SnakeGame) h.H { func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames []*snake.SnakeGame) h.H {
var presetButtons []h.H // Solo play buttons
var soloButtons []h.H
for i, preset := range snake.GridPresets { for i, preset := range snake.GridPresets {
var click h.H var click h.H
if i < len(presetClicks) { if i < len(soloClicks) {
click = presetClicks[i] click = soloClicks[i]
} }
presetButtons = append(presetButtons, soloButtons = append(soloButtons,
h.Button(
h.Class("btn btn-secondary"),
h.Type("button"),
h.Text(fmt.Sprintf("%s (%d×%d)", preset.Name, preset.Width, preset.Height)),
click,
),
)
}
// Multiplayer buttons
var multiButtons []h.H
for i, preset := range snake.GridPresets {
var click h.H
if i < len(multiClicks) {
click = multiClicks[i]
}
multiButtons = append(multiButtons,
h.Button( h.Button(
h.Class("btn btn-primary"), h.Class("btn btn-primary"),
h.Type("button"), h.Type("button"),
@@ -24,9 +42,7 @@ func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.Sn
) )
} }
createSection := h.Div(h.Class("mb-6"), nicknameField := h.Div(h.Class("mb-4"),
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Create Game")),
h.Div(h.Class("mb-4"),
h.FieldSet(h.Class("fieldset"), h.FieldSet(h.Class("fieldset"),
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")), h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")),
h.Input( h.Input(
@@ -38,8 +54,16 @@ func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.Sn
h.Attr("required"), h.Attr("required"),
), ),
), ),
), )
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, presetButtons...)...),
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...)...),
)
multiSection := h.Div(h.Class("mb-6"),
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Create Multiplayer Game")),
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, multiButtons...)...),
) )
var gameListEl h.H var gameListEl h.H
@@ -68,7 +92,9 @@ func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.Sn
} }
return h.Div( return h.Div(
createSection, nicknameField,
soloSection,
multiSection,
gameListEl, gameListEl,
) )
} }

View File

@@ -12,6 +12,11 @@ import (
func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H { func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
switch sg.Status { switch sg.Status {
case snake.StatusWaitingForPlayers: case snake.StatusWaitingForPlayers:
if sg.Mode == snake.ModeSinglePlayer {
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
h.Text("Ready?"),
)
}
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"), return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
h.Text("Waiting for players..."), h.Text("Waiting for players..."),
) )
@@ -35,6 +40,12 @@ func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
) )
} }
} }
// Show score during single player gameplay
if sg.Mode == snake.ModeSinglePlayer {
return h.Div(h.Class("alert alert-success text-xl font-bold"),
h.Text(fmt.Sprintf("Score: %d", sg.Score)),
)
}
return h.Div(h.Class("alert alert-success text-xl font-bold"), return h.Div(h.Class("alert alert-success text-xl font-bold"),
h.Text("Go!"), h.Text("Go!"),
) )
@@ -42,7 +53,11 @@ func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
case snake.StatusFinished: case snake.StatusFinished:
var msg string var msg string
var class string var class string
if sg.Winner != nil {
if sg.Mode == snake.ModeSinglePlayer {
msg = fmt.Sprintf("Game Over! Score: %d", sg.Score)
class = "alert alert-info text-xl font-bold"
} else if sg.Winner != nil {
if sg.Winner.Slot == mySlot { if sg.Winner.Slot == mySlot {
msg = "You win!" msg = "You win!"
class = "alert alert-success text-xl font-bold" class = "alert alert-success text-xl font-bold"