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

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
c4
c4.db
data/

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)
}
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{}),
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()
}
}

15
go.mod
View File

@@ -15,12 +15,22 @@ require (
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de // indirect
github.com/alexedwards/scs/v2 v2.9.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/antithesishq/antithesis-sdk-go v0.5.0 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/delaneyj/toolbelt v0.9.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/go-tpm v0.9.7 // indirect
github.com/hookenz/gotailwind/v4 v4.1.18 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
github.com/nats-io/jwt/v2 v2.8.0 // indirect
github.com/nats-io/nats-server/v2 v2.12.2 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/zerolog v1.34.0 // indirect
@@ -28,9 +38,10 @@ require (
github.com/starfederation/datastar-go v1.0.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/time v0.14.0 // indirect
maragu.dev/gomponents v1.2.0 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect

39
go.sum
View File

@@ -7,16 +7,24 @@ github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gv
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/antithesishq/antithesis-sdk-go v0.5.0 h1:cudCFF83pDDANcXFzkQPUHHedfnnIbUO3JMr9fqwFJs=
github.com/antithesishq/antithesis-sdk-go v0.5.0/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/delaneyj/toolbelt v0.9.1 h1:QJComn2qoaQ4azl5uRkGpdHSO9e+JtoxDTXCiQHvH8o=
github.com/delaneyj/toolbelt v0.9.1/go.mod h1:eNXpPuThjTD4tpRNCBl4JEz9jdg9LpyzNuyG+stnIbs=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -40,6 +48,18 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g=
github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA=
github.com/nats-io/nats-server/v2 v2.12.2 h1:4TEQd0Y4zvcW0IsVxjlXnRso1hBkQl3TS0BI+SxgPhE=
github.com/nats-io/nats-server/v2 v2.12.2/go.mod h1:j1AAttYeu7WnvD8HLJ+WWKNMSyxsqmZ160pNtCQRMyE=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
@@ -81,19 +101,22 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

19
main.go
View File

@@ -15,6 +15,7 @@ import (
"github.com/ryanhamamura/c4/ui"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
"github.com/ryanhamamura/via/vianats"
)
var store = game.NewGameStore()
@@ -43,12 +44,20 @@ func main() {
log.Fatal(err)
}
ctx := context.Background()
ns, err := vianats.New(ctx, "./data/nats")
if err != nil {
log.Fatal(err)
}
store.SetPubSub(ns)
v := via.New()
v.Config(via.Options{
LogLevel: via.LogLevelDebug,
DocumentTitle: "Connect 4",
ServerAddress: ":7331",
SessionManager: sessionManager,
PubSub: ns,
Plugins: []via.Plugin{DaisyUIPlugin},
})
@@ -307,7 +316,6 @@ func main() {
}
gi.Join(&game.PlayerSession{
Player: player,
Sync: c,
})
}
c.Sync()
@@ -336,6 +344,11 @@ func main() {
}
})
// Subscribe to game updates so the opponent's moves trigger a re-render
if gameExists {
c.Subscribe("game."+gameID, func(data []byte) { c.Sync() })
}
// If nickname exists in session and game exists, join immediately
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{
@@ -347,11 +360,7 @@ func main() {
}
gi.Join(&game.PlayerSession{
Player: player,
Sync: c,
})
} else if gameExists && gi.GetPlayerColor(playerID) != 0 {
// Re-register sync context for existing players on reconnect
gi.RegisterSync(playerID, c)
}
c.View(func() h.H {