WIP: Add multiplayer Snake game

N-player (2-8) real-time Snake game alongside Connect 4.
Lobby has tabs to switch between games. Players join via
invite link with 10-second countdown. Game loop runs at
tick-based intervals with NATS pub/sub for state sync.

Keyboard input not yet working (Datastar keydown binding
issue still under investigation).
This commit is contained in:
Ryan Hamamura
2026-02-02 07:26:28 -10:00
parent a6b5a46a8a
commit 7e78664534
18 changed files with 2289 additions and 40 deletions

View File

@@ -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
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
`
type CreateGameParams struct {
@@ -41,6 +41,10 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
&i.GameType,
&i.GridWidth,
&i.GridHeight,
&i.MaxPlayers,
)
return i, err
}
@@ -81,7 +85,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 FROM games WHERE 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 FROM games WHERE game_type = 'connect4' AND status < 2
`
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
@@ -103,6 +107,10 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
&i.GameType,
&i.GridWidth,
&i.GridHeight,
&i.MaxPlayers,
); err != nil {
return nil, err
}
@@ -118,7 +126,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 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 FROM games WHERE id = ?
`
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
@@ -134,6 +142,10 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
&i.GameType,
&i.GridWidth,
&i.GridHeight,
&i.MaxPlayers,
)
return i, err
}
@@ -174,7 +186,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 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 FROM games g
JOIN game_players gp ON g.id = gp.game_id
WHERE gp.user_id = ?
ORDER BY g.updated_at DESC
@@ -199,6 +211,10 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
&i.GameType,
&i.GridWidth,
&i.GridHeight,
&i.MaxPlayers,
); err != nil {
return nil, err
}
@@ -224,7 +240,7 @@ SELECT
FROM games g
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ?
LEFT JOIN game_players gp_opponent ON g.id = gp_opponent.game_id AND gp_opponent.slot != gp_user.slot
WHERE g.status < 2
WHERE g.game_type = 'connect4' AND g.status < 2
ORDER BY g.updated_at DESC
`

View File

