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

@@ -61,3 +61,27 @@
.player-chip { width: 20px; height: 20px; border-radius: 50%; background: #666; } .player-chip { width: 20px; height: 20px; border-radius: 50%; background: #666; }
.player-chip.red { background: #dc2626; } .player-chip.red { background: #dc2626; }
.player-chip.yellow { background: #facc15; } .player-chip.yellow { background: #facc15; }
/* Snake game */
.snake-board {
display: inline-grid;
gap: 0;
background: #1a1a2e;
border-radius: 8px;
overflow: hidden;
border: 3px solid #333;
}
.snake-row { display: contents; }
.snake-cell {
background: #16213e;
border: 1px solid rgba(255,255,255,0.03);
transition: background 0.05s;
}
.snake-cell.snake-food {
background: #ff6b6b;
border-radius: 50%;
box-shadow: 0 0 6px rgba(255,107,107,0.6);
}
.snake-cell.snake-head { border-radius: 4px; }
.snake-cell.snake-dead { opacity: 0.35; }
.snake-wrapper:focus { outline: none; }

View File

@@ -12,6 +12,7 @@
--color-white: #fff; --color-white: #fff;
--spacing: 0.25rem; --spacing: 0.25rem;
--container-sm: 24rem; --container-sm: 24rem;
--container-md: 28rem;
--text-xs: 0.75rem; --text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75); --text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem; --text-sm: 0.875rem;
@@ -179,6 +180,93 @@
} }
} }
@layer utilities { @layer utilities {
.tab {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-flex;
cursor: pointer;
appearance: none;
flex-wrap: wrap;
align-items: center;
justify-content: center;
text-align: center;
webkit-user-select: none;
user-select: none;
&:hover {
@media (hover: hover) {
color: var(--color-base-content);
}
}
--tab-p: 0.75rem;
--tab-bg: var(--color-base-100);
--tab-border-color: var(--color-base-300);
--tab-radius-ss: 0;
--tab-radius-se: 0;
--tab-radius-es: 0;
--tab-radius-ee: 0;
--tab-order: 0;
--tab-radius-min: calc(0.75rem - var(--border));
--tab-radius-limit: min(var(--radius-field), var(--tab-radius-min));
--tab-radius-grad: #0000 calc(69% - var(--border)),
var(--tab-border-color) calc(calc(69% - var(--border)) + 0.25px),
var(--tab-border-color) calc(calc(69% - var(--border)) + var(--border)),
var(--tab-bg) calc(calc(69% - var(--border)) + var(--border) + 0.25px);
border-color: #0000;
order: var(--tab-order);
height: var(--tab-height);
font-size: 0.875rem;
padding-inline-start: var(--tab-p);
padding-inline-end: var(--tab-p);
&:is(input[type="radio"]) {
min-width: fit-content;
&:after {
--tw-content: attr(aria-label);
content: var(--tw-content);
}
}
&:is(label) {
position: relative;
input {
position: absolute;
inset: calc(0.25rem * 0);
cursor: pointer;
appearance: none;
opacity: 0%;
}
}
&:checked, &:is(label:has(:checked)), &:is(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"]) {
& + .tab-content {
display: block;
}
}
&:not( :checked, label:has(:checked), :hover, .tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"] ) {
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
}
}
&:not(input):empty {
flex-grow: 1;
cursor: default;
}
&:focus {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
&:focus-visible, &:is(label:has(:checked:focus-visible)) {
outline: 2px solid currentColor;
outline-offset: -5px;
}
&[disabled] {
pointer-events: none;
opacity: 40%;
}
}
}
.btn { .btn {
:where(&) { :where(&) {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
@@ -311,6 +399,58 @@
} }
} }
} }
.countdown {
&.countdown {
line-height: 1em;
}
@layer daisyui.l1.l2.l3 {
display: inline-flex;
& > * {
visibility: hidden;
position: relative;
display: inline-block;
overflow-y: clip;
transition: width 0.4s ease-out 0.2s;
height: 1em;
--value-v: calc(mod(max(0, var(--value)), 1000));
--value-hundreds: calc(round(to-zero, var(--value-v) / 100, 1));
--value-tens: calc(round(to-zero, mod(var(--value-v), 100) / 10, 1));
--value-ones: calc(mod(var(--value-v), 100));
--show-hundreds: clamp(clamp(0, var(--digits, 1) - 2, 1), var(--value-hundreds), 1);
--show-tens: clamp(
clamp(0, var(--digits, 1) - 1, 1),
var(--value-tens) + var(--show-hundreds),
1
);
--first-digits: calc(round(to-zero, var(--value-v) / 10, 1));
width: calc(1ch + var(--show-tens) * 1ch + var(--show-hundreds) * 1ch);
direction: ltr;
&:before, &:after {
visibility: visible;
position: absolute;
overflow-x: clip;
--tw-content: "00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A";
content: var(--tw-content);
font-variant-numeric: tabular-nums;
white-space: pre;
text-align: end;
direction: rtl;
transition: all 1s cubic-bezier(1, 0, 0, 1), width 0.2s ease-out 0.2s, opacity 0.2s ease-out 0.2s;
}
&:before {
width: calc(1ch + var(--show-hundreds) * 1ch);
top: calc(var(--first-digits) * -1em);
inset-inline-end: 0;
opacity: var(--show-tens);
}
&:after {
width: 1ch;
top: calc(var(--value-ones) * -1em);
inset-inline-start: 0;
}
}
}
}
.input { .input {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
cursor: text; cursor: text;
@@ -797,12 +937,46 @@
} }
} }
} }
.tabs-box {
@layer daisyui.l1.l2 {
background-color: var(--color-base-200);
padding: calc(0.25rem * 1);
--tabs-box-radius: calc(3 * var(--radius-field));
border-radius: calc( min(calc(var(--tab-height) / 2), var(--radius-field)) + min(0.25rem, var(--tabs-box-radius)) );
box-shadow: 0 -0.5px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 0.5px oklch(0% 0 0 / calc(var(--depth) * 0.05)) inset;
> .tab {
border-radius: var(--radius-field);
border-style: none;
&:focus-visible, &:is(label:has(:checked:focus-visible)) {
outline-offset: 2px;
}
}
> :is(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"]):not( .tab-disabled, [disabled] ), > :is(input:checked), > :is(label:has(:checked)) {
background-color: var(--tab-bg, var(--color-base-100));
box-shadow: 0 1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px 1px -1px var(--color-neutral), 0 1px 6px -4px var(--color-neutral);
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
}
@media (forced-colors: active) {
border: 1px solid;
}
}
> .tab-content {
margin-top: calc(0.25rem * 1);
height: calc(100% - var(--tab-height) + var(--border) - 0.5rem);
border-radius: calc( min(calc(var(--tab-height) / 2), var(--radius-field)) + min(0.25rem, var(--tabs-box-radius)) - var(--border) );
}
}
}
.mt-2 { .mt-2 {
margin-top: calc(var(--spacing) * 2); margin-top: calc(var(--spacing) * 2);
} }
.mt-4 { .mt-4 {
margin-top: calc(var(--spacing) * 4); margin-top: calc(var(--spacing) * 4);
} }
.mt-6 {
margin-top: calc(var(--spacing) * 6);
}
.mt-8 { .mt-8 {
margin-top: calc(var(--spacing) * 8); margin-top: calc(var(--spacing) * 8);
} }
@@ -812,6 +986,9 @@
.mb-4 { .mb-4 {
margin-bottom: calc(var(--spacing) * 4); margin-bottom: calc(var(--spacing) * 4);
} }
.mb-6 {
margin-bottom: calc(var(--spacing) * 6);
}
.ml-4 { .ml-4 {
margin-left: calc(var(--spacing) * 4); margin-left: calc(var(--spacing) * 4);
} }
@@ -840,6 +1017,17 @@
} }
} }
} }
.tabs {
@layer daisyui.l1.l2.l3 {
display: flex;
flex-wrap: wrap;
--tabs-height: auto;
--tabs-direction: row;
--tab-height: calc(var(--size-field, 0.25rem) * 10);
height: var(--tabs-height);
flex-direction: var(--tabs-direction);
}
}
.alert { .alert {
border-width: var(--border); border-width: var(--border);
border-color: var(--alert-border-color, var(--color-base-200)); border-color: var(--alert-border-color, var(--color-base-200));
@@ -947,6 +1135,9 @@
.flex { .flex {
display: flex; display: flex;
} }
.grid {
display: grid;
}
.btn-square { .btn-square {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
padding-inline: calc(0.25rem * 0); padding-inline: calc(0.25rem * 0);
@@ -957,6 +1148,9 @@
.w-full { .w-full {
width: 100%; width: 100%;
} }
.max-w-md {
max-width: var(--container-md);
}
.max-w-sm { .max-w-sm {
max-width: var(--container-sm); max-width: var(--container-sm);
} }
@@ -984,6 +1178,9 @@
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
.flex-wrap {
flex-wrap: wrap;
}
.items-center { .items-center {
align-items: center; align-items: center;
} }
@@ -1021,6 +1218,9 @@
.p-2 { .p-2 {
padding: calc(var(--spacing) * 2); padding: calc(var(--spacing) * 2);
} }
.p-3 {
padding: calc(var(--spacing) * 3);
}
.p-4 { .p-4 {
padding: calc(var(--spacing) * 4); padding: calc(var(--spacing) * 4);
} }
@@ -1073,6 +1273,13 @@
--alert-color: var(--color-error); --alert-color: var(--color-error);
} }
} }
.alert-info {
@layer daisyui.l1.l2 {
color: var(--color-info-content);
--alert-border-color: var(--color-info);
--alert-color: var(--color-info);
}
}
.alert-success { .alert-success {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
color: var(--color-success-content); color: var(--color-success-content);
@@ -1099,6 +1306,9 @@
.no-underline { .no-underline {
text-decoration-line: none; text-decoration-line: none;
} }
.opacity-40 {
opacity: 40%;
}
.opacity-60 { .opacity-60 {
opacity: 60%; opacity: 60%;
} }
@@ -1226,6 +1436,36 @@
.player-chip.yellow { .player-chip.yellow {
background: #facc15; background: #facc15;
} }
.snake-board {
display: inline-grid;
gap: 0;
background: #1a1a2e;
border-radius: 8px;
overflow: hidden;
border: 3px solid #333;
}
.snake-row {
display: contents;
}
.snake-cell {
background: #16213e;
border: 1px solid rgba(255,255,255,0.03);
transition: background 0.05s;
}
.snake-cell.snake-food {
background: #ff6b6b;
border-radius: 50%;
box-shadow: 0 0 6px rgba(255,107,107,0.6);
}
.snake-cell.snake-head {
border-radius: 4px;
}
.snake-cell.snake-dead {
opacity: 0.35;
}
.snake-wrapper:focus {
outline: none;
}
@layer base { @layer base {
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
color-scheme: light; color-scheme: light;

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

View File

@@ -18,6 +18,10 @@ type Game struct {
CreatedAt sql.NullTime CreatedAt sql.NullTime
UpdatedAt sql.NullTime UpdatedAt sql.NullTime
RematchGameID sql.NullString RematchGameID sql.NullString
GameType string
GridWidth sql.NullInt64
GridHeight sql.NullInt64
MaxPlayers int64
} }
type GamePlayer struct { 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/db/gen"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/snake"
) )
type GamePersister struct { type GamePersister struct {
@@ -138,3 +139,144 @@ func (p *GamePersister) DeleteGame(id string) error {
ctx := context.Background() ctx := context.Background()
return p.queries.DeleteGame(ctx, id) 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 = ?; DELETE FROM games WHERE id = ?;
-- name: GetActiveGames :many -- name: GetActiveGames :many
SELECT * FROM games WHERE status < 2; SELECT * FROM games WHERE game_type = 'connect4' AND status < 2;
-- name: CreateGamePlayer :exec -- name: CreateGamePlayer :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)
@@ -41,5 +41,5 @@ SELECT
FROM games g FROM games g
JOIN game_players gp_user ON g.id = gp_user.game_id AND gp_user.user_id = ? 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 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; 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;

296
main.go
View File

@@ -1,17 +1,25 @@
package main package main
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
_ "embed" _ "embed"
"fmt"
"html"
"io"
"log" "log"
"net/http" "net/http"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
g "maragu.dev/gomponents"
"github.com/ryanhamamura/c4/auth" "github.com/ryanhamamura/c4/auth"
"github.com/ryanhamamura/c4/db" "github.com/ryanhamamura/c4/db"
"github.com/ryanhamamura/c4/db/gen" "github.com/ryanhamamura/c4/db/gen"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/snake"
"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"
@@ -19,11 +27,43 @@ import (
) )
var store = game.NewGameStore() var store = game.NewGameStore()
var snakeStore = snake.NewSnakeStore()
var queries *gen.Queries var queries *gen.Queries
//go:embed assets/css/output.css //go:embed assets/css/output.css
var daisyUICSS []byte var daisyUICSS []byte
// dataExpr renders an h.H attribute node and extracts the raw Datastar expression.
// h.Data/h.Attr HTML-escape values, so we unescape to get the original expression.
func dataExpr(node h.H) string {
var buf bytes.Buffer
node.Render(&buf)
s := buf.String()
start := strings.Index(s, `="`) + 2
end := strings.LastIndex(s, `"`)
if start < 2 || end <= start {
return ""
}
return html.UnescapeString(s[start:end])
}
// rawDataAttr outputs an unescaped data attribute. Needed because gomponents
// HTML-escapes attribute values, which double-escapes expressions extracted
// from rendered nodes. Implements gomponents' attribute interface so it
// renders in the element's opening tag rather than as a child.
type rawDataAttr struct {
name, value string
}
func (a rawDataAttr) Render(w io.Writer) error {
_, err := fmt.Fprintf(w, ` %s="%s"`, a.name, a.value)
return err
}
func (a rawDataAttr) Type() g.NodeType {
return g.AttributeType
}
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) { v.HTTPServeMux().HandleFunc("GET /_plugins/daisyui/style.css", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css") w.Header().Set("Content-Type", "text/css")
@@ -38,6 +78,7 @@ func main() {
} }
queries = gen.New(db.DB) queries = gen.New(db.DB)
store.SetPersister(db.NewGamePersister(queries)) store.SetPersister(db.NewGamePersister(queries))
snakeStore.SetPersister(db.NewSnakePersister(queries))
sessionManager, err := via.NewSQLiteSessionManager(db.DB) sessionManager, err := via.NewSQLiteSessionManager(db.DB)
if err != nil { if err != nil {
@@ -50,18 +91,19 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
store.SetPubSub(ns) 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: "Connect 4", DocumentTitle: "Game Lobby",
ServerAddress: ":7331", ServerAddress: ":7331",
SessionManager: sessionManager, SessionManager: sessionManager,
PubSub: ns, PubSub: ns,
Plugins: []via.Plugin{DaisyUIPlugin}, Plugins: []via.Plugin{DaisyUIPlugin},
}) })
// Home page - enter nickname and create game // 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")
username := c.Session().GetString("username") username := c.Session().GetString("username")
@@ -89,6 +131,7 @@ func main() {
if isLoggedIn { if isLoggedIn {
nickname = c.Signal(username) nickname = c.Signal(username)
} }
activeTab := c.Signal("connect4")
logout := c.Action(func() { logout := c.Action(func() {
c.Session().Clear() c.Session().Clear()
@@ -118,17 +161,53 @@ func main() {
}).OnClick() }).OnClick()
} }
tabClickConnect4 := c.Action(func() {
activeTab.SetValue("connect4")
c.Sync()
})
tabClickSnake := c.Action(func() {
activeTab.SetValue("snake")
c.Sync()
})
snakeNickname := c.Signal("")
if isLoggedIn {
snakeNickname = c.Signal(username)
}
// Snake create game actions — one per preset
var snakePresetClicks []h.H
for _, preset := range snake.GridPresets {
w, ht := preset.Width, preset.Height
snakePresetClicks = append(snakePresetClicks, c.Action(func() {
name := snakeNickname.String()
if name == "" {
return
}
c.Session().Set("nickname", name)
si := snakeStore.Create(w, ht)
c.Redirectf("/snake/%s", si.ID())
}).OnClick())
}
c.View(func() h.H { c.View(func() h.H {
return ui.LobbyView( return ui.LobbyView(ui.LobbyProps{
nickname.Bind(), NicknameBind: nickname.Bind(),
createGame.OnKeyDown("Enter"), CreateGameKeyDown: createGame.OnKeyDown("Enter"),
createGame.OnClick(), CreateGameClick: createGame.OnClick(),
isLoggedIn, IsLoggedIn: isLoggedIn,
username, Username: username,
logout.OnClick(), LogoutClick: logout.OnClick(),
userGames, UserGames: userGames,
deleteGame, DeleteGameClick: deleteGame,
) ActiveTab: activeTab.String(),
TabClickConnect4: tabClickConnect4.OnClick(),
TabClickSnake: tabClickSnake.OnClick(),
SnakeNicknameBind: snakeNickname.Bind(),
SnakePresetClicks: snakePresetClicks,
ActiveSnakeGames: snakeStore.ActiveGames(),
})
}) })
}) })
@@ -250,7 +329,7 @@ func main() {
}) })
}) })
// Game page // Connect 4 game page
v.Page("/game/{game_id}", func(c *via.Context) { v.Page("/game/{game_id}", func(c *via.Context) {
gameID := c.GetPathParam("game_id") gameID := c.GetPathParam("game_id")
sessionNickname := c.Session().GetString("nickname") sessionNickname := c.Session().GetString("nickname")
@@ -278,19 +357,16 @@ func main() {
var gi *game.GameInstance var gi *game.GameInstance
var gameExists bool var gameExists bool
// Look up game (may not exist during warmup or invalid ID)
if gameID != "" { if gameID != "" {
gi, gameExists = store.Get(gameID) gi, gameExists = store.Get(gameID)
} }
// Generate a stable player ID for this session
playerID := game.PlayerID(c.Session().GetString("player_id")) playerID := game.PlayerID(c.Session().GetString("player_id"))
if playerID == "" { if playerID == "" {
playerID = game.PlayerID(game.GenerateID(8)) playerID = game.PlayerID(game.GenerateID(8))
c.Session().Set("player_id", string(playerID)) c.Session().Set("player_id", string(playerID))
} }
// Use user_id as player_id if logged in
if sessionUserID != "" { if sessionUserID != "" {
playerID = game.PlayerID(sessionUserID) playerID = game.PlayerID(sessionUserID)
} }
@@ -305,7 +381,6 @@ func main() {
} }
c.Session().Set("nickname", name) c.Session().Set("nickname", name)
// Try to join if not already in game
if gi.GetPlayerColor(playerID) == 0 { if gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{ player := &game.Player{
ID: playerID, ID: playerID,
@@ -344,12 +419,10 @@ func main() {
} }
}) })
// Subscribe to game updates so the opponent's moves trigger a re-render
if gameExists { if gameExists {
c.Subscribe("game."+gameID, func(data []byte) { c.Sync() }) c.Subscribe("game."+gameID, func(data []byte) { c.Sync() })
} }
// If nickname exists in session and game exists, join immediately
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 { if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{ player := &game.Player{
ID: playerID, ID: playerID,
@@ -364,7 +437,6 @@ func main() {
} }
c.View(func() h.H { c.View(func() h.H {
// Game not found - redirect to home
if !gameExists { if !gameExists {
c.Redirect("/") c.Redirect("/")
return h.Div() return h.Div()
@@ -372,9 +444,7 @@ func main() {
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
// Need nickname first / not joined yet
if myColor == 0 { if myColor == 0 {
// Unauthenticated user who hasn't chosen to continue as guest
if sessionUserID == "" && !showGuestPrompt.Bool() { if sessionUserID == "" && !showGuestPrompt.Bool() {
return ui.GameJoinPrompt( return ui.GameJoinPrompt(
goToLogin.OnClick(), goToLogin.OnClick(),
@@ -391,7 +461,6 @@ func main() {
g := gi.GetGame() g := gi.GetGame()
// Create column click function
columnClick := func(col int) h.H { columnClick := func(col int) h.H {
return dropPiece.OnClick(via.WithSignalInt(colSignal, col)) return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
} }
@@ -404,7 +473,6 @@ func main() {
ui.BoardComponent(g, columnClick, myColor), ui.BoardComponent(g, columnClick, myColor),
) )
// Show invite link when waiting for opponent
if g.Status == game.StatusWaitingForPlayer { if g.Status == game.StatusWaitingForPlayer {
content = append(content, ui.InviteLink(g.ID)) content = append(content, ui.InviteLink(g.ID))
} }
@@ -415,5 +483,185 @@ func main() {
}) })
}) })
// Snake game page
v.Page("/snake/{game_id}", func(c *via.Context) {
gameID := c.GetPathParam("game_id")
sessionNickname := c.Session().GetString("nickname")
sessionUserID := c.Session().GetString("user_id")
nickname := c.Signal(sessionNickname)
showGuestPrompt := c.Signal(false)
goToLogin := c.Action(func() {
c.Session().Set("return_url", "/snake/"+gameID)
c.Redirect("/login")
})
goToRegister := c.Action(func() {
c.Session().Set("return_url", "/snake/"+gameID)
c.Redirect("/register")
})
continueAsGuest := c.Action(func() {
showGuestPrompt.SetValue(true)
c.Sync()
})
var si *snake.SnakeGameInstance
var gameExists bool
if gameID != "" {
si, gameExists = snakeStore.Get(gameID)
}
playerID := snake.PlayerID(c.Session().GetString("player_id"))
if playerID == "" {
pid := game.GenerateID(8)
playerID = snake.PlayerID(pid)
c.Session().Set("player_id", pid)
}
if sessionUserID != "" {
playerID = snake.PlayerID(sessionUserID)
}
setNickname := c.Action(func() {
if si == nil {
return
}
name := nickname.String()
if name == "" {
return
}
c.Session().Set("nickname", name)
if si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{
ID: playerID,
Nickname: name,
}
if sessionUserID != "" {
player.UserID = &sessionUserID
}
si.Join(player)
}
c.Sync()
})
// Direction input: single action with a direction signal
dirSignal := c.Signal(-1)
handleDir := c.Action(func() {
if si == nil {
return
}
slot := si.GetPlayerSlot(playerID)
if slot < 0 {
return
}
dir := snake.Direction(dirSignal.Int())
si.SetDirection(slot, dir)
})
createRematch := c.Action(func() {
if si == nil {
return
}
newSI := si.CreateRematch()
if newSI != nil {
c.Redirectf("/snake/%s", newSI.ID())
}
})
if gameExists {
c.Subscribe("snake."+gameID, func(data []byte) { c.Sync() })
}
// Auto-join if nickname exists
if gameExists && sessionNickname != "" && si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{
ID: playerID,
Nickname: sessionNickname,
}
if sessionUserID != "" {
player.UserID = &sessionUserID
}
si.Join(player)
}
c.View(func() h.H {
if !gameExists {
c.Redirect("/")
return h.Div()
}
mySlot := si.GetPlayerSlot(playerID)
if mySlot < 0 {
if sessionUserID == "" && !showGuestPrompt.Bool() {
return ui.GameJoinPrompt(
goToLogin.OnClick(),
continueAsGuest.OnClick(),
goToRegister.OnClick(),
)
}
return ui.NicknamePrompt(
nickname.Bind(),
setNickname.OnKeyDown("Enter"),
setNickname.OnClick(),
)
}
sg := si.GetGame()
var content []h.H
content = append(content,
h.H1(h.Class("text-3xl font-bold"), h.Text("Snake")),
ui.SnakePlayerList(sg, mySlot),
ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()),
)
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
content = append(content, ui.SnakeBoard(sg))
}
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
content = append(content, ui.SnakeInviteLink(sg.ID))
}
// Build keydown attributes with unique __suffix names so the
// browser doesn't deduplicate them (all share data-on:keydown).
type keyBinding struct {
suffix string
key string
dir snake.Direction
}
bindings := []keyBinding{
{"arrowup", "ArrowUp", snake.DirUp},
{"arrowdown", "ArrowDown", snake.DirDown},
{"arrowleft", "ArrowLeft", snake.DirLeft},
{"arrowright", "ArrowRight", snake.DirRight},
{"w", "w", snake.DirUp},
{"s", "s", snake.DirDown},
{"a", "a", snake.DirLeft},
{"d", "d", snake.DirRight},
}
wrapperAttrs := []h.H{
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
h.Attr("tabindex", "0"),
h.Data("on:load", "this.focus()"),
}
for _, kb := range bindings {
expr := dataExpr(handleDir.OnKeyDown(kb.key, via.WithSignalInt(dirSignal, int(kb.dir))))
wrapperAttrs = append(wrapperAttrs, h.H(rawDataAttr{
name: "data-on:keydown__" + kb.suffix,
value: expr,
}))
}
wrapperAttrs = append(wrapperAttrs, content...)
return h.Main(wrapperAttrs...)
})
})
v.Start() v.Start()
} }

296
snake/logic.go Normal file
View File

@@ -0,0 +1,296 @@
package snake
import "math/rand"
// SpawnSnakes creates snakes for the given active slot indices, evenly spaced
// around the grid perimeter facing inward. Returns a length-8 slice where
// only active slots have non-nil entries.
func SpawnSnakes(activeSlots []int, width, height int) []*Snake {
snakes := make([]*Snake, 8)
n := len(activeSlots)
if n == 0 {
return snakes
}
perimeter := 2*(width+height) - 4
spacing := perimeter / n
for i, slot := range activeSlots {
pos := (i * spacing) + spacing/2
sp := perimeterToSpawn(pos, width, height)
body := make([]Point, 3)
dx, dy := dirDelta(sp.dir)
for j := 0; j < 3; j++ {
bx := sp.x - dx*j
by := sp.y - dy*j
// Clamp to grid bounds
if bx < 0 {
bx = 0
} else if bx >= width {
bx = width - 1
}
if by < 0 {
by = 0
} else if by >= height {
by = height - 1
}
body[j] = Point{X: bx, Y: by}
}
snakes[slot] = &Snake{
Body: body,
Dir: sp.dir,
Alive: true,
Color: slot + 1,
}
}
return snakes
}
type spawnInfo struct {
x, y int
dir Direction
}
// perimeterToSpawn converts a linear position along the perimeter to coordinates
// and an inward-facing direction.
func perimeterToSpawn(pos, width, height int) spawnInfo {
// Top edge: left to right
if pos < width {
return spawnInfo{pos, 0, DirDown}
}
pos -= width
// Right edge: top to bottom
if pos < height-1 {
return spawnInfo{width - 1, pos + 1, DirLeft}
}
pos -= height - 1
// Bottom edge: right to left
if pos < width-1 {
return spawnInfo{width - 2 - pos, height - 1, DirUp}
}
pos -= width - 1
// Left edge: bottom to top
return spawnInfo{0, height - 2 - pos, DirRight}
}
func dirDelta(d Direction) (dx, dy int) {
switch d {
case DirUp:
return 0, -1
case DirDown:
return 0, 1
case DirLeft:
return -1, 0
case DirRight:
return 1, 0
}
return 0, 0
}
// Tick advances all alive snakes one cell in their direction.
func Tick(state *GameState) {
for _, s := range state.Snakes {
if s == nil || !s.Alive {
continue
}
dx, dy := dirDelta(s.Dir)
head := s.Body[0]
newHead := Point{X: head.X + dx, Y: head.Y + dy}
s.Body = append([]Point{newHead}, s.Body...)
if s.Growing {
s.Growing = false
} else {
s.Body = s.Body[:len(s.Body)-1]
}
}
}
// CheckFood checks if any snake head is on food. Returns indices of eaten food.
func CheckFood(state *GameState) []int {
eaten := make(map[int]bool)
for _, s := range state.Snakes {
if s == nil || !s.Alive {
continue
}
head := s.Body[0]
for fi, f := range state.Food {
if !eaten[fi] && head.X == f.X && head.Y == f.Y {
s.Growing = true
eaten[fi] = true
break
}
}
}
var indices []int
for fi := range eaten {
indices = append(indices, fi)
}
return indices
}
// RemoveFood removes food items at the given indices.
func RemoveFood(state *GameState, indices []int) {
if len(indices) == 0 {
return
}
remove := make(map[int]bool, len(indices))
for _, i := range indices {
remove[i] = true
}
var remaining []Point
for i, f := range state.Food {
if !remove[i] {
remaining = append(remaining, f)
}
}
state.Food = remaining
}
// SpawnFood adds food items to maintain the target count.
func SpawnFood(state *GameState, playerCount int) {
target := playerCount/2 + 1
for len(state.Food) < target {
p := randomEmptyCell(state)
if p == nil {
break
}
state.Food = append(state.Food, *p)
}
}
func randomEmptyCell(state *GameState) *Point {
occupied := make(map[Point]bool)
for _, s := range state.Snakes {
if s == nil {
continue
}
for _, p := range s.Body {
occupied[p] = true
}
}
for _, f := range state.Food {
occupied[f] = true
}
var empty []Point
for y := 0; y < state.Height; y++ {
for x := 0; x < state.Width; x++ {
p := Point{X: x, Y: y}
if !occupied[p] {
empty = append(empty, p)
}
}
}
if len(empty) == 0 {
return nil
}
choice := empty[rand.Intn(len(empty))]
return &choice
}
// CheckCollisions checks for wall, self, and other-snake collisions.
// Returns the set of snake indices that died this tick.
func CheckCollisions(state *GameState) map[int]bool {
dead := make(map[int]bool)
for i, s := range state.Snakes {
if s == nil || !s.Alive {
continue
}
head := s.Body[0]
// Wall collision
if head.X < 0 || head.X >= state.Width || head.Y < 0 || head.Y >= state.Height {
dead[i] = true
continue
}
// Self-body collision (skip head at index 0)
for _, bp := range s.Body[1:] {
if head.X == bp.X && head.Y == bp.Y {
dead[i] = true
break
}
}
if dead[i] {
continue
}
// Other snake body collision
for j, other := range state.Snakes {
if i == j || other == nil || !other.Alive {
continue
}
for _, bp := range other.Body[1:] {
if head.X == bp.X && head.Y == bp.Y {
dead[i] = true
break
}
}
if dead[i] {
break
}
}
}
// Head-to-head collision
for i, s := range state.Snakes {
if s == nil || !s.Alive || dead[i] {
continue
}
for j := i + 1; j < len(state.Snakes); j++ {
other := state.Snakes[j]
if other == nil || !other.Alive || dead[j] {
continue
}
if s.Body[0].X == other.Body[0].X && s.Body[0].Y == other.Body[0].Y {
dead[i] = true
dead[j] = true
}
}
}
return dead
}
// MarkDead sets Alive=false for snakes in the dead set.
func MarkDead(state *GameState, dead map[int]bool) {
for i := range dead {
state.Snakes[i].Alive = false
}
}
// AliveCount returns the number of living snakes.
func AliveCount(state *GameState) int {
count := 0
for _, s := range state.Snakes {
if s != nil && s.Alive {
count++
}
}
return count
}
// LastAlive returns the index of the last alive snake, or -1 if none or multiple.
func LastAlive(state *GameState) int {
idx := -1
for i, s := range state.Snakes {
if s != nil && s.Alive {
if idx != -1 {
return -1
}
idx = i
}
}
return idx
}
// ValidateDirection returns true if the new direction is not a 180 reversal.
func ValidateDirection(current, proposed Direction) bool {
return !current.Opposite(proposed)
}

167
snake/loop.go Normal file
View File

@@ -0,0 +1,167 @@
package snake
import (
"time"
)
const (
tickInterval = 500 * time.Millisecond
countdownSeconds = 10
inactivityLimit = 60 * time.Second
)
func (si *SnakeGameInstance) startOrResetCountdownLocked() {
si.game.Status = StatusCountdown
si.game.CountdownEnd = time.Now().Add(countdownSeconds * time.Second)
si.loopOnce.Do(func() {
si.stopCh = make(chan struct{})
go si.runLoop()
})
}
func (si *SnakeGameInstance) runLoop() {
si.countdownPhase()
si.gameMu.RLock()
stopped := si.game.Status != StatusInProgress
si.gameMu.RUnlock()
if stopped {
return
}
si.gamePhase()
}
func (si *SnakeGameInstance) countdownPhase() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-si.stopCh:
return
case <-ticker.C:
si.gameMu.Lock()
if si.game.Status != StatusCountdown {
si.gameMu.Unlock()
return
}
remaining := time.Until(si.game.CountdownEnd)
if remaining <= 0 {
si.initGame()
si.game.Status = StatusInProgress
if si.persister != nil {
si.persister.SaveSnakeGame(si.game)
}
si.gameMu.Unlock()
si.notify()
return
}
if si.persister != nil {
si.persister.SaveSnakeGame(si.game)
}
si.gameMu.Unlock()
si.notify()
}
}
}
// initGame sets up snakes and food for the start of a game.
func (si *SnakeGameInstance) initGame() {
// Collect active player slots
var activeSlots []int
for i, p := range si.game.Players {
if p != nil {
activeSlots = append(activeSlots, i)
}
}
state := si.game.State
state.Snakes = SpawnSnakes(activeSlots, state.Width, state.Height)
SpawnFood(state, len(activeSlots))
}
func (si *SnakeGameInstance) gamePhase() {
ticker := time.NewTicker(tickInterval)
defer ticker.Stop()
lastInput := time.Now()
for {
select {
case <-si.stopCh:
return
case <-ticker.C:
si.gameMu.Lock()
if si.game.Status != StatusInProgress {
si.gameMu.Unlock()
return
}
// Apply pending directions (iterate all 8 slots)
inputReceived := false
for i := 0; i < 8; i++ {
if si.pendingDir[i] != nil && i < len(si.game.State.Snakes) && si.game.State.Snakes[i] != nil {
si.game.State.Snakes[i].Dir = *si.pendingDir[i]
si.pendingDir[i] = nil
inputReceived = true
}
}
if inputReceived {
lastInput = time.Now()
}
// Inactivity timeout
if time.Since(lastInput) > inactivityLimit {
si.game.Status = StatusFinished
if si.persister != nil {
si.persister.SaveSnakeGame(si.game)
}
si.gameMu.Unlock()
si.notify()
return
}
state := si.game.State
// Advance snakes
Tick(state)
// Check collisions first (before food, so dead snakes don't eat)
dead := CheckCollisions(state)
MarkDead(state, dead)
// Check food eaten (only by surviving snakes)
eaten := CheckFood(state)
RemoveFood(state, eaten)
SpawnFood(state, si.game.PlayerCount())
// Check game over
alive := AliveCount(state)
if alive <= 1 {
si.game.Status = StatusFinished
winnerIdx := LastAlive(state)
if winnerIdx >= 0 && winnerIdx < len(si.game.Players) {
si.game.Winner = si.game.Players[winnerIdx]
}
}
if si.persister != nil {
si.persister.SaveSnakeGame(si.game)
}
si.gameMu.Unlock()
si.notify()
if alive <= 1 {
return
}
}
}
}

