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

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
}