10 Commits

Author SHA1 Message Date
Ryan Hamamura
427521505b feat: add Docker Compose deployment and serve assets via StaticFS
Embed full assets directory and serve with via's StaticFS instead of a
custom HTTP handler. Move SQLite DB to data/c4.db for clean volume
mounting. Add multi-stage Dockerfile, docker-compose.yml, and
.dockerignore.
2026-02-13 08:02:15 -10:00
Ryan Hamamura
b0449fbeb9 chore: rebuild CSS output 2026-02-12 15:10:38 -10:00
Ryan Hamamura
d2ed3cffd9 deps: update via to v0.15.0, remove vianats dependency
NATS is now built into via.New() automatically. Stores use the new
v.PubSub() accessor instead of the removed vianats package.
2026-02-12 15:10:18 -10:00
Ryan Hamamura
3d019fd948 feat: add snake multiplayer chat as sidebar with vivid player colors
Move chat to the right of the board using a flex wrapper that stacks
vertically on narrow screens. Restore original snake player colors by
removing the desaturation filter added with the dark theme.
2026-02-05 10:15:26 -10:00
Ryan Hamamura
0279615b36 feat: add back to lobby navigation on game pages 2026-02-05 09:55:43 -10:00
Ryan Hamamura
73128dc119 style: dark stealth theme with teal/burgundy pieces and cache-busting
Darken the DaisyUI theme and game board colors for a muted, low-chroma
aesthetic. Pieces use dark teal vs burgundy for subtle contrast.
Add MD5-based cache busting to the DaisyUI stylesheet link so CSS
changes are picked up without a hard refresh.
2026-02-05 09:51:36 -10:00
Ryan Hamamura
9a3d1fd164 refactor: reduce snake animation delays for snappier gameplay
Remove transitions and simplify head-pop animation to feel more
responsive at higher speed settings.
2026-02-04 10:04:27 -10:00
Ryan Hamamura
e239e948ae 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
2026-02-04 10:02:40 -10:00
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
Ryan Hamamura
7faf94fa6d feat: make invite link base URL configurable via APP_URL
Load environment variables from .env file using godotenv.
Defaults to https://demo.adriatica.io if APP_URL is not set.
2026-02-04 07:02:52 -10:00
27 changed files with 710 additions and 195 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
c4
c4.db
data/
deploy/
.env
.git
.gitignore
assets/css/output.css
c4-deploy-*.tar.gz
c4-deploy-*_b64*.txt

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Application URL for invite links (defaults to https://demo.adriatica.io)
# APP_URL=http://localhost:7331
# Server port (defaults to 7331)
# PORT=7331

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
c4 c4
c4.db c4.db
data/ data/
.env
# Deploy artifacts # Deploy artifacts
c4-deploy-*.tar.gz c4-deploy-*.tar.gz

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM golang:1.25.4-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /c4 .
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates wget && \
rm -rf /var/lib/apt/lists/*
COPY --from=build /c4 /usr/local/bin/c4
WORKDIR /app
RUN mkdir data && chown games:games data
USER games
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO /dev/null http://localhost:8080/
CMD ["c4"]

View File

@@ -7,35 +7,35 @@
default: true; default: true;
color-scheme: light; color-scheme: light;
/* Playful & brightwarm, casual board game feel */ /* Muted stealthdark, subtle chroma */
--color-base-100: oklch(98% 0.005 90); /* warm white */ --color-base-100: oklch(78% 0.008 260);
--color-base-200: oklch(95% 0.01 90); /* light cream */ --color-base-200: oklch(72% 0.008 260);
--color-base-300: oklch(90% 0.015 85); /* warm gray border */ --color-base-300: oklch(64% 0.008 260);
--color-base-content: oklch(25% 0.02 50); /* dark warm text */ --color-base-content: oklch(22% 0.01 260);
--color-primary: oklch(60% 0.22 30); /* warm red — matches game pieces */ --color-primary: oklch(38% 0.02 260);
--color-primary-content: oklch(98% 0.005 90); --color-primary-content: oklch(90% 0.006 260);
--color-secondary: oklch(82% 0.17 85); /* golden yellow — matches game pieces */ --color-secondary: oklch(52% 0.015 260);
--color-secondary-content: oklch(25% 0.02 50); --color-secondary-content: oklch(22% 0.01 260);
--color-accent: oklch(65% 0.19 250); /* board blue */ --color-accent: oklch(48% 0.02 280);
--color-accent-content: oklch(98% 0.005 90); --color-accent-content: oklch(90% 0.006 260);
--color-neutral: oklch(35% 0.03 260); --color-neutral: oklch(35% 0.015 260);
--color-neutral-content: oklch(95% 0.01 90); --color-neutral-content: oklch(88% 0.006 260);
--color-success: oklch(72% 0.19 155); --color-success: oklch(52% 0.02 160);
--color-success-content: oklch(20% 0.04 155); --color-success-content: oklch(22% 0.01 160);
--color-warning: oklch(82% 0.17 85); --color-warning: oklch(58% 0.02 80);
--color-warning-content: oklch(25% 0.04 85); --color-warning-content: oklch(28% 0.01 80);
--color-error: oklch(60% 0.22 30); --color-error: oklch(45% 0.03 20);
--color-error-content: oklch(97% 0.01 30); --color-error-content: oklch(90% 0.006 20);
--color-info: oklch(70% 0.15 240); --color-info: oklch(48% 0.02 250);
--color-info-content: oklch(20% 0.04 240); --color-info-content: oklch(22% 0.01 250);
--radius-selector: 0.5rem; --radius-selector: 0.5rem;
--radius-field: 0.5rem; --radius-field: 0.5rem;
--radius-box: 0.75rem; --radius-box: 0.75rem;
--border: 1px; --border: 1px;
--depth: 1; --depth: 0;
--noise: 0; --noise: 0;
}; };
@@ -43,62 +43,112 @@
.board { .board {
display: flex; display: flex;
gap: 8px; gap: 8px;
background: #2563eb; background: #334;
padding: 16px; padding: 16px;
border-radius: 12px; border-radius: 12px;
} }
.column { display: flex; flex-direction: column; gap: 8px; padding: 4px; border-radius: 8px; } .column { display: flex; flex-direction: column; gap: 8px; padding: 4px; border-radius: 8px; }
.column.clickable { cursor: pointer; } .column.clickable { cursor: pointer; }
.column.clickable:hover { background: rgba(255,255,255,0.15); } .column.clickable:hover { background: rgba(255,255,255,0.08); }
.cell { width: 48px; height: 48px; border-radius: 50%; background: #1e40af; transition: background 0.2s; } .cell { width: 48px; height: 48px; border-radius: 50%; background: #556; transition: background 0.2s; }
.cell.red { background: #dc2626; } .cell.red { background: #4a2a3a; }
.cell.yellow { background: #facc15; } .cell.yellow { background: #2a4545; }
.cell.winning { animation: pulse 0.5s ease-in-out infinite alternate; } .cell.winning { animation: pulse 0.5s ease-in-out infinite alternate; }
@keyframes pulse { @keyframes pulse {
from { transform: scale(1); box-shadow: 0 0 10px rgba(255,255,255,0.5); } from { transform: scale(1); box-shadow: 0 0 4px rgba(0,0,0,0.15); }
to { transform: scale(1.1); box-shadow: 0 0 20px rgba(255,255,255,0.8); } to { transform: scale(1.03); box-shadow: 0 0 8px rgba(0,0,0,0.25); }
} }
.player-chip { width: 20px; height: 20px; border-radius: 50%; background: #666; } .player-chip { width: 20px; height: 20px; border-radius: 50%; background: #445; }
.player-chip.red { background: #dc2626; } .player-chip.red { background: #4a2a3a; }
.player-chip.yellow { background: #facc15; } .player-chip.yellow { background: #2a4545; }
/* Snake game */ /* Snake game */
.snake-board { .snake-board {
display: inline-grid; display: inline-grid;
gap: 0; gap: 0;
background: #1a1a2e; background: #556;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
border: 3px solid #333; border: 3px solid #445;
} }
.snake-row { display: contents; } .snake-row { display: contents; }
.snake-cell { .snake-cell {
background: #16213e; background: #667;
border: 1px solid rgba(255,255,255,0.03); border: 1px solid rgba(0,0,0,0.08);
transition: background 130ms ease-in-out, box-shadow 130ms ease-out;
} }
.snake-cell.snake-head { .snake-cell.snake-head {
border-radius: 4px; border-radius: 4px;
animation: head-pop 130ms ease-out; animation: head-pop 50ms ease-out;
} }
@keyframes head-pop { @keyframes head-pop {
0% { transform: scale(0.6); } 0% { transform: scale(0.85); }
50% { transform: scale(1.08); }
100% { transform: scale(1); } 100% { transform: scale(1); }
} }
.snake-cell.snake-food { .snake-cell.snake-food {
background: #ff6b6b; background: #334;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 6px rgba(255,107,107,0.6); box-shadow: 0 0 3px rgba(0,0,0,0.2);
animation: food-pulse 1.2s ease-in-out infinite alternate; animation: food-pulse 1.2s ease-in-out infinite alternate;
} }
@keyframes food-pulse { @keyframes food-pulse {
from { box-shadow: 0 0 6px rgba(255,107,107,0.4); transform: scale(0.85); } from { box-shadow: 0 0 3px rgba(0,0,0,0.1); transform: scale(0.85); }
to { box-shadow: 0 0 12px rgba(255,107,107,0.9); transform: scale(1); } to { box-shadow: 0 0 6px rgba(0,0,0,0.2); transform: scale(1); }
} }
.snake-cell.snake-dead { .snake-cell.snake-dead {
opacity: 0.35; opacity: 0.35;
filter: grayscale(0.5); filter: grayscale(0.5);
transition: opacity 400ms ease-out, background 130ms ease-in-out; transition: opacity 400ms ease-out;
} }
.snake-wrapper:focus { outline: none; } .snake-wrapper:focus { outline: none; }
/* Snake game area: board + chat side-by-side */
.snake-game-area {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: center;
}
@media (max-width: 768px) {
.snake-game-area { flex-direction: column; align-items: center; }
}
/* Snake chat */
.snake-chat { width: 100%; max-width: 480px; }
.snake-game-area .snake-chat { width: 260px; max-width: none; flex-shrink: 0; }
.snake-chat-history {
height: 300px;
overflow-y: auto;
background: #334;
border-radius: 8px 8px 0 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.snake-chat-msg { font-size: 0.85rem; line-height: 1.3; }
.snake-chat-input {
display: flex;
gap: 0;
background: #445;
border-radius: 0 0 8px 8px;
overflow: hidden;
}
.snake-chat-input input {
flex: 1;
padding: 6px 10px;
background: transparent;
border: none;
color: inherit;
outline: none;
font-size: 0.85rem;
}
.snake-chat-input button {
padding: 6px 14px;
background: #556;
border: none;
color: inherit;
cursor: pointer;
font-size: 0.85rem;
}
.snake-chat-input button:hover { background: #667; }

File diff suppressed because one or more lines are too long

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, snake_speed
` `
type CreateGameParams struct { type CreateGameParams struct {
@@ -45,6 +45,9 @@ 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,
&i.SnakeSpeed,
) )
return i, err return i, err
} }
@@ -85,7 +88,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, snake_speed 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 +114,9 @@ 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,
&i.SnakeSpeed,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -126,7 +132,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, snake_speed 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 +152,9 @@ 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,
&i.SnakeSpeed,
) )
return i, err return i, err
} }
@@ -186,7 +195,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, g.snake_speed 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 +224,9 @@ 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,
&i.SnakeSpeed,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View File

@@ -22,6 +22,9 @@ type Game struct {
GridWidth sql.NullInt64 GridWidth sql.NullInt64
GridHeight sql.NullInt64 GridHeight sql.NullInt64
MaxPlayers int64 MaxPlayers int64
GameMode int64
Score int64
SnakeSpeed 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, snake_speed)
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, snake_speed
` `
type CreateSnakeGameParams struct { type CreateSnakeGameParams struct {
@@ -22,6 +22,8 @@ type CreateSnakeGameParams struct {
Status int64 Status int64
GridWidth sql.NullInt64 GridWidth sql.NullInt64
GridHeight sql.NullInt64 GridHeight sql.NullInt64
GameMode int64
SnakeSpeed 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 +33,8 @@ func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams
arg.Status, arg.Status,
arg.GridWidth, arg.GridWidth,
arg.GridHeight, arg.GridHeight,
arg.GameMode,
arg.SnakeSpeed,
) )
var i Game var i Game
err := row.Scan( err := row.Scan(
@@ -47,6 +51,9 @@ 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,
&i.SnakeSpeed,
) )
return i, err return i, err
} }
@@ -87,7 +94,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, snake_speed 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 +120,9 @@ 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,
&i.SnakeSpeed,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -128,7 +138,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, snake_speed 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 +158,9 @@ 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,
&i.SnakeSpeed,
) )
return i, err return i, err
} }
@@ -239,7 +252,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 +261,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 +271,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