296
snake/store.go Normal file
View File

@@ -0,0 +1,296 @@
package snake
import (
"crypto/rand"
"encoding/hex"
"sync"
)
type PubSub interface {
Publish(subject string, data []byte) error
}
type Persister interface {
SaveSnakeGame(sg *SnakeGame) error
LoadSnakeGame(id string) (*SnakeGame, error)
SaveSnakePlayer(gameID string, player *Player) error
LoadSnakePlayers(gameID string) ([]*Player, error)
DeleteSnakeGame(id string) error
}
type SnakeStore struct {
games map[string]*SnakeGameInstance
gamesMu sync.RWMutex
persister Persister
pubsub PubSub
}
func NewSnakeStore() *SnakeStore {
return &SnakeStore{
games: make(map[string]*SnakeGameInstance),
}
}
func (ss *SnakeStore) SetPersister(p Persister) {
ss.persister = p
}
func (ss *SnakeStore) SetPubSub(ps PubSub) {
ss.pubsub = ps
}
func (ss *SnakeStore) makeNotify(gameID string) func() {
return func() {
if ss.pubsub != nil {
ss.pubsub.Publish("snake."+gameID, nil)
}
}
}
func (ss *SnakeStore) Create(width, height int) *SnakeGameInstance {
id := generateID(4)
sg := &SnakeGame{
ID: id,
State: &GameState{
Width: width,
Height: height,
},
Players: make([]*Player, 8),
Status: StatusWaitingForPlayers,
}
si := &SnakeGameInstance{
game: sg,
notify: ss.makeNotify(id),
persister: ss.persister,
store: ss,
}
ss.gamesMu.Lock()
ss.games[id] = si
ss.gamesMu.Unlock()
if ss.persister != nil {
ss.persister.SaveSnakeGame(sg)
}
return si
}
func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
ss.gamesMu.RLock()
si, ok := ss.games[id]
ss.gamesMu.RUnlock()
if ok {
return si, true
}
if ss.persister == nil {
return nil, false
}
sg, err := ss.persister.LoadSnakeGame(id)
if err != nil || sg == nil {
return nil, false
}
players, _ := ss.persister.LoadSnakePlayers(id)
if sg.Players == nil {
sg.Players = make([]*Player, 8)
}
for _, p := range players {
if p.Slot >= 0 && p.Slot < 8 {
sg.Players[p.Slot] = p
}
}
si = &SnakeGameInstance{
game: sg,
notify: ss.makeNotify(id),
persister: ss.persister,
store: ss,
}
ss.gamesMu.Lock()
ss.games[id] = si
ss.gamesMu.Unlock()
return si, true
}
func (ss *SnakeStore) Delete(id string) error {
ss.gamesMu.Lock()
si, ok := ss.games[id]
delete(ss.games, id)
ss.gamesMu.Unlock()
if ok && si != nil {
si.Stop()
}
if ss.persister != nil {
return ss.persister.DeleteSnakeGame(id)
}
return nil
}
// ActiveGames returns metadata of games that can be joined.
// Copies game data to avoid holding nested locks.
func (ss *SnakeStore) ActiveGames() []*SnakeGame {
ss.gamesMu.RLock()
instances := make([]*SnakeGameInstance, 0, len(ss.games))
for _, si := range ss.games {
instances = append(instances, si)
}
ss.gamesMu.RUnlock()
var games []*SnakeGame
for _, si := range instances {
si.gameMu.RLock()
g := si.game
if g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown {
games = append(games, g)
}
si.gameMu.RUnlock()
}
return games
}
type SnakeGameInstance struct {
game *SnakeGame
gameMu sync.RWMutex
pendingDir [8]*Direction
notify func()
persister Persister
store *SnakeStore
stopCh chan struct{}
loopOnce sync.Once
}
func (si *SnakeGameInstance) ID() string {
si.gameMu.RLock()
defer si.gameMu.RUnlock()
return si.game.ID
}
// GetGame returns a snapshot of the game state safe for concurrent read.
func (si *SnakeGameInstance) GetGame() *SnakeGame {
si.gameMu.RLock()
defer si.gameMu.RUnlock()
return si.game.snapshot()
}
func (si *SnakeGameInstance) GetPlayerSlot(pid PlayerID) int {
si.gameMu.RLock()
defer si.gameMu.RUnlock()
for i, p := range si.game.Players {
if p != nil && p.ID == pid {
return i
}
}
return -1
}
func (si *SnakeGameInstance) Join(player *Player) bool {
si.gameMu.Lock()
defer si.gameMu.Unlock()
if si.game.Status == StatusInProgress || si.game.Status == StatusFinished {
return false
}
slot := -1
for i, p := range si.game.Players {
if p == nil {
slot = i
break
}
}
if slot == -1 {
return false
}
player.Slot = slot
si.game.Players[slot] = player
if si.persister != nil {
si.persister.SaveSnakePlayer(si.game.ID, player)
si.persister.SaveSnakeGame(si.game)
}
si.notify()
if si.game.PlayerCount() >= 2 {
si.startOrResetCountdownLocked()
}
return true
}
// SetDirection buffers a direction change for the given slot.
// The write happens under the game lock to avoid a data race with the game loop.
func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
if slot < 0 || slot >= 8 {
return
}
si.gameMu.Lock()
defer si.gameMu.Unlock()
if si.game.State != nil && slot < len(si.game.State.Snakes) {
s := si.game.State.Snakes[slot]
if s != nil && s.Alive && !ValidateDirection(s.Dir, dir) {
return
}
}
si.pendingDir[slot] = &dir
}
func (si *SnakeGameInstance) Stop() {
if si.stopCh != nil {
select {
case <-si.stopCh:
default:
close(si.stopCh)
}
}
}
func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
si.gameMu.Lock()
if !si.game.IsFinished() || si.game.RematchGameID != nil {
si.gameMu.Unlock()
return nil
}
// Capture state needed, then release lock before calling store.Create
// (which acquires gamesMu) to avoid lock ordering deadlock.
width := si.game.State.Width
height := si.game.State.Height
si.gameMu.Unlock()
newSI := si.store.Create(width, height)
newID := newSI.ID()
si.gameMu.Lock()
// Re-check after reacquiring lock
if si.game.RematchGameID != nil {
si.gameMu.Unlock()
return newSI
}
si.game.RematchGameID = &newID
if si.persister != nil {
si.persister.SaveSnakeGame(si.game)
}
si.gameMu.Unlock()
si.notify()
return newSI
}
func generateID(size int) string {
b := make([]byte, size)
rand.Read(b)
return hex.EncodeToString(b)
}

