Hello again! We're back!
Welcome back again to my adventure series on the NATS JetStream Key/Value Store!
We're getting close! We just need to make the Dashboard and the Game routes! Let's go!
Where did we leave off now?
We had just finished setting up logging into our application and saving sessions. Get ready, the meat of our application is starting now!
Getting into the thick of it
The Dashboard route has a lot more going on. We're going to be watching for games created by us and any other person playing. We need to keep track of state for these games, such as if the game is available to join or full. We also need to watch for other actions such as game deletions!
This should be fun! Let's start with the code itself and break it down! Let's checkout setupDashboardRoute()
.
setupDashboardRoute
package routes
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/delaneyj/toolbelt"
"github.com/go-chi/chi/v5"
"github.com/goombaio/namegenerator"
"github.com/gorilla/sessions"
"github.com/nats-io/nats.go/jetstream"
"github.com/rphumulock/ds_nats_ttt/web/components"
"github.com/rphumulock/ds_nats_ttt/web/pages"
datastar "github.com/starfederation/datastar/sdk/go"
)
func setupDashboardRoute(router chi.Router, store sessions.Store, js jetstream.JetStream) error {
ctx := context.Background()
gameLobbiesKV, err := js.KeyValue(ctx, "gameLobbies")
if err != nil {
return fmt.Errorf("failed to get game lobbies key value: %w", err)
}
gameBoardsKV, err := js.KeyValue(ctx, "gameBoards")
if err != nil {
return fmt.Errorf("failed to get game lobbies key value: %w", err)
}
usersKV, err := js.KeyValue(ctx, "users")
if err != nil {
return fmt.Errorf("failed to get users key value: %w", err)
}
handleGetDashboard := func(w http.ResponseWriter, r *http.Request) {
sessionId, err := getSessionId(store, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if sessionId == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
user, _, err := GetObject[components.User](ctx, usersKV, sessionId)
if err != nil {
deleteSessionId(store, w, r)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
pages.Dashboard(user.Name).Render(r.Context(), w)
}
router.Get("/dashboard", handleGetDashboard)
// API
generateGameDetails := func() (string, string) {
id := toolbelt.NextEncodedID()
seed := time.Now().UTC().UnixNano()
nameGenerator := namegenerator.NewNameGenerator(seed)
name := strings.ToUpper(nameGenerator.Generate())
return id, name
}
createGameLobby := func(id, name, sessionId string) components.GameLobby {
return components.GameLobby{
Id: id,
Name: name,
HostId: sessionId,
ChallengerId: "",
}
}
createGameState := func(id string) components.GameState {
return components.GameState{
Id: id,
Board: [9]string{},
XIsNext: true,
Winner: "",
}
}
handleCreate := func(w http.ResponseWriter, r *http.Request) {
sessionId, err := getSessionId(store, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
id, name := generateGameDetails()
gameLobby := createGameLobby(id, name, sessionId)
if err := PutData(r.Context(), gameLobbiesKV, id, gameLobby); err != nil {
http.Error(w, fmt.Sprintf("failed to store game lobby: %v", err), http.StatusInternalServerError)
return
}
gameState := createGameState(id)
if err := PutData(r.Context(), gameBoardsKV, id, gameState); err != nil {
http.Error(w, fmt.Sprintf("failed to store game state: %v", err), http.StatusInternalServerError)
return
}
}
handleLogout := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sessionId, err := getSessionId(store, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if sessionId == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
keys, err := gameLobbiesKV.Keys(ctx)
if err != nil {
log.Printf("%v", err)
}
for _, key := range keys {
entry, err := gameLobbiesKV.Get(ctx, key)
if err != nil {
log.Printf("Failed to get value for key %s: %v", key, err)
continue
}
var gameLobby components.GameLobby
if err := json.Unmarshal(entry.Value(), &gameLobby); err != nil {
log.Printf("Error unmarshalling update value: %v", err)
return
}
if gameLobby.HostId == sessionId {
gameLobbiesKV.Delete(ctx, key)
gameBoardsKV.Delete(ctx, key)
}
}
if err := usersKV.Delete(ctx, sessionId); err != nil {
http.Error(w, fmt.Sprintf("failed to delete key '%s': %v", sessionId, err), http.StatusInternalServerError)
return
}
deleteSessionId(store, w, r)
sse := datastar.NewSSE(w, r)
sse.Redirect("/")
}
handleHistoricalUpdates := func(dashboardItems []components.GameLobby, sessionId string, sse *datastar.ServerSentEventGenerator) {
if len(dashboardItems) == 0 {
return
}
c := components.DashboardList(dashboardItems, sessionId)
if err := sse.MergeFragmentTempl(c); err != nil {
sse.ConsoleError(err)
}
dashboardItems = nil
}
handleKeyValueDelete := func(historicalMode bool, update jetstream.KeyValueEntry, sse *datastar.ServerSentEventGenerator) {
if historicalMode {
log.Printf("Ignoring historical delete for key: %s", update.Key())
return
}
if err := sse.RemoveFragments("#game-"+update.Key(),
datastar.WithRemoveSettleDuration(1*time.Millisecond),
datastar.WithRemoveUseViewTransitions(false)); err != nil {
sse.ConsoleError(err)
}
}
handleKeyValuePut := func(
ctx context.Context,
historicalMode bool,
dashboardItems *[]components.GameLobby,
entry jetstream.KeyValueEntry,
sessionId string,
sse *datastar.ServerSentEventGenerator,
) {
var gameLobby components.GameLobby
if err := json.Unmarshal(entry.Value(), &gameLobby); err != nil {
log.Printf("Error unmarshalling update value for key %s: %v", entry.Key(), err)
return
}
if historicalMode {
*dashboardItems = append(*dashboardItems, gameLobby)
return
}
history, err := gameLobbiesKV.History(ctx, entry.Key())
if err != nil {
log.Printf("Error getting history for key %s: %v", entry.Key(), err)
return
}
c := components.DashboardListItem(&gameLobby, sessionId)
if len(history) == 1 {
if err := sse.MergeFragmentTempl(c,
datastar.WithSelectorID("list-container"),
datastar.WithMergeAppend()); err != nil {
sse.ConsoleError(err)
}
} else {
if err := sse.MergeFragmentTempl(c,
datastar.WithSelectorID("game-"+entry.Key()),
datastar.WithMergeMorph()); err != nil {
sse.ConsoleError(err)
}
}
}
handleUpdates := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sse := datastar.NewSSE(w, r)
sessionId, err := getSessionId(store, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
watcher, err := gameLobbiesKV.WatchAll(ctx)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to start watcher: %v", err), http.StatusInternalServerError)
return
}
defer watcher.Stop()
historicalMode := true
dashboardItems := &[]components.GameLobby{}
for {
select {
case <-ctx.Done():
log.Println("Context canceled, stopping watcher updates")
return
case entry, ok := <-watcher.Updates():
if !ok {
log.Println("Watcher updates channel closed")
return
}
if entry == nil {
handleHistoricalUpdates(*dashboardItems, sessionId, sse)
historicalMode = false
continue
}
switch entry.Operation() {
case jetstream.KeyValuePut:
handleKeyValuePut(ctx, historicalMode, dashboardItems, entry, sessionId, sse)
case jetstream.KeyValuePurge:
handleKeyValueDelete(historicalMode, entry, sse)
}
}
}
}
handlePurge := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
keys, err := gameLobbiesKV.Keys(ctx)
if err != nil {
log.Printf("Error listing keys: %v", err)
return
}
for _, key := range keys {
err = gameLobbiesKV.Purge(ctx, key)
if err != nil {
log.Printf("Error deleting key '%s': %v", key, err)
continue
}
err = gameBoardsKV.Purge(ctx, key)
if err != nil {
log.Printf("Error deleting key '%s': %v", key, err)
continue
}
log.Printf("Deleted key: %s", key)
}
fmt.Fprintln(w, "All games have been purged.")
}
handleJoin := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sse := datastar.NewSSE(w, r)
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "missing 'id' parameter", http.StatusBadRequest)
return
}
sessionID, err := getSessionId(store, r)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get session: %v", err), http.StatusInternalServerError)
return
}
if sessionID == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
gameLobby, entry, err := GetObject[components.GameLobby](ctx, gameLobbiesKV, id)
if err != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if sessionID != gameLobby.HostId {
if gameLobby.ChallengerId != "" && gameLobby.ChallengerId != sessionID {
sse.Redirect("/dashboard")
sse.ExecuteScript("alert('Another player has already joined. Game is full.');")
return
}
gameLobby.ChallengerId = sessionID
if err := UpdateData(ctx, gameLobbiesKV, id, gameLobby, entry); err != nil {
sse.ExecuteScript("alert('Someone else joined first. This lobby is now full.');")
sse.Redirect("/dashboard")
return
}
}
sse.Redirect("/game/" + id)
}
handleDelete := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "missing 'id' parameter", http.StatusBadRequest)
return
}
if err := gameLobbiesKV.Purge(ctx, id); err != nil {
http.Error(w, fmt.Sprintf("failed to delete key '%s': %v", id, err), http.StatusInternalServerError)
return
}
if err := gameBoardsKV.Purge(ctx, id); err != nil {
http.Error(w, fmt.Sprintf("failed to delete key '%s': %v", id, err), http.StatusInternalServerError)
return
}
}
router.Route("/api/dashboard", func(dashboardRouter chi.Router) {
dashboardRouter.Post("/create", handleCreate)
dashboardRouter.Post("/logout", handleLogout)
dashboardRouter.Get("/updates", handleUpdates)
dashboardRouter.Delete("/purge", handlePurge)
dashboardRouter.Route("/{id}", func(gameIdRouter chi.Router) {
gameIdRouter.Post("/join", handleJoin)
gameIdRouter.Delete("/delete", handleDelete)
})
})
return nil
}
Break it down!
Let's break this bad mamma-jamma down. When we first hit the route we render the page after checking the session and getting the user so we can pass the users name to our component template. We use GetObject()
as a helper function to do this. Go ahead and add this function to routes/utils.go
.
func GetObject[T any](ctx context.Context, kv jetstream.KeyValue, key string) (*T, jetstream.KeyValueEntry, error) {
entry, err := kv.Get(ctx, key)
if err != nil {
return nil, nil, fmt.Errorf("failed to get key %s: %w", key, err)
}
var obj T
if err := json.Unmarshal(entry.Value(), &obj); err != nil {
return nil, nil, fmt.Errorf("failed to unmarshal value for key %s: %w", key, err)
}
return &obj, entry, nil
}
We will be using this function more in the future.
handleGetDashboard := func(w http.ResponseWriter, r *http.Request) {
sessionId, err := getSessionId(store, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if sessionId == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
user, _, err := GetObject[components.User](ctx, usersKV, sessionId)
if err != nil {
deleteSessionId(store, w, r)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
pages.Dashboard(user.Name).Render(r.Context(), w)
}
router.Get("/dashboard", handleGetDashboard)
Let's create that template now.
$ touch components/dashboard.templ
Copy this into it.
package components
import (
"fmt"
datastar "github.com/starfederation/datastar/sdk/go"
)
templ Dashboard(name string) {
{{
isAdmin := name == "admin"
}}
<div data-on-load={ datastar.GetSSE("/api/dashboard/updates") }>
<div class="flex flex-col sm:flex-row items-center p-4 bg-accent shadow-md w-full mb-4 rounded-md">
<div class="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
<button
class="btn btn-primary rounded-md flex items-center justify-center text-center text-primary-content px-4 py-2 sm:px-6 sm:py-3 w-full sm:w-auto"
data-on-click={ datastar.PostSSE("/api/dashboard/create") }
>
🎮 Create Game
</button>
<button
class="btn btn-secondary rounded-md px-4 py-2 sm:px-6 sm:py-3 text-secondary-content w-full sm:w-auto"
data-on-click={ datastar.PostSSE("/api/dashboard/logout") }
>
🚪 Logout
</button>
if (isAdmin) {
<button
class="btn btn-error rounded-md flex items-center justify-center text-center text-error-content px-4 py-2 sm:px-6 sm:py-3 w-full sm:w-auto"
data-on-click={ datastar.DeleteSSE("/api/dashboard/purge") }
>
🗑️ Delete Games
</button>
}
</div>
</div>
<div
id="list-container"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full overflow-y-auto"
style="max-height: 75vh;"
></div>
</div>
}
templ DashboardList(list []GameLobby, sessionId string) {
<div
id="list-container"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full overflow-y-auto"
style="max-height: 75vh;"
>
for _, listItem := range list {
@DashboardListItem(&listItem, sessionId)
}
</div>
}
templ DashboardListItem(gameLobby *GameLobby, sessionId string) {
{{
isHost := gameLobby.HostId == sessionId
isChallenger := gameLobby.ChallengerId == sessionId
isFull := gameLobby.ChallengerId != ""
gameSelector := fmt.Sprintf("game-%s", gameLobby.Id)
showJoinButton := isHost || isChallenger || !isFull
var status string
if isFull && !isHost && !isChallenger {
status = "🔒 Game is Full"
} else if isFull && isHost {
status = "✅ Game is Ready!"
} else if isFull && isChallenger {
status = "✅ Game is Ready!"
} else {
status = "🟢 Game is Open"
}
colorClass := "bg-base-300 text-base-content"
if isHost {
colorClass = "bg-base-100 text-primary-content"
}
cardClasses := fmt.Sprintf("p-6 shadow-lg flex flex-col w-full min-h-[220px] rounded-md %s", colorClass)
}}
<div id={ gameSelector } class={ cardClasses }>
<p class="tracking-widest text-secondary-content text-sm font-bold">
🎮 Game: { gameLobby.Name }
</p>
<p class="tracking-widest text-secondary-content text-sm font-bold">
📊 Status: { status }
</p>
<div class="flex flex-col items-center justify-center w-full gap-2">
if showJoinButton {
<button
class="btn btn-primary rounded-md flex items-center justify-center text-center text-primary-content w-full m-2 h-12 px-4"
data-on-click={ datastar.PostSSE("/api/dashboard/%s/join", gameLobby.Id) }
>
🕹️ Join
</button>
}
if isHost {
<button
class="btn btn-secondary rounded-md flex items-center justify-center text-center text-secondary-content w-full m-2 h-12 px-4"
data-on-click={ datastar.DeleteSSE("/api/dashboard/%s/delete", gameLobby.Id) }
>
🗑️ Delete
</button>
}
</div>
</div>
}
Here we have three components. One for the Dashboard page itself, one for the list of items on the Dashboard and one for the items themselves.
So you see when we land on the Dashboard route, we show the Dashboard page which will call our updates endpoint on load.
<div data-on-load={ datastar.GetSSE("/api/dashboard/updates")
What does this end up doing?
dashboardRouter.Get("/updates", handleUpdates)
Let's check it out!
handleUpdates := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sse := datastar.NewSSE(w, r)
sessionId, err := getSessionId(store, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
watcher, err := gameLobbiesKV.WatchAll(ctx)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to start watcher: %v", err), http.StatusInternalServerError)
return
}
defer watcher.Stop()
historicalMode := true
dashboardItems := &[]components.GameLobby{}
for {
select {
case <-ctx.Done():
log.Println("Context canceled, stopping watcher updates")
return
case entry, ok := <-watcher.Updates():
if !ok {
log.Println("Watcher updates channel closed")
return
}
if entry == nil {
handleHistoricalUpdates(*dashboardItems, sessionId, sse)
historicalMode = false
continue
}
switch entry.Operation() {
case jetstream.KeyValuePut:
handleKeyValuePut(ctx, historicalMode, dashboardItems, entry, sessionId, sse)
case jetstream.KeyValuePurge:
handleKeyValueDelete(historicalMode, entry, sse)
}
}
}
}
Does anything look familiar? It should! This is entirely powered by NATS Jetstream KV Store watch
command!
All we do is get the id
of the current game from the url parameters. We then simply watch on that key! We've done this before!
If you recall, it will always iterate over the historical data before streaming new current events. We use the variable historicalMode
to keep track of this.
historicalMode := true
dashboardItems := &[]components.GameLobby{}
for {
select {
case <-ctx.Done():
log.Println("Context canceled, stopping watcher updates")
return
case entry, ok := <-watcher.Updates():
if !ok {
log.Println("Watcher updates channel closed")
return
}
if entry == nil {
handleHistoricalUpdates(*dashboardItems, sessionId, sse)
historicalMode = false
continue
}
switch entry.Operation() {
case jetstream.KeyValuePut:
handleKeyValuePut(ctx, historicalMode, dashboardItems, entry, sessionId, sse)
case jetstream.KeyValuePurge:
handleKeyValueDelete(historicalMode, entry, sse)
}
}
}
As events come in from the stream, historical or current, we check the operation type. Let's check out when it is a put
operation.
handleKeyValuePut
First, let's talk about historical events.
handleKeyValuePut := func(
ctx context.Context,
historicalMode bool,
dashboardItems *[]components.GameLobby,
entry jetstream.KeyValueEntry,
sessionId string,
sse *datastar.ServerSentEventGenerator,
) {
var gameLobby components.GameLobby
if err := json.Unmarshal(entry.Value(), &gameLobby); err != nil {
log.Printf("Error unmarshalling update value for key %s: %v", entry.Key(), err)
return
}
if historicalMode {
*dashboardItems = append(*dashboardItems, gameLobby)
return
}
If the event was historical and being replayed and was a put
operation, we simply get the event and unmarshal it into our struct and then add it to a slice. Then we can see, back in our loop over our stream, once all historical events are played we call handleHisotricalUpdates()
handleHistoricalUpdates := func(dashboardItems []components.GameLobby, sessionId string, sse *datastar.ServerSentEventGenerator) {
if len(dashboardItems) == 0 {
return
}
c := components.DashboardList(dashboardItems, sessionId)
if err := sse.MergeFragmentTempl(c); err != nil {
sse.ConsoleError(err)
}
dashboardItems = nil
}
This function simply takes that array and creates a list of the items. Then using Datastar, we merge that lits into the UI. Easy peasy.
What about real time updates?
Glad you asked Let's check out the code again.
case entry, ok := <-watcher.Updates():
if !ok {
log.Println("Watcher updates channel closed")
return
}
if entry == nil {
handleHistoricalUpdates(*dashboardItems, sessionId, sse)
historicalMode = false
continue
}
switch entry.Operation() {
case jetstream.KeyValuePut:
handleKeyValuePut(ctx, historicalMode, dashboardItems, entry, sessionId, sse)
case jetstream.KeyValuePurge:
handleKeyValueDelete(historicalMode, entry, sse)
}
}
Once historical events are finished, we set the historicalMode variable to false. Now when we get new put
events we do something else in handleKeyValuePut()
if historicalMode {
*dashboardItems = append(*dashboardItems, gameLobby)
return
}
history, err := gameLobbiesKV.History(ctx, entry.Key())
if err != nil {
log.Printf("Error getting history for key %s: %v", entry.Key(), err)
return
}
c := components.DashboardListItem(&gameLobby, sessionId)
if len(history) == 1 {
if err := sse.MergeFragmentTempl(c,
datastar.WithSelectorID("list-container"),
datastar.WithMergeAppend()); err != nil {
sse.ConsoleError(err)
}
} else {
if err := sse.MergeFragmentTempl(c,
datastar.WithSelectorID("game-"+entry.Key()),
datastar.WithMergeMorph()); err != nil {
sse.ConsoleError(err)
}
}
If we are not receiving historical updates anymore, we continue on and create a DashboardListItem
component. Check it out-we use the History command here!
We use the history
command to check the history of the key. If it's freshly created, we want to append it to the DOM, so using Datastar we do just that and append it to the list. If it's not just being created then it already exists in the DOM and we're performing a different operation on the list item itself, so we morph it. This will take place to alter the list items information for everyone to see!
This is all that this is doing. We iterate over the historical events from our stream-when they are finished the event entry will be nil. This is how we know that the historical replay of events is over with. We then just set the historicalMode
variable to reflect this.
After that, any event that occurs is a current/in the moment event. So we can see that for historical events we call handleHistoricalUpdates()
and for current events we check the operation type and call either handleKeyValuePut()
or handleKeyValueDelete()
.
Delete is rather simple.
if historicalMode {
log.Printf("Ignoring historical delete for key: %s", update.Key())
return
}
if err := sse.RemoveFragments("#game-"+update.Key(),
datastar.WithRemoveSettleDuration(1*time.Millisecond),
datastar.WithRemoveUseViewTransitions(false)); err != nil {
sse.ConsoleError(err)
}
If it's a historical delete, we simply ignore it. The item was deleted and we don't need to react to this at all. If it's live, then we remove the specific DashboardListItem
based on it's key!
This is it for live updates, it's really that easy. This code isn't even that good and it works well. I really should refactor it to make it better!
NATS Jetstream makes it so easy to do this sort of thing on the fly.
What else?
Let's check out some of our other endpoints.
router.Route("/api/dashboard", func(dashboardRouter chi.Router) {
dashboardRouter.Post("/create", handleCreate)
dashboardRouter.Post("/logout", handleLogout)
dashboardRouter.Get("/updates", handleUpdates)
dashboardRouter.Delete("/purge", handlePurge)
dashboardRouter.Route("/{id}", func(gameIdRouter chi.Router) {
gameIdRouter.Post("/join", handleJoin)
gameIdRouter.Delete("/delete", handleDelete)
})
})
We've covered update. The others are self explanatory but let's go over them anyhow.
Create
Create is fairly straight-forward. Nothing you haven't seen before.
generateGameDetails := func() (string, string) {
id := toolbelt.NextEncodedID()
seed := time.Now().UTC().UnixNano()
nameGenerator := namegenerator.NewNameGenerator(seed)
name := strings.ToUpper(nameGenerator.Generate())
return id, name
}
createGameLobby := func(id, name, sessionId string) components.GameLobby {
return components.GameLobby{
Id: id,
Name: name,
HostId: sessionId,
ChallengerId: "",
}
}
createGameState := func(id string) components.GameState {
return components.GameState{
Id: id,
Board: [9]string{},
XIsNext: true,
Winner: "",
}
}
handleCreate := func(w http.ResponseWriter, r *http.Request) {
sessionId, err := getSessionId(store, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
id, name := generateGameDetails()
gameLobby := createGameLobby(id, name, sessionId)
if err := PutData(r.Context(), gameLobbiesKV, id, gameLobby); err != nil {
http.Error(w, fmt.Sprintf("failed to store game lobby: %v", err), http.StatusInternalServerError)
return
}
gameState := createGameState(id)
if err := PutData(r.Context(), gameBoardsKV, id, gameState); err != nil {
http.Error(w, fmt.Sprintf("failed to store game state: %v", err), http.StatusInternalServerError)
return
}
}
We generate a name an random id for our game with generateGameDetails()
. Then we just use that to create a GameLobby which will be shown on our Dashboard as a DashboardListItem
. We also create a new game, instantiating these both with some default values. You can see that a GameLobby is created with the HostId
supplied and an empty ChallengerId
. You can see the GameState itself defaults to X being first and has no current winner, just an empty board represented by a slice of 9 which represents our game cells.
Logout
Logout is also simple.
handleLogout := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sessionId, err := getSessionId(store, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if sessionId == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
keys, err := gameLobbiesKV.Keys(ctx)
if err != nil {
log.Printf("%v", err)
}
for _, key := range keys {
entry, err := gameLobbiesKV.Get(ctx, key)
if err != nil {
log.Printf("Failed to get value for key %s: %v", key, err)
continue
}
var gameLobby components.GameLobby
if err := json.Unmarshal(entry.Value(), &gameLobby); err != nil {
log.Printf("Error unmarshalling update value: %v", err)
return
}
if gameLobby.HostId == sessionId {
gameLobbiesKV.Delete(ctx, key)
gameBoardsKV.Delete(ctx, key)
}
}
if err := usersKV.Delete(ctx, sessionId); err != nil {
http.Error(w, fmt.Sprintf("failed to delete key '%s': %v", sessionId, err), http.StatusInternalServerError)
return
}
deleteSessionId(store, w, r)
sse := datastar.NewSSE(w, r)
sse.Redirect("/")
}
It's essentially just clean up. We get our session for our current user and then iterate over all of that users Games and delete them all. Then we delete the User. This isn't normal logout behavior but we're doing it just for the sake of this project. In the future maybe I'll make a system that keeps track of users better, but currently this is what we're doing.
Purge
We'll cover the purge
endpoint because it's similar. With this, we don't delete the user session, but we do delete all the games that exist. I added this for debugging but also just for a fun learning experience. To use this endpoint, login as admin
.
handlePurge := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
keys, err := gameLobbiesKV.Keys(ctx)
if err != nil {
log.Printf("Error listing keys: %v", err)
return
}
for _, key := range keys {
err = gameLobbiesKV.Purge(ctx, key)
if err != nil {
log.Printf("Error deleting key '%s': %v", key, err)
continue
}
err = gameBoardsKV.Purge(ctx, key)
if err != nil {
log.Printf("Error deleting key '%s': %v", key, err)
continue
}
log.Printf("Deleted key: %s", key)
}
fmt.Fprintln(w, "All games have been purged.")
}
Quite simple if you ask me.
Joining and Removing Games
The last two! How do we Join and Remove games? For one of these, we will use the update
command! See? We're using all of our commands!
Joining Games
We will be using our update command, let's add a helper for it. In our routes/utils.go
file add this.
func UpdateData(ctx context.Context, kv jetstream.KeyValue, id string, data interface{}, entry jetstream.KeyValueEntry) error {
bytes, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
_, err = kv.Update(ctx, id, bytes, entry.Revision())
if err != nil {
return fmt.Errorf("failed to update key-value: %w", err)
}
return nil
}
This will use the current entrys revision number, just like we used earlier in our series, to update our value!
handleJoin := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sse := datastar.NewSSE(w, r)
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "missing 'id' parameter", http.StatusBadRequest)
return
}
sessionID, err := getSessionId(store, r)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get session: %v", err), http.StatusInternalServerError)
return
}
if sessionID == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
gameLobby, entry, err := GetObject[components.GameLobby](ctx, gameLobbiesKV, id)
if err != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if sessionID != gameLobby.HostId {
if gameLobby.ChallengerId != "" && gameLobby.ChallengerId != sessionID {
sse.Redirect("/dashboard")
sse.ExecuteScript("alert('Another player has already joined. Game is full.');")
return
}
gameLobby.ChallengerId = sessionID
if err := UpdateData(ctx, gameLobbiesKV, id, gameLobby, entry); err != nil {
sse.ExecuteScript("alert('Someone else joined first. This lobby is now full.');")
sse.Redirect("/dashboard")
return
}
}
sse.Redirect("/game/" + id)
}
Similar stuff, we get the session and the GameLobby and then we check if the person joining is the host or not. If it's the host, we just redirect them to their game. If it's a challenger, we check to make sure the game doesn't already have a challenger. If it does we yell at the person. If it doesn't we add this person as the new challenger.
We haven't saved this to the KV Store yet though, so it's not solid! We attempt to make an update
to the store, and if the revision number we had gotten earlier with the GameLobby isn't correct, we yell at this person! (A lot of yelling going on, sheesh!)
We do this because two people might click this at the same time! In the words of Connor and Duncan, THERE CAN BE ONLY ONE!
If all goes well, the challenger is now added and we redirect to the game!
Deleting a Game
This is far more simple.
handleDelete := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "missing 'id' parameter", http.StatusBadRequest)
return
}
if err := gameLobbiesKV.Purge(ctx, id); err != nil {
http.Error(w, fmt.Sprintf("failed to delete key '%s': %v", id, err), http.StatusInternalServerError)
return
}
if err := gameBoardsKV.Purge(ctx, id); err != nil {
http.Error(w, fmt.Sprintf("failed to delete key '%s': %v", id, err), http.StatusInternalServerError)
return
}
}
We just get the id of the game and purge
it from the lobbies and games buckets.
Wowo! Ok the dashboard should be in working order. Test it and see!
Time to play, let's move on to Part 11 and finish the Game page!
Top comments (0)