@@ -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;

View File

@@ -171,6 +171,8 @@ 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),
SnakeSpeed: int64(sg.Speed),
}) })
return err return err
} }
@@ -193,6 +195,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 +223,9 @@ 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),
Speed: int(row.SnakeSpeed),
} }
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, snake_speed)
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)

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
c4:
build: .
container_name: c4
restart: unless-stopped
ports:
- "8080:8080"
env_file:
- path: .env
required: false
environment:
- PORT=8080
volumes:
- c4-data:/app/data
volumes:
c4-data:

5
go.mod
View File

@@ -4,10 +4,10 @@ go 1.25.4
require ( require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/pressly/goose/v3 v3.26.0 github.com/pressly/goose/v3 v3.26.0
github.com/ryanhamamura/via v0.4.0 github.com/ryanhamamura/via v0.15.0
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.47.0
maragu.dev/gomponents v1.2.0
modernc.org/sqlite v1.44.0 modernc.org/sqlite v1.44.0
) )
@@ -43,6 +43,7 @@ require (
golang.org/x/sync v0.18.0 // indirect golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
maragu.dev/gomponents v1.2.0 // indirect
modernc.org/libc v1.67.4 // indirect modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

6
go.sum
View File

@@ -33,6 +33,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hookenz/gotailwind/v4 v4.1.18 h1:1h3XwTVx1dEBm6A0bcosAplNCde+DCmVJG0arLy5fBE= github.com/hookenz/gotailwind/v4 v4.1.18 h1:1h3XwTVx1dEBm6A0bcosAplNCde+DCmVJG0arLy5fBE=
github.com/hookenz/gotailwind/v4 v4.1.18/go.mod h1:IfiJtdp8ExV9HV2XUiVjRBvB3QewVXVKWoAGEcpjfNE= github.com/hookenz/gotailwind/v4 v4.1.18/go.mod h1:IfiJtdp8ExV9HV2XUiVjRBvB3QewVXVKWoAGEcpjfNE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
@@ -76,8 +78,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/ryanhamamura/via v0.4.0 h1:/8gfjcPhTl+SEYTPF+Guc6qB2vuW+FtNRQv+HpkV2k8= github.com/ryanhamamura/via v0.15.0 h1:f9ZMzWZQamu8MgdKiPPX6U8rIGfI3P3zVlmd/DTUUQ0=
github.com/ryanhamamura/via v0.4.0/go.mod h1:w6dKEB+TYAyg2VTGh01doTjYP3xjDX7UO5Bis8nFt1A= github.com/ryanhamamura/via v0.15.0/go.mod h1:rpJewNVG6tgginZN7Be3qqRuol70+v1sFCKD4UjHsQo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU= github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=

186
main.go
View File

@@ -2,13 +2,19 @@ package main
import ( import (
"context" "context"
"crypto/md5"
"database/sql" "database/sql"
_ "embed" "embed"
"encoding/hex"
"encoding/json"
"io/fs"
"log" "log"
"net/http"
"os" "os"
"sync"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/ryanhamamura/c4/auth" "github.com/ryanhamamura/c4/auth"
"github.com/ryanhamamura/c4/db" "github.com/ryanhamamura/c4/db"
@@ -18,7 +24,6 @@ import (
"github.com/ryanhamamura/c4/ui" "github.com/ryanhamamura/c4/ui"
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
"github.com/ryanhamamura/via/vianats"
) )
var ( var (
@@ -27,15 +32,14 @@ var (
queries *gen.Queries queries *gen.Queries
) )
//go:embed assets/css/output.css //go:embed assets
var daisyUICSS []byte var assets embed.FS
func DaisyUIPlugin(v *via.V) { func DaisyUIPlugin(v *via.V) {
v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) { css, _ := fs.ReadFile(assets, "assets/css/output.css")
w.Header().Set("Content-Type", "text/css") sum := md5.Sum(css)
_, _ = w.Write(daisyUICSS) version := hex.EncodeToString(sum[:4])
}) v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/assets/css/output.css?v="+version)))
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/_plugins/daisyui/style.css")))
} }
func port() string { func port() string {
@@ -46,7 +50,12 @@ func port() string {
} }
func main() { func main() {
if err := db.Init("c4.db"); err != nil { _ = godotenv.Load()
if err := os.MkdirAll("data", 0o755); err != nil {
log.Fatal(err)
}
if err := db.Init("data/c4.db"); err != nil {
log.Fatal(err) log.Fatal(err)
} }
queries = gen.New(db.DB) queries = gen.New(db.DB)
@@ -58,24 +67,21 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
ctx := context.Background()
ns, err := vianats.New(ctx, "./data/nats")
if err != nil {
log.Fatal(err)
}
store.SetPubSub(ns)
snakeStore.SetPubSub(ns)
v := via.New() v := via.New()
v.Config(via.Options{ v.Config(via.Options{
LogLevel: via.LogLevelDebug, LogLevel: via.LogLevelDebug,
DocumentTitle: "Game Lobby", DocumentTitle: "Game Lobby",
ServerAddress: ":" + port(), ServerAddress: ":" + port(),
SessionManager: sessionManager, SessionManager: sessionManager,
PubSub: ns,
Plugins: []via.Plugin{DaisyUIPlugin}, Plugins: []via.Plugin{DaisyUIPlugin},
}) })
subFS, _ := fs.Sub(assets, "assets")
v.StaticFS("/assets/", subFS)
store.SetPubSub(v.PubSub())
snakeStore.SetPubSub(v.PubSub())
// Home page - tabbed lobby // Home page - tabbed lobby
v.Page("/", func(c *via.Context) { v.Page("/", func(c *via.Context) {
userID := c.Session().GetString("user_id") userID := c.Session().GetString("user_id")
@@ -149,37 +155,73 @@ func main() {
snakeNickname = c.Signal(username) snakeNickname = c.Signal(username)
} }
// Snake create game actions — one per preset // Speed selection signal (index into SpeedPresets, default to Normal which is index 1)
var snakePresetClicks []h.H 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
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) 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() {
name := snakeNickname.String()
if name == "" {
return
}
c.Session().Set("nickname", name)
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()) c.Redirectf("/snake/%s", si.ID())
}).OnClick()) }).OnClick())
} }
c.View(func() h.H { c.View(func() h.H {
return ui.LobbyView(ui.LobbyProps{ return ui.LobbyView(ui.LobbyProps{
NicknameBind: nickname.Bind(), NicknameBind: nickname.Bind(),
CreateGameKeyDown: createGame.OnKeyDown("Enter"), CreateGameKeyDown: createGame.OnKeyDown("Enter"),
CreateGameClick: createGame.OnClick(), CreateGameClick: createGame.OnClick(),
IsLoggedIn: isLoggedIn, IsLoggedIn: isLoggedIn,
Username: username, Username: username,
LogoutClick: logout.OnClick(), LogoutClick: logout.OnClick(),
UserGames: userGames, UserGames: userGames,
DeleteGameClick: deleteGame, DeleteGameClick: deleteGame,
ActiveTab: activeTab.String(), ActiveTab: activeTab.String(),
TabClickConnect4: tabClickConnect4.OnClick(), TabClickConnect4: tabClickConnect4.OnClick(),
TabClickSnake: tabClickSnake.OnClick(), TabClickSnake: tabClickSnake.OnClick(),
SnakeNicknameBind: snakeNickname.Bind(), SnakeNicknameBind: snakeNickname.Bind(),
SnakePresetClicks: snakePresetClicks, SnakeSoloClicks: snakeSoloClicks,
ActiveSnakeGames: snakeStore.ActiveGames(), SnakeMultiClicks: snakeMultiClicks,
ActiveSnakeGames: snakeStore.ActiveGames(),
SelectedSpeedIndex: selectedSpeedIndex.Int(),
SpeedSelectClicks: speedSelectClicks,
}) })
}) })
}) })
@@ -440,6 +482,7 @@ func main() {
var content []h.H var content []h.H
content = append(content, content = append(content,
ui.BackToLobby(),
h.H1(h.Class("text-3xl font-bold"), h.Text("Connect 4")), h.H1(h.Class("text-3xl font-bold"), h.Text("Connect 4")),
ui.PlayerInfo(g, myColor), ui.PlayerInfo(g, myColor),
ui.StatusBanner(g, myColor, createRematch.OnClick()), ui.StatusBanner(g, myColor, createRematch.OnClick()),
@@ -544,8 +587,51 @@ func main() {
} }
}) })
chatMsg := c.Signal("")
var chatMessages []ui.ChatMessage
var chatMu sync.Mutex
sendChat := c.Action(func() {
msg := chatMsg.String()
if msg == "" || si == nil {
return
}
slot := si.GetPlayerSlot(playerID)
if slot < 0 {
return
}
cm := ui.ChatMessage{
Nickname: si.GetGame().Players[slot].Nickname,
Slot: slot,
Message: msg,
Time: time.Now().UnixMilli(),
}
data, err := json.Marshal(cm)
if err != nil {
return
}
c.Publish("snake.chat."+gameID, data)
chatMsg.SetValue("")
})
if gameExists { if gameExists {
c.Subscribe("snake."+gameID, func(data []byte) { c.Sync() }) c.Subscribe("snake."+gameID, func(data []byte) { c.Sync() })
if si.GetGame().Mode == snake.ModeMultiplayer {
c.Subscribe("snake.chat."+gameID, func(data []byte) {
var cm ui.ChatMessage
if err := json.Unmarshal(data, &cm); err != nil {
return
}
chatMu.Lock()
chatMessages = append(chatMessages, cm)
if len(chatMessages) > 50 {
chatMessages = chatMessages[len(chatMessages)-50:]
}
chatMu.Unlock()
c.Sync()
})
}
} }
// Auto-join if nickname exists // Auto-join if nickname exists
@@ -587,16 +673,36 @@ func main() {
var content []h.H var content []h.H
content = append(content, content = append(content,
ui.BackToLobby(),
h.H1(h.Class("text-3xl font-bold"), h.Text("Snake")), h.H1(h.Class("text-3xl font-bold"), h.Text("Snake")),
ui.SnakePlayerList(sg, mySlot), ui.SnakePlayerList(sg, mySlot),
ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()), ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()),
) )
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished { if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
content = append(content, ui.SnakeBoard(sg)) board := ui.SnakeBoard(sg)
if sg.Mode == snake.ModeMultiplayer {
chatMu.Lock()
msgs := make([]ui.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
chat := ui.SnakeChat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter"))
content = append(content, h.Div(h.Class("snake-game-area"), board, chat))
} else {
content = append(content, board)
}
} else if sg.Mode == snake.ModeMultiplayer {
// Show chat even before game starts (waiting/countdown)
chatMu.Lock()
msgs := make([]ui.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
content = append(content, ui.SnakeChat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter")))
} }
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

@@ -5,17 +5,21 @@ import (
) )
const ( const (
targetFPS = 60 targetFPS = 60
tickInterval = time.Second / targetFPS tickInterval = time.Second / targetFPS
snakeSpeed = 7 // cells per second countdownSecondsMultiplayer = 10
moveInterval = time.Second / snakeSpeed countdownSecondsSinglePlayer = 3
countdownSeconds = 10 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 +90,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() {
@@ -108,18 +112,13 @@ func (si *SnakeGameInstance) gamePhase() {
return return
} }
// Apply pending directions every tick for responsive input // Track input activity for inactivity timeout
inputReceived := false
for i := 0; i < 8; i++ { for i := 0; i < 8; i++ {
if si.pendingDir[i] != nil && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil { if len(si.pendingDirQueue[i]) > 0 {
si.game.State.Snakes[i].Dir = *si.pendingDir[i] lastInput = time.Now()
si.pendingDir[i] = nil break
inputReceived = true
} }
} }
if inputReceived {
lastInput = time.Now()
}
// Inactivity timeout // Inactivity timeout
if time.Since(lastInput) > inactivityLimit { if time.Since(lastInput) > inactivityLimit {
@@ -132,7 +131,14 @@ func (si *SnakeGameInstance) gamePhase() {
return 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 moveAccum += tickInterval
if moveAccum < moveInterval { if moveAccum < moveInterval {
si.gameMu.Unlock() si.gameMu.Unlock()
@@ -140,6 +146,14 @@ func (si *SnakeGameInstance) gamePhase() {
} }
moveAccum -= moveInterval 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 state := si.game.State
// Advance snakes // Advance snakes
@@ -152,16 +166,33 @@ 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)
if alive <= 1 { gameOver := false
si.game.Status = StatusFinished if si.game.Mode == ModeSinglePlayer {
winnerIdx := LastAlive(state) // Single player ends when the player dies (alive == 0)
if winnerIdx >= 0 && winnerIdx < len(si.game.Players) { if alive == 0 {
si.game.Winner = si.game.Players[winnerIdx] gameOver = true
// No winner in single player - just final score
} }
} else {
// Multiplayer ends when 1 or fewer alive
if alive <= 1 {
gameOver = true
winnerIdx := LastAlive(state)
if winnerIdx >= 0 && winnerIdx < len(si.game.Players) {
si.game.Winner = si.game.Players[winnerIdx]
}
}
}
if gameOver {
si.game.Status = StatusFinished
} }
if si.persister != nil { if si.persister != nil {
@@ -171,7 +202,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,10 @@ func (ss *SnakeStore) makeNotify(gameID string) func() {
} }
} }
func (ss *SnakeStore) Create(width, height int) *SnakeGameInstance { func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *SnakeGameInstance {
if speed <= 0 {
speed = DefaultSpeed
}
id := generateID(4) id := generateID(4)
sg := &SnakeGame{ sg := &SnakeGame{
ID: id, ID: id,
@@ -57,6 +60,8 @@ func (ss *SnakeStore) Create(width, height int) *SnakeGameInstance {
}, },
Players: make([]*Player, 8), Players: make([]*Player, 8),
Status: StatusWaitingForPlayers, Status: StatusWaitingForPlayers,
Mode: mode,
Speed: speed,
} }
si := &SnakeGameInstance{ si := &SnakeGameInstance{
game: sg, game: sg,
@@ -134,8 +139,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 +153,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()
@@ -157,14 +162,14 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame {
} }
type SnakeGameInstance struct { type SnakeGameInstance struct {
game *SnakeGame game *SnakeGame
gameMu sync.RWMutex gameMu sync.RWMutex
pendingDir [8]*Direction pendingDirQueue [8][]Direction // queued directions per slot (max 3)
notify func() notify func()
persister Persister persister Persister
store *SnakeStore store *SnakeStore
stopCh chan struct{} stopCh chan struct{}
loopOnce sync.Once loopOnce sync.Once
} }
func (si *SnakeGameInstance) ID() string { func (si *SnakeGameInstance) ID() string {
@@ -220,15 +225,18 @@ 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()
} }
return true return true
} }
// SetDirection buffers a direction change for the given slot. // SetDirection queues a direction change for the given slot.
// The write happens under the game lock to avoid a data race with the game loop. // Validates against the last queued direction (or current snake dir) to prevent 180° turns.
func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) { func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
if slot < 0 || slot >= 8 { if slot < 0 || slot >= 8 {
return return
@@ -236,13 +244,28 @@ func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
si.gameMu.Lock() si.gameMu.Lock()
defer si.gameMu.Unlock() defer si.gameMu.Unlock()
if si.game.State != nil && slot < len(si.game.State.Snakes) { if si.game.State == nil || slot >= len(si.game.State.Snakes) {
s := si.game.State.Snakes[slot] return
if s != nil && s.Alive && !ValidateDirection(s.Dir, dir) {
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() { func (si *SnakeGameInstance) Stop() {
@@ -267,9 +290,11 @@ 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
speed := si.game.Speed
si.gameMu.Unlock() si.gameMu.Unlock()
newSI := si.store.Create(width, height) newSI := si.store.Create(width, height, mode, speed)
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,8 +95,26 @@ 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
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 { func (sg *SnakeGame) IsFinished() bool {
return sg.Status == StatusFinished return sg.Status == StatusFinished
} }
@@ -112,9 +137,11 @@ type GridPreset struct {
} }
var GridPresets = []GridPreset{ var GridPresets = []GridPreset{
{Name: "Tiny", Width: 15, Height: 15},
{Name: "Small", Width: 20, Height: 20}, {Name: "Small", Width: 20, Height: 20},
{Name: "Medium", Width: 30, Height: 20}, {Name: "Medium", Width: 30, Height: 20},
{Name: "Large", Width: 40, 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. // snapshot returns a shallow copy of the game safe for reading outside the lock.
@@ -132,6 +159,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

@@ -6,20 +6,27 @@ import (
) )
type LobbyProps struct { type LobbyProps struct {
NicknameBind h.H NicknameBind h.H
CreateGameKeyDown h.H CreateGameKeyDown h.H
CreateGameClick h.H CreateGameClick h.H
IsLoggedIn bool IsLoggedIn bool
Username string Username string
LogoutClick h.H LogoutClick h.H
UserGames []GameListItem UserGames []GameListItem
DeleteGameClick func(id string) h.H DeleteGameClick func(id string) h.H
ActiveTab string ActiveTab string
TabClickConnect4 h.H TabClickConnect4 h.H
TabClickSnake h.H TabClickSnake h.H
SnakeNicknameBind h.H SnakeNicknameBind h.H
SnakePresetClicks []h.H SnakeSoloClicks []h.H
ActiveSnakeGames []*snake.SnakeGame SnakeMultiClicks []h.H
ActiveSnakeGames []*snake.SnakeGame
SelectedSpeedIndex int
SpeedSelectClicks []h.H
}
func BackToLobby() h.H {
return h.A(h.Class("link text-sm opacity-70"), h.Href("/"), h.Text("← Back to Lobby"))
} }
func LobbyView(p LobbyProps) h.H { func LobbyView(p LobbyProps) h.H {
@@ -40,7 +47,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, p.SelectedSpeedIndex, p.SpeedSelectClicks)
} else { } else {
tabContent = connect4LobbyContent(p) tabContent = connect4LobbyContent(p)
} }

View File

@@ -45,10 +45,7 @@ func SnakeBoard(sg *snake.SnakeGame) h.H {
} }
// Cell size scales with grid dimensions // Cell size scales with grid dimensions
cellSize := 20 cellSize := cellSizeForGrid(state.Width, state.Height)
if state.Width <= 20 {
cellSize = 24
}
var rows []h.H var rows []h.H
for y := 0; y < state.Height; y++ { for y := 0; y < state.Height; y++ {
@@ -94,3 +91,22 @@ func SnakeBoard(sg *snake.SnakeGame) h.H {
attrs = append(attrs, rows...) attrs = append(attrs, rows...)
return h.Div(attrs...) 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
}
}

63
ui/snakechat.go Normal file
View File

@@ -0,0 +1,63 @@
package ui
import (
"fmt"
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/via/h"
)
type ChatMessage struct {
Nickname string `json:"nickname"`
Slot int `json:"slot"`
Message string `json:"message"`
Time int64 `json:"time"`
}
func SnakeChat(messages []ChatMessage, msgBind, sendClick, sendKeyDown h.H) h.H {
var msgEls []h.H
for _, m := range messages {
color := "#666"
if m.Slot >= 0 && m.Slot < len(snake.SnakeColors) {
color = snake.SnakeColors[m.Slot]
}
msgEls = append(msgEls, h.Div(h.Class("snake-chat-msg"),
h.Span(
h.Attr("style", fmt.Sprintf("color:%s;font-weight:bold;", color)),
h.Text(m.Nickname+": "),
),
h.Span(h.Text(m.Message)),
))
}
// Auto-scroll chat history to bottom on new messages
autoScroll := h.Script(h.Text(`
(function(){
var el = document.querySelector('.snake-chat-history');
if (!el) return;
el.scrollTop = el.scrollHeight;
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
.observe(el, {childList:true, subtree:true});
})();
`))
historyAttrs := []h.H{h.Class("snake-chat-history")}
historyAttrs = append(historyAttrs, msgEls...)
historyAttrs = append(historyAttrs, autoScroll)
return h.Div(h.Class("snake-chat"),
h.Div(historyAttrs...),
h.Div(h.Class("snake-chat-input"),
h.Input(
h.Type("text"),
h.Attr("placeholder", "Chat..."),
h.Attr("autocomplete", "off"),
// Prevent key events from bubbling to the game's window-level handler
h.Attr("onkeydown", "event.stopPropagation()"),
msgBind,
sendKeyDown,
),
h.Button(h.Type("button"), h.Text("Send"), sendClick),
),
)
}

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, selectedSpeedIndex int, speedSelectClicks []h.H) 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,22 +42,51 @@ 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.FieldSet(h.Class("fieldset"),
h.Div(h.Class("mb-4"), h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")),
h.FieldSet(h.Class("fieldset"), h.Input(
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")), h.Class("input input-bordered w-full"),
h.Input( h.ID("snake-nickname"),
h.Class("input input-bordered w-full"), h.Type("text"),
h.ID("snake-nickname"), h.Placeholder("Enter your nickname"),
h.Type("text"), nicknameBind,
h.Placeholder("Enter your nickname"), h.Attr("required"),
nicknameBind,
h.Attr("required"),
),
), ),
), ),
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, presetButtons...)...), )
// 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...)...),
)
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 +115,10 @@ func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.Sn
} }
return h.Div( return h.Div(
createSection, nicknameField,
speedSelector,
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"
@@ -130,7 +145,7 @@ func SnakePlayerList(sg *snake.SnakeGame, mySlot int) h.H {
} }
func SnakeInviteLink(gameID string) h.H { func SnakeInviteLink(gameID string) h.H {
fullURL := baseURL + "/snake/" + gameID fullURL := getBaseURL() + "/snake/" + gameID
return h.Div(h.Class("mt-4 text-center"), return h.Div(h.Class("mt-4 text-center"),
h.P(h.Text("Share this link to invite players:")), h.P(h.Text("Share this link to invite players:")),
h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"), h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"),

View File

@@ -1,6 +1,8 @@
package ui package ui
import ( import (
"os"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
@@ -115,10 +117,15 @@ func PlayerInfo(g *game.Game, myColor int) h.H {
) )
} }
const baseURL = "https://demo.adriatica.io" func getBaseURL() string {
if url := os.Getenv("APP_URL"); url != "" {
return url
}
return "https://demo.adriatica.io"
}
func InviteLink(gameID string) h.H { func InviteLink(gameID string) h.H {
fullURL := baseURL + "/game/" + gameID fullURL := getBaseURL() + "/game/" + gameID
return h.Div(h.Class("mt-4 text-center"), return h.Div(h.Class("mt-4 text-center"),
h.P(h.Text("Share this link with your opponent:")), h.P(h.Text("Share this link with your opponent:")),
h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"), h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"),