148
snake/types.go Normal file
View File

@@ -0,0 +1,148 @@
package snake
import (
"encoding/json"
"time"
)
type Direction int
const (
DirUp Direction = iota
DirDown
DirLeft
DirRight
)
// Opposite returns true if a and b are 180-degree reversals.
func (d Direction) Opposite(other Direction) bool {
switch d {
case DirUp:
return other == DirDown
case DirDown:
return other == DirUp
case DirLeft:
return other == DirRight
case DirRight:
return other == DirLeft
}
return false
}
type Point struct {
X int `json:"x"`
Y int `json:"y"`
}
type Snake struct {
Body []Point `json:"body"`
Dir Direction `json:"dir"`
Alive bool `json:"alive"`
Growing bool `json:"growing"`
Color int `json:"color"` // 1-8
}
type GameState struct {
Width int `json:"width"`
Height int `json:"height"`
Snakes []*Snake `json:"snakes"`
Food []Point `json:"food"`
}
func (gs *GameState) ToJSON() string {
data, _ := json.Marshal(gs)
return string(data)
}
func GameStateFromJSON(data string) (*GameState, error) {
var gs GameState
if err := json.Unmarshal([]byte(data), &gs); err != nil {
return nil, err
}
return &gs, nil
}
type Status int
const (
StatusWaitingForPlayers Status = iota
StatusCountdown
StatusInProgress
StatusFinished
)
type PlayerID string
type Player struct {
ID PlayerID
UserID *string
Nickname string
Slot int // 0-7
}
type SnakeGame struct {
ID string
State *GameState
Players []*Player // up to 8
Status Status
Winner *Player // nil if draw
CountdownEnd time.Time // when countdown reaches 0
RematchGameID *string
}
func (sg *SnakeGame) IsFinished() bool {
return sg.Status == StatusFinished
}
func (sg *SnakeGame) PlayerCount() int {
count := 0
for _, p := range sg.Players {
if p != nil {
count++
}
}
return count
}
// Grid presets
type GridPreset struct {
Name string
Width int
Height int
}
var GridPresets = []GridPreset{
{Name: "Small", Width: 20, Height: 20},
{Name: "Medium", Width: 30, Height: 20},
{Name: "Large", Width: 40, Height: 20},
}
// snapshot returns a shallow copy of the game safe for reading outside the lock.
// Slices and pointers are shared but the top-level struct is copied.
func (sg *SnakeGame) snapshot() *SnakeGame {
cp := *sg
if sg.State != nil {
stateCp := *sg.State
// Copy slices so the caller's iteration is safe
stateCp.Snakes = make([]*Snake, len(sg.State.Snakes))
copy(stateCp.Snakes, sg.State.Snakes)
stateCp.Food = make([]Point, len(sg.State.Food))
copy(stateCp.Food, sg.State.Food)
cp.State = &stateCp
}
cp.Players = make([]*Player, len(sg.Players))
copy(cp.Players, sg.Players)
return &cp
}
// Snake colors (hex values for CSS)
var SnakeColors = []string{
"#00b894", // 1: Green
"#e17055", // 2: Orange
"#0984e3", // 3: Blue
"#6c5ce7", // 4: Purple
"#fd79a8", // 5: Pink
"#00cec9", // 6: Cyan
"#d63031", // 7: Red
"#fdcb6e", // 8: Yellow
}

