Files
games/snake/logic.go
Ryan Hamamura 7e78664534 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).
2026-02-02 07:26:28 -10:00

297 lines
6.0 KiB
Go

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)
}