From 063b03ce25a25c37cdec73121f034be310e2d858 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 19:09:01 -1000
Subject: [PATCH 01/14] refactor: extract shared player.ID type and GenerateID
to player package
Both game and snake packages had identical PlayerID types and the snake
package imported game.GenerateID. Now both use player.ID and
player.GenerateID from the shared player package.
---
features/c4game/handlers.go | 23 ++++++++++++-----------
features/snakegame/handlers.go | 10 +++++-----
game/persist.go | 11 ++++++-----
game/store.go | 13 +++----------
game/types.go | 8 +++++---
player/player.go | 18 ++++++++++++++++++
snake/persist.go | 11 ++++++-----
snake/store.go | 6 +++---
snake/types.go | 6 +++---
9 files changed, 61 insertions(+), 45 deletions(-)
create mode 100644 player/player.go
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index 8b9d0d7..8b8b251 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -18,6 +18,7 @@ import (
"github.com/ryanhamamura/c4/features/c4game/components"
"github.com/ryanhamamura/c4/features/c4game/pages"
"github.com/ryanhamamura/c4/game"
+ "github.com/ryanhamamura/c4/player"
)
func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
@@ -30,15 +31,15 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
return
}
- playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
+ playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
if playerID == "" {
- playerID = game.PlayerID(game.GenerateID(8))
+ playerID = player.ID(player.GenerateID(8))
sessions.Put(r.Context(), "player_id", string(playerID))
}
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
- playerID = game.PlayerID(userID)
+ playerID = player.ID(userID)
}
nickname := sessions.GetString(r.Context(), "nickname")
@@ -95,10 +96,10 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
return
}
- playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
+ playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
- playerID = game.PlayerID(userID)
+ playerID = player.ID(userID)
}
myColor := gi.GetPlayerColor(playerID)
@@ -185,10 +186,10 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
return
}
- playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
+ playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
- playerID = game.PlayerID(userID)
+ playerID = player.ID(userID)
}
myColor := gi.GetPlayerColor(playerID)
@@ -229,10 +230,10 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM
return
}
- playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
+ playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
- playerID = game.PlayerID(userID)
+ playerID = player.ID(userID)
}
myColor := gi.GetPlayerColor(playerID)
@@ -298,10 +299,10 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http
sessions.Put(r.Context(), "nickname", signals.Nickname)
- playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
+ playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
- playerID = game.PlayerID(userID)
+ playerID = player.ID(userID)
}
if gi.GetPlayerColor(playerID) == 0 {
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index 03292a6..3fa0143 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -13,21 +13,21 @@ import (
"github.com/ryanhamamura/c4/features/snakegame/components"
"github.com/ryanhamamura/c4/features/snakegame/pages"
- "github.com/ryanhamamura/c4/game"
+ "github.com/ryanhamamura/c4/player"
"github.com/ryanhamamura/c4/snake"
)
-func getPlayerID(sessions *scs.SessionManager, r *http.Request) snake.PlayerID {
+func getPlayerID(sessions *scs.SessionManager, r *http.Request) player.ID {
pid := sessions.GetString(r.Context(), "player_id")
if pid == "" {
- pid = game.GenerateID(8)
+ pid = player.GenerateID(8)
sessions.Put(r.Context(), "player_id", pid)
}
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
- return snake.PlayerID(userID)
+ return player.ID(userID)
}
- return snake.PlayerID(pid)
+ return player.ID(pid)
}
func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
diff --git a/game/persist.go b/game/persist.go
index 0b615b7..5efe140 100644
--- a/game/persist.go
+++ b/game/persist.go
@@ -4,6 +4,7 @@ import (
"context"
"github.com/ryanhamamura/c4/db/repository"
+ "github.com/ryanhamamura/c4/player"
"github.com/rs/zerolog/log"
)
@@ -109,19 +110,19 @@ func gameFromRow(row *repository.Game) (*Game, error) {
func playersFromRows(rows []*repository.GamePlayer) []*Player {
players := make([]*Player, 0, len(rows))
for _, row := range rows {
- player := &Player{
+ p := &Player{
Nickname: row.Nickname,
Color: int(row.Color),
}
if row.UserID != nil {
- player.UserID = row.UserID
- player.ID = PlayerID(*row.UserID)
+ p.UserID = row.UserID
+ p.ID = player.ID(*row.UserID)
} else if row.GuestPlayerID != nil {
- player.ID = PlayerID(*row.GuestPlayerID)
+ p.ID = player.ID(*row.GuestPlayerID)
}
- players = append(players, player)
+ players = append(players, p)
}
return players
}
diff --git a/game/store.go b/game/store.go
index d919441..818942c 100644
--- a/game/store.go
+++ b/game/store.go
@@ -2,11 +2,10 @@ package game
import (
"context"
- "crypto/rand"
- "encoding/hex"
"sync"
"github.com/ryanhamamura/c4/db/repository"
+ "github.com/ryanhamamura/c4/player"
)
type PlayerSession struct {
@@ -40,7 +39,7 @@ func (gs *GameStore) makeNotify(gameID string) func() {
}
func (gs *GameStore) Create() *GameInstance {
- id := GenerateID(4)
+ id := player.GenerateID(4)
gi := NewGameInstance(id)
gi.queries = gs.queries
gi.notify = gs.makeNotify(id)
@@ -107,12 +106,6 @@ func (gs *GameStore) Delete(id string) error {
return nil
}
-func GenerateID(size int) string {
- b := make([]byte, size)
- _, _ = rand.Read(b)
- return hex.EncodeToString(b)
-}
-
type GameInstance struct {
game *Game
gameMu sync.RWMutex
@@ -166,7 +159,7 @@ func (gi *GameInstance) GetGame() *Game {
return gi.game
}
-func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
+func (gi *GameInstance) GetPlayerColor(pid player.ID) int {
gi.gameMu.RLock()
defer gi.gameMu.RUnlock()
for _, p := range gi.game.Players {
diff --git a/game/types.go b/game/types.go
index 71f0ae8..2eec2b1 100644
--- a/game/types.go
+++ b/game/types.go
@@ -1,11 +1,13 @@
package game
-import "encoding/json"
+import (
+ "encoding/json"
-type PlayerID string
+ "github.com/ryanhamamura/c4/player"
+)
type Player struct {
- ID PlayerID
+ ID player.ID
UserID *string // UUID for authenticated users, nil for guests
Nickname string
Color int // 1 = Red, 2 = Yellow
diff --git a/player/player.go b/player/player.go
new file mode 100644
index 0000000..ca65a3f
--- /dev/null
+++ b/player/player.go
@@ -0,0 +1,18 @@
+// Package player provides shared identity types used across game packages.
+package player
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+)
+
+// ID uniquely identifies a player within a session. For authenticated users
+// this is their user UUID; for guests it's a random hex string.
+type ID string
+
+// GenerateID returns a random hex string of 2*size characters.
+func GenerateID(size int) string {
+ b := make([]byte, size)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)
+}
diff --git a/snake/persist.go b/snake/persist.go
index dba9da9..741cb40 100644
--- a/snake/persist.go
+++ b/snake/persist.go
@@ -4,6 +4,7 @@ import (
"context"
"github.com/ryanhamamura/c4/db/repository"
+ "github.com/ryanhamamura/c4/player"
"github.com/rs/zerolog/log"
)
@@ -122,19 +123,19 @@ func snakeGameFromRow(row *repository.Game) (*SnakeGame, error) {
func snakePlayersFromRows(rows []*repository.GamePlayer) []*Player {
players := make([]*Player, 0, len(rows))
for _, row := range rows {
- player := &Player{
+ p := &Player{
Nickname: row.Nickname,
Slot: int(row.Slot),
}
if row.UserID != nil {
- player.UserID = row.UserID
- player.ID = PlayerID(*row.UserID)
+ p.UserID = row.UserID
+ p.ID = player.ID(*row.UserID)
} else if row.GuestPlayerID != nil {
- player.ID = PlayerID(*row.GuestPlayerID)
+ p.ID = player.ID(*row.GuestPlayerID)
}
- players = append(players, player)
+ players = append(players, p)
}
return players
}
diff --git a/snake/store.go b/snake/store.go
index 4543a92..2590a4a 100644
--- a/snake/store.go
+++ b/snake/store.go
@@ -5,7 +5,7 @@ import (
"sync"
"github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/game"
+ "github.com/ryanhamamura/c4/player"
)
type SnakeStore struct {
@@ -38,7 +38,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
if speed <= 0 {
speed = DefaultSpeed
}
- id := game.GenerateID(4)
+ id := player.GenerateID(4)
sg := &SnakeGame{
ID: id,
State: &GameState{
@@ -172,7 +172,7 @@ func (si *SnakeGameInstance) GetGame() *SnakeGame {
return si.game.snapshot()
}
-func (si *SnakeGameInstance) GetPlayerSlot(pid PlayerID) int {
+func (si *SnakeGameInstance) GetPlayerSlot(pid player.ID) int {
si.gameMu.RLock()
defer si.gameMu.RUnlock()
for i, p := range si.game.Players {
diff --git a/snake/types.go b/snake/types.go
index 8272765..bbcb836 100644
--- a/snake/types.go
+++ b/snake/types.go
@@ -3,6 +3,8 @@ package snake
import (
"encoding/json"
"time"
+
+ "github.com/ryanhamamura/c4/player"
)
type Direction int
@@ -78,10 +80,8 @@ const (
StatusFinished
)
-type PlayerID string
-
type Player struct {
- ID PlayerID
+ ID player.ID
UserID *string
Nickname string
Slot int // 0-7
From 7eadfbbb0c075e31540605d5c5e19bf46b4bb746 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 19:16:09 -1000
Subject: [PATCH 02/14] refactor: extract session helpers for player identity
resolution
Add GetPlayerID, GetUserID, GetNickname to the sessions package.
Remove the inline player-ID-from-session pattern duplicated across
every handler in c4game and snakegame, and the local getPlayerID
helper in snakegame.
---
features/c4game/handlers.go | 71 +++++++++++-----------------------
features/snakegame/handlers.go | 45 ++++++++-------------
sessions/sessions.go | 30 +++++++++++++-
3 files changed, 67 insertions(+), 79 deletions(-)
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index 8b8b251..c47551b 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -18,10 +18,10 @@ import (
"github.com/ryanhamamura/c4/features/c4game/components"
"github.com/ryanhamamura/c4/features/c4game/pages"
"github.com/ryanhamamura/c4/game"
- "github.com/ryanhamamura/c4/player"
+ "github.com/ryanhamamura/c4/sessions"
)
-func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
+func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -31,29 +31,20 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
return
}
- playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
- if playerID == "" {
- playerID = player.ID(player.GenerateID(8))
- sessions.Put(r.Context(), "player_id", string(playerID))
- }
-
- userID := sessions.GetString(r.Context(), "user_id")
- if userID != "" {
- playerID = player.ID(userID)
- }
-
- nickname := sessions.GetString(r.Context(), "nickname")
+ playerID := sessions.GetPlayerID(sm, r)
+ userID := sessions.GetUserID(sm, r)
+ nickname := sessions.GetNickname(sm, r)
// Auto-join if player has a nickname but isn't in the game yet
if nickname != "" && gi.GetPlayerColor(playerID) == 0 {
- player := &game.Player{
+ p := &game.Player{
ID: playerID,
Nickname: nickname,
}
if userID != "" {
- player.UserID = &userID
+ p.UserID = &userID
}
- gi.Join(&game.PlayerSession{Player: player})
+ gi.Join(&game.PlayerSession{Player: p})
}
myColor := gi.GetPlayerColor(playerID)
@@ -86,7 +77,7 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
}
}
-func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
+func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -96,12 +87,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
return
}
- playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
- userID := sessions.GetString(r.Context(), "user_id")
- if userID != "" {
- playerID = player.ID(userID)
- }
-
+ playerID := sessions.GetPlayerID(sm, r)
myColor := gi.GetPlayerColor(playerID)
sse := datastar.NewSSE(w, r, datastar.WithCompression(
@@ -169,7 +155,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
}
}
-func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
+func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -186,12 +172,7 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
return
}
- playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
- userID := sessions.GetString(r.Context(), "user_id")
- if userID != "" {
- playerID = player.ID(userID)
- }
-
+ playerID := sessions.GetPlayerID(sm, r)
myColor := gi.GetPlayerColor(playerID)
if myColor == 0 {
http.Error(w, "not in game", http.StatusForbidden)
@@ -206,7 +187,7 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
}
}
-func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
+func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -230,12 +211,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM
return
}
- playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
- userID := sessions.GetString(r.Context(), "user_id")
- if userID != "" {
- playerID = player.ID(userID)
- }
-
+ playerID := sessions.GetPlayerID(sm, r)
myColor := gi.GetPlayerColor(playerID)
if myColor == 0 {
datastar.NewSSE(w, r)
@@ -272,7 +248,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM
}
}
-func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
+func HandleSetNickname(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -297,23 +273,20 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http
return
}
- sessions.Put(r.Context(), "nickname", signals.Nickname)
+ sm.Put(r.Context(), "nickname", signals.Nickname)
- playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
- userID := sessions.GetString(r.Context(), "user_id")
- if userID != "" {
- playerID = player.ID(userID)
- }
+ playerID := sessions.GetPlayerID(sm, r)
+ userID := sessions.GetUserID(sm, r)
if gi.GetPlayerColor(playerID) == 0 {
- player := &game.Player{
+ p := &game.Player{
ID: playerID,
Nickname: signals.Nickname,
}
if userID != "" {
- player.UserID = &userID
+ p.UserID = &userID
}
- gi.Join(&game.PlayerSession{Player: player})
+ gi.Join(&game.PlayerSession{Player: p})
}
sse := datastar.NewSSE(w, r)
@@ -321,7 +294,7 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http
}
}
-func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
+func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index 3fa0143..b72fcc1 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -13,24 +13,11 @@ import (
"github.com/ryanhamamura/c4/features/snakegame/components"
"github.com/ryanhamamura/c4/features/snakegame/pages"
- "github.com/ryanhamamura/c4/player"
+ "github.com/ryanhamamura/c4/sessions"
"github.com/ryanhamamura/c4/snake"
)
-func getPlayerID(sessions *scs.SessionManager, r *http.Request) player.ID {
- pid := sessions.GetString(r.Context(), "player_id")
- if pid == "" {
- pid = player.GenerateID(8)
- sessions.Put(r.Context(), "player_id", pid)
- }
- userID := sessions.GetString(r.Context(), "user_id")
- if userID != "" {
- return player.ID(userID)
- }
- return player.ID(pid)
-}
-
-func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
+func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID)
@@ -39,9 +26,9 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager)
return
}
- playerID := getPlayerID(sessions, r)
- nickname := sessions.GetString(r.Context(), "nickname")
- userID := sessions.GetString(r.Context(), "user_id")
+ playerID := sessions.GetPlayerID(sm, r)
+ nickname := sessions.GetNickname(sm, r)
+ userID := sessions.GetUserID(sm, r)
// Auto-join if nickname exists and not already in game
if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
@@ -79,7 +66,7 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager)
}
}
-func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc {
+func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID)
@@ -88,7 +75,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
return
}
- playerID := getPlayerID(sessions, r)
+ playerID := sessions.GetPlayerID(sm, r)
mySlot := si.GetPlayerSlot(playerID)
sse := datastar.NewSSE(w, r, datastar.WithCompression(
@@ -187,7 +174,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
}
}
-func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
+func HandleSetDirection(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID)
@@ -196,7 +183,7 @@ func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManag
return
}
- playerID := getPlayerID(sessions, r)
+ playerID := sessions.GetPlayerID(sm, r)
slot := si.GetPlayerSlot(playerID)
if slot < 0 {
http.Error(w, "not in game", http.StatusForbidden)
@@ -219,7 +206,7 @@ type chatSignals struct {
ChatMsg string `json:"chatMsg"`
}
-func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc {
+func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID)
@@ -238,7 +225,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.S
return
}
- playerID := getPlayerID(sessions, r)
+ playerID := sessions.GetPlayerID(sm, r)
slot := si.GetPlayerSlot(playerID)
if slot < 0 {
http.Error(w, "not in game", http.StatusForbidden)
@@ -266,7 +253,7 @@ type nicknameSignals struct {
Nickname string `json:"nickname"`
}
-func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
+func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID)
@@ -285,10 +272,10 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManage
return
}
- sessions.Put(r.Context(), "nickname", signals.Nickname)
+ sm.Put(r.Context(), "nickname", signals.Nickname)
- playerID := getPlayerID(sessions, r)
- userID := sessions.GetString(r.Context(), "user_id")
+ playerID := sessions.GetPlayerID(sm, r)
+ userID := sessions.GetUserID(sm, r)
if si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{
@@ -306,7 +293,7 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManage
}
}
-func HandleRematch(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
+func HandleRematch(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID)
diff --git a/sessions/sessions.go b/sessions/sessions.go
index 5b52bb4..c4e50d6 100644
--- a/sessions/sessions.go
+++ b/sessions/sessions.go
@@ -1,4 +1,5 @@
-// Package sessions configures the SCS session manager backed by SQLite.
+// Package sessions configures the SCS session manager and provides
+// helpers for resolving player identity from the session.
package sessions
import (
@@ -7,6 +8,8 @@ import (
"net/http"
"time"
+ "github.com/ryanhamamura/c4/player"
+
"github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2"
)
@@ -30,3 +33,28 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
return sessionManager, cleanup
}
+
+// GetPlayerID returns the current player's identity from the session.
+// Authenticated users get their user UUID; guests get a random ID that
+// is generated and persisted on first access.
+func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID {
+ pid := sm.GetString(r.Context(), "player_id")
+ if pid == "" {
+ pid = player.GenerateID(8)
+ sm.Put(r.Context(), "player_id", pid)
+ }
+ if userID := sm.GetString(r.Context(), "user_id"); userID != "" {
+ return player.ID(userID)
+ }
+ return player.ID(pid)
+}
+
+// GetUserID returns the authenticated user's UUID, or empty string for guests.
+func GetUserID(sm *scs.SessionManager, r *http.Request) string {
+ return sm.GetString(r.Context(), "user_id")
+}
+
+// GetNickname returns the player's display name from the session.
+func GetNickname(sm *scs.SessionManager, r *http.Request) string {
+ return sm.GetString(r.Context(), "nickname")
+}
From 10de5d21ad6609f86a309dda3460fbd657d5b1dc Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 19:20:21 -1000
Subject: [PATCH 03/14] refactor: extract standalone chat package from
game-specific handlers
Create chat/ package with Message type, Room (NATS pub/sub + buffer),
DB persistence helpers, and a unified templ component parameterized by
Config (CSS prefix, post URL, color function, key propagation).
Both c4game and snakegame now use chat.Room for message management and
chatcomponents.Chat for rendering, eliminating the duplicated
ChatMessage types, chat templ components, chatAutoScroll scripts,
color functions, and inline buffer management.
---
chat/chat.go | 92 ++++++++++++++
chat/components/chat.templ | 74 +++++++++++
chat/persist.go | 45 +++++++
features/c4game/components/chat.templ | 69 ----------
features/c4game/handlers.go | 152 +++++++----------------
features/c4game/pages/game.templ | 6 +-
features/snakegame/components/chat.templ | 66 ----------
features/snakegame/handlers.go | 72 ++++++-----
features/snakegame/pages/game.templ | 8 +-
game/types.go | 8 --
10 files changed, 305 insertions(+), 287 deletions(-)
create mode 100644 chat/chat.go
create mode 100644 chat/components/chat.templ
create mode 100644 chat/persist.go
delete mode 100644 features/c4game/components/chat.templ
delete mode 100644 features/snakegame/components/chat.templ
diff --git a/chat/chat.go b/chat/chat.go
new file mode 100644
index 0000000..632f919
--- /dev/null
+++ b/chat/chat.go
@@ -0,0 +1,92 @@
+// Package chat provides a reusable chat room backed by NATS pub/sub
+// with optional database persistence.
+package chat
+
+import (
+ "encoding/json"
+ "sync"
+
+ "github.com/nats-io/nats.go"
+ "github.com/rs/zerolog/log"
+)
+
+// Message is the wire format for chat messages over NATS.
+type Message struct {
+ Nickname string `json:"nickname"`
+ Slot int `json:"slot"` // player slot/color index
+ Message string `json:"message"`
+ Time int64 `json:"time"` // unix millis, zero for ephemeral messages
+}
+
+const maxMessages = 50
+
+// Room manages an in-memory message buffer and NATS pub/sub for a single
+// chat room (typically one per game).
+type Room struct {
+ subject string
+ nc *nats.Conn
+ messages []Message
+ mu sync.Mutex
+}
+
+// NewRoom creates a chat room that publishes and subscribes on the given
+// NATS subject (e.g. "chat.abc123").
+func NewRoom(nc *nats.Conn, subject string, initial []Message) *Room {
+ return &Room{
+ subject: subject,
+ nc: nc,
+ messages: initial,
+ }
+}
+
+// Send publishes a message to the room's NATS subject.
+func (r *Room) Send(msg Message) {
+ data, err := json.Marshal(msg)
+ if err != nil {
+ log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message")
+ return
+ }
+ if err := r.nc.Publish(r.subject, data); err != nil {
+ log.Error().Err(err).Str("subject", r.subject).Msg("failed to publish chat message")
+ }
+}
+
+// Receive processes an incoming NATS message, appending it to the buffer.
+// Returns the new message and a snapshot of all messages.
+func (r *Room) Receive(data []byte) (Message, []Message) {
+ var msg Message
+ if err := json.Unmarshal(data, &msg); err != nil {
+ return msg, nil
+ }
+
+ r.mu.Lock()
+ r.messages = append(r.messages, msg)
+ if len(r.messages) > maxMessages {
+ r.messages = r.messages[len(r.messages)-maxMessages:]
+ }
+ snapshot := make([]Message, len(r.messages))
+ copy(snapshot, r.messages)
+ r.mu.Unlock()
+
+ return msg, snapshot
+}
+
+// Messages returns a snapshot of the current message buffer.
+func (r *Room) Messages() []Message {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ snapshot := make([]Message, len(r.messages))
+ copy(snapshot, r.messages)
+ return snapshot
+}
+
+// Subscribe creates a NATS channel subscription for the room's subject.
+// Caller is responsible for unsubscribing.
+func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) {
+ ch := make(chan *nats.Msg, 64)
+ sub, err := r.nc.ChanSubscribe(r.subject, ch)
+ if err != nil {
+ return nil, nil, err
+ }
+ return ch, sub, nil
+}
diff --git a/chat/components/chat.templ b/chat/components/chat.templ
new file mode 100644
index 0000000..2a2199e
--- /dev/null
+++ b/chat/components/chat.templ
@@ -0,0 +1,74 @@
+package components
+
+import (
+ "fmt"
+
+ "github.com/ryanhamamura/c4/chat"
+ "github.com/starfederation/datastar-go/datastar"
+)
+
+// ColorFunc resolves a player slot to a CSS color string.
+type ColorFunc func(slot int) string
+
+// Config holds the game-specific settings for rendering a chat component.
+type Config struct {
+ // CSSPrefix is used for element IDs and CSS classes (e.g. "c4" or "snake").
+ CSSPrefix string
+ // PostURL is the URL to POST chat messages to (e.g. "/games/{id}/chat").
+ PostURL string
+ // Color resolves a player slot to a CSS color string.
+ Color ColorFunc
+ // StopKeyPropagation adds data-on:keydown.stop="" to the input to prevent
+ // key events from propagating (needed for snake to avoid steering while typing).
+ StopKeyPropagation bool
+}
+
+templ Chat(messages []chat.Message, cfg Config) {
+
+
+ for _, m := range messages {
+
+
+ { m.Nickname + ": " }
+
+ { m.Message }
+
+ }
+
+
+ if cfg.StopKeyPropagation {
+
+ } else {
+
+ }
+
+
+ @chatAutoScroll(cfg.CSSPrefix)
+
+}
+
+script chatAutoScroll(cssPrefix string) {
+ var el = document.querySelector('.' + cssPrefix + '-chat-history');
+ if (!el) return;
+ el.scrollTop = el.scrollHeight;
+ new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
+ .observe(el, {childList:true, subtree:true});
+}
diff --git a/chat/persist.go b/chat/persist.go
new file mode 100644
index 0000000..0297b42
--- /dev/null
+++ b/chat/persist.go
@@ -0,0 +1,45 @@
+package chat
+
+import (
+ "context"
+ "slices"
+
+ "github.com/ryanhamamura/c4/db/repository"
+
+ "github.com/rs/zerolog/log"
+)
+
+// SaveMessage persists a chat message to the database.
+func SaveMessage(queries *repository.Queries, roomID string, msg Message) {
+ err := queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
+ GameID: roomID,
+ Nickname: msg.Nickname,
+ Color: int64(msg.Slot),
+ Message: msg.Message,
+ CreatedAt: msg.Time,
+ })
+ if err != nil {
+ log.Error().Err(err).Str("room_id", roomID).Msg("failed to save chat message")
+ }
+}
+
+// LoadMessages loads persisted chat messages for a room, returning them
+// in chronological order (oldest first).
+func LoadMessages(queries *repository.Queries, roomID string) []Message {
+ rows, err := queries.GetChatMessages(context.Background(), roomID)
+ if err != nil {
+ return nil
+ }
+ msgs := make([]Message, len(rows))
+ for i, r := range rows {
+ msgs[i] = Message{
+ Nickname: r.Nickname,
+ Slot: int(r.Color),
+ Message: r.Message,
+ Time: r.CreatedAt,
+ }
+ }
+ // DB returns newest-first; reverse for chronological display
+ slices.Reverse(msgs)
+ return msgs
+}
diff --git a/features/c4game/components/chat.templ b/features/c4game/components/chat.templ
deleted file mode 100644
index c1e6c07..0000000
--- a/features/c4game/components/chat.templ
+++ /dev/null
@@ -1,69 +0,0 @@
-package components
-
-import (
- "fmt"
-
- "github.com/starfederation/datastar-go/datastar"
-)
-
-type ChatMessage struct {
- Nickname string `json:"nickname"`
- Color int `json:"color"`
- Message string `json:"message"`
- Time int64 `json:"time"`
-}
-
-var chatColors = map[int]string{
- 1: "#4a2a3a",
- 2: "#2a4545",
-}
-
-templ Chat(messages []ChatMessage, gameID string) {
-
-
- for _, m := range messages {
-
-
- { m.Nickname }:
-
- { m.Message }
-
- }
- @chatAutoScroll()
-
-
-
-
-
-
-}
-
-templ chatAutoScroll() {
-
-}
-
-func chatColor(color int) string {
- if c, ok := chatColors[color]; ok {
- return c
- }
- return "#666"
-}
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index c47551b..906541a 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -1,12 +1,9 @@
package c4game
import (
- "context"
- "encoding/json"
+ "fmt"
"net/http"
- "slices"
"strconv"
- "sync"
"time"
"github.com/alexedwards/scs/v2"
@@ -14,6 +11,8 @@ import (
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
+ "github.com/ryanhamamura/c4/chat"
+ chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/features/c4game/components"
"github.com/ryanhamamura/c4/features/c4game/pages"
@@ -21,6 +20,27 @@ import (
"github.com/ryanhamamura/c4/sessions"
)
+// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors.
+var c4ChatColors = map[int]string{
+ 0: "#4a2a3a", // color 1 stored as slot 0
+ 1: "#2a4545", // color 2 stored as slot 1
+}
+
+func c4ChatColor(slot int) string {
+ if c, ok := c4ChatColors[slot]; ok {
+ return c
+ }
+ return "#666"
+}
+
+func c4ChatConfig(gameID string) chatcomponents.Config {
+ return chatcomponents.Config{
+ CSSPrefix: "c4",
+ PostURL: fmt.Sprintf("/games/%s/chat", gameID),
+ Color: c4ChatColor,
+ }
+}
+
func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -53,25 +73,21 @@ func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repo
// Player not in game
isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest {
- // Show join prompt (login vs guest)
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
- // Show nickname prompt
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
- // Player is in the game — render full game page
g := gi.GetGame()
- chatMsgs := loadChatMessages(queries, gameID)
- msgs := chatToComponents(chatMsgs)
+ msgs := chat.LoadMessages(queries, gameID)
- if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil {
+ if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
@@ -94,13 +110,11 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
- // Load initial chat messages
- chatMsgs := loadChatMessages(queries, gameID)
- var chatMu sync.Mutex
- chatMessages := chatToComponents(chatMsgs)
+ chatCfg := c4ChatConfig(gameID)
+ room := chat.NewRoom(nc, "game.chat."+gameID, chat.LoadMessages(queries, gameID))
- // Send initial render of all components
- sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
+ // Send initial render
+ sendGameComponents(sse, gi, myColor, room, chatCfg)
// Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64)
@@ -111,8 +125,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages
- chatCh := make(chan *nats.Msg, 64)
- chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh)
+ chatCh, chatSub, err := room.Subscribe()
if err != nil {
return
}
@@ -124,30 +137,14 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
case <-ctx.Done():
return
case <-gameCh:
- // Re-read player color in case we just joined
myColor = gi.GetPlayerColor(playerID)
- sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
+ sendGameComponents(sse, gi, myColor, room, chatCfg)
case msg := <-chatCh:
- var uiMsg game.ChatMessage
- if err := json.Unmarshal(msg.Data, &uiMsg); err != nil {
+ _, snapshot := room.Receive(msg.Data)
+ if snapshot == nil {
continue
}
- cm := components.ChatMessage{
- Nickname: uiMsg.Nickname,
- Color: uiMsg.Color,
- Message: uiMsg.Message,
- Time: uiMsg.Time,
- }
- chatMu.Lock()
- chatMessages = append(chatMessages, cm)
- if len(chatMessages) > 50 {
- chatMessages = chatMessages[len(chatMessages)-50:]
- }
- msgs := make([]components.ChatMessage, len(chatMessages))
- copy(msgs, chatMessages)
- chatMu.Unlock()
-
- if err := sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")); err != nil {
+ if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg), datastar.WithSelectorID("c4-chat")); err != nil {
return
}
}
@@ -180,9 +177,6 @@ func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.Handler
}
gi.DropPiece(col, myColor)
-
- // The store's notifyFunc publishes to NATS, which triggers SSE updates.
- // Return empty SSE response.
datastar.NewSSE(w, r)
}
}
@@ -227,22 +221,18 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager
}
}
- cm := game.ChatMessage{
+ // Map color (1-based) to slot (0-based) for the unified chat message
+ msg := chat.Message{
Nickname: nick,
- Color: myColor,
+ Slot: myColor - 1,
Message: signals.ChatMsg,
Time: time.Now().UnixMilli(),
}
- saveChatMessage(queries, gameID, cm)
+ chat.SaveMessage(queries, gameID, msg)
- data, err := json.Marshal(cm)
- if err != nil {
- datastar.NewSSE(w, r)
- return
- }
- nc.Publish("game.chat."+gameID, data) //nolint:errcheck
+ room := chat.NewRoom(nc, "game.chat."+gameID, nil)
+ room.Send(msg)
- // Clear the chat input
sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
}
@@ -314,61 +304,11 @@ func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFu
}
// sendGameComponents patches all game-related SSE components.
-func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, chatMessages []components.ChatMessage, chatMu *sync.Mutex, gameID string) {
+func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) {
g := gi.GetGame()
- sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
- sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
- sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck
-
- chatMu.Lock()
- msgs := make([]components.ChatMessage, len(chatMessages))
- copy(msgs, chatMessages)
- chatMu.Unlock()
-
- sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
-}
-
-// Chat persistence helpers — inlined from the former ChatPersister.
-
-func saveChatMessage(queries *repository.Queries, gameID string, msg game.ChatMessage) {
- queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ //nolint:errcheck
- GameID: gameID,
- Nickname: msg.Nickname,
- Color: int64(msg.Color),
- Message: msg.Message,
- CreatedAt: msg.Time,
- })
-}
-
-func loadChatMessages(queries *repository.Queries, gameID string) []game.ChatMessage {
- rows, err := queries.GetChatMessages(context.Background(), gameID)
- if err != nil {
- return nil
- }
- msgs := make([]game.ChatMessage, len(rows))
- for i, r := range rows {
- msgs[i] = game.ChatMessage{
- Nickname: r.Nickname,
- Color: int(r.Color),
- Message: r.Message,
- Time: r.CreatedAt,
- }
- }
- // DB returns newest-first; reverse for display
- slices.Reverse(msgs)
- return msgs
-}
-
-func chatToComponents(chatMsgs []game.ChatMessage) []components.ChatMessage {
- msgs := make([]components.ChatMessage, len(chatMsgs))
- for i, m := range chatMsgs {
- msgs[i] = components.ChatMessage{
- Nickname: m.Nickname,
- Color: m.Color,
- Message: m.Message,
- Time: m.Time,
- }
- }
- return msgs
+ sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
+ sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
+ sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck
+ sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
}
diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ
index eb328ad..eee6222 100644
--- a/features/c4game/pages/game.templ
+++ b/features/c4game/pages/game.templ
@@ -1,6 +1,8 @@
package pages
import (
+ "github.com/ryanhamamura/c4/chat"
+ chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/features/c4game/components"
sharedcomponents "github.com/ryanhamamura/c4/features/common/components"
"github.com/ryanhamamura/c4/features/common/layouts"
@@ -8,7 +10,7 @@ import (
"github.com/starfederation/datastar-go/datastar"
)
-templ GamePage(g *game.Game, myColor int, messages []components.ChatMessage) {
+templ GamePage(g *game.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
@layouts.Base("Connect 4") {
@components.Board(g, myColor)
- @components.Chat(messages, g.ID)
+ @chatcomponents.Chat(messages, chatCfg)
if g.Status == game.StatusWaitingForPlayer {
@components.InviteLink(g.ID)
diff --git a/features/snakegame/components/chat.templ b/features/snakegame/components/chat.templ
deleted file mode 100644
index 58c137b..0000000
--- a/features/snakegame/components/chat.templ
+++ /dev/null
@@ -1,66 +0,0 @@
-package components
-
-import (
- "fmt"
-
- "github.com/ryanhamamura/c4/snake"
- "github.com/starfederation/datastar-go/datastar"
-)
-
-type ChatMessage struct {
- Nickname string `json:"nickname"`
- Slot int `json:"slot"`
- Message string `json:"message"`
- Time int64 `json:"time"`
-}
-
-templ Chat(messages []ChatMessage, gameID string) {
-
-
- for _, m := range messages {
-
-
- { m.Nickname + ": " }
-
- { m.Message }
-
- }
-
-
-
-
-
- @chatAutoScroll()
-
-}
-
-templ chatAutoScroll() {
-
-}
-
-func chatColor(slot int) string {
- if slot >= 0 && slot < len(snake.SnakeColors) {
- return snake.SnakeColors[slot]
- }
- return "#666"
-}
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index b72fcc1..13dde02 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -1,22 +1,39 @@
package snakegame
import (
- "encoding/json"
+ "fmt"
"net/http"
"strconv"
- "sync"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
+ "github.com/ryanhamamura/c4/chat"
+ chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/features/snakegame/components"
"github.com/ryanhamamura/c4/features/snakegame/pages"
"github.com/ryanhamamura/c4/sessions"
"github.com/ryanhamamura/c4/snake"
)
+func snakeChatColor(slot int) string {
+ if slot >= 0 && slot < len(snake.SnakeColors) {
+ return snake.SnakeColors[slot]
+ }
+ return "#666"
+}
+
+func snakeChatConfig(gameID string) chatcomponents.Config {
+ return chatcomponents.Config{
+ CSSPrefix: "snake",
+ PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
+ Color: snakeChatColor,
+ StopKeyPropagation: true,
+ }
+}
+
func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -32,20 +49,19 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
// Auto-join if nickname exists and not already in game
if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
- player := &snake.Player{
+ p := &snake.Player{
ID: playerID,
Nickname: nickname,
}
if userID != "" {
- player.UserID = &userID
+ p.UserID = &userID
}
- si.Join(player)
+ si.Join(p)
}
mySlot := si.GetPlayerSlot(playerID)
if mySlot < 0 {
- // Not in game yet
isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest {
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
@@ -60,7 +76,7 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
}
sg := si.GetGame()
- if err := pages.GamePage(sg, mySlot, nil, gameID).Render(r.Context(), w); err != nil {
+ if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
@@ -82,13 +98,15 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
+ chatCfg := snakeChatConfig(gameID)
+
// Send initial render
sg := si.GetGame()
sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
if sg.Mode == snake.ModeMultiplayer {
- sse.PatchElementTempl(components.Chat(nil, gameID)) //nolint:errcheck
+ sse.PatchElementTempl(chatcomponents.Chat(nil, chatCfg)) //nolint:errcheck
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
}
@@ -105,12 +123,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
// Chat subscription (multiplayer only)
var chatCh chan *nats.Msg
var chatSub *nats.Subscription
- var chatMessages []components.ChatMessage
- var chatMu sync.Mutex
+ var room *chat.Room
if sg.Mode == snake.ModeMultiplayer {
- chatCh = make(chan *nats.Msg, 64)
- chatSub, err = nc.ChanSubscribe("snake.chat."+gameID, chatCh)
+ room = chat.NewRoom(nc, "snake.chat."+gameID, nil)
+ chatCh, chatSub, err = room.Subscribe()
if err != nil {
return
}
@@ -153,20 +170,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
if msg == nil {
continue
}
- var cm components.ChatMessage
- if err := json.Unmarshal(msg.Data, &cm); err != nil {
+ _, snapshot := room.Receive(msg.Data)
+ if snapshot == nil {
continue
}
- chatMu.Lock()
- chatMessages = append(chatMessages, cm)
- if len(chatMessages) > 50 {
- chatMessages = chatMessages[len(chatMessages)-50:]
- }
- msgs := make([]components.ChatMessage, len(chatMessages))
- copy(msgs, chatMessages)
- chatMu.Unlock()
-
- if err := sse.PatchElementTempl(components.Chat(msgs, gameID)); err != nil {
+ if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil {
return
}
}
@@ -233,16 +241,14 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
}
sg := si.GetGame()
- cm := components.ChatMessage{
+ msg := chat.Message{
Nickname: sg.Players[slot].Nickname,
Slot: slot,
Message: signals.ChatMsg,
}
- data, err := json.Marshal(cm)
- if err != nil {
- return
- }
- nc.Publish("snake.chat."+gameID, data) //nolint:errcheck
+
+ room := chat.NewRoom(nc, "snake.chat."+gameID, nil)
+ room.Send(msg)
sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
@@ -278,14 +284,14 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) htt
userID := sessions.GetUserID(sm, r)
if si.GetPlayerSlot(playerID) < 0 {
- player := &snake.Player{
+ p := &snake.Player{
ID: playerID,
Nickname: signals.Nickname,
}
if userID != "" {
- player.UserID = &userID
+ p.UserID = &userID
}
- si.Join(player)
+ si.Join(p)
}
sse := datastar.NewSSE(w, r)
diff --git a/features/snakegame/pages/game.templ b/features/snakegame/pages/game.templ
index 49b90de..46fe32d 100644
--- a/features/snakegame/pages/game.templ
+++ b/features/snakegame/pages/game.templ
@@ -3,6 +3,8 @@ package pages
import (
"fmt"
+ "github.com/ryanhamamura/c4/chat"
+ chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/features/common/components"
"github.com/ryanhamamura/c4/features/common/layouts"
snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components"
@@ -26,7 +28,7 @@ func keydownScript(gameID string) string {
)
}
-templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatMessage, gameID string) {
+templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
@layouts.Base("Snake") {
@snakecomponents.Board(sg)
- @snakecomponents.Chat(messages, gameID)
+ @chatcomponents.Chat(messages, chatCfg)
} else {
@snakecomponents.Board(sg)
}
} else if sg.Mode == snake.ModeMultiplayer {
- @snakecomponents.Chat(messages, gameID)
+ @chatcomponents.Chat(messages, chatCfg)
}
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
@snakecomponents.InviteLink(gameID)
diff --git a/game/types.go b/game/types.go
index 2eec2b1..2ceef46 100644
--- a/game/types.go
+++ b/game/types.go
@@ -69,11 +69,3 @@ func (g *Game) WinningCellsFromJSON(data string) error {
}
return json.Unmarshal([]byte(data), &g.WinningCells)
}
-
-// ChatMessage is the domain type for persisted C4 chat messages.
-type ChatMessage struct {
- Nickname string `json:"nickname"`
- Color int `json:"color"` // 1=Red, 2=Yellow
- Message string `json:"message"`
- Time int64 `json:"time"`
-}
From f71acfc73ef57ff08a14def3467c39fcb4c188fa Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 19:47:05 -1000
Subject: [PATCH 04/14] fix: use format string for datastar.PostSSE in chat
component
PostSSE requires a constant format string; pass "%s" with the URL
as an argument instead of passing the URL directly.
---
chat/components/chat.templ | 28 ++++++++++++++--------------
1 file changed, 14 insertions(+), 14 deletions(-)
diff --git a/chat/components/chat.templ b/chat/components/chat.templ
index 2a2199e..da7e329 100644
--- a/chat/components/chat.templ
+++ b/chat/components/chat.templ
@@ -43,20 +43,20 @@ templ Chat(messages []chat.Message, cfg Config) {
autocomplete="off"
data-bind="chatMsg"
data-on:keydown.stop=""
- data-on:keydown.key_enter={ datastar.PostSSE(cfg.PostURL) }
- />
- } else {
-
- }
-
+ } else {
+
+ }
+
From 38eb9ee39878882a64a8d32fb6f789c9ae7589ba Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 20:31:00 -1000
Subject: [PATCH 05/14] refactor: rename game package to connect4, drop Game
prefix from types
Rename game/ -> connect4/ to avoid c4/game stutter. Drop redundant
Game prefix from exported types (GameStore -> Store, GameInstance ->
Instance, GameStatus -> Status). Rename NATS subjects from game.{id}
to connect4.{id}. URL routes unchanged.
---
{game => connect4}/logic.go | 4 +-
{game => connect4}/persist.go | 24 +++---
{game => connect4}/store.go | 98 ++++++++++++------------
{game => connect4}/types.go | 8 +-
features/c4game/components/board.templ | 14 ++--
features/c4game/components/status.templ | 30 ++++----
features/c4game/handlers.go | 30 ++++----
features/c4game/pages/game.templ | 6 +-
features/c4game/routes.go | 4 +-
features/lobby/components/gamelist.templ | 14 ++--
features/lobby/handlers.go | 6 +-
features/lobby/routes.go | 4 +-
main.go | 6 +-
router/router.go | 4 +-
14 files changed, 125 insertions(+), 127 deletions(-)
rename {game => connect4}/logic.go (95%)
rename {game => connect4}/persist.go (85%)
rename {game => connect4}/store.go (59%)
rename {game => connect4}/types.go (93%)
diff --git a/game/logic.go b/connect4/logic.go
similarity index 95%
rename from game/logic.go
rename to connect4/logic.go
index 7a4d167..8abbb74 100644
--- a/game/logic.go
+++ b/connect4/logic.go
@@ -1,5 +1,5 @@
-// Package game implements Connect 4 game logic, state management, and persistence.
-package game
+// Package connect4 implements Connect 4 game logic, state management, and persistence.
+package connect4
// DropPiece attempts to drop a piece in the given column.
// Returns (row placed, success).
diff --git a/game/persist.go b/connect4/persist.go
similarity index 85%
rename from game/persist.go
rename to connect4/persist.go
index 5efe140..9772b93 100644
--- a/game/persist.go
+++ b/connect4/persist.go
@@ -1,4 +1,4 @@
-package game
+package connect4
import (
"context"
@@ -9,7 +9,7 @@ import (
"github.com/rs/zerolog/log"
)
-func (gi *GameInstance) save() error {
+func (gi *Instance) save() error {
err := saveGame(gi.queries, gi.game)
if err != nil {
log.Error().Err(err).Str("game_id", gi.game.ID).Msg("failed to save game")
@@ -17,8 +17,8 @@ func (gi *GameInstance) save() error {
return err
}
-func (gi *GameInstance) savePlayer(player *Player, slot int) error {
- err := saveGamePlayer(gi.queries, gi.game.ID, player, slot)
+func (gi *Instance) savePlayer(p *Player, slot int) error {
+ err := saveGamePlayer(gi.queries, gi.game.ID, p, slot)
if err != nil {
log.Error().Err(err).Str("game_id", gi.game.ID).Int("slot", slot).Msg("failed to save game player")
}
@@ -48,12 +48,12 @@ func saveGame(queries *repository.Queries, g *Game) error {
})
}
-func saveGamePlayer(queries *repository.Queries, gameID string, player *Player, slot int) error {
+func saveGamePlayer(queries *repository.Queries, gameID string, p *Player, slot int) error {
var userID, guestPlayerID *string
- if player.UserID != nil {
- userID = player.UserID
+ if p.UserID != nil {
+ userID = p.UserID
} else {
- id := string(player.ID)
+ id := string(p.ID)
guestPlayerID = &id
}
@@ -61,8 +61,8 @@ func saveGamePlayer(queries *repository.Queries, gameID string, player *Player,
GameID: gameID,
UserID: userID,
GuestPlayerID: guestPlayerID,
- Nickname: player.Nickname,
- Color: int64(player.Color),
+ Nickname: p.Nickname,
+ Color: int64(p.Color),
Slot: int64(slot),
})
}
@@ -83,13 +83,11 @@ func loadGamePlayers(queries *repository.Queries, id string) ([]*Player, error)
return playersFromRows(rows), nil
}
-// Domain ↔ DB mapping helpers.
-
func gameFromRow(row *repository.Game) (*Game, error) {
g := &Game{
ID: row.ID,
CurrentTurn: int(row.CurrentTurn),
- Status: GameStatus(row.Status),
+ Status: Status(row.Status),
}
if err := g.BoardFromJSON(row.Board); err != nil {
diff --git a/game/store.go b/connect4/store.go
similarity index 59%
rename from game/store.go
rename to connect4/store.go
index 818942c..fe68b1e 100644
--- a/game/store.go
+++ b/connect4/store.go
@@ -1,4 +1,4 @@
-package game
+package connect4
import (
"context"
@@ -12,67 +12,67 @@ type PlayerSession struct {
Player *Player
}
-type GameStore struct {
- games map[string]*GameInstance
+type Store struct {
+ games map[string]*Instance
gamesMu sync.RWMutex
queries *repository.Queries
notifyFunc func(gameID string)
}
-func NewGameStore(queries *repository.Queries) *GameStore {
- return &GameStore{
- games: make(map[string]*GameInstance),
+func NewStore(queries *repository.Queries) *Store {
+ return &Store{
+ games: make(map[string]*Instance),
queries: queries,
}
}
-func (gs *GameStore) SetNotifyFunc(f func(gameID string)) {
- gs.notifyFunc = f
+func (s *Store) SetNotifyFunc(f func(gameID string)) {
+ s.notifyFunc = f
}
-func (gs *GameStore) makeNotify(gameID string) func() {
+func (s *Store) makeNotify(gameID string) func() {
return func() {
- if gs.notifyFunc != nil {
- gs.notifyFunc(gameID)
+ if s.notifyFunc != nil {
+ s.notifyFunc(gameID)
}
}
}
-func (gs *GameStore) Create() *GameInstance {
+func (s *Store) Create() *Instance {
id := player.GenerateID(4)
- gi := NewGameInstance(id)
- gi.queries = gs.queries
- gi.notify = gs.makeNotify(id)
- gs.gamesMu.Lock()
- gs.games[id] = gi
- gs.gamesMu.Unlock()
+ gi := NewInstance(id)
+ gi.queries = s.queries
+ gi.notify = s.makeNotify(id)
+ s.gamesMu.Lock()
+ s.games[id] = gi
+ s.gamesMu.Unlock()
- if gs.queries != nil {
+ if s.queries != nil {
gi.save() //nolint:errcheck
}
return gi
}
-func (gs *GameStore) Get(id string) (*GameInstance, bool) {
- gs.gamesMu.RLock()
- gi, ok := gs.games[id]
- gs.gamesMu.RUnlock()
+func (s *Store) Get(id string) (*Instance, bool) {
+ s.gamesMu.RLock()
+ gi, ok := s.games[id]
+ s.gamesMu.RUnlock()
if ok {
return gi, true
}
- if gs.queries == nil {
+ if s.queries == nil {
return nil, false
}
- g, err := loadGame(gs.queries, id)
+ g, err := loadGame(s.queries, id)
if err != nil || g == nil {
return nil, false
}
- players, _ := loadGamePlayers(gs.queries, id)
+ players, _ := loadGamePlayers(s.queries, id)
for _, p := range players {
switch p.Color {
case 1:
@@ -82,51 +82,51 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) {
}
}
- gi = &GameInstance{
+ gi = &Instance{
game: g,
- queries: gs.queries,
- notify: gs.makeNotify(id),
+ queries: s.queries,
+ notify: s.makeNotify(id),
}
- gs.gamesMu.Lock()
- gs.games[id] = gi
- gs.gamesMu.Unlock()
+ s.gamesMu.Lock()
+ s.games[id] = gi
+ s.gamesMu.Unlock()
return gi, true
}
-func (gs *GameStore) Delete(id string) error {
- gs.gamesMu.Lock()
- delete(gs.games, id)
- gs.gamesMu.Unlock()
+func (s *Store) Delete(id string) error {
+ s.gamesMu.Lock()
+ delete(s.games, id)
+ s.gamesMu.Unlock()
- if gs.queries != nil {
- return gs.queries.DeleteGame(context.Background(), id)
+ if s.queries != nil {
+ return s.queries.DeleteGame(context.Background(), id)
}
return nil
}
-type GameInstance struct {
+type Instance struct {
game *Game
gameMu sync.RWMutex
notify func()
queries *repository.Queries
}
-func NewGameInstance(id string) *GameInstance {
- return &GameInstance{
+func NewInstance(id string) *Instance {
+ return &Instance{
game: NewGame(id),
notify: func() {},
}
}
-func (gi *GameInstance) ID() string {
+func (gi *Instance) ID() string {
gi.gameMu.RLock()
defer gi.gameMu.RUnlock()
return gi.game.ID
}
-func (gi *GameInstance) Join(ps *PlayerSession) bool {
+func (gi *Instance) Join(ps *PlayerSession) bool {
gi.gameMu.Lock()
defer gi.gameMu.Unlock()
@@ -153,13 +153,13 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool {
return true
}
-func (gi *GameInstance) GetGame() *Game {
+func (gi *Instance) GetGame() *Game {
gi.gameMu.RLock()
defer gi.gameMu.RUnlock()
return gi.game
}
-func (gi *GameInstance) GetPlayerColor(pid player.ID) int {
+func (gi *Instance) GetPlayerColor(pid player.ID) int {
gi.gameMu.RLock()
defer gi.gameMu.RUnlock()
for _, p := range gi.game.Players {
@@ -170,7 +170,7 @@ func (gi *GameInstance) GetPlayerColor(pid player.ID) int {
return 0
}
-func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
+func (gi *Instance) CreateRematch(s *Store) *Instance {
gi.gameMu.Lock()
defer gi.gameMu.Unlock()
@@ -178,13 +178,13 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
return nil
}
- newGI := gs.Create()
+ newGI := s.Create()
newID := newGI.ID()
gi.game.RematchGameID = &newID
if gi.queries != nil {
if err := gi.save(); err != nil {
- gs.Delete(newID) //nolint:errcheck
+ s.Delete(newID) //nolint:errcheck
gi.game.RematchGameID = nil
return nil
}
@@ -194,7 +194,7 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
return newGI
}
-func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
+func (gi *Instance) DropPiece(col int, playerColor int) bool {
gi.gameMu.Lock()
defer gi.gameMu.Unlock()
diff --git a/game/types.go b/connect4/types.go
similarity index 93%
rename from game/types.go
rename to connect4/types.go
index 2ceef46..0dac3d8 100644
--- a/game/types.go
+++ b/connect4/types.go
@@ -1,4 +1,4 @@
-package game
+package connect4
import (
"encoding/json"
@@ -13,10 +13,10 @@ type Player struct {
Color int // 1 = Red, 2 = Yellow
}
-type GameStatus int
+type Status int
const (
- StatusWaitingForPlayer GameStatus = iota
+ StatusWaitingForPlayer Status = iota
StatusInProgress
StatusWon
StatusDraw
@@ -27,7 +27,7 @@ type Game struct {
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
+ Status Status
Winner *Player
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
RematchGameID *string // ID of the rematch game, if one was created
diff --git a/features/c4game/components/board.templ b/features/c4game/components/board.templ
index ee4cb72..6ac381c 100644
--- a/features/c4game/components/board.templ
+++ b/features/c4game/components/board.templ
@@ -3,11 +3,11 @@ package components
import (
"fmt"
- "github.com/ryanhamamura/c4/game"
+ "github.com/ryanhamamura/c4/connect4"
"github.com/starfederation/datastar-go/datastar"
)
-templ Board(g *game.Game, myColor int) {
+templ Board(g *connect4.Game, myColor int) {
for col := 0; col < 7; col++ {
@column(g, col, myColor)
@@ -15,8 +15,8 @@ templ Board(g *game.Game, myColor int) {
}
-templ column(g *game.Game, colIdx int, myColor int) {
- if g.Status == game.StatusInProgress && myColor == g.CurrentTurn {
+templ column(g *connect4.Game, colIdx int, myColor int) {
+ if g.Status == connect4.StatusInProgress && myColor == g.CurrentTurn {
}
-func cellClass(g *game.Game, row, col int) string {
+func cellClass(g *connect4.Game, row, col int) string {
color := g.Board[row][col]
activeTurn := 0
- if g.Status == game.StatusInProgress {
+ if g.Status == connect4.StatusInProgress {
activeTurn = g.CurrentTurn
}
diff --git a/features/c4game/components/status.templ b/features/c4game/components/status.templ
index d4f4e71..c2e6483 100644
--- a/features/c4game/components/status.templ
+++ b/features/c4game/components/status.templ
@@ -2,11 +2,11 @@ package components
import (
"github.com/ryanhamamura/c4/config"
- "github.com/ryanhamamura/c4/game"
+ "github.com/ryanhamamura/c4/connect4"
"github.com/starfederation/datastar-go/datastar"
)
-templ StatusBanner(g *game.Game, myColor int) {
+templ StatusBanner(g *connect4.Game, myColor int) {
{ statusMessage(g, myColor) }
if g.IsFinished() {
@@ -30,7 +30,7 @@ templ StatusBanner(g *game.Game, myColor int) {
}
-templ PlayerInfo(g *game.Game, myColor int) {
+templ PlayerInfo(g *connect4.Game, myColor int) {
for _, info := range playerInfoPairs(g, myColor) {
@@ -61,36 +61,36 @@ script copyToClipboard(url string) {
navigator.clipboard.writeText(url)
}
-func statusClass(g *game.Game, myColor int) string {
+func statusClass(g *connect4.Game, myColor int) string {
switch g.Status {
- case game.StatusWaitingForPlayer:
+ case connect4.StatusWaitingForPlayer:
return "alert bg-base-200 text-xl font-bold"
- case game.StatusInProgress:
+ case connect4.StatusInProgress:
if g.CurrentTurn == myColor {
return "alert alert-success text-xl font-bold"
}
return "alert bg-base-200 text-xl font-bold"
- case game.StatusWon:
+ case connect4.StatusWon:
if g.Winner != nil && g.Winner.Color == myColor {
return "alert alert-success text-xl font-bold"
}
return "alert alert-error text-xl font-bold"
- case game.StatusDraw:
+ case connect4.StatusDraw:
return "alert alert-warning text-xl font-bold"
}
return "alert bg-base-200 text-xl font-bold"
}
-func statusMessage(g *game.Game, myColor int) string {
+func statusMessage(g *connect4.Game, myColor int) string {
switch g.Status {
- case game.StatusWaitingForPlayer:
+ case connect4.StatusWaitingForPlayer:
return "Waiting for opponent..."
- case game.StatusInProgress:
+ case connect4.StatusInProgress:
if g.CurrentTurn == myColor {
return "Your turn!"
}
return opponentName(g, myColor) + "'s turn"
- case game.StatusWon:
+ case connect4.StatusWon:
if g.Winner != nil && g.Winner.Color == myColor {
return "You win!"
}
@@ -98,13 +98,13 @@ func statusMessage(g *game.Game, myColor int) string {
return g.Winner.Nickname + " wins!"
}
return "Game over"
- case game.StatusDraw:
+ case connect4.StatusDraw:
return "It's a draw!"
}
return ""
}
-func opponentName(g *game.Game, myColor int) string {
+func opponentName(g *connect4.Game, myColor int) string {
for _, p := range g.Players {
if p != nil && p.Color != myColor {
return p.Nickname
@@ -118,7 +118,7 @@ type playerInfoData struct {
Label string
}
-func playerInfoPairs(g *game.Game, myColor int) []playerInfoData {
+func playerInfoPairs(g *connect4.Game, myColor int) []playerInfoData {
var result []playerInfoData
var myName, oppName string
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index 906541a..c82573d 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -13,10 +13,10 @@ import (
"github.com/ryanhamamura/c4/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
+ "github.com/ryanhamamura/c4/connect4"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/features/c4game/components"
"github.com/ryanhamamura/c4/features/c4game/pages"
- "github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/sessions"
)
@@ -41,7 +41,7 @@ func c4ChatConfig(gameID string) chatcomponents.Config {
}
}
-func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
+func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -57,14 +57,14 @@ func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repo
// Auto-join if player has a nickname but isn't in the game yet
if nickname != "" && gi.GetPlayerColor(playerID) == 0 {
- p := &game.Player{
+ p := &connect4.Player{
ID: playerID,
Nickname: nickname,
}
if userID != "" {
p.UserID = &userID
}
- gi.Join(&game.PlayerSession{Player: p})
+ gi.Join(&connect4.PlayerSession{Player: p})
}
myColor := gi.GetPlayerColor(playerID)
@@ -93,7 +93,7 @@ func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repo
}
}
-func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
+func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -111,14 +111,14 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
))
chatCfg := c4ChatConfig(gameID)
- room := chat.NewRoom(nc, "game.chat."+gameID, chat.LoadMessages(queries, gameID))
+ room := chat.NewRoom(nc, "connect4.chat."+gameID, chat.LoadMessages(queries, gameID))
// Send initial render
sendGameComponents(sse, gi, myColor, room, chatCfg)
// Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64)
- gameSub, err := nc.ChanSubscribe("game."+gameID, gameCh)
+ gameSub, err := nc.ChanSubscribe("connect4."+gameID, gameCh)
if err != nil {
return
}
@@ -152,7 +152,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
}
}
-func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc {
+func HandleDropPiece(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -181,7 +181,7 @@ func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.Handler
}
}
-func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
+func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -230,7 +230,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager
}
chat.SaveMessage(queries, gameID, msg)
- room := chat.NewRoom(nc, "game.chat."+gameID, nil)
+ room := chat.NewRoom(nc, "connect4.chat."+gameID, nil)
room.Send(msg)
sse := datastar.NewSSE(w, r)
@@ -238,7 +238,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager
}
}
-func HandleSetNickname(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc {
+func HandleSetNickname(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -269,14 +269,14 @@ func HandleSetNickname(store *game.GameStore, sm *scs.SessionManager) http.Handl
userID := sessions.GetUserID(sm, r)
if gi.GetPlayerColor(playerID) == 0 {
- p := &game.Player{
+ p := &connect4.Player{
ID: playerID,
Nickname: signals.Nickname,
}
if userID != "" {
p.UserID = &userID
}
- gi.Join(&game.PlayerSession{Player: p})
+ gi.Join(&connect4.PlayerSession{Player: p})
}
sse := datastar.NewSSE(w, r)
@@ -284,7 +284,7 @@ func HandleSetNickname(store *game.GameStore, sm *scs.SessionManager) http.Handl
}
}
-func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc {
+func HandleRematch(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -304,7 +304,7 @@ func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFu
}
// sendGameComponents patches all game-related SSE components.
-func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) {
+func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *connect4.Instance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) {
g := gi.GetGame()
sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ
index eee6222..19d8418 100644
--- a/features/c4game/pages/game.templ
+++ b/features/c4game/pages/game.templ
@@ -3,14 +3,14 @@ package pages
import (
"github.com/ryanhamamura/c4/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
+ "github.com/ryanhamamura/c4/connect4"
"github.com/ryanhamamura/c4/features/c4game/components"
sharedcomponents "github.com/ryanhamamura/c4/features/common/components"
"github.com/ryanhamamura/c4/features/common/layouts"
- "github.com/ryanhamamura/c4/game"
"github.com/starfederation/datastar-go/datastar"
)
-templ GamePage(g *game.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
+templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
@layouts.Base("Connect 4") {
- if g.Status == game.StatusWaitingForPlayer {
+ if g.Status == connect4.StatusWaitingForPlayer {
@components.InviteLink(g.ID)
}
diff --git a/features/c4game/routes.go b/features/c4game/routes.go
index 2ffd2dc..bc35047 100644
--- a/features/c4game/routes.go
+++ b/features/c4game/routes.go
@@ -6,13 +6,13 @@ import (
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
+ "github.com/ryanhamamura/c4/connect4"
"github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/game"
)
func SetupRoutes(
router chi.Router,
- store *game.GameStore,
+ store *connect4.Store,
nc *nats.Conn,
sessions *scs.SessionManager,
queries *repository.Queries,
diff --git a/features/lobby/components/gamelist.templ b/features/lobby/components/gamelist.templ
index 24563e5..3fbcb5c 100644
--- a/features/lobby/components/gamelist.templ
+++ b/features/lobby/components/gamelist.templ
@@ -4,7 +4,7 @@ import (
"fmt"
"time"
- "github.com/ryanhamamura/c4/game"
+ "github.com/ryanhamamura/c4/connect4"
"github.com/starfederation/datastar-go/datastar"
)
@@ -46,10 +46,10 @@ templ gameListEntry(g GameListItem) {
}
func statusText(g GameListItem) string {
- switch game.GameStatus(g.Status) {
- case game.StatusWaitingForPlayer:
+ switch connect4.Status(g.Status) {
+ case connect4.StatusWaitingForPlayer:
return "Waiting for opponent"
- case game.StatusInProgress:
+ case connect4.StatusInProgress:
if g.IsMyTurn {
return "Your turn!"
}
@@ -59,10 +59,10 @@ func statusText(g GameListItem) string {
}
func statusClass(g GameListItem) string {
- switch game.GameStatus(g.Status) {
- case game.StatusWaitingForPlayer:
+ switch connect4.Status(g.Status) {
+ case connect4.StatusWaitingForPlayer:
return "text-sm opacity-60"
- case game.StatusInProgress:
+ case connect4.StatusInProgress:
if g.IsMyTurn {
return "text-sm text-success font-bold"
}
diff --git a/features/lobby/handlers.go b/features/lobby/handlers.go
index 938c02a..609eee3 100644
--- a/features/lobby/handlers.go
+++ b/features/lobby/handlers.go
@@ -7,10 +7,10 @@ import (
"strconv"
"time"
+ "github.com/ryanhamamura/c4/connect4"
"github.com/ryanhamamura/c4/db/repository"
lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components"
"github.com/ryanhamamura/c4/features/lobby/pages"
- "github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/snake"
"github.com/alexedwards/scs/v2"
@@ -80,7 +80,7 @@ func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager,
}
// HandleCreateGame reads the nickname signal, creates a connect4 game, and redirects via SSE.
-func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
+func HandleCreateGame(store *connect4.Store, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type Signals struct {
Nickname string `json:"nickname"`
@@ -104,7 +104,7 @@ func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http.
}
// HandleDeleteGame deletes a connect4 game and redirects to the lobby.
-func HandleDeleteGame(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
+func HandleDeleteGame(store *connect4.Store, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
if gameID == "" {
diff --git a/features/lobby/routes.go b/features/lobby/routes.go
index ea8a433..103f3de 100644
--- a/features/lobby/routes.go
+++ b/features/lobby/routes.go
@@ -2,8 +2,8 @@
package lobby
import (
+ "github.com/ryanhamamura/c4/connect4"
"github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/snake"
"github.com/alexedwards/scs/v2"
@@ -14,7 +14,7 @@ func SetupRoutes(
router chi.Router,
queries *repository.Queries,
sessions *scs.SessionManager,
- store *game.GameStore,
+ store *connect4.Store,
snakeStore *snake.SnakeStore,
) {
router.Get("/", HandleLobbyPage(queries, sessions, snakeStore))
diff --git a/main.go b/main.go
index 1b97103..04c3f51 100644
--- a/main.go
+++ b/main.go
@@ -12,9 +12,9 @@ import (
"time"
"github.com/ryanhamamura/c4/config"
+ "github.com/ryanhamamura/c4/connect4"
"github.com/ryanhamamura/c4/db"
"github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/logging"
appnats "github.com/ryanhamamura/c4/nats"
"github.com/ryanhamamura/c4/router"
@@ -71,9 +71,9 @@ func run(ctx context.Context) error {
defer cleanupNATS()
// Game stores
- store := game.NewGameStore(queries)
+ store := connect4.NewStore(queries)
store.SetNotifyFunc(func(gameID string) {
- nc.Publish("game."+gameID, nil) //nolint:errcheck // best-effort notification
+ nc.Publish("connect4."+gameID, nil) //nolint:errcheck // best-effort notification
})
snakeStore := snake.NewSnakeStore(queries)
diff --git a/router/router.go b/router/router.go
index 6f8b6eb..ce62137 100644
--- a/router/router.go
+++ b/router/router.go
@@ -8,12 +8,12 @@ import (
"sync"
"github.com/ryanhamamura/c4/config"
+ "github.com/ryanhamamura/c4/connect4"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/features/auth"
"github.com/ryanhamamura/c4/features/c4game"
"github.com/ryanhamamura/c4/features/lobby"
"github.com/ryanhamamura/c4/features/snakegame"
- "github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/snake"
"github.com/alexedwards/scs/v2"
@@ -27,7 +27,7 @@ func SetupRoutes(
queries *repository.Queries,
sessions *scs.SessionManager,
nc *nats.Conn,
- store *game.GameStore,
+ store *connect4.Store,
snakeStore *snake.SnakeStore,
assets embed.FS,
) {
From c6885a069bd8032a6540ef46eefd8bc672136f91 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 20:41:20 -1000
Subject: [PATCH 06/14] refactor: rename Go module from c4 to games
Rename module path github.com/ryanhamamura/c4 to
github.com/ryanhamamura/games across go.mod, all source files,
and golangci config.
---
.golangci.yml | 2 +-
chat/components/chat.templ | 2 +-
chat/persist.go | 2 +-
connect4/persist.go | 4 ++--
connect4/store.go | 4 ++--
connect4/types.go | 2 +-
features/auth/handlers.go | 6 +++---
features/auth/pages/login.templ | 2 +-
features/auth/pages/register.templ | 2 +-
features/auth/routes.go | 2 +-
features/c4game/components/board.templ | 2 +-
features/c4game/components/status.templ | 4 ++--
features/c4game/handlers.go | 14 +++++++-------
features/c4game/pages/game.templ | 12 ++++++------
features/c4game/routes.go | 4 ++--
features/common/layouts/base.templ | 2 +-
features/lobby/components/gamelist.templ | 2 +-
features/lobby/handlers.go | 10 +++++-----
features/lobby/pages/lobby.templ | 8 ++++----
features/lobby/pages/types.go | 2 +-
features/lobby/routes.go | 6 +++---
features/snakegame/components/board.templ | 2 +-
features/snakegame/components/status.templ | 4 ++--
features/snakegame/handlers.go | 12 ++++++------
features/snakegame/pages/game.templ | 12 ++++++------
features/snakegame/routes.go | 2 +-
go.mod | 2 +-
logging/log.go | 2 +-
logging/middleware.go | 2 +-
main.go | 18 +++++++++---------
router/router.go | 16 ++++++++--------
sessions/sessions.go | 2 +-
snake/persist.go | 4 ++--
snake/store.go | 4 ++--
snake/types.go | 2 +-
testutil/db.go | 4 ++--
36 files changed, 91 insertions(+), 91 deletions(-)
diff --git a/.golangci.yml b/.golangci.yml
index 961c8c8..59bc7bc 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -35,7 +35,7 @@ formatters:
settings:
goimports:
local-prefixes:
- - github.com/ryanhamamura/c4
+ - github.com/ryanhamamura/games
issues:
exclude-rules:
diff --git a/chat/components/chat.templ b/chat/components/chat.templ
index da7e329..ec10136 100644
--- a/chat/components/chat.templ
+++ b/chat/components/chat.templ
@@ -3,7 +3,7 @@ package components
import (
"fmt"
- "github.com/ryanhamamura/c4/chat"
+ "github.com/ryanhamamura/games/chat"
"github.com/starfederation/datastar-go/datastar"
)
diff --git a/chat/persist.go b/chat/persist.go
index 0297b42..45b6ee7 100644
--- a/chat/persist.go
+++ b/chat/persist.go
@@ -4,7 +4,7 @@ import (
"context"
"slices"
- "github.com/ryanhamamura/c4/db/repository"
+ "github.com/ryanhamamura/games/db/repository"
"github.com/rs/zerolog/log"
)
diff --git a/connect4/persist.go b/connect4/persist.go
index 9772b93..5e311ad 100644
--- a/connect4/persist.go
+++ b/connect4/persist.go
@@ -3,8 +3,8 @@ package connect4
import (
"context"
- "github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/player"
+ "github.com/ryanhamamura/games/db/repository"
+ "github.com/ryanhamamura/games/player"
"github.com/rs/zerolog/log"
)
diff --git a/connect4/store.go b/connect4/store.go
index fe68b1e..2f1bba6 100644
--- a/connect4/store.go
+++ b/connect4/store.go
@@ -4,8 +4,8 @@ import (
"context"
"sync"
- "github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/player"
+ "github.com/ryanhamamura/games/db/repository"
+ "github.com/ryanhamamura/games/player"
)
type PlayerSession struct {
diff --git a/connect4/types.go b/connect4/types.go
index 0dac3d8..a423030 100644
--- a/connect4/types.go
+++ b/connect4/types.go
@@ -3,7 +3,7 @@ package connect4
import (
"encoding/json"
- "github.com/ryanhamamura/c4/player"
+ "github.com/ryanhamamura/games/player"
)
type Player struct {
diff --git a/features/auth/handlers.go b/features/auth/handlers.go
index dc34675..8c7c8fb 100644
--- a/features/auth/handlers.go
+++ b/features/auth/handlers.go
@@ -8,9 +8,9 @@ import (
"github.com/google/uuid"
"github.com/starfederation/datastar-go/datastar"
- "github.com/ryanhamamura/c4/auth"
- "github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/features/auth/pages"
+ "github.com/ryanhamamura/games/auth"
+ "github.com/ryanhamamura/games/db/repository"
+ "github.com/ryanhamamura/games/features/auth/pages"
)
type LoginSignals struct {
diff --git a/features/auth/pages/login.templ b/features/auth/pages/login.templ
index 3a4bcae..8a93ff9 100644
--- a/features/auth/pages/login.templ
+++ b/features/auth/pages/login.templ
@@ -1,7 +1,7 @@
package pages
import (
- "github.com/ryanhamamura/c4/features/common/layouts"
+ "github.com/ryanhamamura/games/features/common/layouts"
"github.com/starfederation/datastar-go/datastar"
)
diff --git a/features/auth/pages/register.templ b/features/auth/pages/register.templ
index 00ef50a..92fd0c4 100644
--- a/features/auth/pages/register.templ
+++ b/features/auth/pages/register.templ
@@ -1,7 +1,7 @@
package pages
import (
- "github.com/ryanhamamura/c4/features/common/layouts"
+ "github.com/ryanhamamura/games/features/common/layouts"
"github.com/starfederation/datastar-go/datastar"
)
diff --git a/features/auth/routes.go b/features/auth/routes.go
index 16e5e85..bb39e44 100644
--- a/features/auth/routes.go
+++ b/features/auth/routes.go
@@ -5,7 +5,7 @@ import (
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
- "github.com/ryanhamamura/c4/db/repository"
+ "github.com/ryanhamamura/games/db/repository"
)
func SetupRoutes(router chi.Router, queries *repository.Queries, sessions *scs.SessionManager) {
diff --git a/features/c4game/components/board.templ b/features/c4game/components/board.templ
index 6ac381c..07bbd2a 100644
--- a/features/c4game/components/board.templ
+++ b/features/c4game/components/board.templ
@@ -3,7 +3,7 @@ package components
import (
"fmt"
- "github.com/ryanhamamura/c4/connect4"
+ "github.com/ryanhamamura/games/connect4"
"github.com/starfederation/datastar-go/datastar"
)
diff --git a/features/c4game/components/status.templ b/features/c4game/components/status.templ
index c2e6483..161d1c6 100644
--- a/features/c4game/components/status.templ
+++ b/features/c4game/components/status.templ
@@ -1,8 +1,8 @@
package components
import (
- "github.com/ryanhamamura/c4/config"
- "github.com/ryanhamamura/c4/connect4"
+ "github.com/ryanhamamura/games/config"
+ "github.com/ryanhamamura/games/connect4"
"github.com/starfederation/datastar-go/datastar"
)
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index c82573d..79461d9 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -11,13 +11,13 @@ import (
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
- "github.com/ryanhamamura/c4/chat"
- chatcomponents "github.com/ryanhamamura/c4/chat/components"
- "github.com/ryanhamamura/c4/connect4"
- "github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/features/c4game/components"
- "github.com/ryanhamamura/c4/features/c4game/pages"
- "github.com/ryanhamamura/c4/sessions"
+ "github.com/ryanhamamura/games/chat"
+ chatcomponents "github.com/ryanhamamura/games/chat/components"
+ "github.com/ryanhamamura/games/connect4"
+ "github.com/ryanhamamura/games/db/repository"
+ "github.com/ryanhamamura/games/features/c4game/components"
+ "github.com/ryanhamamura/games/features/c4game/pages"
+ "github.com/ryanhamamura/games/sessions"
)
// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors.
diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ
index 19d8418..16e0155 100644
--- a/features/c4game/pages/game.templ
+++ b/features/c4game/pages/game.templ
@@ -1,12 +1,12 @@
package pages
import (
- "github.com/ryanhamamura/c4/chat"
- chatcomponents "github.com/ryanhamamura/c4/chat/components"
- "github.com/ryanhamamura/c4/connect4"
- "github.com/ryanhamamura/c4/features/c4game/components"
- sharedcomponents "github.com/ryanhamamura/c4/features/common/components"
- "github.com/ryanhamamura/c4/features/common/layouts"
+ "github.com/ryanhamamura/games/chat"
+ chatcomponents "github.com/ryanhamamura/games/chat/components"
+ "github.com/ryanhamamura/games/connect4"
+ "github.com/ryanhamamura/games/features/c4game/components"
+ sharedcomponents "github.com/ryanhamamura/games/features/common/components"
+ "github.com/ryanhamamura/games/features/common/layouts"
"github.com/starfederation/datastar-go/datastar"
)
diff --git a/features/c4game/routes.go b/features/c4game/routes.go
index bc35047..e936fd4 100644
--- a/features/c4game/routes.go
+++ b/features/c4game/routes.go
@@ -6,8 +6,8 @@ import (
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
- "github.com/ryanhamamura/c4/connect4"
- "github.com/ryanhamamura/c4/db/repository"
+ "github.com/ryanhamamura/games/connect4"
+ "github.com/ryanhamamura/games/db/repository"
)
func SetupRoutes(
diff --git a/features/common/layouts/base.templ b/features/common/layouts/base.templ
index ffd9585..86d563a 100644
--- a/features/common/layouts/base.templ
+++ b/features/common/layouts/base.templ
@@ -1,6 +1,6 @@
package layouts
-import "github.com/ryanhamamura/c4/config"
+import "github.com/ryanhamamura/games/config"
templ Base(title string) {
diff --git a/features/lobby/components/gamelist.templ b/features/lobby/components/gamelist.templ
index 3fbcb5c..73efab3 100644
--- a/features/lobby/components/gamelist.templ
+++ b/features/lobby/components/gamelist.templ
@@ -4,7 +4,7 @@ import (
"fmt"
"time"
- "github.com/ryanhamamura/c4/connect4"
+ "github.com/ryanhamamura/games/connect4"
"github.com/starfederation/datastar-go/datastar"
)
diff --git a/features/lobby/handlers.go b/features/lobby/handlers.go
index 609eee3..5698447 100644
--- a/features/lobby/handlers.go
+++ b/features/lobby/handlers.go
@@ -7,11 +7,11 @@ import (
"strconv"
"time"
- "github.com/ryanhamamura/c4/connect4"
- "github.com/ryanhamamura/c4/db/repository"
- lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components"
- "github.com/ryanhamamura/c4/features/lobby/pages"
- "github.com/ryanhamamura/c4/snake"
+ "github.com/ryanhamamura/games/connect4"
+ "github.com/ryanhamamura/games/db/repository"
+ lobbycomponents "github.com/ryanhamamura/games/features/lobby/components"
+ "github.com/ryanhamamura/games/features/lobby/pages"
+ "github.com/ryanhamamura/games/snake"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
diff --git a/features/lobby/pages/lobby.templ b/features/lobby/pages/lobby.templ
index cff0080..c2dde2e 100644
--- a/features/lobby/pages/lobby.templ
+++ b/features/lobby/pages/lobby.templ
@@ -3,10 +3,10 @@ package pages
import (
"fmt"
- "github.com/ryanhamamura/c4/features/common/components"
- "github.com/ryanhamamura/c4/features/common/layouts"
- lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components"
- "github.com/ryanhamamura/c4/snake"
+ "github.com/ryanhamamura/games/features/common/components"
+ "github.com/ryanhamamura/games/features/common/layouts"
+ lobbycomponents "github.com/ryanhamamura/games/features/lobby/components"
+ "github.com/ryanhamamura/games/snake"
"github.com/starfederation/datastar-go/datastar"
)
diff --git a/features/lobby/pages/types.go b/features/lobby/pages/types.go
index a386a6f..89acc28 100644
--- a/features/lobby/pages/types.go
+++ b/features/lobby/pages/types.go
@@ -1,6 +1,6 @@
package pages
-import "github.com/ryanhamamura/c4/features/lobby/components"
+import "github.com/ryanhamamura/games/features/lobby/components"
// SnakeGameListItem represents a joinable snake game in the lobby.
type SnakeGameListItem struct {
diff --git a/features/lobby/routes.go b/features/lobby/routes.go
index 103f3de..bec75e2 100644
--- a/features/lobby/routes.go
+++ b/features/lobby/routes.go
@@ -2,9 +2,9 @@
package lobby
import (
- "github.com/ryanhamamura/c4/connect4"
- "github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/snake"
+ "github.com/ryanhamamura/games/connect4"
+ "github.com/ryanhamamura/games/db/repository"
+ "github.com/ryanhamamura/games/snake"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
diff --git a/features/snakegame/components/board.templ b/features/snakegame/components/board.templ
index 6083935..9c4c156 100644
--- a/features/snakegame/components/board.templ
+++ b/features/snakegame/components/board.templ
@@ -3,7 +3,7 @@ package components
import (
"fmt"
- "github.com/ryanhamamura/c4/snake"
+ "github.com/ryanhamamura/games/snake"
)
func cellSizeForGrid(width, height int) int {
diff --git a/features/snakegame/components/status.templ b/features/snakegame/components/status.templ
index b09613d..d1b045e 100644
--- a/features/snakegame/components/status.templ
+++ b/features/snakegame/components/status.templ
@@ -5,8 +5,8 @@ import (
"math"
"time"
- "github.com/ryanhamamura/c4/config"
- "github.com/ryanhamamura/c4/snake"
+ "github.com/ryanhamamura/games/config"
+ "github.com/ryanhamamura/games/snake"
"github.com/starfederation/datastar-go/datastar"
)
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index 13dde02..5f518dd 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -10,12 +10,12 @@ import (
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
- "github.com/ryanhamamura/c4/chat"
- chatcomponents "github.com/ryanhamamura/c4/chat/components"
- "github.com/ryanhamamura/c4/features/snakegame/components"
- "github.com/ryanhamamura/c4/features/snakegame/pages"
- "github.com/ryanhamamura/c4/sessions"
- "github.com/ryanhamamura/c4/snake"
+ "github.com/ryanhamamura/games/chat"
+ chatcomponents "github.com/ryanhamamura/games/chat/components"
+ "github.com/ryanhamamura/games/features/snakegame/components"
+ "github.com/ryanhamamura/games/features/snakegame/pages"
+ "github.com/ryanhamamura/games/sessions"
+ "github.com/ryanhamamura/games/snake"
)
func snakeChatColor(slot int) string {
diff --git a/features/snakegame/pages/game.templ b/features/snakegame/pages/game.templ
index 46fe32d..63bda61 100644
--- a/features/snakegame/pages/game.templ
+++ b/features/snakegame/pages/game.templ
@@ -3,12 +3,12 @@ package pages
import (
"fmt"
- "github.com/ryanhamamura/c4/chat"
- chatcomponents "github.com/ryanhamamura/c4/chat/components"
- "github.com/ryanhamamura/c4/features/common/components"
- "github.com/ryanhamamura/c4/features/common/layouts"
- snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components"
- "github.com/ryanhamamura/c4/snake"
+ "github.com/ryanhamamura/games/chat"
+ chatcomponents "github.com/ryanhamamura/games/chat/components"
+ "github.com/ryanhamamura/games/features/common/components"
+ "github.com/ryanhamamura/games/features/common/layouts"
+ snakecomponents "github.com/ryanhamamura/games/features/snakegame/components"
+ "github.com/ryanhamamura/games/snake"
"github.com/starfederation/datastar-go/datastar"
)
diff --git a/features/snakegame/routes.go b/features/snakegame/routes.go
index 880c073..0f30757 100644
--- a/features/snakegame/routes.go
+++ b/features/snakegame/routes.go
@@ -6,7 +6,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
- "github.com/ryanhamamura/c4/snake"
+ "github.com/ryanhamamura/games/snake"
)
func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) {
diff --git a/go.mod b/go.mod
index d7a24c5..069eb86 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module github.com/ryanhamamura/c4
+module github.com/ryanhamamura/games
go 1.25.4
diff --git a/logging/log.go b/logging/log.go
index 4cdaed3..7a4245d 100644
--- a/logging/log.go
+++ b/logging/log.go
@@ -6,7 +6,7 @@ import (
stdlog "log"
"os"
- "github.com/ryanhamamura/c4/config"
+ "github.com/ryanhamamura/games/config"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
diff --git a/logging/middleware.go b/logging/middleware.go
index dd6404e..be6c21a 100644
--- a/logging/middleware.go
+++ b/logging/middleware.go
@@ -5,7 +5,7 @@ import (
"net/http"
"time"
- "github.com/ryanhamamura/c4/config"
+ "github.com/ryanhamamura/games/config"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
diff --git a/main.go b/main.go
index 04c3f51..4bf49a3 100644
--- a/main.go
+++ b/main.go
@@ -11,15 +11,15 @@ import (
"syscall"
"time"
- "github.com/ryanhamamura/c4/config"
- "github.com/ryanhamamura/c4/connect4"
- "github.com/ryanhamamura/c4/db"
- "github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/logging"
- appnats "github.com/ryanhamamura/c4/nats"
- "github.com/ryanhamamura/c4/router"
- "github.com/ryanhamamura/c4/sessions"
- "github.com/ryanhamamura/c4/snake"
+ "github.com/ryanhamamura/games/config"
+ "github.com/ryanhamamura/games/connect4"
+ "github.com/ryanhamamura/games/db"
+ "github.com/ryanhamamura/games/db/repository"
+ "github.com/ryanhamamura/games/logging"
+ appnats "github.com/ryanhamamura/games/nats"
+ "github.com/ryanhamamura/games/router"
+ "github.com/ryanhamamura/games/sessions"
+ "github.com/ryanhamamura/games/snake"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
diff --git a/router/router.go b/router/router.go
index ce62137..1a2cd8f 100644
--- a/router/router.go
+++ b/router/router.go
@@ -7,14 +7,14 @@ import (
"net/http"
"sync"
- "github.com/ryanhamamura/c4/config"
- "github.com/ryanhamamura/c4/connect4"
- "github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/features/auth"
- "github.com/ryanhamamura/c4/features/c4game"
- "github.com/ryanhamamura/c4/features/lobby"
- "github.com/ryanhamamura/c4/features/snakegame"
- "github.com/ryanhamamura/c4/snake"
+ "github.com/ryanhamamura/games/config"
+ "github.com/ryanhamamura/games/connect4"
+ "github.com/ryanhamamura/games/db/repository"
+ "github.com/ryanhamamura/games/features/auth"
+ "github.com/ryanhamamura/games/features/c4game"
+ "github.com/ryanhamamura/games/features/lobby"
+ "github.com/ryanhamamura/games/features/snakegame"
+ "github.com/ryanhamamura/games/snake"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
diff --git a/sessions/sessions.go b/sessions/sessions.go
index c4e50d6..fdbbe08 100644
--- a/sessions/sessions.go
+++ b/sessions/sessions.go
@@ -8,7 +8,7 @@ import (
"net/http"
"time"
- "github.com/ryanhamamura/c4/player"
+ "github.com/ryanhamamura/games/player"
"github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2"
diff --git a/snake/persist.go b/snake/persist.go
index 741cb40..e044a61 100644
--- a/snake/persist.go
+++ b/snake/persist.go
@@ -3,8 +3,8 @@ package snake
import (
"context"
- "github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/player"
+ "github.com/ryanhamamura/games/db/repository"
+ "github.com/ryanhamamura/games/player"
"github.com/rs/zerolog/log"
)
diff --git a/snake/store.go b/snake/store.go
index 2590a4a..888dfa2 100644
--- a/snake/store.go
+++ b/snake/store.go
@@ -4,8 +4,8 @@ import (
"context"
"sync"
- "github.com/ryanhamamura/c4/db/repository"
- "github.com/ryanhamamura/c4/player"
+ "github.com/ryanhamamura/games/db/repository"
+ "github.com/ryanhamamura/games/player"
)
type SnakeStore struct {
diff --git a/snake/types.go b/snake/types.go
index bbcb836..62376f9 100644
--- a/snake/types.go
+++ b/snake/types.go
@@ -4,7 +4,7 @@ import (
"encoding/json"
"time"
- "github.com/ryanhamamura/c4/player"
+ "github.com/ryanhamamura/games/player"
)
type Direction int
diff --git a/testutil/db.go b/testutil/db.go
index d2cb2f9..e642aba 100644
--- a/testutil/db.go
+++ b/testutil/db.go
@@ -8,8 +8,8 @@ import (
"io/fs"
"testing"
- "github.com/ryanhamamura/c4/db"
- "github.com/ryanhamamura/c4/db/repository"
+ "github.com/ryanhamamura/games/db"
+ "github.com/ryanhamamura/games/db/repository"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite"
From 6d43bdea16bb13ed9d585701fb5e6b78047af7c6 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:16:12 -1000
Subject: [PATCH 07/14] refactor: rename remaining c4 references to games
Update binary name, DB path, session cookie, deploy scripts, systemd
service, Docker config, CI workflow, and .dockerignore. Remove stale
Claude command and settings files.
---
.claude/commands/release.md | 45 ----------------------------
.dockerignore | 8 ++---
.env.example | 6 ++--
.gitea/workflows/deploy.yml | 2 +-
Dockerfile | 8 ++---
Taskfile.yml | 10 +++----
config/config.go | 2 +-
deploy/deploy.sh | 14 ++++-----
deploy/{c4.service => games.service} | 8 ++---
deploy/package.sh | 28 ++++++++---------
deploy/reassemble.sh | 26 ++++++++--------
deploy/setup.sh | 10 +++----
docker-compose.yml | 4 +--
sessions/sessions.go | 2 +-
14 files changed, 64 insertions(+), 109 deletions(-)
delete mode 100644 .claude/commands/release.md
rename deploy/{c4.service => games.service} (70%)
diff --git a/.claude/commands/release.md b/.claude/commands/release.md
deleted file mode 100644
index df59678..0000000
--- a/.claude/commands/release.md
+++ /dev/null
@@ -1,45 +0,0 @@
-Create a new Gitea release for this project using semantic versioning.
-
-## Current state
-
-Fetch tags and find the latest version:
-
-```
-!git fetch --tags && git tag --sort=-v:refname | head -5
-```
-
-Commits since the last release (if no tags exist, this shows all commits):
-
-```
-!git log $(git describe --tags --abbrev=0 2>/dev/null && echo "$(git describe --tags --abbrev=0)..HEAD" || echo "") --oneline
-```
-
-## Instructions
-
-1. **Determine current version** from the tag output above. If no `vX.Y.Z` tags exist, treat current version as `v0.0.0`.
-
-2. **Analyze commits** using conventional commit prefixes to pick the semver bump:
- - Breaking changes (`!` after type, or `BREAKING CHANGE` in body) → **major** bump
- - `feat:` → **minor** bump
- - `fix:`, `chore:`, `deps:`, `revert:`, and everything else → **patch** bump
- - Use the **highest** applicable bump level across all commits
-
-3. **Generate release notes** — group commits into sections:
- - **Features** — `feat:` commits
- - **Fixes** — `fix:` commits
- - **Other** — everything else (`chore:`, `deps:`, `revert:`, etc.)
- - Omit empty sections. Each commit is a bullet point with its short description (strip the prefix).
-
-4. **Present for approval** — show the user:
- - Current version → proposed new version
- - The full release notes
- - The exact `tea` command that will run
- - Ask the user to confirm before proceeding
-
-5. **Create the release** — on user approval, run:
- ```
- tea releases create --login gitea --repo ryan/c4 --tag
--target main -t "" -n ""
- ```
- Do NOT create a local git tag — Gitea creates it server-side.
-
-6. **Verify** — run `tea releases ls --login gitea --repo ryan/c4` to confirm the release was created.
diff --git a/.dockerignore b/.dockerignore
index 8362dd4..5471e11 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,10 +1,10 @@
-c4
-c4.db
+games
+games.db
data/
deploy/
.env
.git
.gitignore
assets/css/output.css
-c4-deploy-*.tar.gz
-c4-deploy-*_b64*.txt
+games-deploy-*.tar.gz
+games-deploy-*_b64*.txt
diff --git a/.env.example b/.env.example
index 7ae4ff9..c317103 100644
--- a/.env.example
+++ b/.env.example
@@ -1,8 +1,8 @@
# Log level (TRACE, DEBUG, INFO, WARN, ERROR). Defaults to INFO.
# LOG_LEVEL=DEBUG
-# SQLite database path. Defaults to data/c4.db.
-# DB_PATH=data/c4.db
+# SQLite database path. Defaults to data/games.db.
+# DB_PATH=data/games.db
# Application URL for invite links. Defaults to https://games.adriatica.io.
# APP_URL=http://localhost:7331
@@ -12,5 +12,5 @@
# Goose CLI migration config (only needed for running goose manually)
GOOSE_DRIVER=sqlite3
-GOOSE_DBSTRING=data/c4.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)
+GOOSE_DBSTRING=data/games.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)
GOOSE_MIGRATION_DIR=db/migrations
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 28baa5a..9aa9673 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -6,7 +6,7 @@ on:
pull_request:
env:
- DEPLOY_DIR: /home/ryan/c4
+ DEPLOY_DIR: /home/ryan/games
jobs:
test:
diff --git a/Dockerfile b/Dockerfile
index a22afc9..e0b0d87 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,10 +9,10 @@ RUN go mod download
COPY . .
RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
RUN --mount=type=cache,target=/root/.cache/go-build \
- CGO_ENABLED=0 go build -ldflags="-s" -o /bin/c4 .
-RUN upx -9 -k /bin/c4
+ CGO_ENABLED=0 go build -ldflags="-s" -o /bin/games .
+RUN upx -9 -k /bin/games
FROM scratch
ENV PORT=8080
-COPY --from=build /bin/c4 /
-ENTRYPOINT ["/c4"]
+COPY --from=build /bin/games /
+ENTRYPOINT ["/games"]
diff --git a/Taskfile.yml b/Taskfile.yml
index e0df8ef..fcb0a9d 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -27,9 +27,9 @@ tasks:
- "assets/css/output.css"
build:
- desc: Production build to bin/c4
+ desc: Production build to bin/games
cmds:
- - go build -o bin/c4 .
+ - go build -o bin/games .
deps:
- build:templ
- build:styles
@@ -49,8 +49,8 @@ tasks:
cmds:
- |
go tool air \
- -build.cmd "go build -tags=dev -o tmp/bin/c4 ." \
- -build.bin "tmp/bin/c4" \
+ -build.cmd "go build -tags=dev -o tmp/bin/games ." \
+ -build.bin "tmp/bin/games" \
-build.exclude_dir "data,bin,tmp,deploy" \
-build.include_ext "go,templ" \
-misc.clean_on_exit "true"
@@ -75,7 +75,7 @@ tasks:
run:
desc: Build and run the server
cmds:
- - ./bin/c4
+ - ./bin/games
deps:
- build
diff --git a/config/config.go b/config/config.go
index 31774f2..330c660 100644
--- a/config/config.go
+++ b/config/config.go
@@ -71,6 +71,6 @@ func loadBase() *Config {
}
}(),
AppURL: getEnv("APP_URL", "https://games.adriatica.io"),
- DBPath: getEnv("DB_PATH", "data/c4.db"),
+ DBPath: getEnv("DB_PATH", "data/games.db"),
}
}
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
index 4834fc9..c1f96f5 100755
--- a/deploy/deploy.sh
+++ b/deploy/deploy.sh
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
-# Deploy the c4 binary to /opt/c4, then restart the service.
+# Deploy the games binary to /opt/games, then restart the service.
# Works from the repo (builds first) or from an extracted tarball (pre-built binary).
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
-INSTALL_DIR="/opt/c4"
-BINARY="$ROOT_DIR/c4"
+INSTALL_DIR="/opt/games"
+BINARY="$ROOT_DIR/games"
# If Go is available and we have source, build fresh
if [[ -f "$ROOT_DIR/go.mod" ]] && command -v go &>/dev/null; then
@@ -13,7 +13,7 @@ if [[ -f "$ROOT_DIR/go.mod" ]] && command -v go &>/dev/null; then
(cd "$ROOT_DIR" && go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify)
echo "Building binary..."
- (cd "$ROOT_DIR" && CGO_ENABLED=0 go build -o c4 .)
+ (cd "$ROOT_DIR" && CGO_ENABLED=0 go build -o games .)
fi
if [[ ! -f "$BINARY" ]]; then
@@ -22,10 +22,10 @@ if [[ ! -f "$BINARY" ]]; then
fi
echo "Installing to $INSTALL_DIR..."
-install -o games -g games -m 755 "$BINARY" "$INSTALL_DIR/c4"
+install -o games -g games -m 755 "$BINARY" "$INSTALL_DIR/games"
echo "Restarting service..."
-systemctl restart c4.service
+systemctl restart games.service
echo "Done. Status:"
-systemctl status c4.service --no-pager
+systemctl status games.service --no-pager
diff --git a/deploy/c4.service b/deploy/games.service
similarity index 70%
rename from deploy/c4.service
rename to deploy/games.service
index f1aa7c9..1836bac 100644
--- a/deploy/c4.service
+++ b/deploy/games.service
@@ -1,13 +1,13 @@
[Unit]
-Description=C4 Game Lobby
+Description=Games Lobby
After=network.target
[Service]
Type=simple
User=games
Group=games
-WorkingDirectory=/opt/c4
-ExecStart=/opt/c4/c4
+WorkingDirectory=/opt/games
+ExecStart=/opt/games/games
Restart=on-failure
RestartSec=5
@@ -17,7 +17,7 @@ Environment=PORT=8080
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
-ReadWritePaths=/opt/c4
+ReadWritePaths=/opt/games
PrivateTmp=true
[Install]
diff --git a/deploy/package.sh b/deploy/package.sh
index add6a3e..d200f67 100755
--- a/deploy/package.sh
+++ b/deploy/package.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# Build the c4 binary, bundle it with deploy files into a tarball,
+# Build the games binary, bundle it with deploy files into a tarball,
# base64-encode it, and split into 25MB chunks for transfer.
set -euo pipefail
@@ -7,14 +7,14 @@ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_DIR"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
-TARBALL="c4-deploy-${TIMESTAMP}.tar.gz"
-BASE64_FILE="c4-deploy-${TIMESTAMP}_b64.txt"
+TARBALL="games-deploy-${TIMESTAMP}.tar.gz"
+BASE64_FILE="games-deploy-${TIMESTAMP}_b64.txt"
#==============================================================================
# Clean previous artifacts
#==============================================================================
echo "--- Cleaning old artifacts ---"
-rm -f ./c4 c4-deploy-*.tar.gz c4-deploy-*_b64*.txt
+rm -f ./games games-deploy-*.tar.gz games-deploy-*_b64*.txt
#==============================================================================
# Build
@@ -23,18 +23,18 @@ echo "--- Building CSS ---"
go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
echo "--- Building binary (linux/amd64) ---"
-CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o c4 .
+CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o games .
#==============================================================================
# Verify required files
#==============================================================================
echo "--- Verifying files ---"
REQUIRED_FILES=(
- c4
+ games
deploy/setup.sh
deploy/deploy.sh
deploy/reassemble.sh
- deploy/c4.service
+ deploy/games.service
)
for f in "${REQUIRED_FILES[@]}"; do
if [[ ! -f "$f" ]]; then
@@ -48,12 +48,12 @@ done
# Create tarball
#==============================================================================
echo "--- Creating tarball ---"
-tar -czf "/tmp/${TARBALL}" --transform 's,^,c4/,' \
- c4 \
+tar -czf "/tmp/${TARBALL}" --transform 's,^,games/,' \
+ games \
deploy/setup.sh \
deploy/deploy.sh \
deploy/reassemble.sh \
- deploy/c4.service
+ deploy/games.service
mv "/tmp/${TARBALL}" .
echo " -> ${TARBALL} ($(du -h "${TARBALL}" | cut -f1))"
@@ -66,10 +66,10 @@ base64 "${TARBALL}" > "${BASE64_FILE}"
echo " -> ${BASE64_FILE} ($(du -h "${BASE64_FILE}" | cut -f1))"
echo "--- Splitting into 25MB chunks ---"
-split -b 25M -d --additional-suffix=.txt "${BASE64_FILE}" "c4-deploy-${TIMESTAMP}_b64_part"
+split -b 25M -d --additional-suffix=.txt "${BASE64_FILE}" "games-deploy-${TIMESTAMP}_b64_part"
rm -f "${BASE64_FILE}"
-CHUNKS=(c4-deploy-${TIMESTAMP}_b64_part*.txt)
+CHUNKS=(games-deploy-${TIMESTAMP}_b64_part*.txt)
echo " -> ${#CHUNKS[@]} chunk(s):"
for chunk in "${CHUNKS[@]}"; do
echo " $chunk ($(du -h "$chunk" | cut -f1))"
@@ -83,5 +83,5 @@ echo "=== Package Complete ==="
echo ""
echo "Transfer the chunk files to the target server, then run:"
echo " ./reassemble.sh"
-echo " cd ~/c4 && sudo ./deploy/setup.sh # first time only"
-echo " cd ~/c4 && sudo ./deploy/deploy.sh"
+echo " cd ~/games && sudo ./deploy/setup.sh # first time only"
+echo " cd ~/games && sudo ./deploy/deploy.sh"
diff --git a/deploy/reassemble.sh b/deploy/reassemble.sh
index 73127ee..59278ed 100755
--- a/deploy/reassemble.sh
+++ b/deploy/reassemble.sh
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
-# Reassembles base64 chunks and extracts the c4 deployment tarball.
+# Reassembles base64 chunks and extracts the games deployment tarball.
# Expects chunk files in the current directory.
set -euo pipefail
cd "$HOME"
-echo "=== C4 Deployment Reassembler ==="
+echo "=== Games Deployment Reassembler ==="
echo "Working directory: $HOME"
echo ""
@@ -14,10 +14,10 @@ echo ""
#==============================================================================
echo "--- Finding chunk files ---"
-CHUNKS=($(ls -1 c4-deploy-*_b64_part*.txt 2>/dev/null | sort))
+CHUNKS=($(ls -1 games-deploy-*_b64_part*.txt 2>/dev/null | sort))
if [[ ${#CHUNKS[@]} -eq 0 ]]; then
- echo "ERROR: No chunk files found matching c4-deploy-*_b64_part*.txt"
+ echo "ERROR: No chunk files found matching games-deploy-*_b64_part*.txt"
exit 1
fi
@@ -32,8 +32,8 @@ done
echo ""
echo "--- Reassembling chunks ---"
-TIMESTAMP=$(echo "${CHUNKS[0]}" | sed -E 's/c4-deploy-([0-9]+-[0-9]+)_b64_part.*/\1/')
-TARBALL="c4-deploy-${TIMESTAMP}.tar.gz"
+TIMESTAMP=$(echo "${CHUNKS[0]}" | sed -E 's/games-deploy-([0-9]+-[0-9]+)_b64_part.*/\1/')
+TARBALL="games-deploy-${TIMESTAMP}.tar.gz"
COMBINED="combined_b64.txt"
echo "Concatenating chunks..."
@@ -58,12 +58,12 @@ fi
echo ""
echo "--- Archiving existing source ---"
-if [[ -d c4 ]]; then
- rm -rf c4.bak
- mv c4 c4.bak
- echo " -> Moved c4 -> c4.bak"
+if [[ -d games ]]; then
+ rm -rf games.bak
+ mv games games.bak
+ echo " -> Moved games -> games.bak"
else
- echo " -> No existing c4 directory"
+ echo " -> No existing games directory"
fi
#==============================================================================
@@ -73,7 +73,7 @@ echo ""
echo "--- Extracting tarball ---"
tar -xzf "$TARBALL"
-echo " -> Extracted to ~/c4"
+echo " -> Extracted to ~/games"
#==============================================================================
# Cleanup
@@ -91,6 +91,6 @@ echo ""
echo "=== Reassembly Complete ==="
echo ""
echo "Next steps:"
-echo " cd ~/c4"
+echo " cd ~/games"
echo " sudo ./deploy/setup.sh # first time only"
echo " sudo ./deploy/deploy.sh"
diff --git a/deploy/setup.sh b/deploy/setup.sh
index 920dde2..917137f 100755
--- a/deploy/setup.sh
+++ b/deploy/setup.sh
@@ -10,20 +10,20 @@ fi
# Create system user if it doesn't exist
if ! id -u games &>/dev/null; then
- useradd --system --shell /usr/sbin/nologin --home-dir /opt/c4 --create-home games
+ useradd --system --shell /usr/sbin/nologin --home-dir /opt/games --create-home games
echo "Created system user: games"
else
echo "User 'games' already exists"
fi
# Ensure install directory exists with correct ownership
-install -d -o games -g games -m 755 /opt/c4
-install -d -o games -g games -m 755 /opt/c4/data
+install -d -o games -g games -m 755 /opt/games
+install -d -o games -g games -m 755 /opt/games/data
# Install systemd unit
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-cp "$SCRIPT_DIR/c4.service" /etc/systemd/system/c4.service
+cp "$SCRIPT_DIR/games.service" /etc/systemd/system/games.service
systemctl daemon-reload
-systemctl enable c4.service
+systemctl enable games.service
echo "Setup complete. Run deploy.sh to build and start the service."
diff --git a/docker-compose.yml b/docker-compose.yml
index c22954e..79083e0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,7 +1,7 @@
services:
- c4:
+ games:
build: .
- container_name: c4
+ container_name: games
restart: unless-stopped
ports:
- "8080:8080"
diff --git a/sessions/sessions.go b/sessions/sessions.go
index fdbbe08..02ebae2 100644
--- a/sessions/sessions.go
+++ b/sessions/sessions.go
@@ -23,7 +23,7 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
sessionManager := scs.New()
sessionManager.Store = store
sessionManager.Lifetime = 30 * 24 * time.Hour
- sessionManager.Cookie.Name = "c4_session"
+ sessionManager.Cookie.Name = "games_session"
sessionManager.Cookie.Path = "/"
sessionManager.Cookie.HttpOnly = true
sessionManager.Cookie.Secure = true
From 2cfd42b606af4fb540bdf8108ce649cc91aee8ec Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:25:03 -1000
Subject: [PATCH 08/14] refactor: integrate chat persistence into Room
Move SaveMessage/LoadMessages logic into Room as private methods.
NewPersistentRoom auto-loads history and auto-saves on Send, removing
the need for handlers to coordinate persistence separately.
---
chat/chat.go | 73 ++++++++++++++++++++++++++++++----
chat/persist.go | 45 ---------------------
features/c4game/handlers.go | 10 ++---
features/snakegame/handlers.go | 4 +-
4 files changed, 71 insertions(+), 61 deletions(-)
delete mode 100644 chat/persist.go
diff --git a/chat/chat.go b/chat/chat.go
index 632f919..c5b98be 100644
--- a/chat/chat.go
+++ b/chat/chat.go
@@ -3,11 +3,15 @@
package chat
import (
+ "context"
"encoding/json"
+ "slices"
"sync"
"github.com/nats-io/nats.go"
"github.com/rs/zerolog/log"
+
+ "github.com/ryanhamamura/games/db/repository"
)
// Message is the wire format for chat messages over NATS.
@@ -21,26 +25,47 @@ type Message struct {
const maxMessages = 50
// Room manages an in-memory message buffer and NATS pub/sub for a single
-// chat room (typically one per game).
+// chat room (typically one per game). When created with NewPersistentRoom,
+// messages are automatically loaded from and saved to the database.
type Room struct {
subject string
nc *nats.Conn
messages []Message
mu sync.Mutex
+
+ // Optional persistence; nil for ephemeral rooms (e.g. snake).
+ queries *repository.Queries
+ roomID string
}
-// NewRoom creates a chat room that publishes and subscribes on the given
-// NATS subject (e.g. "chat.abc123").
-func NewRoom(nc *nats.Conn, subject string, initial []Message) *Room {
+// NewRoom creates an ephemeral chat room with no database persistence.
+func NewRoom(nc *nats.Conn, subject string) *Room {
return &Room{
- subject: subject,
- nc: nc,
- messages: initial,
+ subject: subject,
+ nc: nc,
}
}
-// Send publishes a message to the room's NATS subject.
+// NewPersistentRoom creates a chat room backed by the database. It loads
+// existing messages on creation and auto-saves new messages on Send.
+func NewPersistentRoom(nc *nats.Conn, subject string, queries *repository.Queries, roomID string) *Room {
+ r := &Room{
+ subject: subject,
+ nc: nc,
+ queries: queries,
+ roomID: roomID,
+ }
+ r.messages = r.loadMessages()
+ return r
+}
+
+// Send publishes a message to the room's NATS subject and persists it
+// if the room is backed by a database.
func (r *Room) Send(msg Message) {
+ if r.queries != nil {
+ r.saveMessage(msg)
+ }
+
data, err := json.Marshal(msg)
if err != nil {
log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message")
@@ -90,3 +115,35 @@ func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) {
}
return ch, sub, nil
}
+
+func (r *Room) saveMessage(msg Message) {
+ err := r.queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
+ GameID: r.roomID,
+ Nickname: msg.Nickname,
+ Color: int64(msg.Slot),
+ Message: msg.Message,
+ CreatedAt: msg.Time,
+ })
+ if err != nil {
+ log.Error().Err(err).Str("room_id", r.roomID).Msg("failed to save chat message")
+ }
+}
+
+func (r *Room) loadMessages() []Message {
+ rows, err := r.queries.GetChatMessages(context.Background(), r.roomID)
+ if err != nil {
+ return nil
+ }
+ msgs := make([]Message, len(rows))
+ for i, row := range rows {
+ msgs[i] = Message{
+ Nickname: row.Nickname,
+ Slot: int(row.Color),
+ Message: row.Message,
+ Time: row.CreatedAt,
+ }
+ }
+ // DB returns newest-first; reverse for chronological display
+ slices.Reverse(msgs)
+ return msgs
+}
diff --git a/chat/persist.go b/chat/persist.go
deleted file mode 100644
index 45b6ee7..0000000
--- a/chat/persist.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package chat
-
-import (
- "context"
- "slices"
-
- "github.com/ryanhamamura/games/db/repository"
-
- "github.com/rs/zerolog/log"
-)
-
-// SaveMessage persists a chat message to the database.
-func SaveMessage(queries *repository.Queries, roomID string, msg Message) {
- err := queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
- GameID: roomID,
- Nickname: msg.Nickname,
- Color: int64(msg.Slot),
- Message: msg.Message,
- CreatedAt: msg.Time,
- })
- if err != nil {
- log.Error().Err(err).Str("room_id", roomID).Msg("failed to save chat message")
- }
-}
-
-// LoadMessages loads persisted chat messages for a room, returning them
-// in chronological order (oldest first).
-func LoadMessages(queries *repository.Queries, roomID string) []Message {
- rows, err := queries.GetChatMessages(context.Background(), roomID)
- if err != nil {
- return nil
- }
- msgs := make([]Message, len(rows))
- for i, r := range rows {
- msgs[i] = Message{
- Nickname: r.Nickname,
- Slot: int(r.Color),
- Message: r.Message,
- Time: r.CreatedAt,
- }
- }
- // DB returns newest-first; reverse for chronological display
- slices.Reverse(msgs)
- return msgs
-}
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index 79461d9..777b0c6 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -85,9 +85,9 @@ func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repo
}
g := gi.GetGame()
- msgs := chat.LoadMessages(queries, gameID)
+ room := chat.NewPersistentRoom(nil, "", queries, gameID)
- if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
+ if err := pages.GamePage(g, myColor, room.Messages(), c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
@@ -111,7 +111,7 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
))
chatCfg := c4ChatConfig(gameID)
- room := chat.NewRoom(nc, "connect4.chat."+gameID, chat.LoadMessages(queries, gameID))
+ room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID)
// Send initial render
sendGameComponents(sse, gi, myColor, room, chatCfg)
@@ -228,9 +228,7 @@ func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager
Message: signals.ChatMsg,
Time: time.Now().UnixMilli(),
}
- chat.SaveMessage(queries, gameID, msg)
-
- room := chat.NewRoom(nc, "connect4.chat."+gameID, nil)
+ room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID)
room.Send(msg)
sse := datastar.NewSSE(w, r)
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index 5f518dd..e42471d 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -126,7 +126,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
var room *chat.Room
if sg.Mode == snake.ModeMultiplayer {
- room = chat.NewRoom(nc, "snake.chat."+gameID, nil)
+ room = chat.NewRoom(nc, "snake.chat."+gameID)
chatCh, chatSub, err = room.Subscribe()
if err != nil {
return
@@ -247,7 +247,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
Message: signals.ChatMsg,
}
- room := chat.NewRoom(nc, "snake.chat."+gameID, nil)
+ room := chat.NewRoom(nc, "snake.chat."+gameID)
room.Send(msg)
sse := datastar.NewSSE(w, r)
From fb6c0e3d90da6ec46dfe542e4c0635b7e6c003fb Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:30:47 -1000
Subject: [PATCH 09/14] refactor: replace hardcoded NATS subjects with typed
helpers
Add GameSubject/ChatSubject helpers to connect4 and snake packages,
eliminating magic string concatenation from handlers and main.go.
---
connect4/types.go | 6 ++++++
features/c4game/handlers.go | 6 +++---
features/snakegame/handlers.go | 6 +++---
main.go | 4 ++--
snake/types.go | 6 ++++++
5 files changed, 20 insertions(+), 8 deletions(-)
diff --git a/connect4/types.go b/connect4/types.go
index a423030..0aa56b7 100644
--- a/connect4/types.go
+++ b/connect4/types.go
@@ -6,6 +6,12 @@ import (
"github.com/ryanhamamura/games/player"
)
+// NATS subject helpers.
+const SubjectPrefix = "connect4"
+
+func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID }
+func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID }
+
type Player struct {
ID player.ID
UserID *string // UUID for authenticated users, nil for guests
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index 777b0c6..8a8c309 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -111,14 +111,14 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
))
chatCfg := c4ChatConfig(gameID)
- room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID)
+ room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
// Send initial render
sendGameComponents(sse, gi, myColor, room, chatCfg)
// Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64)
- gameSub, err := nc.ChanSubscribe("connect4."+gameID, gameCh)
+ gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh)
if err != nil {
return
}
@@ -228,7 +228,7 @@ func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager
Message: signals.ChatMsg,
Time: time.Now().UnixMilli(),
}
- room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID)
+ room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
room.Send(msg)
sse := datastar.NewSSE(w, r)
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index e42471d..effa3ff 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -114,7 +114,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
// Subscribe to game updates via NATS
gameCh := make(chan *nats.Msg, 64)
- gameSub, err := nc.ChanSubscribe("snake."+gameID, gameCh)
+ gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh)
if err != nil {
return
}
@@ -126,7 +126,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
var room *chat.Room
if sg.Mode == snake.ModeMultiplayer {
- room = chat.NewRoom(nc, "snake.chat."+gameID)
+ room = chat.NewRoom(nc, snake.ChatSubject(gameID))
chatCh, chatSub, err = room.Subscribe()
if err != nil {
return
@@ -247,7 +247,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
Message: signals.ChatMsg,
}
- room := chat.NewRoom(nc, "snake.chat."+gameID)
+ room := chat.NewRoom(nc, snake.ChatSubject(gameID))
room.Send(msg)
sse := datastar.NewSSE(w, r)
diff --git a/main.go b/main.go
index 4bf49a3..041e13c 100644
--- a/main.go
+++ b/main.go
@@ -73,12 +73,12 @@ func run(ctx context.Context) error {
// Game stores
store := connect4.NewStore(queries)
store.SetNotifyFunc(func(gameID string) {
- nc.Publish("connect4."+gameID, nil) //nolint:errcheck // best-effort notification
+ nc.Publish(connect4.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification
})
snakeStore := snake.NewSnakeStore(queries)
snakeStore.SetNotifyFunc(func(gameID string) {
- nc.Publish("snake."+gameID, nil) //nolint:errcheck // best-effort notification
+ nc.Publish(snake.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification
})
// Router
diff --git a/snake/types.go b/snake/types.go
index 62376f9..d1284c1 100644
--- a/snake/types.go
+++ b/snake/types.go
@@ -7,6 +7,12 @@ import (
"github.com/ryanhamamura/games/player"
)
+// NATS subject helpers.
+const SubjectPrefix = "snake"
+
+func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID }
+func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID }
+
type Direction int
const (
From 42211439c98bf06b27fc7b4d673a1f7a8d9dbc7d Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:34:46 -1000
Subject: [PATCH 10/14] refactor: drop redundant WithSelectorID from SSE
patches
All templ components already have id attributes on their root elements,
which PatchElementTempl uses automatically.
---
features/c4game/handlers.go | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index 8a8c309..e385bbb 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -144,7 +144,7 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
if snapshot == nil {
continue
}
- if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg), datastar.WithSelectorID("c4-chat")); err != nil {
+ if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil {
return
}
}
@@ -305,8 +305,8 @@ func HandleRematch(store *connect4.Store, sm *scs.SessionManager) http.HandlerFu
func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *connect4.Instance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) {
g := gi.GetGame()
- sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
- sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
- sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck
- sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
+ sse.PatchElementTempl(components.Board(g, myColor)) //nolint:errcheck
+ sse.PatchElementTempl(components.StatusBanner(g, myColor)) //nolint:errcheck
+ sse.PatchElementTempl(components.PlayerInfo(g, myColor)) //nolint:errcheck
+ sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg)) //nolint:errcheck
}
From 0808c4d972145348aaf1138b5c5010f3aaeb5126 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:43:25 -1000
Subject: [PATCH 11/14] refactor: patch entire game content instead of
individual components
Extract GameContent from GamePage so the SSE handler can patch a single
element and let DOM morphing diff the changes, replacing the per-component
sendGameComponents helper.
---
features/c4game/handlers.go | 33 ++++++++++++++------------------
features/c4game/pages/game.templ | 28 ++++++++++++++++-----------
2 files changed, 31 insertions(+), 30 deletions(-)
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index e385bbb..68cead1 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -15,7 +15,6 @@ import (
chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
- "github.com/ryanhamamura/games/features/c4game/components"
"github.com/ryanhamamura/games/features/c4game/pages"
"github.com/ryanhamamura/games/sessions"
)
@@ -113,8 +112,16 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
chatCfg := c4ChatConfig(gameID)
room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
+ patchAll := func() error {
+ myColor = gi.GetPlayerColor(playerID)
+ g := gi.GetGame()
+ return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
+ }
+
// Send initial render
- sendGameComponents(sse, gi, myColor, room, chatCfg)
+ if err := patchAll(); err != nil {
+ return
+ }
// Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64)
@@ -137,14 +144,12 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
case <-ctx.Done():
return
case <-gameCh:
- myColor = gi.GetPlayerColor(playerID)
- sendGameComponents(sse, gi, myColor, room, chatCfg)
- case msg := <-chatCh:
- _, snapshot := room.Receive(msg.Data)
- if snapshot == nil {
- continue
+ if err := patchAll(); err != nil {
+ return
}
- if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil {
+ case msg := <-chatCh:
+ room.Receive(msg.Data)
+ if err := patchAll(); err != nil {
return
}
}
@@ -300,13 +305,3 @@ func HandleRematch(store *connect4.Store, sm *scs.SessionManager) http.HandlerFu
}
}
}
-
-// sendGameComponents patches all game-related SSE components.
-func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *connect4.Instance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) {
- g := gi.GetGame()
-
- sse.PatchElementTempl(components.Board(g, myColor)) //nolint:errcheck
- sse.PatchElementTempl(components.StatusBanner(g, myColor)) //nolint:errcheck
- sse.PatchElementTempl(components.PlayerInfo(g, myColor)) //nolint:errcheck
- sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg)) //nolint:errcheck
-}
diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ
index 16e0155..c175b42 100644
--- a/features/c4game/pages/game.templ
+++ b/features/c4game/pages/game.templ
@@ -17,21 +17,27 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c
data-signals="{chatMsg: ''}"
data-init={ datastar.GetSSE("/games/%s/events", g.ID) }
>
- @sharedcomponents.BackToLobby()
- @sharedcomponents.StealthTitle("text-3xl font-bold")
- @components.PlayerInfo(g, myColor)
- @components.StatusBanner(g, myColor)
-
- @components.Board(g, myColor)
- @chatcomponents.Chat(messages, chatCfg)
-
- if g.Status == connect4.StatusWaitingForPlayer {
- @components.InviteLink(g.ID)
- }
+ @GameContent(g, myColor, messages, chatCfg)
}
}
+templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
+
+ @sharedcomponents.BackToLobby()
+ @sharedcomponents.StealthTitle("text-3xl font-bold")
+ @components.PlayerInfo(g, myColor)
+ @components.StatusBanner(g, myColor)
+
+ @components.Board(g, myColor)
+ @chatcomponents.Chat(messages, chatCfg)
+
+ if g.Status == connect4.StatusWaitingForPlayer {
+ @components.InviteLink(g.ID)
+ }
+
+}
+
templ JoinPage(gameID string) {
@layouts.Base("Connect 4 - Join") {
@sharedcomponents.GameJoinPrompt(
From 4faf4f73b078d0be3c493403ab472dc691c28660 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 22:34:20 -1000
Subject: [PATCH 12/14] refactor: patch entire game content for snake SSE
handler
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Same approach as connect4 — extract GameContent component and patch it
as a single element, letting DOM morphing handle the diff.
---
features/snakegame/handlers.go | 57 ++++++++++++++---------------
features/snakegame/pages/game.templ | 44 ++++++++++++----------
2 files changed, 53 insertions(+), 48 deletions(-)
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index effa3ff..9620377 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -12,7 +12,6 @@ import (
"github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
- "github.com/ryanhamamura/games/features/snakegame/components"
"github.com/ryanhamamura/games/features/snakegame/pages"
"github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake"
@@ -100,16 +99,33 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
chatCfg := snakeChatConfig(gameID)
- // Send initial render
+ // Chat room (multiplayer only)
+ var room *chat.Room
sg := si.GetGame()
- sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
- sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
- sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
if sg.Mode == snake.ModeMultiplayer {
- sse.PatchElementTempl(chatcomponents.Chat(nil, chatCfg)) //nolint:errcheck
- if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
- sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
+ room = chat.NewRoom(nc, snake.ChatSubject(gameID))
+ }
+
+ chatMessages := func() []chat.Message {
+ if room == nil {
+ return nil
}
+ return room.Messages()
+ }
+
+ patchAll := func() error {
+ si, ok = snakeStore.Get(gameID)
+ if !ok {
+ return fmt.Errorf("game not found")
+ }
+ mySlot = si.GetPlayerSlot(playerID)
+ sg = si.GetGame()
+ return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID))
+ }
+
+ // Send initial render
+ if err := patchAll(); err != nil {
+ return
}
// Subscribe to game updates via NATS
@@ -123,10 +139,8 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
// Chat subscription (multiplayer only)
var chatCh chan *nats.Msg
var chatSub *nats.Subscription
- var room *chat.Room
- if sg.Mode == snake.ModeMultiplayer {
- room = chat.NewRoom(nc, snake.ChatSubject(gameID))
+ if room != nil {
chatCh, chatSub, err = room.Subscribe()
if err != nil {
return
@@ -150,19 +164,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
}
}
drained:
- si, ok = snakeStore.Get(gameID)
- if !ok {
- return
- }
- mySlot = si.GetPlayerSlot(playerID)
- sg = si.GetGame()
- if err := sse.PatchElementTempl(components.Board(sg)); err != nil {
- return
- }
- if err := sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)); err != nil {
- return
- }
- if err := sse.PatchElementTempl(components.PlayerList(sg, mySlot)); err != nil {
+ if err := patchAll(); err != nil {
return
}
@@ -170,11 +172,8 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
if msg == nil {
continue
}
- _, snapshot := room.Receive(msg.Data)
- if snapshot == nil {
- continue
- }
- if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil {
+ room.Receive(msg.Data)
+ if err := patchAll(); err != nil {
return
}
}
diff --git a/features/snakegame/pages/game.templ b/features/snakegame/pages/game.templ
index 63bda61..98b98fb 100644
--- a/features/snakegame/pages/game.templ
+++ b/features/snakegame/pages/game.templ
@@ -37,29 +37,35 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg
data-on:keydown.throttle_100ms={ keydownScript(gameID) }
tabindex="0"
>
- @components.BackToLobby()
- ~~~~
- @snakecomponents.PlayerList(sg, mySlot)
- @snakecomponents.StatusBanner(sg, mySlot, gameID)
- if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
- if sg.Mode == snake.ModeMultiplayer {
-
- @snakecomponents.Board(sg)
- @chatcomponents.Chat(messages, chatCfg)
-
- } else {
- @snakecomponents.Board(sg)
- }
- } else if sg.Mode == snake.ModeMultiplayer {
- @chatcomponents.Chat(messages, chatCfg)
- }
- if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
- @snakecomponents.InviteLink(gameID)
- }
+ @GameContent(sg, mySlot, messages, chatCfg, gameID)
}
}
+templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
+
+ @components.BackToLobby()
+
~~~~
+ @snakecomponents.PlayerList(sg, mySlot)
+ @snakecomponents.StatusBanner(sg, mySlot, gameID)
+ if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
+ if sg.Mode == snake.ModeMultiplayer {
+
+ @snakecomponents.Board(sg)
+ @chatcomponents.Chat(messages, chatCfg)
+
+ } else {
+ @snakecomponents.Board(sg)
+ }
+ } else if sg.Mode == snake.ModeMultiplayer {
+ @chatcomponents.Chat(messages, chatCfg)
+ }
+ if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
+ @snakecomponents.InviteLink(gameID)
+ }
+
+}
+
templ JoinPage(gameID string) {
@layouts.Base("Snake - Join") {
@components.GameJoinPrompt(
From dcf76bb7736171be6c166a3459d05751dc2996ae Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 22:40:10 -1000
Subject: [PATCH 13/14] refactor: replace session key strings with consts
Define KeyPlayerID, KeyUserID, and KeyNickname in the sessions package
and use them across all handlers to avoid duplicated magic strings.
---
features/auth/handlers.go | 9 +++++----
features/c4game/handlers.go | 2 +-
features/lobby/handlers.go | 7 ++++---
features/snakegame/handlers.go | 2 +-
sessions/sessions.go | 17 ++++++++++++-----
5 files changed, 23 insertions(+), 14 deletions(-)
diff --git a/features/auth/handlers.go b/features/auth/handlers.go
index 8c7c8fb..3ca7f6b 100644
--- a/features/auth/handlers.go
+++ b/features/auth/handlers.go
@@ -11,6 +11,7 @@ import (
"github.com/ryanhamamura/games/auth"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/auth/pages"
+ appsessions "github.com/ryanhamamura/games/sessions"
)
type LoginSignals struct {
@@ -65,9 +66,9 @@ func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http
}
sessions.RenewToken(r.Context()) //nolint:errcheck
- sessions.Put(r.Context(), "user_id", user.ID)
+ sessions.Put(r.Context(), appsessions.KeyUserID, user.ID)
sessions.Put(r.Context(), "username", user.Username)
- sessions.Put(r.Context(), "nickname", user.Username)
+ sessions.Put(r.Context(), appsessions.KeyNickname, user.Username)
redirectURL := "/"
if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" {
@@ -119,9 +120,9 @@ func HandleRegister(queries *repository.Queries, sessions *scs.SessionManager) h
}
sessions.RenewToken(r.Context()) //nolint:errcheck
- sessions.Put(r.Context(), "user_id", user.ID)
+ sessions.Put(r.Context(), appsessions.KeyUserID, user.ID)
sessions.Put(r.Context(), "username", user.Username)
- sessions.Put(r.Context(), "nickname", user.Username)
+ sessions.Put(r.Context(), appsessions.KeyNickname, user.Username)
redirectURL := "/"
if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" {
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index 68cead1..c5a5bc0 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -266,7 +266,7 @@ func HandleSetNickname(store *connect4.Store, sm *scs.SessionManager) http.Handl
return
}
- sm.Put(r.Context(), "nickname", signals.Nickname)
+ sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetUserID(sm, r)
diff --git a/features/lobby/handlers.go b/features/lobby/handlers.go
index 5698447..074b0aa 100644
--- a/features/lobby/handlers.go
+++ b/features/lobby/handlers.go
@@ -11,6 +11,7 @@ import (
"github.com/ryanhamamura/games/db/repository"
lobbycomponents "github.com/ryanhamamura/games/features/lobby/components"
"github.com/ryanhamamura/games/features/lobby/pages"
+ appsessions "github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake"
"github.com/alexedwards/scs/v2"
@@ -21,7 +22,7 @@ import (
// HandleLobbyPage renders the main lobby page with active games for logged-in users.
func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager, snakeStore *snake.SnakeStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- userID := sessions.GetString(r.Context(), "user_id")
+ userID := sessions.GetString(r.Context(), appsessions.KeyUserID)
username := sessions.GetString(r.Context(), "username")
isLoggedIn := userID != ""
@@ -95,7 +96,7 @@ func HandleCreateGame(store *connect4.Store, sessions *scs.SessionManager) http.
return
}
- sessions.Put(r.Context(), "nickname", signals.Nickname)
+ sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname)
gi := store.Create()
sse := datastar.NewSSE(w, r)
@@ -137,7 +138,7 @@ func HandleCreateSnakeGame(snakeStore *snake.SnakeStore, sessions *scs.SessionMa
return
}
- sessions.Put(r.Context(), "nickname", signals.Nickname)
+ sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname)
mode := snake.ModeMultiplayer
if r.URL.Query().Get("mode") == "solo" {
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index 9620377..dffb6a0 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -277,7 +277,7 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) htt
return
}
- sm.Put(r.Context(), "nickname", signals.Nickname)
+ sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetUserID(sm, r)
diff --git a/sessions/sessions.go b/sessions/sessions.go
index 02ebae2..3880d47 100644
--- a/sessions/sessions.go
+++ b/sessions/sessions.go
@@ -14,6 +14,13 @@ import (
"github.com/alexedwards/scs/v2"
)
+// Session key names.
+const (
+ KeyPlayerID = "player_id"
+ KeyUserID = "user_id"
+ KeyNickname = "nickname"
+)
+
// SetupSessionManager creates a configured session manager backed by SQLite.
// Returns the manager and a cleanup function the caller should defer.
func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
@@ -38,12 +45,12 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
// Authenticated users get their user UUID; guests get a random ID that
// is generated and persisted on first access.
func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID {
- pid := sm.GetString(r.Context(), "player_id")
+ pid := sm.GetString(r.Context(), KeyPlayerID)
if pid == "" {
pid = player.GenerateID(8)
- sm.Put(r.Context(), "player_id", pid)
+ sm.Put(r.Context(), KeyPlayerID, pid)
}
- if userID := sm.GetString(r.Context(), "user_id"); userID != "" {
+ if userID := sm.GetString(r.Context(), KeyUserID); userID != "" {
return player.ID(userID)
}
return player.ID(pid)
@@ -51,10 +58,10 @@ func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID {
// GetUserID returns the authenticated user's UUID, or empty string for guests.
func GetUserID(sm *scs.SessionManager, r *http.Request) string {
- return sm.GetString(r.Context(), "user_id")
+ return sm.GetString(r.Context(), KeyUserID)
}
// GetNickname returns the player's display name from the session.
func GetNickname(sm *scs.SessionManager, r *http.Request) string {
- return sm.GetString(r.Context(), "nickname")
+ return sm.GetString(r.Context(), KeyNickname)
}
From 718e0c55c9031b995070f92349481865f2ec86b1 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Mon, 2 Mar 2026 22:48:16 -1000
Subject: [PATCH 14/14] fix: satisfy staticcheck comment style for exported
consts
---
connect4/types.go | 5 ++++-
snake/types.go | 5 ++++-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/connect4/types.go b/connect4/types.go
index 0aa56b7..078947c 100644
--- a/connect4/types.go
+++ b/connect4/types.go
@@ -6,10 +6,13 @@ import (
"github.com/ryanhamamura/games/player"
)
-// NATS subject helpers.
+// SubjectPrefix is the NATS subject namespace for connect4 games.
const SubjectPrefix = "connect4"
+// GameSubject returns the NATS subject for game state updates.
func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID }
+
+// ChatSubject returns the NATS subject for chat messages.
func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID }
type Player struct {
diff --git a/snake/types.go b/snake/types.go
index d1284c1..d94b5d2 100644
--- a/snake/types.go
+++ b/snake/types.go
@@ -7,10 +7,13 @@ import (
"github.com/ryanhamamura/games/player"
)
-// NATS subject helpers.
+// SubjectPrefix is the NATS subject namespace for snake games.
const SubjectPrefix = "snake"
+// GameSubject returns the NATS subject for game state updates.
func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID }
+
+// ChatSubject returns the NATS subject for chat messages.
func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID }
type Direction int