View File

@@ -1,20 +1,63 @@
package ui package ui
import ( import (
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn bool, username string, logoutClick h.H, userGames []GameListItem, deleteGameClick func(id string) h.H) h.H { type LobbyProps struct {
NicknameBind h.H
CreateGameKeyDown h.H
CreateGameClick h.H
IsLoggedIn bool
Username string
LogoutClick h.H
UserGames []GameListItem
DeleteGameClick func(id string) h.H
ActiveTab string
TabClickConnect4 h.H
TabClickSnake h.H
SnakeNicknameBind h.H
SnakePresetClicks []h.H
ActiveSnakeGames []*snake.SnakeGame
}
func LobbyView(p LobbyProps) h.H {
var authSection h.H var authSection h.H
if isLoggedIn { if p.IsLoggedIn {
authSection = AuthHeader(username, logoutClick) authSection = AuthHeader(p.Username, p.LogoutClick)
} else { } else {
authSection = GuestBanner() authSection = GuestBanner()
} }
return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"), connect4Class := "tab"
snakeClass := "tab"
if p.ActiveTab == "snake" {
snakeClass += " tab-active"
} else {
connect4Class += " tab-active"
}
var tabContent h.H
if p.ActiveTab == "snake" {
tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakePresetClicks, p.ActiveSnakeGames)
} else {
tabContent = connect4LobbyContent(p)
}
return h.Main(h.Class("max-w-md mx-auto mt-8 text-center"),
authSection, authSection,
h.H1(h.Class("text-3xl font-bold"), h.Text("Connect 4")), h.H1(h.Class("text-3xl font-bold mb-4"), h.Text("Game Lobby")),
h.Div(h.Class("tabs tabs-box mb-6 justify-center"),
h.Button(h.Class(connect4Class), h.Type("button"), h.Text("Connect 4"), p.TabClickConnect4),
h.Button(h.Class(snakeClass), h.Type("button"), h.Text("Snake"), p.TabClickSnake),
),
tabContent,
)
}
func connect4LobbyContent(p LobbyProps) h.H {
return h.Div(
h.P(h.Class("mb-4"), h.Text("Challenge a friend to a game of Connect 4!")), h.P(h.Class("mb-4"), h.Text("Challenge a friend to a game of Connect 4!")),
h.Form( h.Form(
h.FieldSet(h.Class("fieldset"), h.FieldSet(h.Class("fieldset"),
@@ -24,19 +67,19 @@ func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn
h.ID("nickname"), h.ID("nickname"),
h.Type("text"), h.Type("text"),
h.Placeholder("Enter your nickname"), h.Placeholder("Enter your nickname"),
nicknameBind, p.NicknameBind,
h.Attr("required"), h.Attr("required"),
createGameKeyDown, p.CreateGameKeyDown,
), ),
), ),
h.Button( h.Button(
h.Class("btn btn-primary w-full"), h.Class("btn btn-primary w-full"),
h.Type("button"), h.Type("button"),
h.Text("Create Game"), h.Text("Create Game"),
createGameClick, p.CreateGameClick,
), ),
), ),
GameList(userGames, deleteGameClick), GameList(p.UserGames, p.DeleteGameClick),
) )
} }

