Forms, Resets, & Transitions
In the first two articles of this series we taught our game world how to exist (Go server + templates) and how to move (boosted navigation).
But a text adventure without text commands is like a wizard without a spellbook! Sure, you can still walk around, but you can’t do anything. You can’t “look.” You can’t “wait.” You can’t ask for “help.” The world is there… and you’re basically just sightseeing.
So in this article we give the player a voice and in doing so, we introduce one of the most important HTMX patterns: sever-driven interaction via forms.
The Shape of the Solution
Let’s frame the core loop we want:
- The player types a command into an HTML
<form>. - The form submits a
POST /commandrequest. - The server parses the command, updates state, and renders one HTML fragment: a single “log entry.”
- HTMX takes that fragment and appends it into the
#logelement.
HTMX is perfect here because it’s really good at two things we need:
- Issuing requests directly from HTML (
hx-post). - Deciding where and how to swap the response (
hx-target,hx-swap).
And we’ll add two quality of life touches that make it feel like a real game UI:
- Reset the command input automatically after each request `hx-on::after-request=“this.reset()”.
- Make room navigation and log updates feel less “snappy” (View Transitions + a tiny animation).
Add the Game Log & Command Prompt
In part two, we used hx-boost to make link transitions feel instant. But commands require a different interaction model. Instead of navigating, a command submits input and updates a single part of the page. That specific “form + partial swap” flow is exactly what HTMX was built to do.
We’re going to add two UI elements to our room page:
- A Game Log (
#log) where turns appear. - A Command Prompt (
<form>) that posts to/commandand appends the response into the log.
Add a place to display turns: the log container
Open templates/room.html and add this block below your Exits section:
<h2>Game Log</h2>
<div id="log" class="log" aria-live="polite" aria-relevant="additions text">
<!-- Server-rendered log entries will go here -->
</div>
Why these attributes?
id="log"is what we’ll target from HTMXhx-target="#log".aria-live="polite"makes screen readers announce new entries without being obnoxious. It’s a small touch that costs us nothing.
At this point it won’t show anything yet, but we’ve created the “terminal window” where the story will appear.
Add the command prompt: a form that appends to the log
Now add the command form directly below the log:
<form
class="command"
action="/command"
method="post"
hx-boost="false"
hx-post="/command"
hx-target="#log"
hx-swap="beforeend scroll:bottom"
hx-on::after-request="this.reset()"
>
<input
id="command-input"
type="text"
name="cmd"
placeholder="Type a command…"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
required
/>
<button type="submit">Send</button>
</form>
Let’s break down exactly what you just wrote. This form is effectively our “text adventure controller.” Its job is simple: capture a user command, send it to the server, and determine precisely how the server’s HTML response gets swapped into the page.
First, notice that the form keeps normal HTML semantics action="/command" method="post". This gives us progressive enhancement for free. If HTMX (or JavaScript) isn’t available, the browser still knows how to submit the form as a standard POST request. Strictly speaking, this will only work if the server knows what to return. If the /command returns only fragment and there is no HTMX then browser will not know what to do with it. So the correct progressive enhancement pattern for HTMX forms is:
- If it’s an HTMX request -> return the fragment (log entry).
- If it’s a normal browser request -> return a full page (or redirect to one).
That’s why, when we get to Go code we’ll branch on HX-Request header to determine what to do:
if isHTMX(r) {
// return fragment
} else {
// redirect back to /room/{id} so the user sees the full page with updated log
}
Moving on. Now the HTMX part begins. hx-post="/command" tells HTMX to intercept the submit event and issue an AJAX POST request to /command instead of performing full navigation. The key point is that the server still receives a normal HTTP POST and can respond with HTML. HTMX is simply changing how browser applies the response. That “apply the response” part is controlled by the next two attributes: hx-target and hx-swap.
hx-target="#log" chooses where the response should go. If we didn’t specify a target, HTMX would default to swapping into the element that triggered the request (in this case the form), which would be… awkward, because the form would replace itself with a paragraph of dungeon narration. By targeting #log, we’re saying: “whatever HTML the server returns for this command, treat it as a new log entry and put it into the log container.”
Then comes the important “feel” knob: hx-swap="beforeend scroll:bottom". In HTMX, hx-swap controls the swap strategy, meaning how the response content is inserted relative to the target. The default swap strategy is innerHTML, which replaces the target’s contents entirely. That’s great for replacing a panel, but it’s wrong for a running transcript. For a transcript, we want each new entry append, not a full replacement.
That’s what beforeend does. It inserts the response after the last child of the target element. In DOM terms, it maps to the same family of positions as Element.insertAdjacentHTML, and HTMX deliberately uses those standard names. So with hx-target="#log" and hx-swap="beforeend", every successful command returns a small HTML fragment (a single <div class="log-entry">...</div>) and HTMX appends it as the newest line in the log.
It’s worth knowing the other swap strategies because they show up constantly in real apps (and we’ll use several later):
| Swap Strategy | Description |
|---|---|
innerHTML |
replace the target’s contents |
outerHTML |
replace the entire target element |
textContent |
replace only text without parsing HTML |
HTMX also supports the “adjacent insertion” strategies:
| Swap Strategy | Description |
|---|---|
beforebegin |
insert before the target element itself |
afterbegin |
insert as the first child of the target |
beforeend |
insert as the last child |
afterend |
insert after the target element |
Finally, there are two special ones:
| Swap Strategy | Description |
|---|---|
delete |
remove the target element regardless of response |
none |
don’t swap response content at all, though out-of-band swaps can still be processed |
To visualize how these work: afterbegin is the prepend strategy (newest at the top), while beforeend is the append strategy (newest at the bottom). We pick beforeend because the game log reads like a conversation. Earlier turns stay at the top, and new turns arrive at the bottom.
The scroll:bottom part is a modifier on hx-swap. After HTMX appends the new fragment, it scrolls the target element (our fixed height log div) to the bottom so the latest turn is visible. HTMX also supports a related modifier called show, which scrolls the page so an element becomes visible in the viewport, and for boosted links/forms the default is typically show:top unless you change it.
hx-on::after-request="this.reset()" is the small quality of life touch that keeps input usable. HTMX emits lifecycle events around every request, the after-request hook runs after the request completes. Resetting the form clears the input without writing any JavaScript. This pattern is often gated on event.detail.successful so you only clear the input on a successful response.
Finally, notice hx-boost="false". In part 2 we placed hx-boost="true" on <body>, which means links and forms inside the body are eligible for boosting (it’s inherited). In this case, boosting a form means submit via AJAX and swap into the <body> by default. This is good for navigation style forms but not for our command prompt. By setting hx-boost="false" directly on the command form, we opt it out for the global behavior and let hx-post, hx-target, and hx-swap define a precise, log append interaction.
Add a little structure and styling
Still in templates/room.html, add a few styles to make the log feel like a terminal and give entries a fade-in. You can drop these into you <style> block:
.log {
margin-top: 1rem;
border: 1px solid #ccc;
border-radius: 10px;
padding: 0.75rem;
height: 220px;
overflow-y: auto;
background: #111;
color: #eee;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", monospace;
font-size: 0.95rem;
line-height: 1.35;
}
.log-entry {
margin: 0 0 0.75rem 0;
opacity: 0;
transform: translateY(2px);
animation: log-fade-in 140ms ease-out forwards;
}
@keyframes log-fade-in {
to {
opacity: 1;
transform: none;
}
}
.log-entry .prompt {
color: #bbb;
margin-bottom: 0.15rem;
}
.log-entry .output {
white-space: pre-wrap;
}
.log-entry.error .output {
color: #ff8a8a;
}
.log-entry.system .output {
color: #b6e3ff;
}
.command {
margin-top: 0.75rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
.command input[type="text"] {
flex: 1;
padding: 0.6rem 0.7rem;
border-radius: 10px;
border: 1px solid #ccc;
font: inherit;
}
.command button {
padding: 0.6rem 0.9rem;
border-radius: 10px;
border: 1px solid #ccc;
background: #eee;
cursor: pointer;
}
This makes messages roll in smoothly instead of snapping onto the screen instantly.
Enable View Transitions for boosted navigation
Our room to room navigation is boosted, and we want it to fade rather than snap. HTMX supports the browser View Transition API by adding transition:true as an hx-swap modifier. Since our boosted navigation swap the <body>, we can add this to <body>:
<body hx-boost="true" hx-swap="innerHTML transition:true"></body>
And then we attach a transition name to the element we want animate (the .room wrapper is a good choice):
.room {
view-transition-name: room;
}
Then add transition keyframes:
@keyframes vt-fade-out {
to {
opacity: 0;
transform: translateY(-2px);
}
}
@keyframes vt-fade-in {
from {
opacity: 0;
transform: translateY(2px);
}
}
::view-transition-old(room) {
animation: 120ms ease-out both vt-fade-out;
}
::view-transition-new(room) {
animation: 160ms ease-out both vt-fade-in;
}
That’s it. We didn’t add frontend state. We just told the browser: when room swaps, animate it.
Render existing entries
Now, inside the log container, we’ll render the server’s log entries. For now, add this inside #log:
<div id="log" class="log" aria-live="polite" aria-relevant="additions text">
{{range .Log}} {{template "logEntry" .}} {{else}}
<div class="log-entry system">
<div class="output">
The world is quiet. Type <code>help</code> or <code>look</code>.
</div>
</div>
{{end}}
</div>
The log is server state, rendered as HTML. HTMX will append new entries, but on a refresh, the server can always reconstruct the page from its canonical state.
Add a Template for Log Entries
We want the server to return a clean HTML fragment for a single log entry. Create a new file templates/log_entry.html:
{{define "logEntry"}}
<div class="log-entry {{.Kind}}">
{{if .Command}}
<div class="prompt">> {{.Command}}</div>
{{end}}
<div class="output">{{.Output}}</div>
</div>
{{end}}
This template can render both “system messages” (no command prompt) and “player turns” (with > command). It also gives us stable HTML fragment that we can append into #log with hx-swap="beforeend". We’ll come back to this template in a moment.
At this point, HTML is ready. Now we need a /command handler and log state.
Teach the Sever to Understand Commands
Now we move to Go land. We need two pieces of state:
- Where the player is (current room id).
- The conversation log (list of turn entries).
In a real game we’d have proper persistence and multi-user data. We’ll get there later in the book. For now, we’ll keep it in memory per browser session, using a cookie as a key.
Also as we eluded to before, the form should still work if JavaScript is off, our /command handler will do something different depending on whether the request is HTMX driven. To do this we can check HX-Request header we saw in the previous article.
- If it’s an HTMX request -> return just the fragment
logEntry. - Otherwise -> follow Post/Redirect/Get and send the player back to their room page.
So here is our plan:
- Store per-player state (current room + log).
- Implement simple command interpreter (
look,wait,help). - Add
POST /commandthat returns either:- a fragment (for HTMX), or
- a redirect (for non-JS fallback)
We’ll do these in order.
Add new types: LogEntry and RoomPageData.Log
In main.go, add a log entry type and extend the data passed to the room template.
type LogEntry struct {
Turn int
At time.Time
Command string
Output string
Kind string // "system", "user", ""
}
type RoomPageData struct {
Room Room
Log []LogEntry
}
Why store Kind?
Because it’s the simplest way to render different “tones” in HTML. We’ll add CSS classes like .log-entry.user and .log-entry.system without inventing a UI component framework.
Add a session store
We need a way to maintain game state across requests. This is still early stages of our game so we’re going to keep this deliberately simple, a cookie identifies a session, and a map holds the session state.
Add this state container:
type GameState struct {
mu sync.Mutex
RoomID string
Turn int
Log []LogEntry
}
func (g *GameState) SetRoom(id string) {
g.mu.Lock()
defer g.mu.Unlock()
g.RoomID = id
}
func (g *GameState) Snapshot() (roomID string, logCopy []LogEntry) {
g.mu.Lock()
defer g.mu.Unlock()
roomID = g.RoomID
logCopy = append([]LogEntry(nil), g.Log...)
return
}
func (g *GameState) Append(command, output, kind string) LogEntry {
g.mu.Lock()
defer g.mu.Unlock()
g.Turn++
entry := LogEntry{
Turn: g.Turn,
At: time.Now(),
Command: command,
Output: output,
Kind: kind,
}
g.Log = append(g.Log, entry)
return entry
}
Snapshot() uses Mutex to return copy of the log so templates don’t race with concurrent appends.
Append() returns the entry that was added. This becomes our fragment response for HTMX.
Now the session store:
type SessionStore struct {
mu sync.Mutex
sessions map[string]*GameState
}
func NewSessionStore() *SessionStore {
return &SessionStore{sessions: make(map[string]*GameState)}
}
const sessionCookieName = "adv_session"
func (s *SessionStore) Get(w http.ResponseWriter, r *http.Request) *GameState {
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
}
}
// Create a new session
sid := newSessionID()
gs := &GameState{RoomID: "hallway"}
gs.Append("", "You awaken in a place that smells faintly of dust and HTTP. Type `help`.", "system")
s.mu.Lock()
s.sessions[sid] = gs
s.mu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sid,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return gs
}
And a small session id helper:
func newSessionID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
Parse templates: room page and log fragment
We talked about this already and here is where we’re render two different kinds of HTML from the sever:
- For
GET /room/{id}we return a full page (the entire room screen). - For
POST /command(when HTMX submits the form) we return just one small HTML snippet: a single log entry that HTMX can append into#log.
To make that easy, we keep the log-entry markup in its own template file, which we already created: templates/log_entry.html, and parse it alongside templates/room.html at startup:
tmpl := template.Must(template.ParseFiles(
"templates/room.html",
"templates/log_entry.html",
))
Now we can reuse the same log-entry HTML in two places:
- Inside the full page (
room.html), we render the existing log by calling the named template:{{template "logEntry" .}} - Inside the command handler (
POST /command), we render only that named template and send it as the response body. HTMX receives it and appends it to the log.
This approach gives us exactly one canonical HTML shape for “a turn,” and both the full page render and the HTMX fragment response use it.
Add the command interpreter
We’ll start with three commands but we’ll build on top of this foundation in later articles.
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"
}
}
Implement GET /room/{id} with state and log
Now update room handler so it:
- Loads the session state.
- Sets the current room in the state.
- Renders room and log.
mux.HandleFunc("GET /room/{id}", func(w http.ResponseWriter, r *http.Request) {
roomID := r.PathValue("id")
room, ok := rooms[roomID]
if !ok {
http.NotFound(w, r)
return
}
state := sessions.Get(w, r)
state.SetRoom(roomID)
_, logEntries := state.Snapshot()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "room.html", RoomPageData{
Room: room,
Log: logEntries,
}); err != nil {
log.Printf("template execute error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
})
This is an important milestone for us. When you refresh the page, your log is still there because it comes from the server’s state.
Notice, that you will also need to initialize session. You can do it right before defining mux:
sessions := NewSessionStore()
mux := http.NewServeMux()
Implement POST /command with HTMX fragment responses
Now we wire up the moment where the player actually speaks.
When the command form submits, we always hit the same endpoint: POST /command. What changes is what we send back. If the request came from HTMX, we want to return a HTML fragment for the single log entry. If the request did not come from HTMX (maybe JavaScript is disabled, or you’re hitting the endpoint directly while debugging), we send the browser back to the current room page, where the full log is rendered server side.
The easiest way to tell which case we’re in is to check the HX-Request header. HTMX sets HX-Request: true on requests it issues. Here’s a helper function for checking that:
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
And here is the handler, which reads very naturally if you follow it from top to bottom: load the player’s session state, read the command, interpret it, append a new entry to the log, and then decide whether to return a fragment (HTMX) or redirect (non-HTMX):
mux.HandleFunc("POST /command", func(w http.ResponseWriter, r *http.Request) {
state := sessions.Get(w, r)
roomID, _ := state.Snapshot()
cmd := r.FormValue("cmd")
output, kind := interpretCommand(rooms, 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") // useful if you ever add caching
if err := tmpl.ExecuteTemplate(w, "logEntry", entry); err != nil {
log.Printf("logEntry template execute error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
// non-JS fallback: classic Post/Redirect/Get
http.Redirect(w, r, "/room/"+roomID, http.StatusSeeOther)
})
Run and Talk to the Dungeon
Run the server and try:
- Click Go North / Go South (movement still works, still boosted)
- Type
lookand press Enter - Type
wait - Type
help
You should see:
- Each command produces a new log entry appended to the bottom.
- The command input clears automatically. That’s the
hx-on::after-requestreset. - The log scrolls internally to keep the newest turn visible. That’s
scroll:bottom. - Room navigation fades instead of snapping. That’s View Transitions and
transition:true.
If you pop open DevTools->Network, you’ll notice /command requests are XHR/fetch instead of full document navigation because the form is now “speaking” through HTMX.
What’s Next?
In the next article, we’ll add a backpack. That’s where HTMX gets even more fun: picking up an item needs to update two places on the screen (the log and the inventory sidebar). That’s our doorway into partial swaps and out-of-band updates.
Code
You can find full code on GitHub.