Add Connect 4 multiplayer game server
Real-time two-player Connect 4 using Via framework with: - Game creation and invite links - SSE-based live updates for both players - Win detection with animated highlighting - Session-based nickname persistence
This commit is contained in:
97
game/logic.go
Normal file
97
game/logic.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package game
|
||||
|
||||
// DropPiece attempts to drop a piece in the given column.
|
||||
// Returns (row placed, success).
|
||||
func (g *Game) DropPiece(col, playerColor int) (int, bool) {
|
||||
if col < 0 || col > 6 {
|
||||
return -1, false
|
||||
}
|
||||
if g.CurrentTurn != playerColor {
|
||||
return -1, false
|
||||
}
|
||||
if g.Status != StatusInProgress {
|
||||
return -1, false
|
||||
}
|
||||
|
||||
// Find lowest empty row in column
|
||||
for row := 5; row >= 0; row-- {
|
||||
if g.Board[row][col] == 0 {
|
||||
g.Board[row][col] = playerColor
|
||||
return row, true
|
||||
}
|
||||
}
|
||||
return -1, false // Column full
|
||||
}
|
||||
|
||||
// CheckWin checks if the last move at (row, col) created a win.
|
||||
func (g *Game) CheckWin(row, col int) bool {
|
||||
color := g.Board[row][col]
|
||||
directions := [][2]int{
|
||||
{0, 1}, // Horizontal
|
||||
{1, 0}, // Vertical
|
||||
{1, 1}, // Diagonal down-right
|
||||
{1, -1}, // Diagonal down-left
|
||||
}
|
||||
|
||||
for _, dir := range directions {
|
||||
cells := g.countLine(row, col, dir[0], dir[1], color)
|
||||
if len(cells) >= 4 {
|
||||
g.Status = StatusWon
|
||||
g.WinningCells = cells[:4]
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *Game) countLine(row, col, dr, dc, color int) [][2]int {
|
||||
cells := [][2]int{{row, col}}
|
||||
|
||||
// Count in positive direction
|
||||
for r, c := row+dr, col+dc; r >= 0 && r < 6 && c >= 0 && c < 7; r, c = r+dr, c+dc {
|
||||
if g.Board[r][c] != color {
|
||||
break
|
||||
}
|
||||
cells = append(cells, [2]int{r, c})
|
||||
}
|
||||
|
||||
// Count in negative direction
|
||||
for r, c := row-dr, col-dc; r >= 0 && r < 6 && c >= 0 && c < 7; r, c = r-dr, c-dc {
|
||||
if g.Board[r][c] != color {
|
||||
break
|
||||
}
|
||||
cells = append(cells, [2]int{r, c})
|
||||
}
|
||||
|
||||
return cells
|
||||
}
|
||||
|
||||
// CheckDraw checks if board is full with no winner.
|
||||
func (g *Game) CheckDraw() bool {
|
||||
for col := 0; col < 7; col++ {
|
||||
if g.Board[0][col] == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
g.Status = StatusDraw
|
||||
return true
|
||||
}
|
||||
|
||||
// SwitchTurn alternates the current turn.
|
||||
func (g *Game) SwitchTurn() {
|
||||
if g.CurrentTurn == 1 {
|
||||
g.CurrentTurn = 2
|
||||
} else {
|
||||
g.CurrentTurn = 1
|
||||
}
|
||||
}
|
||||
|
||||
// IsWinningCell checks if a cell is part of the winning line.
|
||||
func (g *Game) IsWinningCell(row, col int) bool {
|
||||
for _, cell := range g.WinningCells {
|
||||
if cell[0] == row && cell[1] == col {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
192
game/store.go
Normal file
192
game/store.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Syncable interface {
|
||||
Sync()
|
||||
}
|
||||
|
||||
type PlayerSession struct {
|
||||
Player *Player
|
||||
Sync Syncable
|
||||
}
|
||||
|
||||
type GameStore struct {
|
||||
games map[string]*GameInstance
|
||||
gamesMu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewGameStore() *GameStore {
|
||||
return &GameStore{
|
||||
games: make(map[string]*GameInstance),
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *GameStore) Create() *GameInstance {
|
||||
id := generateGameID()
|
||||
gi := NewGameInstance(id)
|
||||
gs.gamesMu.Lock()
|
||||
gs.games[id] = gi
|
||||
gs.gamesMu.Unlock()
|
||||
go gi.run()
|
||||
return gi
|
||||
}
|
||||
|
||||
func (gs *GameStore) Get(id string) (*GameInstance, bool) {
|
||||
gs.gamesMu.RLock()
|
||||
defer gs.gamesMu.RUnlock()
|
||||
gi, ok := gs.games[id]
|
||||
return gi, ok
|
||||
}
|
||||
|
||||
func generateGameID() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
type GameInstance struct {
|
||||
game *Game
|
||||
gameMu sync.RWMutex
|
||||
players map[PlayerID]Syncable
|
||||
playersMu sync.RWMutex
|
||||
join chan *PlayerSession
|
||||
leave chan PlayerID
|
||||
done chan struct{}
|
||||
dirty bool
|
||||
}
|
||||
|
||||
func NewGameInstance(id string) *GameInstance {
|
||||
return &GameInstance{
|
||||
game: NewGame(id),
|
||||
players: make(map[PlayerID]Syncable),
|
||||
join: make(chan *PlayerSession, 5),
|
||||
leave: make(chan PlayerID, 5),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (gi *GameInstance) ID() string {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
return gi.game.ID
|
||||
}
|
||||
|
||||
func (gi *GameInstance) Join(ps *PlayerSession) bool {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
// Assign player to an open slot
|
||||
if gi.game.Players[0] == nil {
|
||||
ps.Player.Color = 1 // Red
|
||||
gi.game.Players[0] = ps.Player
|
||||
} else if gi.game.Players[1] == nil {
|
||||
ps.Player.Color = 2 // Yellow
|
||||
gi.game.Players[1] = ps.Player
|
||||
gi.game.Status = StatusInProgress
|
||||
} else {
|
||||
return false // Game is full
|
||||
}
|
||||
|
||||
gi.playersMu.Lock()
|
||||
gi.players[ps.Player.ID] = ps.Sync
|
||||
gi.playersMu.Unlock()
|
||||
|
||||
gi.dirty = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (gi *GameInstance) Leave(pid PlayerID) {
|
||||
gi.leave <- pid
|
||||
}
|
||||
|
||||
func (gi *GameInstance) GetGame() *Game {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
return gi.game
|
||||
}
|
||||
|
||||
func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
for _, p := range gi.game.Players {
|
||||
if p != nil && p.ID == pid {
|
||||
return p.Color
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
row, ok := gi.game.DropPiece(col, playerColor)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if gi.game.CheckWin(row, col) {
|
||||
for _, p := range gi.game.Players {
|
||||
if p != nil && p.Color == playerColor {
|
||||
gi.game.Winner = p
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if gi.game.CheckDraw() {
|
||||
// Status already set by CheckDraw
|
||||
} else {
|
||||
gi.game.SwitchTurn()
|
||||
}
|
||||
|
||||
gi.dirty = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (gi *GameInstance) run() {
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case pid := <-gi.leave:
|
||||
gi.playersMu.Lock()
|
||||
delete(gi.players, pid)
|
||||
gi.playersMu.Unlock()
|
||||
case <-ticker.C:
|
||||
gi.publish()
|
||||
case <-gi.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gi *GameInstance) publish() {
|
||||
gi.gameMu.Lock()
|
||||
if !gi.dirty {
|
||||
gi.gameMu.Unlock()
|
||||
return
|
||||
}
|
||||
gi.dirty = false
|
||||
|
||||
gi.playersMu.RLock()
|
||||
syncers := make([]Syncable, 0, len(gi.players))
|
||||
for _, sync := range gi.players {
|
||||
syncers = append(syncers, sync)
|
||||
}
|
||||
gi.playersMu.RUnlock()
|
||||
gi.gameMu.Unlock()
|
||||
|
||||
for _, sync := range syncers {
|
||||
sync.Sync()
|
||||
}
|
||||
}
|
||||
|
||||
func (gi *GameInstance) Stop() {
|
||||
close(gi.done)
|
||||
}
|
||||
41
game/types.go
Normal file
41
game/types.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package game
|
||||
|
||||
import "time"
|
||||
|
||||
type PlayerID string
|
||||
|
||||
type Player struct {
|
||||
ID PlayerID
|
||||
Nickname string
|
||||
Color int // 1 = Red, 2 = Yellow
|
||||
}
|
||||
|
||||
type GameStatus int
|
||||
|
||||
const (
|
||||
StatusWaitingForPlayer GameStatus = iota
|
||||
StatusInProgress
|
||||
StatusWon
|
||||
StatusDraw
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
ID string
|
||||
Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow
|
||||
Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow)
|
||||
CurrentTurn int // 1 or 2 (matches player color)
|
||||
Status GameStatus
|
||||
Winner *Player
|
||||
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewGame(id string) *Game {
|
||||
return &Game{
|
||||
ID: id,
|
||||
Board: [6][7]int{},
|
||||
CurrentTurn: 1, // Red goes first
|
||||
Status: StatusWaitingForPlayer,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user