92
ui/snakeboard.go Normal file
View File

@@ -0,0 +1,92 @@
package ui
import (
"fmt"
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/via/h"
)
func SnakeBoard(sg *snake.SnakeGame) h.H {
state := sg.State
if state == nil || sg.Status != snake.StatusInProgress && sg.Status != snake.StatusFinished {
return nil
}
// Build a lookup grid for rendering
type cellInfo struct {
snakeIdx int // -1 = empty, -2 = food
isHead bool
}
grid := make([][]cellInfo, state.Height)
for y := 0; y < state.Height; y++ {
grid[y] = make([]cellInfo, state.Width)
for x := 0; x < state.Width; x++ {
grid[y][x] = cellInfo{snakeIdx: -1}
}
}
for fi := range state.Food {
f := state.Food[fi]
if f.X >= 0 && f.X < state.Width && f.Y >= 0 && f.Y < state.Height {
grid[f.Y][f.X] = cellInfo{snakeIdx: -2}
}
}
for si, s := range state.Snakes {
if s == nil {
continue
}
for bi, bp := range s.Body {
if bp.X >= 0 && bp.X < state.Width && bp.Y >= 0 && bp.Y < state.Height {
grid[bp.Y][bp.X] = cellInfo{snakeIdx: si, isHead: bi == 0}
}
}
}
// Cell size scales with grid dimensions
cellSize := 20
if state.Width <= 20 {
cellSize = 24
}
var rows []h.H
for y := 0; y < state.Height; y++ {
var cells []h.H
for x := 0; x < state.Width; x++ {
ci := grid[y][x]
class := "snake-cell"
style := fmt.Sprintf("width:%dpx;height:%dpx;", cellSize, cellSize)
switch {
case ci.snakeIdx == -2:
class += " snake-food"
case ci.snakeIdx >= 0:
s := state.Snakes[ci.snakeIdx]
colorIdx := ci.snakeIdx
if colorIdx < len(snake.SnakeColors) {
bg := snake.SnakeColors[colorIdx]
style += fmt.Sprintf("background:%s;", bg)
}
if !s.Alive {
class += " snake-dead"
}
if ci.isHead {
class += " snake-head"
}
}
cells = append(cells, h.Div(h.Class(class), h.Attr("style", style)))
}
rowAttrs := append([]h.H{h.Class("snake-row")}, cells...)
rows = append(rows, h.Div(rowAttrs...))
}
boardStyle := fmt.Sprintf("grid-template-columns:repeat(%d,1fr);", state.Width)
attrs := []h.H{
h.Class("snake-board"),
h.Attr("style", boardStyle),
}
attrs = append(attrs, rows...)
return h.Div(attrs...)
}

