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

@@ -1,20 +1,63 @@
package ui
import (
"github.com/ryanhamamura/c4/snake"
"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
if isLoggedIn {
authSection = AuthHeader(username, logoutClick)
if p.IsLoggedIn {
authSection = AuthHeader(p.Username, p.LogoutClick)
} else {
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,
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.Form(
h.FieldSet(h.Class("fieldset"),
@@ -24,19 +67,19 @@ func LobbyView(nicknameBind, createGameKeyDown, createGameClick h.H, isLoggedIn
h.ID("nickname"),
h.Type("text"),
h.Placeholder("Enter your nickname"),
nicknameBind,
p.NicknameBind,
h.Attr("required"),
createGameKeyDown,
p.CreateGameKeyDown,
),
),
h.Button(
h.Class("btn btn-primary w-full"),
h.Type("button"),
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+"')"),
),
)
}