diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index e6f5b52..d272c05 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -17,6 +17,7 @@ import (
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/c4game/pages"
"github.com/ryanhamamura/games/sessions"
+ appsse "github.com/ryanhamamura/games/sse"
)
// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors.
@@ -118,11 +119,21 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
}
- // Send initial render
+ sendPing := func() error {
+ return appsse.SendPing(sse)
+ }
+
+ // Send initial render and ping
+ if err := sendPing(); err != nil {
+ return
+ }
if err := patchAll(); err != nil {
return
}
+ heartbeat := time.NewTicker(15 * time.Second)
+ defer heartbeat.Stop()
+
// Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64)
gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh)
@@ -140,6 +151,10 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
select {
case <-ctx.Done():
return
+ case <-heartbeat.C:
+ if err := sendPing(); err != nil {
+ return
+ }
case <-gameCh:
if err := patchAll(); err != nil {
return
diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ
index c5d9f11..16204cf 100644
--- a/features/c4game/pages/game.templ
+++ b/features/c4game/pages/game.templ
@@ -15,9 +15,10 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c
@layouts.Base("Connect 4") {
+ @sharedcomponents.ConnectionIndicator()
@GameContent(g, myColor, messages, chatCfg)
}
diff --git a/features/common/components/shared.templ b/features/common/components/shared.templ
index 5567f93..76d67b6 100644
--- a/features/common/components/shared.templ
+++ b/features/common/components/shared.templ
@@ -44,6 +44,21 @@ templ NicknamePrompt(returnPath string) {
}
+// ConnectionIndicator shows a small dot indicating SSE connection status.
+// It requires a `lastPing` signal (unix ms timestamp) to be set by the server.
+templ ConnectionIndicator() {
+
+
+
+}
+
templ GameJoinPrompt(loginURL string, registerURL string, gamePath string) {
Join Game
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index afb6f3a..5600b99 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strconv"
+ "time"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
@@ -15,6 +16,7 @@ import (
"github.com/ryanhamamura/games/features/snakegame/pages"
"github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake"
+ appsse "github.com/ryanhamamura/games/sse"
)
func snakeChatColor(slot int) string {
@@ -123,11 +125,21 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID))
}
- // Send initial render
+ sendPing := func() error {
+ return appsse.SendPing(sse)
+ }
+
+ // Send initial render and ping
+ if err := sendPing(); err != nil {
+ return
+ }
if err := patchAll(); err != nil {
return
}
+ heartbeat := time.NewTicker(15 * time.Second)
+ defer heartbeat.Stop()
+
// Subscribe to game updates via NATS
gameCh := make(chan *nats.Msg, 64)
gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh)
@@ -151,6 +163,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
case <-ctx.Done():
return
+ case <-heartbeat.C:
+ if err := sendPing(); err != nil {
+ return
+ }
+
case <-gameCh:
// Drain backed-up game updates
for {
diff --git a/features/snakegame/pages/game.templ b/features/snakegame/pages/game.templ
index 378690c..f8fe244 100644
--- a/features/snakegame/pages/game.templ
+++ b/features/snakegame/pages/game.templ
@@ -32,11 +32,12 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg
@layouts.Base("Snake") {
+ @components.ConnectionIndicator()
@GameContent(sg, mySlot, messages, chatCfg, gameID)
}
diff --git a/sse/signals.go b/sse/signals.go
new file mode 100644
index 0000000..f494b14
--- /dev/null
+++ b/sse/signals.go
@@ -0,0 +1,28 @@
+// Package sse provides helpers for SSE signal handling.
+package sse
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/starfederation/datastar-go/datastar"
+)
+
+// Signals holds client-side state managed via SSE.
+type Signals struct {
+ LastPing int64 `json:"lastPing,omitempty"`
+}
+
+// SendPing sends a heartbeat signal with the current timestamp.
+func SendPing(sse *datastar.ServerSentEventGenerator) error {
+ return PatchSignals(sse, Signals{LastPing: time.Now().UnixMilli()})
+}
+
+// PatchSignals sends a signals patch to the client.
+func PatchSignals(sse *datastar.ServerSentEventGenerator, s Signals) error {
+ data, err := json.Marshal(s)
+ if err != nil {
+ return err
+ }
+ return sse.PatchSignals(data)
+}