74
ui/snakelobby.go Normal file
View File

@@ -0,0 +1,74 @@
package ui
import (
"fmt"
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/via/h"
)
func SnakeLobbyTab(nicknameBind h.H, presetClicks []h.H, activeGames []*snake.SnakeGame) h.H {
var presetButtons []h.H
for i, preset := range snake.GridPresets {
var click h.H
if i < len(presetClicks) {
click = presetClicks[i]
}
presetButtons = append(presetButtons,
h.Button(
h.Class("btn btn-primary"),
h.Type("button"),
h.Text(fmt.Sprintf("%s (%d×%d)", preset.Name, preset.Width, preset.Height)),
click,
),
)
}
createSection := h.Div(h.Class("mb-6"),
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.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")),
h.Input(
h.Class("input input-bordered w-full"),
h.ID("snake-nickname"),
h.Type("text"),
h.Placeholder("Enter your nickname"),
nicknameBind,
h.Attr("required"),
),
),
),
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, presetButtons...)...),
)
var gameListEl h.H
if len(activeGames) > 0 {
var items []h.H
for _, g := range activeGames {
playerCount := g.PlayerCount()
sizeLabel := fmt.Sprintf("%d×%d", g.State.Width, g.State.Height)
statusLabel := "Waiting"
if g.Status == snake.StatusCountdown {
statusLabel = "Starting soon"
}
items = append(items, h.A(
h.Href("/snake/"+g.ID),
h.Class("flex justify-between items-center p-3 bg-base-200 rounded-lg hover:bg-base-300 no-underline text-base-content"),
h.Span(h.Text(fmt.Sprintf("%s — %d/8 players", sizeLabel, playerCount))),
h.Span(h.Class("text-sm opacity-60"), h.Text(statusLabel)),
))
}
listAttrs := []h.H{h.Class("flex flex-col gap-2")}
listAttrs = append(listAttrs, items...)
gameListEl = h.Div(h.Class("mt-6"),
h.H3(h.Class("text-lg font-bold mb-2 text-center"), h.Text("Join a Game")),
h.Div(listAttrs...),
)
}
return h.Div(
createSection,
gameListEl,
)
}

