The Backpack
You step into the hallway, eyes adjusted to the dim light, and there it is.
A brass lamp. Just sitting there. Practically begging you to become the kind of adventurer who owns a brass lamp.
And then you remember that our hero has no pockets!
Right now our game can move between rooms and talk back via commands, but it can’t do the most emotionally important thing in any adventure: collect mildly suspicious objects.
So this article will continue were we left of in part 3, and we’re going to build a backpack.
We’ll add items to rooms that players can take and drop. Taking an item moves it to player’s backpack. Dropping it, removes it from the backpack.
Along the way, we’ll pick up two core HTMX patterns:
- Deleting elements: Drop removes a row from the inventory list.
- Out-of-band swaps: Take updates multiple targets at once (the log and the inventory sidebar) from a single request.
And by the end of the chapter, you’ll be picking up loot like a professional raccoon.
The Mental Model: Two Piles and a Handshake
Before we write code, let’s agree on the shape of reality.
In a text adventure, items live in exactly one of two piles:
- The pile in the room.
- The pile in your inventory.
Taking an item is just moving it from one pile to the other.
That’s important because it tells us how to design the state:
- Rooms (static): name, description, exits.
- Items (dynamic): which room they are in or in your inventory.
HTMX in Backpack Terms
So far, we’ve used HTMX for Boosting and Form posting. And now we’ll introduce two new techniques.
hx-delete removes something
HTMX can issue a real DELETE request via hx-delete. There is a very specific behavior for deletion.
To remove an element after a successful DELETE, return 200 with an empty body. HTMX will swap the target with “nothing,” effectively removing it from the DOM.
To cancel the removal, server returns a 204 No Content. HTMX interprets this as keep everything exactly as it is and skips the swap entirely.
We will use this to drop items from the inventory.
hx-swap-oob update multiple locations
Out-of-band swaps let you piggyback extra DOM updates onto a response, swapping them into places other than the main target. This is how we’ll make Take command update:
- the game log (main swap)
- the inventory sidebar (OOB swap)
- the items in the room list (OOB swap)
HTMX calls it out of band because it’s literally not in the targeted band.
Put Some Loot on the Floor
Before we build a backpack, we need something worth putting in it.
Right now our world is just rooms and exits. It’s lovely, but emotionally empty. So let’s introduce items as first class citizens.
In main.go, right alongside Room and Exit, add an Item type:
type Item struct {
ID string
Name string
Description string
}
The important part is the ID. It’s the stable handle we’ll use in URLs like /take/lamp.
Now, still in main(), define a small “loot table” for the world:
seedItems := map[string][]Item{
"hallway": {
{ID: "lamp", Name: "Brass Lamp", Description: "A squat brass lamp. It's heavier than it looks."},
{ID: "key", Name: "Rusty Key", Description: "A key that looks like it has opened exactly one door and regretted it."},
},
"atrium": {
{ID: "coin", Name: "Ancient Coin", Description: "A coin stamped with a face you don't recognize, but it recognizes you."},
},
}
This is the design time placement of items. In the next steps we’ll make sure every browser session gets its own copy of these items, so taking the lamp only affects your game.
Teach the Session about Piles
A backpack is just a pile you own. And a room is just a pile you don’t.
So the plan is to represent the world as two piles of items:
RoomItems[roomID]- what’s currently lying around in that room.Inventory- what the player is carrying.
Upgrade GameState to Track Items
In main.go, expand GameState to include the two piles. You’ll also need sort in your imports.
Replace your existing GameState with this version:
type GameState struct {
mu sync.Mutex
RoomID string
Turn int
Log []LogEntry
Inventory map[string]Item
RoomItems map[string]map[string]Item
}
A map of maps looks a bit like a boss fight, but it’s actually simple:
- first key: room ID (“hallway”)
- second key: item ID (“lamp”)
- value: the actual
Item
Make Templates Happy: Snapshot state as Sorted Slices
Maps are great for lookups and terrible for rendering in a predictable order. So we’ll snapshot them into sorted slices. Add these types and helpers:
type GameSnapshot struct {
RoomID string
Log []LogEntry
Inventory []Item
ItemsHere []Item
}
func (g *GameState) Snapshot() GameSnapshot {
g.mu.Lock()
defer g.mu.Unlock()
logCopy := append([]LogEntry(nil), g.Log...)
inv := sortedItems(g.Inventory)
var here []Item
if roomInv, ok := g.RoomItems[g.RoomID]; ok {
here = sortedItems(roomInv)
} else {
here = nil
}
return GameSnapshot{
RoomID: g.RoomID,
Log: logCopy,
Inventory: inv,
ItemsHere: here,
}
}
func sortedItems(m map[string]Item) []Item {
if len(m) == 0 {
return nil
}
out := make([]Item, 0, len(m))
for _, it := range m {
out = append(out, it)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Name == out[j].Name {
return out[i].ID < out[j].ID
}
return out[i].Name < out[j].Name
})
return out
}
Note: We are using a custom comparator function to sort Items. We sort items by their names, if two items have same names, we’ll sort them by their ID.
We keep the canonical state in maps and convert to slices only for rendering.
Add the Two Moves: Take and Drop
Now the actual backpack mechanics: moving an item between piles. Add these methods to GameState:
// Take moves an item from the current room into the player's inventory.
func (g *GameState) Take(itemID string) (Item, bool) {
g.mu.Lock()
defer g.mu.Unlock()
roomInv := g.RoomItems[g.RoomID]
if roomInv == nil {
return Item{}, false
}
it, ok := roomInv[itemID]
if !ok {
return Item{}, false
}
delete(roomInv, itemID)
g.Inventory[itemID] = it
return it, true
}
// Drop moves an item from the inventory into the current room.
func (g *GameState) Drop(itemID string) (Item, bool) {
g.mu.Lock()
defer g.mu.Unlock()
it, ok := g.Inventory[itemID]
if !ok {
return Item{}, false
}
delete(g.Inventory, itemID)
roomInv := g.RoomItems[g.RoomID]
if roomInv == nil {
roomInv = make(map[string]Item)
g.RoomItems[g.RoomID] = roomInv
}
roomInv[itemID] = it
return it, true
}
Give Every Session Its Own Copy of Room Items
This is the important “single player sanity” step. When we create a new game, we don’t just point to the seed items, we clone them. In Go, maps are reference types, so if we don’t create a new map for every session, players will still be fighting over the same items.
Replace your SessionStore with one that holds seedItems, and create a newGameState() helper that deep copies them:
type SessionStore struct {
mu sync.Mutex
sessions map[string]*GameState
seedItems map[string][]Item
}
func NewSessionStore(seedItems map[string][]Item) *SessionStore {
return &SessionStore{
sessions: make(map[string]*GameState),
seedItems: seedItems,
}
}
func newGameState(seedItems map[string][]Item) *GameState {
gs := &GameState{
RoomID: "hallway",
Inventory: make(map[string]Item),
RoomItems: make(map[string]map[string]Item),
Log: nil,
Turn: 0,
}
// Deep copy room items so each session gets its own world-state.
for roomID, items := range seedItems {
roomInv := make(map[string]Item, len(items))
for _, it := range items {
roomInv[it.ID] = it
}
gs.RoomItems[roomID] = roomInv
}
gs.Append("", "You awaken in a place that smells faintly of dust and HTTP. Type `help` or pick something up.", "system")
return gs
}
Then in SessionStore.Get, construct it via newGameState(s.seedItems):
gs := newGameState(s.seedItems)
And remove:
gs.Append("", "You awaken in a place that smells faintly of dust and HTTP. Type `help`.", "system")
as we are adding this message in newGameState function now.
Finally, in main(), initialize your store like this:
sessions := NewSessionStore(seedItems)
Put the Backpack on the Page
Now we let the player see the backpack or inventory and items in the room.
Items in the room will be displayed row wise, under Exits. Each item row will have a take button. When user picks up an item, it moves to their Backpack. Each item in the backpack will have a drop button. Dropping an item, drops the backpack item in the present room.
We’ll do it in a very HTMX friendly way, render the lists via named templates, so we can reuse them later for partial updates.
Extend the Room Page View Model
Update RoomPageData so the room template can render inventory and items in room.
type RoomPageData struct {
Room Room
Log []LogEntry
Inventory []Item
ItemsHere []Item
// OOB is used by partial templates later. For full page renders it's false.
OOB bool
}
That OOB flag will matter in a moment, when we want the same template to optionally include hx-swap-oob="true".
Create Partial Templates for the Two Lists
Create a new file: templates/partials.html.
Start with simple read-only lists (we’ll add Take/Drop buttons in the next steps):
{{define "inventoryList"}}
<ul id="inventory-list" class="inv-list" {{if .OOB}} hx-swap-oob="true" {{end}}>
{{- range .Inventory -}}
<li class="inv-item">
<span class="item-name">{{.Name}}</span>
</li>
{{- end -}}
</ul>
{{end}} {{define "roomItemsList"}}
<ul
id="room-items-list"
class="items-list"
{{if
.OOB}}
hx-swap-oob="true"
{{end}}
>
{{- range .ItemsHere -}}
<li class="room-item">
<span class="item-name">{{.Name}}</span>
</li>
{{- end -}}
</ul>
{{end}}
We intentionally trim whitespace with {{- ... -}} so the <ul> doesn’t contain stray whitespace when the range is empty. Without trimming, whitespace would prevent :empty::before from matching.
Using {{- range .Inventory -}} and {{- end -}} ensures that when Inventory is empty, the <ul> is truly empty, allowing CSS like :empty::before to reliably show placeholder content (e.g., “Nothing here!”).
Show Items
Open templates/room.html and add:
- A layout wrapper (main and sidebar),
- An Items Here section that calls
roomItemsList, - A Backpack sidebar that calls
inventoryList.
Here’s the markup portion to add/adjust (the rest of your template stays the same), insert this inside of <div class="room">:
<div class="layout">
<main class="main">
<!-- existing room title/description/exits -->
<h2>Items Here</h2>
{{template "roomItemsList" .}}
<!-- existing Game Log + command form -->
</main>
<aside class="sidebar">
<h2>Backpack</h2>
{{template "inventoryList" .}}
</aside>
</div>
And add these styles (they match the final code layout and make empty lists readable):
.layout {
display: flex;
gap: 1.25rem;
align-items: flex-start;
}
.main {
flex: 1;
min-width: 0;
}
.sidebar {
width: 260px;
border-left: 1px solid #ddd;
padding-left: 1rem;
}
@media (max-width: 900px) {
.layout {
flex-direction: column;
}
.sidebar {
width: auto;
border-left: none;
padding-left: 0;
border-top: 1px solid #ddd;
padding-top: 1rem;
}
}
.items-list,
.inv-list {
list-style: none;
padding: 0;
margin: 0.5rem 0 0 0;
}
.items-list:empty::before {
content: "(Nothing here.)";
color: #777;
display: block;
padding: 0.25rem 0;
}
.inv-list:empty::before {
content: "(Empty.)";
color: #777;
display: block;
padding: 0.25rem 0;
}
.room-item,
.inv-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.35rem 0;
border-bottom: 1px dashed #ddd;
}
.item-name {
font-weight: 600;
}
Parse the New Template and Pass Data into the Room Page
Update your template parsing to include templates/partials.html:
tmpl := template.Must(template.ParseFiles(
"templates/room.html",
"templates/log_entry.html",
"templates/partials.html",
))
Then update GET /room/{id} so it pulls a snapshot and passes both lists:
snap := state.Snapshot()
if err := tmpl.ExecuteTemplate(w, "room.html", RoomPageData{
Room: room,
Log: snap.Log,
Inventory: snap.Inventory,
ItemsHere: snap.ItemsHere,
}); err != nil {
// ...
}
While we are fixing handlers, let’s update the POST /command handler as well to use updated Snapshot and get rid of compilation errors. Replace the way we get snap instead of roomID:
snap := state.Snapshot()
Then update interpreterCommand call to use snap.RoomID:
output, kind := interpretCommand(rooms, snap.RoomID, cmd)
And finally http.Redirect:
http.Redirect(w, r, "/room/"+snap.RoomID, http.StatusSeeOther)
Refresh the Hallway. You should see Brass Lamp and Rusty Key under Items Here, and an empty backpack. No interactivity yet, but we are displaying items! That’s progress.
Dropping Items with hx-delete
Now we make the backpack interactive.
First, let’s talk about the behavior and flow we want to implement and once we understand it, we’ll go step by step implementing it. The player experience we are after is: you click Drop, and the inventory row vanishes without re-rendering the whole sidebar.
So wire the inventory row like this:
<form hx-delete="/inventory/{{.ID}}" hx-target="closest li" hx-swap="outerHTML">
<button type="submit">Drop</button>
</form>
This reads like this:
- Send a
DELETEto/inventory/lamp. - When the response comes back, replace the closest
<li>. - Use
outerHTML, meaning replace the whole<li>...</li>node.
As already mentioned it, the hx-delete behavior is that when the server responds with 200 OK and an empty body, then the swap becomes: replace <li>...</li> with nothing and the <li> is gone. That’s what we want. Just for reference a 204 No Content response means there is no response content to swap, so the DOM stays as is. For out Drop button, we want the DOM to change, so we use 200 OK and empty body.
So far so good.
If this behavior is all we wanted, we would be done. But our game has another responsibility. If you drop the lamp, the lamp shouldn’t vanish from existence. It should show up on the floor in the current room. And it would be nice if the log said something suitably dramatic.
That’s where new concept of out-of-band swap comes in.
Normally, HTMX has one swap target per request. You click a thing, HTMX swaps the response into a specific element (or the triggering element by default). That’s perfect when one action updates one region.
Dropping an item is different. The Drop button lives in the inventory list, but the side effects live elsewhere:
- append a line to
#log - refresh the Items Here list
We could make the client fire multiple requests but HTMX allows us to do this in one.
One request. One response that contains multiple HTML fragments, each with where it should go.
But you might be thinking, didn’t you just say that for delete we need to return empty body!? Yes… But…
That’s where hx-swap-oob will help us. Don’t worry we will still end up with empty body.
Let’s look at the sample response to illustrate how this will work. Imagine the inventory row triggered the DELETE request, but the server replies with two extra fragments:
<div hx-swap-oob="beforeend:#log">
<div class="log-entry system">
<div class="prompt">> drop lamp</div>
<div class="output">
You drop the Brass Lamp. It lands with the dignity of a potato.
</div>
</div>
</div>
<ul id="room-items-list" hx-swap-oob="true">
<!-- rerendered items-on-the-floor -->
</ul>
Notice what’s missing. There is no main content intended for the <li> target.
And that’s the main idea.
HTMX processes the response roughly like this:
- Parse the response HTML into a temporary DOM.
- Find any nodes marked with
hx-swap-oob. - Apply those swaps to their destinations (log, room items list).
- Remove those OOB nodes from the response.
- Perform the normal swap into the original target (
closest li,outerHTML) with whatever is left.
If the response was only OOB fragments, then after step 4 there’s nothing left. Step 5 becomes: replace <li>...</li> with empty content and the inventory row disappears.
So the trick here isn’t returning empty body. It’s returning only side effects, letting HTMX peel them off and apply them, and then using the empty remainder as our deletion mechanism.
Ok, now that we know what we want to achieve and how, let’s implement this.
Add the Drop Button to the Inventory List Template
Edit templates/partials.html and replace the <li> inside inventoryList with this version:
<li class="inv-item">
<span class="item-name">{{.Name}}</span>
<form
class="inline"
action="/drop/{{.ID}}"
method="post"
hx-boost="false"
hx-delete="/inventory/{{.ID}}"
hx-trigger="submit"
hx-target="closest li"
hx-swap="outerHTML"
>
<button type="submit" class="mini">Drop</button>
</form>
</li>
And add some CSS to room.html:
.inline {
margin: 0;
}
.mini {
padding: 0.35rem 0.6rem;
border-radius: 10px;
border: 1px solid #ccc;
background: #eee;
cursor: pointer;
font: inherit;
}
This gives us exactly what we are after. When the response body is empty (after OOB processing), that <li> becomes nothing.
Create on OOB Log Entry Template
Now we create template for the log entry HTML fragment we want to return along with DELETE response.
Create templates/log_entry_oob.html:
{{define "logEntryOOB"}}
<div class="log-entry {{.Kind}}" hx-swap-oob="beforeend:#log">
{{if .Command}}
<div class="prompt">> {{.Command}}</div>
{{end}}
<div class="output">{{.Output}}</div>
</div>
{{end}}
That hx-swap-oob="beforeend:#log means append me into #log no matter what the main target was.
Add the DELETE Router and a non-JS Fallback
In main.go, update template parsing to include the new files:
tmpl := template.Must(template.ParseFiles(
"templates/room.html",
"templates/log_entry.html",
"templates/partials.html",
"templates/log_entry_oob.html",
))
Now add these two handlers:
DELETE /inventory/{id}for HTMXPOST /drop/{id}for non-JS fallback.
mux.HandleFunc("DELETE /inventory/{id}", func(w http.ResponseWriter, r *http.Request) {
state := sessions.Get(w, r)
itemID := r.PathValue("id")
it, ok := state.Drop(itemID)
if !ok {
w.WriteHeader(http.StatusOK)
return
}
entry := state.Append(
"drop "+itemID,
fmt.Sprintf("You drop the %s.", it.Name),
"system",
)
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Add("Vary", "HX-Request")
// OOB: append to log
_ = tmpl.ExecuteTemplate(w, "logEntryOOB", entry)
// OOB: refresh "Items Here" so the dropped thing appears on the floor
after := state.Snapshot()
_ = tmpl.ExecuteTemplate(w, "roomItemsList", ListPartialData{
OOB: true,
ItemsHere: after.ItemsHere,
})
return
}
snap := state.Snapshot()
http.Redirect(w, r, "/room/"+snap.RoomID, http.StatusSeeOther)
})
mux.HandleFunc("POST /drop/{id}", func(w http.ResponseWriter, r *http.Request) {
state := sessions.Get(w, r)
snap := state.Snapshot()
itemID := r.PathValue("id")
it, ok := state.Drop(itemID)
if ok {
state.Append(
"drop "+itemID,
fmt.Sprintf("You drop the %s.", it.Name),
"system",
)
}
http.Redirect(w, r, "/room/"+snap.RoomID, http.StatusSeeOther)
})
One note, this code introduced ListPartialData, but we haven’t defined it yet. We’ll do it next when working on the Take command as it also needs it.
Taking Items and Updating Two Places at Once
Take command needs to update:
- the log (“You take the lamp…”)
- the backpack sidebar (inventory list)
- the Items Here list (the lamp disappears)
Add the Take Button to the Room Items Template
Edit templates/partials.html and replace the <li> inside roomItemsList with:
<li class="room-item">
<span class="item-name">{{.Name}}</span>
<form
class="inline"
action="/take/{{.ID}}"
method="post"
hx-boost="false"
hx-post="/take/{{.ID}}"
hx-target="#log"
hx-swap="beforeend scroll:bottom"
>
<button type="submit" class="mini">Take</button>
</form>
</li>
This is a very intentional split of responsibilities:
- The main response from
/take/{id}will be normallogEntryfragment (so it appends to#log). - The inventory/sidebar refresh will happen via OOB swaps.
Define ListPartialData Once and Reuse It
In main.go, add this helper struct near your other view models:
type ListPartialData struct {
OOB bool
Inventory []Item
ItemsHere []Item
}
Now both full page renders (RoomPageData) and partial renders (ListPartialData) can satisfy the partial template’s needs.
Add the /take/{id} Handler and Emit OOB Updates
Add this handler:
mux.HandleFunc("POST /take/{id}", func(w http.ResponseWriter, r *http.Request) {
state := sessions.Get(w, r)
before := state.Snapshot()
itemID := r.PathValue("id")
it, ok := state.Take(itemID)
var entry LogEntry
if ok {
entry = state.Append(
"take "+itemID,
fmt.Sprintf("You take the %s and tuck it into your backpack.", it.Name),
"system",
)
} else {
entry = state.Append(
"take "+itemID,
"You reach for it, but your hand closes on air.",
"error",
)
}
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Add("Vary", "HX-Request")
// Main swap: append to #log (because the form targeted #log)
if err := tmpl.ExecuteTemplate(w, "logEntry", entry); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// OOB swaps: refresh both lists
after := state.Snapshot()
_ = tmpl.ExecuteTemplate(w, "inventoryList", ListPartialData{
OOB: true,
Inventory: after.Inventory,
})
_ = tmpl.ExecuteTemplate(w, "roomItemsList", ListPartialData{
OOB: true,
ItemsHere: after.ItemsHere,
})
return
}
http.Redirect(w, r, "/room/"+before.RoomID, http.StatusSeeOther)
})
Let’s unpack what’s happening.
Why the log entry is the main swap?
Remember the Take button’s form:
<form
hx-post="/take/{{.ID}}"
hx-target="#log"
hx-swap="beforeend scroll:bottom"
>
...
</form>
So HTMX is already primed to take the response body and append it to #log. That means our handler’s primary responsibility is simple, render one log entry and send it back.
Why the lists are OOB instead of more main content?
The log isn’t the only thing that changes. The state mutation (state.Take) moves the item:
- from
RoomItems[currentRoom] - into
Inventory
So two other UI regions are now stale:
- the sidebar inventory list
- the “Items Here” list
So we render those two lists as separate fragments, each with:
- a stable
id(inventory-list,room-items-list) hx-swap-oob="true"(enabled whenOOB: true)
Even though the request itself targeted #log.
Run It!
When the page loads you should be in the hallway. You’ll see:
- Items Here: Brass Lamp, Rusty Key
- Backpack: (empty)
- Game Log: “you awaken…” line
Now click “Take” next to Brass Lamp.
Three things should happen in one satisfying moment:
- A log line appears: “You take the Brass Lamp…”
- The lamp disappears from “Items Here”
- The lamp appears in Backpack
Now click Drop next to the lamp in your backpack:
- The lamp’s inventory row disappears immediately.
- A new log entry appears.
- The lamp reappears under Items Here.
Then wander north to the atrium, grab the coin, and drop it in the hallway.
Prove the OOB Behavior
Let’s catch HTMX doing two swaps from one response.
Open browser devtools->Network.
Inspect a Take request
- Click Take on an item.
- Click the
POST /take/{id}request. - Look at the Response.
You should see that the response body is not a single page chunk. It’s a small bundle of fragments:
- First: a
logEntryfragment (the one that gets appended to#logbecause the form targeted#log) - Then: a
<ul id="inventory-list" hx-swap-oob="true">... - Then: a
<ul id="room-items-list" hx-swap-oob="true">...
Inspect a Drop request
Now click Drop in the backpack and inspect the DELETE /inventory/{id} response.
You’ll notice that the response doesn’t replace the <li> with new <li> HTML. Instead it mostly contains OOB fragments (log and room list). Exactly what we expect. After HTMX applies those fragments, there’s nothing else left to swap into the <li> target… so the <li> get replaced by emptiness and disappears.
One Last Sanity Check
Does it work without JavaScript?
Yes, and it’s worth trying once because it proves we built a real web app.
Disable JavaScript and reload the page, then:
- Clicking Take should perform a classic form POST and redirect back.
- Clicking Drop should hit
/drop/{id}. POST fallback and redirect back.
Taming main.go
Take a look at main.go right now.
It’s… fine. It works. It’s also starting to resemble a junk drawer. So let’s end this chapter by doing something deeply unglamorous and deeply professional: we’ll refactor the Go code into small clean modules to add enough structure that part 5 doesn’t feel like adding new rooms to a collapsing cave.
The Plan
We want main.go to do three things and only three things:
- Build dependencies (template, session store, room definitions)
- Wire up routes
- Start the server
Everything else, domain state, session management, handlers, moves out.
By the end, project will look like this:
.
├── cmd
│ └── adventure
│ └── main.go
├── internal
│ └── game
│ ├── model.go
│ ├── state.go
│ ├── session.go
│ └── commands.go
│ └── web
│ ├── handlers.go
│ ├── htmx.go
│ └── templates.go
└── templates
├── room.html
├── log_entry.html
├── log_entry_oob.html
└── partials.html
Let’s do it step by step.
Move the domain types into internal/game
Create a folder:
internal/game
Now create internal/game/model.go and move the pure data structures there:
package game
import "time"
type Exit struct {
Label string
To string
}
type Room struct {
ID string
Name string
Description string
Exits []Exit
}
type Item struct {
ID string
Name string
Description string
}
type LogEntry struct {
Turn int
At time.Time
Command string
Output string
Kind string // "system", "error", etc.
}
Move GameState and snapshot logic into internal/game/state.go
Create internal/game/state.go:
package game
import (
"sort"
"sync"
"time"
)
type State struct {
mu sync.Mutex
RoomID string
Turn int
Log []LogEntry
Inventory map[string]Item
RoomItems map[string]map[string]Item
}
type Snapshot struct {
RoomID string
Log []LogEntry
Inventory []Item
ItemsHere []Item
}
func NewState(seedItems map[string][]Item) *State {
s := &State{
RoomID: "hallway",
Inventory: make(map[string]Item),
RoomItems: make(map[string]map[string]Item),
Log: nil,
Turn: 0,
}
for roomID, items := range seedItems {
roomInv := make(map[string]Item, len(items))
for _, it := range items {
roomInv[it.ID] = it
}
s.RoomItems[roomID] = roomInv
}
s.Append("", "You awaken in a place that smells faintly of dust and HTTP. Type `help` or pick something up.", "system")
return s
}
func (s *State) SetRoom(id string) {
s.mu.Lock()
defer s.mu.Unlock()
s.RoomID = id
}
func (s *State) Append(command, output, kind string) LogEntry {
s.mu.Lock()
defer s.mu.Unlock()
s.Turn++
entry := LogEntry{
Turn: s.Turn,
At: time.Now(),
Command: command,
Output: output,
Kind: kind,
}
s.Log = append(s.Log, entry)
return entry
}
func (s *State) Snapshot() Snapshot {
s.mu.Lock()
defer s.mu.Unlock()
logCopy := append([]LogEntry(nil), s.Log...)
inv := sortedItems(s.Inventory)
var here []Item
if roomInv, ok := s.RoomItems[s.RoomID]; ok {
here = sortedItems(roomInv)
}
return Snapshot{
RoomID: s.RoomID,
Log: logCopy,
Inventory: inv,
ItemsHere: here,
}
}
func (s *State) Take(itemID string) (Item, bool) {
s.mu.Lock()
defer s.mu.Unlock()
roomInv := s.RoomItems[s.RoomID]
if roomInv == nil {
return Item{}, false
}
it, ok := roomInv[itemID]
if !ok {
return Item{}, false
}
delete(roomInv, itemID)
s.Inventory[itemID] = it
return it, true
}
func (s *State) Drop(itemID string) (Item, bool) {
s.mu.Lock()
defer s.mu.Unlock()
it, ok := s.Inventory[itemID]
if !ok {
return Item{}, false
}
delete(s.Inventory, itemID)
roomInv := s.RoomItems[s.RoomID]
if roomInv == nil {
roomInv = make(map[string]Item)
s.RoomItems[s.RoomID] = roomInv
}
roomInv[itemID] = it
return it, true
}
func sortedItems(m map[string]Item) []Item {
if len(m) == 0 {
return nil
}
out := make([]Item, 0, len(m))
for _, it := range m {
out = append(out, it)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Name == out[j].Name {
return out[i].ID < out[j].ID
}
return out[i].Name < out[j].Name
})
return out
}
This looks good. The state has a single lock and a narrow API: Take, Drop, Append, Snapshot. And Snapshot returns render friendly slices.
Move session management into internal/game/session.go
Create internal/game/session.go:
package game
import (
"crypto/rand"
"encoding/hex"
"net/http"
"sync"
)
type SessionStore struct {
mu sync.Mutex
sessions map[string]*State
seedItems map[string][]Item
}
func NewSessionStore(seedItems map[string][]Item) *SessionStore {
return &SessionStore{
sessions: make(map[string]*State),
seedItems: seedItems,
}
}
const SessionCookieName = "adv_session"
func (s *SessionStore) Get(w http.ResponseWriter, r *http.Request) *State {
if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
s.mu.Lock()
gs := s.sessions[c.Value]
s.mu.Unlock()
if gs != nil {
return gs
}
}
sid := newSessionID()
st := NewState(s.seedItems)
s.mu.Lock()
s.sessions[sid] = st
s.mu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sid,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return st
}
func newSessionID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
Now sessions are a self contained unit. The web layer doesn’t care how session IDs are generated. It just asks for store.Get() and receives a *game.State.
This refactor makes later enhancements easy, like:
- storying sessions in Redis
- adding expiration
- supporting multiple players
But we don’t have to do any of that right now. We just want the seams.
Move command interpreter into internal/game/commands.go
Create internal/game/commands.go:
package game
import "strings"
func InterpretCommand(rooms map[string]Room, roomID string, raw string) (output string, kind string) {
cmd := strings.TrimSpace(raw)
if cmd == "" {
return "You open your mouth... and say nothing. (Try typing a command.)", "error"
}
switch strings.ToLower(cmd) {
case "help", "?":
return "Commands: look, wait, help", "system"
case "look", "l":
room, ok := rooms[roomID]
if !ok {
return "You look around, but reality fails to load. (Unknown room id.)", "error"
}
return room.Description, "system"
case "wait":
return "You wait. Somewhere, a pipe ticks. The world does not feel rushed.", "system"
default:
return "You try that. Nothing happens. The dungeon remains unimpressed.", "error"
}
}
Even if you add more commands later, this interpreter flow remains the same: read input -> call game logic -> render output.
Create a web layer that owns handlers
Now, make a internal/web package. This package is where HTTP concerns belong: request parsing, headers, templates, status codes.
Create folder internal/web.
HTMX helpers: internal/web/htmx.go
package web
import "net/http"
func IsHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
Templates: internal/web/templates.go
package web
import "html/template"
type Templates struct {
T *template.Template
}
func MustLoadTemplates() *Templates {
t := template.Must(template.ParseFiles(
"templates/room.html",
"templates/log_entry.html",
"templates/partials.html",
"templates/log_entry_oob.html",
))
return &Templates{T: t}
}
Centralizing template loading makes cmd/.../main.go even cleaner.
Handlers: internal/web/handlers.go
This is the big move. Handlers become methods on a struct that holds dependencies.
package web
import (
"fmt"
"net/http"
"html/template"
"yourmodule/internal/game"
)
type Server struct {
Rooms map[string]game.Room
Sessions *game.SessionStore
Tmpl *template.Template
}
type RoomPageData struct {
Room game.Room
Log []game.LogEntry
Inventory []game.Item
ItemsHere []game.Item
OOB bool
}
type ListPartialData struct {
OOB bool
Inventory []game.Item
ItemsHere []game.Item
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.handleIndex)
mux.HandleFunc("GET /room/{id}", s.handleRoom)
mux.HandleFunc("POST /command", s.handleCommand)
mux.HandleFunc("POST /take/{id}", s.handleTake)
mux.HandleFunc("DELETE /inventory/{id}", s.handleInventoryDelete)
mux.HandleFunc("POST /drop/{id}", s.handleDropFallback)
return mux
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/room/hallway", http.StatusFound)
}
func (s *Server) handleRoom(w http.ResponseWriter, r *http.Request) {
roomID := r.PathValue("id")
room, ok := s.Rooms[roomID]
if !ok {
http.NotFound(w, r)
return
}
state := s.Sessions.Get(w, r)
state.SetRoom(roomID)
snap := state.Snapshot()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = s.Tmpl.ExecuteTemplate(w, "room.html", RoomPageData{
Room: room,
Log: snap.Log,
Inventory: snap.Inventory,
ItemsHere: snap.ItemsHere,
})
}
func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request) {
state := s.Sessions.Get(w, r)
snap := state.Snapshot()
cmd := r.FormValue("cmd")
output, kind := game.InterpretCommand(s.Rooms, snap.RoomID, cmd)
entry := state.Append(cmd, output, kind)
if IsHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Add("Vary", "HX-Request")
_ = s.Tmpl.ExecuteTemplate(w, "logEntry", entry)
return
}
http.Redirect(w, r, "/room/"+snap.RoomID, http.StatusSeeOther)
}
func (s *Server) handleTake(w http.ResponseWriter, r *http.Request) {
state := s.Sessions.Get(w, r)
before := state.Snapshot()
itemID := r.PathValue("id")
it, ok := state.Take(itemID)
var entry game.LogEntry
if ok {
entry = state.Append(
"take "+itemID,
fmt.Sprintf("You take the %s and tuck it into your backpack.", it.Name),
"system",
)
} else {
entry = state.Append(
"take "+itemID,
"You reach for it, but your hand closes on air.",
"error",
)
}
if !IsHTMX(r) {
http.Redirect(w, r, "/room/"+before.RoomID, http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Add("Vary", "HX-Request")
// Main swap: append to #log (because the form targeted #log).
if err := s.Tmpl.ExecuteTemplate(w, "logEntry", entry); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// OOB swaps: refresh both lists.
after := state.Snapshot()
_ = s.Tmpl.ExecuteTemplate(w, "inventoryList", ListPartialData{
OOB: true,
Inventory: after.Inventory,
})
_ = s.Tmpl.ExecuteTemplate(w, "roomItemsList", ListPartialData{
OOB: true,
ItemsHere: after.ItemsHere,
})
}
func (s *Server) handleInventoryDelete(w http.ResponseWriter, r *http.Request) {
state := s.Sessions.Get(w, r)
itemID := r.PathValue("id")
it, ok := state.Drop(itemID)
if !ok {
w.WriteHeader(http.StatusOK)
return
}
entry := state.Append(
"drop "+itemID,
fmt.Sprintf("You drop the %s. It lands with the dignity of a potato.", it.Name),
"system",
)
if !IsHTMX(r) {
snap := state.Snapshot()
http.Redirect(w, r, "/room/"+snap.RoomID, http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Add("Vary", "HX-Request")
// OOB: append to log
_ = s.Tmpl.ExecuteTemplate(w, "logEntryOOB", entry)
// OOB: refresh "Items Here"
after := state.Snapshot()
_ = s.Tmpl.ExecuteTemplate(w, "roomItemsList", ListPartialData{
OOB: true,
ItemsHere: after.ItemsHere,
})
}
func (s *Server) handleDropFallback(w http.ResponseWriter, r *http.Request) {
state := s.Sessions.Get(w, r)
snap := state.Snapshot()
itemID := r.PathValue("id")
it, ok := state.Drop(itemID)
if ok {
state.Append(
"drop "+itemID,
fmt.Sprintf("You drop the %s. The dungeon accepts your offering.", it.Name),
"system",
)
}
http.Redirect(w, r, "/room/"+snap.RoomID, http.StatusSeeOther)
}
Make sure you replace yourmodule/internal/game with your actual module path from go.mod.
Replace main.go with an entry point
Now create cmd/adventure/main.go and move you startup code there.
package main
import (
"log"
"net/http"
"yourmodule/internal/game"
"yourmodule/internal/web"
)
func main() {
rooms := map[string]game.Room{
"hallway": {
ID: "hallway",
Name: "The Hallway",
Description: "You are standing in a dimly lit hallway. Dust motes dance in the air.",
Exits: []game.Exit{
{Label: "Go North", To: "atrium"},
},
},
"atrium": {
ID: "atrium",
Name: "The Atrium",
Description: "You step into a grand atrium. The ceiling is glass, revealing a grey sky.",
Exits: []game.Exit{
{Label: "Go South", To: "hallway"},
},
},
}
seedItems := map[string][]game.Item{
"hallway": {
{ID: "lamp", Name: "Brass Lamp", Description: "A squat brass lamp. It's heavier than it looks."},
{ID: "key", Name: "Rusty Key", Description: "A key that looks like it has opened exactly one door and regretted it."},
},
"atrium": {
{ID: "coin", Name: "Ancient Coin", Description: "A coin stamped with a face you don't recognize, but it recognizes you."},
},
}
templates := web.MustLoadTemplates()
sessions := game.NewSessionStore(seedItems)
srv := &web.Server{
Rooms: rooms,
Sessions: sessions,
Tmpl: templates.T,
}
addr := ":4040"
log.Printf("Server starting on http://localhost%s", addr)
log.Fatal(http.ListenAndServe(addr, srv.Routes()))
}
Now the entry point is almost boring. Which is exactly what you want.
Update how to run the server
Now run the server again and verify everything works:
go run ./cmd/adventure
If you’re using air, your .air.toml has something like:
cmd = "go build -o ./tmp/main ."
Update it to:
cmd = "go build -o ./tmp/main ./cmd/adventure"
And you’re back in business. And in part 5 we’ll get our Sword and Shield and learn how to do optimistic updates with HTMX.
Code
You can find full code on GitHub.