Replace polling loop with NATS pub/sub for game updates

Use via's embedded NATS server to notify players of state changes
instead of a 100ms polling ticker. Each player subscribes to
"game.<id>" on page load; via auto-cleans subscriptions on disconnect,
eliminating the need for manual player tracking and RegisterSync.
This commit is contained in:
Ryan Hamamura
2026-01-31 10:26:31 -10:00
parent d079b90a33
commit a6b5a46a8a
5 changed files with 86 additions and 96 deletions

View File

@@ -4,16 +4,14 @@ import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
type Syncable interface {
Sync()
type PubSub interface {
Publish(subject string, data []byte) error
}
type PlayerSession struct {
Player *Player
Sync Syncable
}
type Persister interface {
@@ -28,6 +26,7 @@ type GameStore struct {
games map[string]*GameInstance
gamesMu sync.RWMutex
persister Persister
pubsub PubSub
}
func NewGameStore() *GameStore {
@@ -40,10 +39,23 @@ func (gs *GameStore) SetPersister(p Persister) {
gs.persister = p
}
func (gs *GameStore) SetPubSub(ps PubSub) {
gs.pubsub = ps
}
func (gs *GameStore) makeNotify(gameID string) func() {
return func() {
if gs.pubsub != nil {
gs.pubsub.Publish("game."+gameID, nil)
}
}
}
func (gs *GameStore) Create() *GameInstance {
id := GenerateID(4)
gi := NewGameInstance(id)
gi.persister = gs.persister
gi.notify = gs.makeNotify(id)
gs.gamesMu.Lock()
gs.games[id] = gi
gs.gamesMu.Unlock()
@@ -52,7 +64,6 @@ func (gs *GameStore) Create() *GameInstance {
gs.persister.SaveGame(gi.game)
}
go gi.run()
return gi
}
@@ -65,7 +76,6 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) {
return gi, true
}
// Try to load from database
if gs.persister == nil {
return nil, false
}
@@ -86,27 +96,20 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) {
gi = &GameInstance{
game: game,
players: make(map[PlayerID]Syncable),
leave: make(chan PlayerID, 5),
done: make(chan struct{}),
persister: gs.persister,
notify: gs.makeNotify(id),
}
gs.gamesMu.Lock()
gs.games[id] = gi
gs.gamesMu.Unlock()
go gi.run()
return gi, true
}
func (gs *GameStore) Delete(id string) error {
gs.gamesMu.Lock()
gi, ok := gs.games[id]
if ok {
delete(gs.games, id)
close(gi.done)
}
delete(gs.games, id)
gs.gamesMu.Unlock()
if gs.persister != nil {
@@ -124,20 +127,14 @@ func GenerateID(size int) string {
type GameInstance struct {
game *Game
gameMu sync.RWMutex
players map[PlayerID]Syncable
playersMu sync.RWMutex
leave chan PlayerID
done chan struct{}
dirty bool
notify func()
persister Persister
}
func NewGameInstance(id string) *GameInstance {
return &GameInstance{
game: NewGame(id),
players: make(map[PlayerID]Syncable),
leave: make(chan PlayerID, 5),
done: make(chan struct{}),
game: NewGame(id),
notify: func() {},
}
}
@@ -152,30 +149,25 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool {
defer gi.gameMu.Unlock()
var slot int
// Assign player to an open slot
if gi.game.Players[0] == nil {
ps.Player.Color = 1 // Red
ps.Player.Color = 1
gi.game.Players[0] = ps.Player
slot = 0
} else if gi.game.Players[1] == nil {
ps.Player.Color = 2 // Yellow
ps.Player.Color = 2
gi.game.Players[1] = ps.Player
gi.game.Status = StatusInProgress
slot = 1
} else {
return false // Game is full
return false
}
gi.playersMu.Lock()
gi.players[ps.Player.ID] = ps.Sync
gi.playersMu.Unlock()
if gi.persister != nil {
gi.persister.SaveGamePlayer(gi.game.ID, ps.Player, slot)
gi.persister.SaveGame(gi.game)
}
gi.dirty = true
gi.notify()
return true
}
@@ -196,13 +188,6 @@ func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
return 0
}
func (gi *GameInstance) RegisterSync(playerID PlayerID, sync Syncable) {
gi.playersMu.Lock()
gi.players[playerID] = sync
gi.playersMu.Unlock()
gi.dirty = true
}
func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
gi.gameMu.Lock()
defer gi.gameMu.Unlock()
@@ -223,7 +208,7 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
}
}
gi.dirty = true
gi.notify()
return newGI
}
@@ -253,45 +238,6 @@ func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
gi.persister.SaveGame(gi.game)
}
gi.dirty = true
gi.notify()
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()
}
}