146
ui/snakestatus.go Normal file
View File

@@ -0,0 +1,146 @@
package ui
import (
"fmt"
"math"
"time"
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/via/h"
)
func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
switch sg.Status {
case snake.StatusWaitingForPlayers:
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
h.Text("Waiting for players..."),
)
case snake.StatusCountdown:
remaining := time.Until(sg.CountdownEnd)
secs := int(math.Ceil(remaining.Seconds()))
if secs < 0 {
secs = 0
}
return h.Div(h.Class("alert alert-info text-xl font-bold"),
h.Text(fmt.Sprintf("Starting in %d...", secs)),
)
case snake.StatusInProgress:
if sg.State != nil && mySlot >= 0 && mySlot < len(sg.State.Snakes) {
s := sg.State.Snakes[mySlot]
if s != nil && !s.Alive {
return h.Div(h.Class("alert alert-error text-xl font-bold"),
h.Text("You're out!"),
)
}
}
return h.Div(h.Class("alert alert-success text-xl font-bold"),
h.Text("Go!"),
)
case snake.StatusFinished:
var msg string
var class string
if sg.Winner != nil {
if sg.Winner.Slot == mySlot {
msg = "You win!"
class = "alert alert-success text-xl font-bold"
} else {
msg = sg.Winner.Nickname + " wins!"
class = "alert alert-error text-xl font-bold"
}
} else {
msg = "It's a draw!"
class = "alert alert-warning text-xl font-bold"
}
content := []h.H{h.Class(class), h.Text(msg)}
if sg.RematchGameID != nil {
content = append(content,
h.A(
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
h.Href("/snake/"+*sg.RematchGameID),
h.Text("Join Rematch"),
),
)
} else if rematchClick != nil {
content = append(content,
h.Button(
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
h.Type("button"),
h.Text("Play again"),
rematchClick,
),
)
}
return h.Div(content...)
}
return nil
}
func SnakePlayerList(sg *snake.SnakeGame, mySlot int) h.H {
var items []h.H
for i, p := range sg.Players {
if p == nil {
continue
}
colorHex := "#666"
if i < len(snake.SnakeColors) {
colorHex = snake.SnakeColors[i]
}
name := p.Nickname
if i == mySlot {
name += " (You)"
}
var statusEl h.H
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
if sg.State != nil && i < len(sg.State.Snakes) {
s := sg.State.Snakes[i]
if s != nil {
if s.Alive {
length := len(s.Body)
statusEl = h.Span(h.Class("text-sm opacity-60"), h.Text(fmt.Sprintf(" (%d)", length)))
} else {
statusEl = h.Span(h.Class("text-sm opacity-40"), h.Text(" (dead)"))
}
}
}
}
chipStyle := fmt.Sprintf("width:16px;height:16px;border-radius:50%%;background:%s;display:inline-block;", colorHex)
items = append(items, h.Div(h.Class("flex items-center gap-2"),
h.Span(h.Attr("style", chipStyle)),
h.Span(h.Text(name)),
statusEl,
))
}
listAttrs := []h.H{h.Class("flex flex-wrap gap-4 mb-2")}
listAttrs = append(listAttrs, items...)
return h.Div(listAttrs...)
}
func SnakeInviteLink(gameID string) h.H {
fullURL := baseURL + "/snake/" + gameID
return h.Div(h.Class("mt-4 text-center"),
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.Text(fullURL),
),
h.Button(
h.Class("btn btn-sm mt-2"),
h.Type("button"),
h.Text("Copy Link"),
h.Attr("onclick", "navigator.clipboard.writeText('"+fullURL+"')"),
),
)
}