@@ -18,6 +18,10 @@ type Game struct {
CreatedAt sql.NullTime
UpdatedAt sql.NullTime
RematchGameID sql.NullString
GameType string
GridWidth sql.NullInt64
GridHeight sql.NullInt64
MaxPlayers int64
}
type GamePlayer struct {

263
db/gen/snake_games.sql.go Normal file
View File

@@ -0,0 +1,263 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: snake_games.sql
package gen
import (
"context"
"database/sql"
)
const createSnakeGame = `-- name: CreateSnakeGame :one
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players)
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
`
type CreateSnakeGameParams struct {
ID string
Board string
Status int64
GridWidth sql.NullInt64
GridHeight sql.NullInt64
}
func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) {
row := q.db.QueryRowContext(ctx, createSnakeGame,
arg.ID,
arg.Board,
arg.Status,
arg.GridWidth,
arg.GridHeight,
)
var i Game
err := row.Scan(
&i.ID,
&i.Board,
&i.CurrentTurn,
&i.Status,
&i.WinnerUserID,
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
&i.GameType,
&i.GridWidth,
&i.GridHeight,
&i.MaxPlayers,
)
return i, err
}
const createSnakePlayer = `-- name: CreateSnakePlayer :exec
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
VALUES (?, ?, ?, ?, ?, ?)
`
type CreateSnakePlayerParams struct {
GameID string
UserID sql.NullString
GuestPlayerID sql.NullString
Nickname string
Color int64
Slot int64
}
func (q *Queries) CreateSnakePlayer(ctx context.Context, arg CreateSnakePlayerParams) error {
_, err := q.db.ExecContext(ctx, createSnakePlayer,
arg.GameID,
arg.UserID,
arg.GuestPlayerID,
arg.Nickname,
arg.Color,
arg.Slot,
)
return err
}
const deleteSnakeGame = `-- name: DeleteSnakeGame :exec
DELETE FROM games WHERE id = ? AND game_type = 'snake'
`
func (q *Queries) DeleteSnakeGame(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteSnakeGame, id)
return err
}
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
`
func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
rows, err := q.db.QueryContext(ctx, getActiveSnakeGames)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Game
for rows.Next() {
var i Game
if err := rows.Scan(
&i.ID,
&i.Board,
&i.CurrentTurn,
&i.Status,
&i.WinnerUserID,
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
&i.GameType,
&i.GridWidth,
&i.GridHeight,
&i.MaxPlayers,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
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'
`
func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
row := q.db.QueryRowContext(ctx, getSnakeGame, id)
var i Game
err := row.Scan(
&i.ID,
&i.Board,
&i.CurrentTurn,
&i.Status,
&i.WinnerUserID,
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
&i.GameType,
&i.GridWidth,
&i.GridHeight,
&i.MaxPlayers,
)
return i, err
}
const getSnakePlayers = `-- name: GetSnakePlayers :many
SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ? ORDER BY slot
`
func (q *Queries) GetSnakePlayers(ctx context.Context, gameID string) ([]GamePlayer, error) {
rows, err := q.db.QueryContext(ctx, getSnakePlayers, gameID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GamePlayer
for rows.Next() {
var i GamePlayer
if err := rows.Scan(
&i.GameID,
&i.UserID,
&i.GuestPlayerID,
&i.Nickname,
&i.Color,
&i.Slot,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUserActiveSnakeGames = `-- name: GetUserActiveSnakeGames :many
SELECT
g.id,
g.status,
g.grid_width,
g.grid_height,
g.updated_at
FROM games g
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ?
WHERE g.game_type = 'snake' AND g.status < 3
ORDER BY g.updated_at DESC
`
type GetUserActiveSnakeGamesRow struct {
ID string
Status int64
GridWidth sql.NullInt64
GridHeight sql.NullInt64
UpdatedAt sql.NullTime
}
func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullString) ([]GetUserActiveSnakeGamesRow, error) {
rows, err := q.db.QueryContext(ctx, getUserActiveSnakeGames, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetUserActiveSnakeGamesRow
for rows.Next() {
var i GetUserActiveSnakeGamesRow
if err := rows.Scan(
&i.ID,
&i.Status,
&i.GridWidth,
&i.GridHeight,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateSnakeGame = `-- name: UpdateSnakeGame :exec
UPDATE games
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND game_type = 'snake'
`
type UpdateSnakeGameParams struct {
Board string
Status int64
WinnerUserID sql.NullString
RematchGameID sql.NullString
ID string
}
func (q *Queries) UpdateSnakeGame(ctx context.Context, arg UpdateSnakeGameParams) error {
_, err := q.db.ExecContext(ctx, updateSnakeGame,
arg.Board,
arg.Status,
arg.WinnerUserID,
arg.RematchGameID,
arg.ID,
)
return err
}

View File

@@ -0,0 +1,13 @@
-- +goose Up
ALTER TABLE games ADD COLUMN game_type TEXT NOT NULL DEFAULT 'connect4';
ALTER TABLE games ADD COLUMN grid_width INTEGER;
ALTER TABLE games ADD COLUMN grid_height INTEGER;
ALTER TABLE games ADD COLUMN max_players INTEGER NOT NULL DEFAULT 2;
CREATE INDEX idx_games_game_type ON games(game_type);
-- +goose Down
DROP INDEX IF EXISTS idx_games_game_type;
ALTER TABLE games DROP COLUMN max_players;
ALTER TABLE games DROP COLUMN grid_height;
ALTER TABLE games DROP COLUMN grid_width;
ALTER TABLE games DROP COLUMN game_type;

View File

@@ -6,6 +6,7 @@ import (
"github.com/ryanhamamura/c4/db/gen"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/snake"
)
type GamePersister struct {
@@ -138,3 +139,144 @@ func (p *GamePersister) DeleteGame(id string) error {
ctx := context.Background()
return p.queries.DeleteGame(ctx, id)
}
// SnakePersister implements snake.Persister
type SnakePersister struct {
queries *gen.Queries
}
func NewSnakePersister(q *gen.Queries) *SnakePersister {
return &SnakePersister{queries: q}
}
func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
ctx := context.Background()
boardJSON := "{}"
if sg.State != nil {
boardJSON = sg.State.ToJSON()
}
var gridWidth, gridHeight sql.NullInt64
if sg.State != nil {
gridWidth = sql.NullInt64{Int64: int64(sg.State.Width), Valid: true}
gridHeight = sql.NullInt64{Int64: int64(sg.State.Height), Valid: true}
}
_, err := p.queries.GetSnakeGame(ctx, sg.ID)
if err == sql.ErrNoRows {
_, err = p.queries.CreateSnakeGame(ctx, gen.CreateSnakeGameParams{
ID: sg.ID,
Board: boardJSON,
Status: int64(sg.Status),
GridWidth: gridWidth,
GridHeight: gridHeight,
})
return err
}
if err != nil {
return err
}
var winnerUserID sql.NullString
if sg.Winner != nil && sg.Winner.UserID != nil {
winnerUserID = sql.NullString{String: *sg.Winner.UserID, Valid: true}
}
rematchGameID := sql.NullString{}
if sg.RematchGameID != nil {
rematchGameID = sql.NullString{String: *sg.RematchGameID, Valid: true}
}
return p.queries.UpdateSnakeGame(ctx, gen.UpdateSnakeGameParams{
Board: boardJSON,
Status: int64(sg.Status),
WinnerUserID: winnerUserID,
RematchGameID: rematchGameID,
ID: sg.ID,
})
}
func (p *SnakePersister) LoadSnakeGame(id string) (*snake.SnakeGame, error) {
ctx := context.Background()
row, err := p.queries.GetSnakeGame(ctx, id)
if err != nil {
return nil, err
}
state, err := snake.GameStateFromJSON(row.Board)
if err != nil {
state = &snake.GameState{}
}
if row.GridWidth.Valid {
state.Width = int(row.GridWidth.Int64)
}
if row.GridHeight.Valid {
state.Height = int(row.GridHeight.Int64)
}
sg := &snake.SnakeGame{
ID: row.ID,
State: state,
Players: make([]*snake.Player, 8),
Status: snake.Status(row.Status),
}
if row.RematchGameID.Valid {
sg.RematchGameID = &row.RematchGameID.String
}
return sg, nil
}
func (p *SnakePersister) SaveSnakePlayer(gameID string, player *snake.Player) error {
ctx := context.Background()
var userID, guestPlayerID sql.NullString
if player.UserID != nil {
userID = sql.NullString{String: *player.UserID, Valid: true}
} else {
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
}
return p.queries.CreateSnakePlayer(ctx, gen.CreateSnakePlayerParams{
GameID: gameID,
UserID: userID,
GuestPlayerID: guestPlayerID,
Nickname: player.Nickname,
Color: int64(player.Slot + 1),
Slot: int64(player.Slot),
})
}
func (p *SnakePersister) LoadSnakePlayers(gameID string) ([]*snake.Player, error) {
ctx := context.Background()
rows, err := p.queries.GetSnakePlayers(ctx, gameID)
if err != nil {
return nil, err
}
players := make([]*snake.Player, 0, len(rows))
for _, row := range rows {
player := &snake.Player{
Nickname: row.Nickname,
Slot: int(row.Slot),
}
if row.UserID.Valid {
player.UserID = &row.UserID.String
player.ID = snake.PlayerID(row.UserID.String)
} else if row.GuestPlayerID.Valid {
player.ID = snake.PlayerID(row.GuestPlayerID.String)
}
players = append(players, player)
}
return players, nil
}
func (p *SnakePersister) DeleteSnakeGame(id string) error {
ctx := context.Background()
return p.queries.DeleteSnakeGame(ctx, id)
}

View File

@@ -15,7 +15,7 @@ WHERE id = ?;
DELETE FROM games WHERE id = ?;
-- name: GetActiveGames :many
SELECT * FROM games WHERE status < 2;
SELECT * FROM games WHERE game_type = 'connect4' AND status < 2;
-- name: CreateGamePlayer :exec
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
@@ -41,5 +41,5 @@ SELECT
FROM games g
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ?
LEFT JOIN game_players gp_opponent ON g.id = gp_opponent.game_id AND gp_opponent.slot != gp_user.slot
WHERE g.status < 2
WHERE g.game_type = 'connect4' AND g.status < 2
ORDER BY g.updated_at DESC;

View File

@@ -0,0 +1,37 @@
-- name: CreateSnakeGame :one
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players)
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8)
RETURNING *;
-- name: GetSnakeGame :one
SELECT * FROM games WHERE id = ? AND game_type = 'snake';
-- name: UpdateSnakeGame :exec
UPDATE games
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND game_type = 'snake';
-- name: DeleteSnakeGame :exec
DELETE FROM games WHERE id = ? AND game_type = 'snake';
-- name: GetActiveSnakeGames :many
SELECT * FROM games WHERE game_type = 'snake' AND status < 2;
-- name: CreateSnakePlayer :exec
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
VALUES (?, ?, ?, ?, ?, ?);
-- name: GetSnakePlayers :many
SELECT * FROM game_players WHERE game_id = ? ORDER BY slot;
-- name: GetUserActiveSnakeGames :many
SELECT
g.id,
g.status,
g.grid_width,
g.grid_height,
g.updated_at
FROM games g
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ?
WHERE g.game_type = 'snake' AND g.status < 3
ORDER BY g.updated_at DESC;