posts / go

Adventures in Go and HTMX - Part 10

Type-Save HTML in Go with Templ

In the last article, we transformed our game into a real game engine by separating the data from code. Rooms, items, and exits now live in world.yaml, and the Go code is simply the machine that reads and runs it. If someone wants to add a new dungeon wing, they edit a YAML file. No recompilation required.

But we left a landmine in the codebase.

Remember what happened when we changed Exits from a list of structs to a map[string]string? Once we updated the Go files to fix compilation issues, the server started but the UI broke in the browser because room.html was still reaching for .Label and .To fields that no longer existed.

This is the fundamental weakness of Go’s html/template package. Templates are parsed at runtime, not compile time. They are opaque strings as far as the compiler is concerned. Rename a struct field, and you won’t find out until a user loads the page and sees a blank screen or worse, a cryptic error.

In this article, we’re going to fix that for good. We’ll replace HTML templates with Templ, a library that turns templates into compiled, type checked Go code.

By the end, our project will have:

  • A build system (Task) to orchestrate code generation, CSS compilation, and hot reload.
  • A verb-noun parser so the player types commands instead of clicking buttons.
  • A retro terminal UI built on Tailwind CSS with custom theming.

Part 1: Understanding Templ

Before we change any code, let’s understand what we’re adopting and why.

What Is Templ?

Templ is a templating language for Go that compiles .templ files into plain Go code. You write something that looks like HTML with Go expressions, and the templ generate command turns it into .go files containing functions that render HTML to an io.Writer.

Here’s the critical difference from html/template:

Before - HTML/Template: Error found at runtime

<!-- templates/room.html -->
{{define "room"}}
  <h1>{{.Room.Label}}</h1>  <!-- Typo! Field is .Name, not .Label -->
{{end}}

This compiles. This starts. This crashes when a user visits the page.

After - Templ: Error found at compile time

// internal/ui/game/room.templ
templ Room(data RoomPageData) {
    <h1>{ data.Room.Label }</h1>  // Compiler error: Room has no field Label
}

This won’t compile. You fix it before anyone sees it.

How Templ Works

A .templ file looks like a hybrid of Go and HTML. Here’s a minimal example:

package greeting

templ Hello(name string) {
    <div class="greeting">
        <h1>Hello, { name }!</h1>
    </div>
}

When you run templ generate, this produces a hello_templ.go file in the same package. That generated file contains a function with this signature:

func Hello(name string) templ.Component

A templ.Component implements io.WriterTo, so you can render it anywhere into an http.ResponseWriter, a buffer, a file. In an HTTP handler, rendering looks like this:

func handleGreeting(w http.ResponseWriter, r *http.Request) {
    component := greeting.Hello("Adventurer")
    component.Render(r.Context(), w)
}

That’s it. No string lookups that can silently fail.

Key Templ Syntax

Here are the patterns you’ll see throughout this article:

Go expressions use single braces and not double like html/template:

<span>{ item.Name }</span>

Conditionals use Go’s if:

if len(items) == 0 {
    <p>Nothing here.</p>
}

Loops use Go’s for:

for _, item := range items {
    <li>{ item.Name }</li>
}

Children like React’s props.children use { children... }:

templ Layout(title string) {
    <html>
        <head><title>{ title }</title></head>
        <body>{ children... }</body>
    </html>
}

Composition uses @ to embed one component in another:

templ Page() {
    @Layout("My Page") {
        <h1>Content goes here</h1>
    }
}

Dynamic attributes use templ.Attributes:

templ Button(attrs templ.Attributes) {
    <button { attrs... }>Click me</button>
}

Part 2: Setting Up the Build System

Our project is about to get more moving parts. Templ needs to compile .templ files into Go, Tailwind needs to compile CSS, and we want hot reload during development. We need a proper build system.

We’ll use Task also known as go-task. Unlike make, which can be cryptic and platform specific, Task uses a simple YAML file and handles dependencies and parallel execution.

Install the Tools

# Install Task (the build runner)
go install github.com/go-task/task/v3/cmd/task@latest

# Install Templ (template compiler)
go install github.com/a-h/templ/cmd/templ@latest

Create the Taskfile

Create Taskfile.yml in your project root. A Taskfile is YAML file that defines named tasks, like a Makefile, but easier to read. Each task has a description, optional dependencies, and a list of shell commands to run.

Let’s build it up piece by piece so you understand what each part does.

The header and shared variables

version: '3'

vars:
  TAILWIND_VERSION: v4.1.4

The version field tells Task which schema to use. The vars block defines variables you can reference anywhere in the file with {{.VAR_NAME}}. We pin the Tailwind version here so upgrading later means changing exactly one line.

The setup task

tasks:
  setup:
    desc: Download the standalone Tailwind CLI binary
    cmds:
      - |
        {{if eq OS "darwin"}}
          curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/{{.TAILWIND_VERSION}}/tailwindcss-macos-arm64
          chmod +x tailwindcss-macos-arm64
          mv tailwindcss-macos-arm64 tailwindcss
        {{else if eq OS "linux"}}
          curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/{{.TAILWIND_VERSION}}/tailwindcss-linux-x64
          chmod +x tailwindcss-linux-x64
          mv tailwindcss-linux-x64 tailwindcss
        {{else}}
          powershell -Command "Invoke-WebRequest -Uri 'https://github.com/tailwindlabs/tailwindcss/releases/download/{{.TAILWIND_VERSION}}/tailwindcss-windows-x64.exe' -OutFile 'tailwindcss.exe'"
        {{end}}

Tailwind v4 ships as a self-contained binary. The setup task downloads the right binary for your operating system using Task’s built-in OS variable. The {{if eq OS "darwin"}} block is Go template syntax that Task evaluates before running the shell commands. On macOS it fetches the ARM binary, on Linux the x64 binary, and on Windows it uses PowerShell. After downloading, the binary is renamed to just tailwindcss so every other task can call it the same way regardless of platform.

The generate task

generate:
    desc: Compile .templ files into Go code
    cmds:
      - templ generate

This runs the Templ compiler across your project, turning every .templ file into a corresponding _templ.go file. The generated files are plain Go and get compiled along with the rest of your code. You must run this whenever you change a .templ file before go build will see your changes.

The css task

css:
    desc: Build CSS with Tailwind
    cmds:
      - ./tailwindcss -i assets/css/input.css -o assets/css/output.css

This invokes the Tailwind binary, telling it to read assets/css/input.css as its entry point and write the compiled output to assets/css/output.css. Tailwind scans your project for class names and only includes the CSS you actually use, so this step needs to run whenever your templates change.

The build task

build:
    desc: Full build (generate + css + compile)
    deps: [generate, css]
    cmds:
      - go build -o ./tmp/main ./cmd/adventure

The deps field is what Task uses to define dependencies. Listing generate and css as dependencies means Task runs both of those tasks first, in parallel, before executing go build. This is the task you’d use in CI or before shipping. The order is always: Templ compilation -> CSS compilation -> Go compilation.

The dev task

dev:
    desc: Start development with hot reload
    deps: [generate, css]
    cmds:
      - |
        trap 'kill $(jobs -p)' EXIT
        templ generate --watch &
        ./tailwindcss -i assets/css/input.css -o assets/css/output.css --watch &
        air

This is your inner development loop. It starts three processes:

  • templ generate --watch monitors .templ files and recompiles them as you save.
  • tailwindcss --watch monitors your templates and rebuilds the CSS whenever class names change.
  • air monitors your Go files and restarts the server on changes.

The & after each of the first two commands runs them in the background. The trap 'kill $(jobs -p)' EXIT line is a shell cleanup hook: when you press Ctrl+C to stop air, it also terminates the two background watchers so they don’t linger as orphan processes.

The deps: [generate, css] ensures both tools have run at least once before any watchers start. This avoids the race where Air restarts the server before the initial Templ or CSS output exists.

The complete Taskfile

Here it is assembled:

version: '3'

vars:
  TAILWIND_VERSION: v4.1.4

tasks:
  setup:
    desc: Download the standalone Tailwind CLI binary
    cmds:
      - |
        {{if eq OS "darwin"}}
          curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/{{.TAILWIND_VERSION}}/tailwindcss-macos-arm64
          chmod +x tailwindcss-macos-arm64
          mv tailwindcss-macos-arm64 tailwindcss
        {{else if eq OS "linux"}}
          curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/{{.TAILWIND_VERSION}}/tailwindcss-linux-x64
          chmod +x tailwindcss-linux-x64
          mv tailwindcss-linux-x64 tailwindcss
        {{else}}
          powershell -Command "Invoke-WebRequest -Uri 'https://github.com/tailwindlabs/tailwindcss/releases/download/{{.TAILWIND_VERSION}}/tailwindcss-windows-x64.exe' -OutFile 'tailwindcss.exe'"
        {{end}}

  generate:
    desc: Compile .templ files into Go code
    cmds:
      - templ generate

  css:
    desc: Build CSS with Tailwind
    cmds:
      - ./tailwindcss -i assets/css/input.css -o assets/css/output.css

  build:
    desc: Full build (generate + css + compile)
    deps: [generate, css]
    cmds:
      - go build -o ./tmp/main ./cmd/adventure

  dev:
    desc: Start development with hot reload
    deps: [generate, css]
    cmds:
      - |
        trap 'kill $(jobs -p)' EXIT
        templ generate --watch &
        ./tailwindcss -i assets/css/input.css -o assets/css/output.css --watch &
        air

Run the setup task to download the Tailwind binary:

task setup

Note: Add tailwindcss and tailwindcss.exe to your .gitignore.

Update .air.toml

Air needs to know about our new file types. Update the include_ext line in .air.toml:

include_ext = ["go", "templ"]

And add _templ.go to the exclude list so Air doesn’t loop on generated files:

exclude_regex = ["_test.go", "_templ\\.go"]

Create the CSS Directory

mkdir -p assets/css

Create assets/css/input.css with a minimal Tailwind import for now. We’ll flesh out the theme later:

@import "tailwindcss";

Add Dependencies

go get github.com/a-h/templ

Verify the Pipeline

At this point, you should be able to run:

task generate   # Should succeed with no .templ files yet (or warn about none found)
task css         # Should produce assets/css/output.css

The build pipeline is ready. Now let’s put something through it.

Part 3: The Verb-Noun Parser

Right now, InterpreterCommand function in commands.go does flat switch on the raw input string. If the player types look, it works. If they type Look or examine, nothing happens. And there’s no concept of a target. You can’t say take Lamp because the function doesn’t separate the verb “take” from the noun “lamp”.

Classic text adventure from the 1980s solved this problem with a verb-noun parser. Split the player’s input into what to do (the verb) and object (the noun), then normalize synonyms so that grab, get, take, and pick up all mean the same thing.

We’ll build this in three pieces, the parser itself, the rewritten command interpreter, and a small state change to track side effects for the UI.

The Action Type

Create a new file internal/game/parser.go. Start with the data structure that the parser will produce:

package game

import "strings"

// Action represents a parsed player command.
type Action struct {
	Verb string
	Noun string
}

Every command that player types, no matter how messy, will be reduced to this clean pair. pick up the rusty key becomes Action{Verb: "take", Noun: "rusty key"}. n becomes Action{Verb: "go", Noun: "north"}.

Parsing the Input

Now add the ParseCommand function to the same file. The first job is simple: lowercase everything and split on whitespace.

func ParseCommand(input string) Action {
	parts := strings.Fields(strings.ToLower(input))
	if len(parts) == 0 {
		return Action{}
	}

	verb := parts[0]
	noun := ""
	if len(parts) > 1 {
		noun = strings.Join(parts[1:], " ")
	}

strings.Fields is better than strings.Split here because it handles multiple spaces and leading/trailing whitespace. The player typing " go north " gets the same result as "go north".

The first word is always the verb. Everything after it is the noun, joined back into a single string. This means take rusty key produces noun "rusty key", which is what we want. Item names can be multiple words.

Stripping Articles

Players will naturally type take the lamp or “look at a door`. The articles add no information, so strip them:

noun = stripArticles(noun)

Add this helper at the bottom of the file:

func stripArticles(s string) string {
	for _, article := range []string{"the ", "a ", "an "} {
		s = strings.TrimPrefix(s, article)
	}
	return s
}

Now take the rusty key produces non rusty key instead of the rusty key, which will match out item names correctly.

Synonym Normalization

This is the heart of the parser. Players will use different words to mean the same action. We map them all to a canonical verb:

switch verb {
	case "walk", "move", "run", "travel":
		verb = "go"
	case "n", "s", "e", "w":
		// Single-letter cardinal directions: "n" → go north
		noun = expandDirection(verb)
		verb = "go"
	case "get", "grab", "pickup":
		verb = "take"
	case "discard", "throw":
		verb = "drop"
	case "i", "bag", "backpack":
		verb = "inventory"
	case "l":
		verb = "look"
	case "wear", "wield":
		verb = "equip"
	case "remove":
		verb = "unequip"
	case "?":
		verb = "help"
	}

	return Action{Verb: verb, Noun: noun}
}

The single letter directions deserve special attention. When the player types n, there’s no noun, the letter is the direction. So we move it from the verb to noun and set the verb to go. The expandDirection helper handles the conversion:

func expandDirection(shorthand string) string {
	switch shorthand {
	case "n":
		return "north"
	case "s":
		return "south"
	case "e":
		return "east"
	case "w":
		return "west"
	default:
		return shorthand
	}
}

That’s the complete parser. It’s deliberately simple. Just split, normalize, done. Here is the full parser.go code:

package game

import "strings"

// Action represents a parsed player command.
type Action struct {
	Verb string
	Noun string
}

// ParseCommand normalizes raw input into a verb-noun pair.
// "pick up the sword" → Action{"take", "sword"}
// "n"                 → Action{"go", "n"}
func ParseCommand(input string) Action {
	parts := strings.Fields(strings.ToLower(input))
	if len(parts) == 0 {
		return Action{}
	}

	verb := parts[0]
	noun := ""
	if len(parts) > 1 {
		noun = strings.Join(parts[1:], " ")
	}

	// Strip articles for more natural input
	noun = stripArticles(noun)

	// Normalize synonyms to canonical verbs
	switch verb {
	case "walk", "move", "run", "travel":
		verb = "go"
	case "n", "s", "e", "w":
		// Single-letter directions: "n" → go north
		noun = expandDirection(verb)
		verb = "go"
	case "get", "grab", "pickup":
		verb = "take"
	case "discard", "throw":
		verb = "drop"
	case "i", "bag", "backpack":
		verb = "inventory"
	case "l":
		verb = "look"
	case "wear", "wield":
		verb = "equip"
	case "remove":
		verb = "unequip"
	case "?":
		verb = "help"
	}

	return Action{Verb: verb, Noun: noun}
}

func stripArticles(s string) string {
	for _, article := range []string{"the ", "a ", "an "} {
		s = strings.TrimPrefix(s, article)
	}
	return s
}

func expandDirection(shorthand string) string {
	switch shorthand {
	case "n":
		return "north"
	case "s":
		return "south"
	case "e":
		return "east"
	case "w":
		return "west"
	default:
		return shorthand
	}
}

Rewriting the Command Interpreter

Now we need to use the parser. Open internal/game/commands.go and replace its entire contents. The new version parses first, then dispatches to dedicated handler functions.

Start with the main dispatch function:

package game

import (
	"fmt"
	"strings"
)

func InterpretCommand(s *State, rooms map[string]Room, items map[string]Item, raw string) (string, string) {
	cmd := strings.TrimSpace(raw)
	if cmd == "" {
		return "You open your mouth... and say nothing. (Try typing a command.)", "error"
	}

	action := ParseCommand(cmd)

	switch action.Verb {
	case "look":
		return handleLook(s, rooms), "system"
	case "go":
		return handleGo(s, rooms, action.Noun)
	case "take":
		return handleTake(s, items, action.Noun)
	case "drop":
		return handleDrop(s, action.Noun)
	case "inventory":
		return handleInventory(s), "system"
	case "equip":
		return handleEquip(s, action.Noun)
	case "unequip":
		return handleEquip(s, action.Noun) // same toggle logic
	case "wait":
		return "You wait. Somewhere, a pipe ticks.", "system"
	case "help":
		return "Commands: go [direction], take [item], drop [item], equip [item], look, inventory, wait", "system"
	default:
		return fmt.Sprintf("I don't know how to '%s'.", action.Verb), "error"
	}
}

Notice the main change. The old InterpreterCommand took rooms and roomID separately. The new one takes *State directly (because it needs to modify state for movement) and also takes the items map (for looking up items by name). This will require an update in out HTTP handler, which we’ll do in Part 6.

Now let’s build each handler. They’re small functions, each responsible for one verb.

Look is the simplest It just returns the current room’s description:

func handleLook(s *State, rooms map[string]Room) string {
	s.mu.Lock()
	roomID := s.RoomID
	s.mu.Unlock()

	room, ok := rooms[roomID]
	if !ok {
		return "You look around, but reality fails to load."
	}
	return room.Description
}

Go is more involved. It needs to validate the direction, find the target room, update the player’s position, and return a description of the new room:

func handleGo(s *State, rooms map[string]Room, direction string) (string, string) {
	if direction == "" {
		return "Go where? Try: go north, go south, go east, go west", "error"
	}

	s.mu.Lock()
	room, ok := rooms[s.RoomID]
	s.mu.Unlock()

	if !ok {
		return "You can't move from a room that doesn't exist.", "error"
	}

	targetID, exists := room.Exits[direction]
	if !exists {
		return fmt.Sprintf("There is no exit to the %s.", direction), "error"
	}

	targetRoom, ok := rooms[targetID]
	if !ok {
		return "That exit leads somewhere that doesn't exist. Spooky.", "error"
	}

	s.SetRoom(targetID)
	s.MarkVisited(targetID)
	return fmt.Sprintf("You head %s.\n\n%s\n%s", direction, targetRoom.Name, targetRoom.Description), "system"
}

Take needs to find an item by name in the current room. Players will type the item’s display name “rusty key”, not its internal ID “key”, so we do a case-insensitive search across both:

func handleTake(s *State, items map[string]Item, noun string) (string, string) {
	if noun == "" {
		return "Take what?", "error"
	}

	// Search the current room's items by name or ID
	s.mu.Lock()
	roomInv := s.RoomItems[s.RoomID]
	var matchID string
	for id, it := range roomInv {
		if strings.EqualFold(it.Name, noun) || strings.EqualFold(id, noun) {
			matchID = id
			break
		}
	}
	s.mu.Unlock()

	if matchID == "" {
		return fmt.Sprintf("You don't see '%s' here.", noun), "error"
	}

	it, ok := s.Take(matchID)
	if !ok {
		return "You reach for it, but your hand closes on air.", "error"
	}

	return fmt.Sprintf("You take the %s.", it.Name), "system"
}

Drop is the mirror image of take, except we search the inventory instead of the room:

func handleDrop(s *State, noun string) (string, string) {
	if noun == "" {
		return "Drop what?", "error"
	}

	s.mu.Lock()
	var matchID string
	for id, it := range s.Inventory {
		if strings.EqualFold(it.Name, noun) || strings.EqualFold(id, noun) {
			matchID = id
			break
		}
	}
	s.mu.Unlock()

	if matchID == "" {
		return fmt.Sprintf("You don't have '%s'.", noun), "error"
	}

	it, ok := s.Drop(matchID)
	if !ok {
		return "You can't drop that.", "error"
	}

	return fmt.Sprintf("You drop the %s.", it.Name), "system"
}

Inventory lists what the player is carrying:

func handleInventory(s *State) string {
	snap := s.Snapshot()
	if len(snap.Inventory) == 0 {
		return "Your backpack is empty."
	}

	var sb strings.Builder
	sb.WriteString("You are carrying:\n")
	for _, it := range snap.Inventory {
		sb.WriteString(fmt.Sprintf("  - %s\n", it.Name))
	}
	return sb.String()
}

Equip toggles equipment using the ToggleEquip method we built in article 8:

func handleEquip(s *State, noun string) (string, string) {
	if noun == "" {
		return "Equip what?", "error"
	}

	s.mu.Lock()
	var matchID string
	for id, it := range s.Inventory {
		if strings.EqualFold(it.Name, noun) || strings.EqualFold(id, noun) {
			matchID = id
			break
		}
	}
	s.mu.Unlock()

	if matchID == "" {
		return fmt.Sprintf("You don't have '%s'.", noun), "error"
	}

	res, err := s.ToggleEquip(matchID)
	if err != nil {
		return "You fumble with your gear. (" + err.Error() + ")", "error"
	}

	if res.Equipped {
		msg := fmt.Sprintf("You equip the %s.", res.Item.Name)
		if res.ReplacedOK {
			msg = fmt.Sprintf("You equip the %s, putting away the %s.", res.Item.Name, res.Replaced.Name)
		}
		return msg, "system"
	}

	return fmt.Sprintf("You unequip the %s.", res.Item.Name), "system"
}

Tracking Side Effects for the UI

Our HTMX frontend needs to know when the inventory changes so it can update the sidebar. In article 9, this was implicit. Each button had its own endpoint. Now that everything goes through /command, the handler needs a signal.

Open internal/game/state.go and add two flags to the State struct:

type State struct {
	mu sync.Mutex

	RoomID  string
	Visited map[string]bool
	Turn    int
	Log     []LogEntry

	Inventory map[string]Item
	RoomItems map[string]map[string]Item

	WeaponID  string
	OffhandID string

	Goblin Monster

	// add these
	InventoryChanged bool
	RoomChanged      bool
}

Now update the Take method to set the flag when it succeeds. Find the line s.Inventory[itemID] = it and add s.InventoryChanged = true:

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
	s.InventoryChanged = true
	return it, true
}

Do the same in Drop, after the item is moved from inventory to the room floor, add:

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
	}

	if s.WeaponID == itemID {
		s.WeaponID = ""
	}
	if s.OffhandID == itemID {
		s.OffhandID = ""
	}

	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
	s.InventoryChanged = true
	return it, true
}

Finally, add method to reset the flags. The HTTP handler will call this at the start of each command:

func (s *State) ResetFlags() {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.InventoryChanged = false
	s.RoomChanged = false
}

Checkpoint

The parser and command system are self contained Go code with no template dependencies. Verify everything compiles:

go build ./internal/game/...

This should succeed. If you try go build you’ll see errors in handlers.go because it still calls the old InterpretCommand signature. We’ll fix the handlers in Part 6 after we build the Templ components.

Part 4: The Design System

Now we set up our UI.

Define the Theme

Open assets/css/input.css and replace its contents:

@import "tailwindcss";

@theme {
    /* Terminal palette */
    --color-terminal-bg: #0d0d0d;
    --color-terminal-text: #33ff00;
    --color-terminal-highlight: #88ff88;
    --color-terminal-border: #333;
    --color-terminal-dim: #666;

    /* Animation */
    --animate-fade-in: fadeIn 0.5s ease-in-out;

    @keyframes fadeIn {
        from { opacity: 0; }
        to { opacity: 1; }
    }
}

/* Retro scrollbar */
::-webkit-scrollbar {
    width: 8px;
    background: var(--color-terminal-bg);
}
::-webkit-scrollbar-thumb {
    background: var(--color-terminal-border);
    border-radius: 4px;
}

The @theme directive is Tailwind v4’s way of defining design tokens. Once defined, these become utility classes automatically: bg-terminal-bg, text-terminal-text, border-terminal-border, and so on. Every component we build will reference these classes, so changing the palette means changing it in one place.

Create a Utility Helper

Create internal/utils/utils.go. This is a tiny helper for combining Tailwind classes conditionally in Templ files, similar to classnames in the React world:

package utils

import "strings"

func TwMerge(classes ...string) string {
	return strings.Join(classes, " ")
}

func If(condition bool, trueVal string) string {
	if condition {
		return trueVal
	}
	return ""
}

Serve Static Assets

Make sure your router server the CSS file. We’ll add this route in the next part but for now note that we need something like:

mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("assets"))))

Build the CSS

task css

Check that assets/css/output.css was created. It should contain Tailwind’s base styles plus your custom theme.

Part 5: Migrating to Templ

This is the big migration. We’ll replace our HTML templates with Templ components, update the server, and clean up the old files. Let’s do it methodically.

Project Structure

Here’s what our directory tree will look like after the migration. New files are marked with (new):

adv-htmx/
├── assets/
│   └── css/
│       ├── input.css              (new)
│       └── output.css             (generated)
├── cmd/adventure/
│   └── main.go                    (modified)
├── internal/
│   ├── components/input/
│   │   └── input.templ            (new)
│   ├── game/
│   │   ├── commands.go            (rewritten)
│   │   ├── model.go               (unchanged)
│   │   ├── parser.go              (new)
│   │   ├── session.go             (unchanged)
│   │   ├── spells.go              (unchanged)
│   │   └── state.go               (modified)
│   ├── ui/
│   │   ├── game/
│   │   │   ├── types.go           (new)
│   │   │   ├── room.templ         (new)
│   │   │   ├── inventory.templ    (new)
│   │   │   └── log_entry.templ    (new)
│   │   └── layout/
│   │       └── base.templ         (new)
│   ├── utils/
│   │   └── utils.go               (new)
│   └── web/
│       ├── handlers.go            (rewritten)
│       ├── htmx.go                (unchanged)
│       └── map.go                 (unchanged)
├── templates/                     ← WILL BE DELETED
│   ├── room.html
│   ├── log_entry.html
│   ├── log_entry_oob.html
│   └── partials.html
├── Taskfile.yml                   (new)
├── world.yaml                     (unchanged)
└── go.mod                         (modified)

Files to delete after migration:

  • templates/room.html
  • templates/log_entry.html
  • templates/log_entry_oob.html
  • templates/partials.html
  • internal/web/templates.go

The Layout Component

Create the directory and file internal/ui/layout/base.templ. This replaces the <html> boilerplate that was at the top of room.html:

package layout

templ Base(title string) {
	<!DOCTYPE html>
	<html lang="en" class="dark">
		<head>
			<meta charset="UTF-8"/>
			<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
			<title>{ title }</title>
			<link rel="stylesheet" href="/assets/css/output.css"/>
			<script src="https://unpkg.com/htmx.org@2.0.8"></script>
		</head>
		<body hx-boost="true" class="bg-terminal-bg text-terminal-text font-mono min-h-screen">
			{ children... }
		<script>
		document.addEventListener('htmx:afterSettle', function() {
			var log = document.getElementById('log');
			if (log) log.scrollTop = log.scrollHeight;
		});
		</script>
		</body>
	</html>
}

Notice { children... }. This is Templ’s slot mechanism. Any component that uses @layout.Base("title") { ... } will have its contents injected here. This gives us a single place to manage the HTML head, CSS link, and HTMX script.

The Input Component

Create internal/components/input/input.templ. This is a reusable text input styled for our terminal:

package input

import "github.com/<your-name>/adv-htmx/internal/utils"

type Props struct {
	Name        string
	Placeholder string
	Autofocus   bool
	Class       string
	Attributes  templ.Attributes
}

templ Input(props Props) {
	<input
		type="text"
		name={ props.Name }
		placeholder={ props.Placeholder }
		if props.Autofocus {
			autofocus
		}
		class={
			utils.TwMerge(
				"flex h-9 w-full rounded-md border border-terminal-border",
				"bg-transparent px-3 py-1 text-base shadow-sm transition-colors",
				"placeholder:text-terminal-dim focus-visible:outline-none",
				"focus-visible:ring-1 focus-visible:ring-terminal-highlight",
				props.Class,
			),
		}
		{ props.Attributes... }
	/>
}

Game UI Types

Create internal/ui/game/types.go. This defines exactly what data each view needs:

package game

import gamepkg "github.com/<your-name>/adv-htmx/internal/game"

// RoomPageData is everything the full room page needs to render.
type RoomPageData struct {
	Room      gamepkg.Room
	Log       []gamepkg.LogEntry
	Inventory []gamepkg.Item
	ItemsHere []gamepkg.Item

	Weapon    gamepkg.Item
	WeaponOK  bool
	Offhand   gamepkg.Item
	OffhandOK bool

	OOB bool
}

// ListPartialData is used for OOB (out-of-band) partial updates.
type ListPartialData struct {
	OOB       bool
	Inventory []gamepkg.Item
	ItemsHere []gamepkg.Item
	Weapon    gamepkg.Item
	WeaponOK  bool
	Offhand   gamepkg.Item
	OffhandOK bool
}

The Log Entry Component

Create internal/ui/game/log_entry.templ. Like any regular Go file, we start with our package declaration and imports:

package game

import (
	gamepkg "github.com/<your-name>/adv-htmx/internal/game"
	"github.com/<your-name>/adv-htmx/internal/utils"
)

Now for the component itself. Instead of func, we declare it using the templ keyword, give it strongly typed Go arguments, and write HTML directly inside:

templ LogEntry(entry gamepkg.LogEntry) {
	<div class={
		"mb-4 animate-fade-in",
		utils.If(entry.Kind == "error", "text-red-500"),
		utils.If(entry.Kind == "system", "text-blue-300"),
	}>
		if entry.Command != "" {
			<div class="text-terminal-dim text-sm">&gt; { entry.Command }</div>
		}
		<div>{ entry.Output }</div>
	</div>
}

Under the hood, the compiler turns this into a standard Go function that returns a templ.Component, which we’ll eventually render from handlers using gameui.LogEntry(entry).Render(ctx, w).

Looking at the HTML body, you can see exactly how Templ blends Go with markup.

Look at the class={} syntax on the outer <div>. By passing multiple string arguments, Templ joins them together while automatically skipping empty strings. This is where our utils.If helper comes in handy. If the entry is an error, it injects “text-red-500”. If it’s a system message, it gets “text-blue-300”. Otherwise, it renders neither, falling back to our terminal’s base green.

We use Go’s standard if directly inside the HTML, no clunky {{if}} / {{end}} pairs required. We only render the > prompt line if there is actually a command to show. For example, the initial system message “You awaken in a place that smells of dust…” lacks a command, so the prompt is entirely skipped.

Data binding is handled with single curly braces. This is where Templ’s type safety comes in. In Templ, an expression like { entry.Output } is validated against the LogEntry struct at compile time. If you rename the field to Text in your model, the build instantly fails.

These patterns, typed arguments, dynamic class={}, embedded if blocks, and { expression } binding, are the exact same tools we’ll use in every component. The inventory list will introduce for loops, and the room page will add composition with @, but the core building blocks are all right here.

The Inventory Component

Create internal/ui/game/inventory.templ:

package game

templ InventoryList(data ListPartialData) {
	<ul
		id="inventory-list"
		if data.OOB {
			hx-swap-oob="true"
		}
	>
		for _, item := range data.Inventory {
			<li class="text-terminal-dim border-b border-terminal-dim/20 py-1">
				{ item.Name }
			</li>
		}
		if len(data.Inventory) == 0 {
			<li class="text-terminal-dim/50 italic">(empty)</li>
		}
	</ul>
}

templ RoomItemsList(data ListPartialData) {
	<ul
		id="room-items-list"
		if data.OOB {
			hx-swap-oob="true"
		}
	>
		for _, item := range data.ItemsHere {
			<li class="text-terminal-dim border-b border-terminal-dim/20 py-1">
				{ item.Name }
			</li>
		}
		if len(data.ItemsHere) == 0 {
			<li class="text-terminal-dim/50 italic">(nothing here)</li>
		}
	</ul>
}

The hx-swap-oob="true" attribute is the HTMX magic we used before and it tells HTMX to find the element with the matching id and replace it, even though this HTML fragment was sent alongside a different response. We use it to update the sidebar when the player takes or drops an item via the command bar.

The Room Page

Create internal/ui/game/room.templ. This is the main page and replaces templates/room.html entirely:

package game

import (
	"github.com/<your-name>/adv-htmx/internal/components/input"
	"github.com/<your-name>/adv-htmx/internal/ui/layout"
)

templ Room(data RoomPageData) {
	@layout.Base("Adventure Terminal") {
		<div class="grid grid-cols-[250px_1fr_250px] h-screen gap-[2px] overflow-hidden">

			// --- LEFT SIDEBAR: MAP ---
			<aside class="border-r border-terminal-border p-4 bg-black overflow-y-auto">
				<h3 class="border-b border-dashed border-terminal-border pb-1 mb-4 text-terminal-highlight">
					MAP
				</h3>
				<div
					id="dungeon-map"
					hx-get="/map"
					hx-trigger="load, every 2s"
					hx-swap="innerHTML"
				>
					Loading map...
				</div>
			</aside>

			// --- CENTER: LOG & COMMAND BAR ---
			<main class="flex flex-col bg-[#050505] h-full min-h-0">
				<div
					id="log"
					class="flex-1 overflow-y-auto p-8 text-lg leading-relaxed"
				>
					for _, entry := range data.Log {
						@LogEntry(entry)
					}
				</div>
				@CommandBar()
			</main>

			// --- RIGHT SIDEBAR: INVENTORY ---
			<aside class="border-l border-terminal-border p-4 bg-black overflow-y-auto">
				<h3 class="border-b border-dashed border-terminal-border pb-1 mb-4 text-terminal-highlight">
					BACKPACK
				</h3>
				@InventoryList(ListPartialData{
					OOB:       false,
					Inventory: data.Inventory,
				})

				<h3 class="border-b border-dashed border-terminal-border pb-1 mb-4 mt-6 text-terminal-highlight">
					ON THE GROUND
				</h3>
				@RoomItemsList(ListPartialData{
					OOB:       false,
					ItemsHere: data.ItemsHere,
				})
			</aside>
		</div>
	}
}

templ CommandBar() {
	<form
		class="p-4 border-t border-terminal-text bg-black flex gap-2 items-center"
		hx-post="/command"
		hx-target="#log"
		hx-swap="beforeend scroll:bottom"
		hx-on::after-request="this.reset()"
	>
		<span class="text-terminal-text">&gt;</span>
		@input.Input(input.Props{
			Name:        "cmd",
			Placeholder: "Enter command...",
			Autofocus:   true,
			Class:       "flex-1 bg-transparent border-none text-terminal-text placeholder:text-terminal-dim focus-visible:ring-0",
			Attributes:  templ.Attributes{"autocomplete": "off"},
		})
	</form>
}

Let’s look at the four major concepts introduced in this file.

Component Composition with @

Look at the very top of the Room component: @layout.Base("Adventure Terminal") { ... }. In Templ, the @ symbol is how you invoke one component from another. When a component is designed to wrap other content, like a base HTML layout wrapping the page body, you open a block { and drop your HTML directly inside.

Standard Go for Loops

In the center column, we render the game log:

for _, entry := range data.Log {
    @LogEntry(entry)
}

If you’re coming from html/template, you might be looking for {{range .Log}}. Templ throws that out entirely. You just write a standard Go for loop directly in the markup, and call our @LogEntry component for each iteration.

Passing Structs as Props

Look at the right sidebar where we render the backpack:

@InventoryList(ListPartialData{
    OOB:       false,
    Inventory: data.Inventory,
})

Instead of passing a generic map or relying on a loose template context, we are passing a concrete Go struct ListPartialData straight into the component.

templ.Attributes for Flexibility

Down in the CommandBar component, we use a shared @input.Input component. But what if we need to pass standard HTML attributes that aren’t explicitly defined in our input.Props struct like autocomplete="off"?

Attributes:  templ.Attributes{"autocomplete": "off"},

templ.Attributes is just a type alias for map[string]any. It allows you to spread arbitrary HTML attributes onto and element.

In summary, this new code structure is identical to the old room.html, a three column grid holding the map, log, inventory. But by migrating to Templ, we’ve replaced brittle string based templates with a fully type-safe, composable UI architecture.

Generate the Go Code

Run the Templ compiler:

task generate

This creates _templ.go files alongside each .templ file. These are the actual Go functions that render HTML. You should see files like:

internal/ui/layout/base_templ.go
internal/ui/game/room_templ.go
internal/ui/game/log_entry_templ.go
internal/ui/game/inventory_templ.go
internal/components/input/input_templ.go

Note: Don’t edit the _templ.go files. They are generated and will be overwritten. Always edit the .templ source files.

Part 6: Updating the Server

Now let’s rewire the HTTP handlers to use our new Templ components and the update command interpreter.

Delete the Old Template System

Remove internal/web/templates.go entirely. We no longer need it.

rm internal/web/templates.go

Update the Server Struct

Open internal/web/handlers.go. Remove the Tmpl field and add the Items map:

type Server struct {
	Rooms    map[string]game.Room
	Items    map[string]game.Item
	Sessions *game.SessionStore
}

Update the Routes

Add the static file server and simplify the routes. Since we’re moving to a command bar driven UI, we can remove several button based endpoints (take, drop, equip by button click). The command parser handles all of those now.

func (s *Server) Routes() http.Handler {
	mux := http.NewServeMux()

	// Pages
	mux.HandleFunc("GET /", s.handleIndex)
	mux.HandleFunc("GET /room/{id}", s.handleRoom)

	// Commands (the parser handles take, drop, equip, etc.)
	mux.HandleFunc("POST /command", s.handleCommand)

	// Map
	mux.HandleFunc("GET /map", s.handleMap)

	// Static assets
	mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("assets"))))

	return mux
}

Rewrite handleRoom

Add the import for the UI package. We alias it gameui because the package name game would collide with internal/game:

import (
	"fmt"
	"net/http"

	"github.com/<your-name>/adv-htmx/internal/game"
	gameui "github.com/<your-name>/adv-htmx/internal/ui/game"
)

Now replace handleRoom. The first half, looking up the room, getting the session, taking a snapshot, is unchanged. What changes is how we turn that data into HTML:

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)
	state.MarkVisited(roomID)
	snap := state.Snapshot()

	w.Header().Set("Content-Type", "text/html; charset=utf-8")

	data := gameui.RoomPageData{
		Room:      room,
		Log:       snap.Log,
		Inventory: snap.Inventory,
		ItemsHere: snap.ItemsHere,
		Weapon:    snap.Weapon,
		WeaponOK:  snap.WeaponOK,
		Offhand:   snap.Offhand,
		OffhandOK: snap.OffhandOK,
	}

	_ = gameui.Room(data).Render(r.Context(), w)
}

The old version called s.Tmpl.ExecuteTemplate(w, "room.html", data). The new version calls gameui.Room(data), which returns a templ.Component, and then .Render(r.Context(), w) writes the HTML into the response. A templ.Component works with any io.Writer, so it slots naturally into Go’s HTTP handler pattern.

Notice that Render takes context.Context as its first argument. This is how Templ supports request scoped values, middleware injected auth data, request IDs, cancellation signals. We pass r.Context() to thread the HTTP request’s context straight through to the template. We won’t use context values in this article, but the plumbing is there when you need it.

Rewrite handleCommand

The command handler has more moving parts. It needs to render one, two, or three Templ components into the same response, depending on what the player did.

Let’s walk through the new version:

func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request) {
	state := s.Sessions.Get(w, r)
	state.ResetFlags()

	cmd := r.FormValue("cmd")
	output, kind := game.InterpretCommand(state, s.Rooms, s.Items, cmd)
	entry := state.Append(cmd, output, kind)

Nothing new here, we reset the UI flags from Part 3, run the command through the parser, and record the result.

Now we need to send HTML back.

The command bar in room.templ has hx-target="#log" and hx-swap="beforeend", so HTMX will take whatever HTML we return and append it to the log div. For a simple command like look`, the only thing to send is the new log entry:

	if IsHTMX(r) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")

		// Render the new log entry (appended to #log via hx-swap="beforeend")
		_ = gameui.LogEntry(entry).Render(r.Context(), w)

This is the same Component.Render(ctx, w) pattern from handleRoom, but here we’re rendering a fragment, not a full page. Templ doesn’t care, a component is a component, whether it produces <!DOCTYPE html> or a single <div>.

Now the interesting part. When the player types take lamp, the inventory sidebar needs to update too. We can’t target two different elements with one hx-target, but we can include extra HTML fragments tagged for out of band swapping. Remember the OOB flag in InventoryList component? When OOB is true, it renders hx-swap-oob="true" on the <ul>, which tells HTMX: “don’t append me to the target but instead find the element with my id and replace it.”

We render these extra components into the same writer:

		// If inventory changed, send an OOB update for the sidebar
		if state.InventoryChanged {
			snap := state.Snapshot()
			_ = gameui.InventoryList(gameui.ListPartialData{
				OOB:       true,
				Inventory: snap.Inventory,
			}).Render(r.Context(), w)
			_ = gameui.RoomItemsList(gameui.ListPartialData{
				OOB:       true,
				ItemsHere: snap.ItemsHere,
			}).Render(r.Context(), w)
		}
		return
	}

So a single HTTP response might contain three HTML fragments, the log entry (targeted at #log), the inventory list (OOB swap into #inventory-list), and the room items list (OOB swap into #room-items-list). HTMX parses them all from one response body. This is the same multi fragment pattern we used earlier with ExecuteTemplate. The only difference is that each fragment is now a Templ function call instead of a named template string.

Finally, the non-JS fallback redirects back to the full room page:

	// Fallback for non-JS browsers
	snap := state.Snapshot()
	http.Redirect(w, r, "/room/"+snap.RoomID, http.StatusSeeOther)
}

Here’s the complete functions:

func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request) {
	state := s.Sessions.Get(w, r)
	state.ResetFlags()

	cmd := r.FormValue("cmd")
	output, kind := game.InterpretCommand(state, s.Rooms, s.Items, cmd)
	entry := state.Append(cmd, output, kind)

	if IsHTMX(r) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")

		_ = gameui.LogEntry(entry).Render(r.Context(), w)

		if state.InventoryChanged {
			snap := state.Snapshot()
			_ = gameui.InventoryList(gameui.ListPartialData{
				OOB:       true,
				Inventory: snap.Inventory,
			}).Render(r.Context(), w)
			_ = gameui.RoomItemsList(gameui.ListPartialData{
				OOB:       true,
				ItemsHere: snap.ItemsHere,
			}).Render(r.Context(), w)
		}
		return
	}

	snap := state.Snapshot()
	http.Redirect(w, r, "/room/"+snap.RoomID, http.StatusSeeOther)
}

Keep handleMap

The map handler doesn’t use templates. It generates SVG directly. It stays mostly the same, but update the handleMap function to use the new command signature pattern. The existing code in map.go should still work unchanged.

Update main.go

Open cmd/adventure/main.go and make these changes:

  1. Remove the templates variable and web.MustLoadTemplates() call.
  2. Pass itemMap to the server.
package main

import (
	"log"
	"net/http"
	"os"

	"github.com/<your-name>/adv-htmx/internal/game"
	"github.com/<your-name>/adv-htmx/internal/web"
	"gopkg.in/yaml.v3"
)

func main() {
	data, err := os.ReadFile("world.yaml")
	if err != nil {
		log.Fatal("Failed to read world.yaml: ", err)
	}

	var world game.World
	if err := yaml.Unmarshal(data, &world); err != nil {
		log.Fatal("Failed to parse YAML: ", err)
	}

	roomsMap := make(map[string]game.Room)
	for i := range world.Rooms {
		r := world.Rooms[i]
		roomsMap[r.ID] = r
	}

	itemsMap := make(map[string]game.Item)
	for i := range world.Items {
		item := world.Items[i]
		itemsMap[item.ID] = item
	}

	// Validate data integrity
	for _, room := range roomsMap {
		for dir, targetID := range room.Exits {
			if _, exists := roomsMap[targetID]; !exists {
				log.Fatalf("DATA ERROR: Room '%s' exit '%s' → unknown room '%s'", room.ID, dir, targetID)
			}
		}
		for _, itemID := range room.Items {
			if _, exists := itemsMap[itemID]; !exists {
				log.Fatalf("DATA ERROR: Room '%s' references unknown item '%s'", room.ID, itemID)
			}
		}
	}

	seedItems := make(map[string][]game.Item)
	for _, r := range world.Rooms {
		var roomItems []game.Item
		for _, itemID := range r.Items {
			if item, exists := itemsMap[itemID]; exists {
				roomItems = append(roomItems, item)
			}
		}
		seedItems[r.ID] = roomItems
	}

	sessions := game.NewSessionStore(seedItems)

	srv := &web.Server{
		Rooms:    roomsMap,
		Items:    itemsMap,
		Sessions: sessions,
	}

	log.Printf("Booting '%s'...", world.Title)
	log.Printf("Loaded %d rooms and %d items.", len(roomsMap), len(itemsMap))
	log.Println("Server starting on http://localhost:4040")
	log.Fatal(http.ListenAndServe(":4040", srv.Routes()))
}

Clean Up

Now delete the old template files. They are fully replaced:

rm -rf templates/
rm -f internal/web/templates.go

Part 7: Build and Run

Let’s bring it all together.

Tidy Dependencies

go mod tidy

Full Build

task build

This runs templ generate, tailwindcss, and go build in sequence. If any step fails, you’ll see a clear error. This is the moment of truth for our type safe templates. If any .templ file references a struct field that doesn’t exist, the build stops here and not in a user’s browser.

Run It

For development with hot reload:

task dev

Or

./tmp/main

Open http://localhost:4040. You should see the terminal interface with dark background, green text, and three column layout. Type look and press Enter. Type go north. Type take sword. Type inventory.

This almost works. Notice that when you go north the list of things on the ground doesn’t change! This happens simply because handleCommand only checks for InventoryChanged but not for RoomChange. To fix this, add check for state.RoomChanged in handleCommand function:

if state.InventoryChanged || state.RoomChanged {
	snap := state.Snapshot()
	_ = gameui.InventoryList(gameui.ListPartialData{
		OOB:       true,
		Inventory: snap.Inventory,
	}).Render(r.Context(), w)
	_ = gameui.RoomItemsList(gameui.ListPartialData{
		OOB:       true,
		ItemsHere: snap.ItemsHere,
	}).Render(r.Context(), w)
}

We also need to set RoomChanged in internal/game/state.go change SetRoom function to set RoomChange to true:

func (s *State) SetRoom(id string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.RoomID != id {
		s.RoomID = id
		s.RoomChanged = true
	}
}

Run again and this time the correct items will show when you are moving from room to room.

Exercises: Restoring Missing Features

While the new terminal UI and command interpreter work great, you likely noticed that several features from the previous version are missing.

This was intentional, allowing us to focus on the Templ migration without getting overwhelmed. However, this provides a perfect opportunity for you to practice on your own.

By now, you should have the foundational knowledge to restore these features and gain hands on experience with Templ and HTMX. Don’t worry, you won’t be left entirely to your own devices. Each exercise outlines the requirements and explains the high level approach. Each exercise also includes solution so you can compare your work or get back on track if you run into a hurdle.

Exercise 1: Rich look with Clickable Items and Exits

Task

In article 9, typing look or arriving in a room showed a rich description with items and exits rendered as clickable elements inline in the log text, things like [Brass Lamp] and [north] that the player could click as an alternative to typing. Our current handleLook returns a plain string, and puts room items in a sidebar panel called ON THE GROUND.

Restore the article 9 behavior: items and exits should appear as interactive elements inside the log output. The ON THE GROUND sidebar panel should be removed.

Plan

  1. Update the LogEntry component to render HTML in the output field.
  2. Write helper functions that generate clickable item and exit elements.
  3. Rewrite handleLook to build rich HTML output.
  4. Update handleGo to show the full room view on arrival.
  5. Remove the ON THE GROUND panel from the sidebar.

Step 1: Teach LogEntry to Render HTML

Right now, our LogEntry component renders the output with { entry.Output }. Templ’s single brace expressions escape HTML by default, if the output contains <button>, the player sees the literal text <button> instead of an actual button. That’s normally good security practice (you never want to render user input as raw HTML), but here the output is generated entirely by our server. We control it.

Templ provides @templ.Raw() for exactly this case. Open internal/ui/game/log_entry.templ and change the output line:

templ LogEntry(entry gamepkg.LogEntry) {
	<div class={
		"mb-4 animate-fade-in",
		utils.If(entry.Kind == "error", "text-red-500"),
		utils.If(entry.Kind == "system", "text-blue-300"),
	}>
		if entry.Command != "" {
			<div class="text-terminal-dim text-sm">&gt; { entry.Command }</div>
		}
		<div>@templ.Raw(entry.Output)</div>
	</div>
}

Notice that the command line still uses { entry.Command } with escaping, that’s the player’s raw input and should never be rendered as HTML. Only the output which we build on the server gets @templ.Raw().

Step 2: Build Clickable Element Helpers

Create a new file internal/game/render.go. These helper functions generate small HTML fragments for items and exits that the player can click:

package game

import "fmt"

// RenderItemLink returns an HTML button that posts a "take" command when clicked.
func RenderItemLink(item Item) string {
	return fmt.Sprintf(
		`<button class="text-terminal-highlight hover:underline cursor-pointer bg-transparent border-none font-mono text-base p-0" `+
			`hx-post="/command" hx-target="#log" hx-swap="beforeend scroll:bottom" `+
			`hx-vals='{"cmd":"take %s"}'>[%s]</button>`,
		item.ID, item.Name,
	)
}

// RenderExitLink returns an HTML button that posts a "go" command when clicked.
func RenderExitLink(direction string) string {
	return fmt.Sprintf(
		`<button class="text-terminal-highlight hover:underline cursor-pointer bg-transparent border-none font-mono text-base p-0" `+
			`hx-post="/command" hx-target="#log" hx-swap="beforeend scroll:bottom" `+
			`hx-vals='{"cmd":"go %s"}'>[%s]</button>`,
		direction, direction,
	)
}

Each function returns a <button> styled to look like a terminal hyperlink, no border, no background, just highlighted text that underlines on hover. The HTMX attributes do the heavy lifting: hx-post="/command" sends the request to our existing command endpoint, hx-target="#log" tells HTMX where to put the response, and hx-vals attaches the command as form data.

Why <button> with hx-vals instead of a <form> with a hidden input? Inside flowing text, a <form> is a block element that would break the line. A <button> is inline and sits naturally alongside words.

There’s an important detail about HTMX here. These buttons are injected into the page via an HTMX swap, the log entry gets appended with hx-swap="beforeend". You might wonder whether HTMX picks up the hx-post attributes on dynamically added elements. It does. HTMX automatically processes new content added through swaps, so the buttons are live the moment they appear.

Step 3: Rewrite handleLook

Open internal/game/commands.go and replace handleLook. Instead of returning a plain string, it now builds an HTML fragment with clickable items and exits:

func handleLook(s *State, rooms map[string]Room) string {
	snap := s.Snapshot()

	room, ok := rooms[snap.RoomID]
	if !ok {
		return "You look around, but reality fails to load."
	}

	var sb strings.Builder
	sb.WriteString("<strong>")
	sb.WriteString(room.Name)
	sb.WriteString("</strong><br>")
	sb.WriteString(room.Description)

	sb.WriteString("<br><br>You see: ")
	if len(snap.ItemsHere) == 0 {
		sb.WriteString("<span class=\"text-terminal-dim\">nothing of interest</span>")
	} else {
		for i, it := range snap.ItemsHere {
			if i > 0 {
				sb.WriteString("  ")
			}
			sb.WriteString(RenderItemLink(it))
		}
	}

	sb.WriteString("<br>Exits: ")
	if len(room.Exits) == 0 {
		sb.WriteString("<span class=\"text-terminal-dim\">none</span>")
	} else {
		i := 0
		for dir := range room.Exits {
			if i > 0 {
				sb.WriteString("  ")
			}
			sb.WriteString(RenderExitLink(dir))
			i++
		}
	}

	return sb.String()
}

The output reads like a terminal dump. The room name in bold, the description, then items as [Brass Lamp] [Rusty Key] and exits as [north] [east]. Each bracketed element is a clickable button. The player can type take lamp or click [Brass Lamp]. Both hit the same /command endpoint and produce the same result.

We use Snapshot() for the items list because items move during gameplay. The room’s YAML defines the starting items, but after the player takes the Brass Lamp, it shouldn’t appear in look output. Snapshot() gives us the current state.

Step 4: Update handleGo

When the player moves to a new room, they should immediately see the full room view, just like typing look after arriving. Replace handleGo to call handleLook after movement:

func handleGo(s *State, rooms map[string]Room, direction string) (string, string) {
	if direction == "" {
		return "Go where? Try: go north, go south, go east, go west", "error"
	}

	s.mu.Lock()
	room, ok := rooms[s.RoomID]
	s.mu.Unlock()
	if !ok {
		return "You can't move from a room that doesn't exist.", "error"
	}

	targetID, exists := room.Exits[direction]
	if !exists {
		return fmt.Sprintf("There is no exit to the %s.", direction), "error"
	}

	if _, ok := rooms[targetID]; !ok {
		return "That exit leads somewhere that doesn't exist. Spooky.", "error"
	}

	s.SetRoom(targetID)
	s.MarkVisited(targetID)
	return handleLook(s, rooms), "system"
}

The player types go north (or n, or clicks [north]), we move them, and they see the same rich output as if they’d typed look. No redundant description building, no inconsistency between arrival and looking around.

Step 5: Remove ON THE GROUND from the Sidebar

Open internal/ui/game/room.templ and find the right sidebar. Remove the “ON THE GROUND” heading and the @RoomItemsList(...) call entirely. Items are now discovered through the log, not a sidebar panel. The right sidebar should contain only the BACKPACK section (and later, equipment):

			// --- RIGHT SIDEBAR ---
			<aside class="border-l border-terminal-border p-4 bg-black overflow-y-auto">
				<h3 class="border-b border-dashed border-terminal-border pb-1 mb-4 text-terminal-highlight">
					BACKPACK
				</h3>
				@InventoryList(ListPartialData{
					OOB:       false,
					Inventory: data.Inventory,
				})
			</aside>

You can also remove RoomItemsList from inventory.templ and the ItemsHere field from ListPartialData and RoomPageData if you want to clean up, but leaving them unused won’t hurt anything.

Step 6: Update the OOB Logic in handleCommand

Since there’s no #room-items-list in the sidebar anymore, we don’t need to OOB swap it. But we still need to refresh the backpack when the player takes or drops something. Open internal/web/handlers.go and simplify the OOB block in handleCommand:

		if state.InventoryChanged {
			snap := state.Snapshot()
			_ = gameui.InventoryList(gameui.ListPartialData{
				OOB:       true,
				Inventory: snap.Inventory,
			}).Render(r.Context(), w)
		}

Step 7: Rebuild and Test

task dev

Visit http://localhost:4040. The log should show the Hallway description with [Brass Lamp], [Rusty Key], [north], and [east] as clickable green text. Click [Brass Lamp] and the log appends “> take lamp / You take the Brass Lamp.” and the backpack sidebar updates. Click [north] and you arrive in the Atrium with a fresh look output showing [Iron Sword], [Kite Shield], [Ancient Coin], and [south]. Type look to confirm it matches.

Exercise 2: Equipment Panel with Interactive Backpack

Task

The backpack sidebar shows item names, but the player can’t interact with them, no equip button, no drop button, no visual indication of what’s equipped. Build a complete equipment system. An equipment panel showing weapon and offhand slots, and backpack rows with contextual action buttons that reflect equipped state.

Plan

  1. Create an equipment panel component.
  2. Upgrade the backpack list with action buttons and equipped state styling.
  3. Wire up OOB updates so equipping, dropping, and taking items refresh both panels.
  4. Add the equipment IDs to the data pipeline.

Step 1: Extend the Data Types

Before building components, we need to make sure the data flows through. Open internal/ui/game/types.go and add weapon/offhand ID fields to both data structs:

type RoomPageData struct {
	Room      gamepkg.Room
	Log       []gamepkg.LogEntry
	Inventory []gamepkg.Item

	Weapon    gamepkg.Item
	WeaponOK  bool
	Offhand   gamepkg.Item
	OffhandOK bool
	WeaponID  string
	OffhandID string

	OOB bool
}

type ListPartialData struct {
	OOB       bool
	Inventory []gamepkg.Item
	Weapon    gamepkg.Item
	WeaponOK  bool
	Offhand   gamepkg.Item
	OffhandOK bool
	WeaponID  string
	OffhandID string
}

The equipment panel needs Weapon/WeaponOK/Offhand/OffhandOK to display slot names. The inventory list needs WeaponID/OffhandID to check whether each item is currently equipped. Both share the same struct. One of the benefits of having a single ListPartialData type.

Then update handleRoom in handlers.go to populate the new fields from the snapshot:

	data := gameui.RoomPageData{
		Room:      room,
		Log:       snap.Log,
		Inventory: snap.Inventory,
		Weapon:    snap.Weapon,
		WeaponOK:  snap.WeaponOK,
		Offhand:   snap.Offhand,
		OffhandOK: snap.OffhandOK,
		WeaponID:  snap.WeaponID,
		OffhandID: snap.OffhandID,
	}

Step 2: Create the Equipment Panel

Create internal/ui/game/equipment.templ. This component shows two slots, right hand (weapon) and left hand (offhand), with the equipped item name or an [EMPTY] placeholder:

package game

templ EquipmentPanel(data ListPartialData) {
	<div
		id="equipment-panel"
		if data.OOB {
			hx-swap-oob="true"
		}
	>
		<div class="mb-2">
			<span class="text-terminal-dim">R.HAND:</span>
			if data.WeaponOK {
				<span class="text-terminal-text"> { data.Weapon.Name }</span>
			} else {
				<span class="text-terminal-dim/60"> [EMPTY]</span>
			}
		</div>
		<div>
			<span class="text-terminal-dim">L.HAND:</span>
			if data.OffhandOK {
				<span class="text-terminal-text"> { data.Offhand.Name }</span>
			} else {
				<span class="text-terminal-dim/60"> [EMPTY]</span>
			}
		</div>
	</div>
}

The OOB flag follows the same pattern as InventoryList. The component works for both initial render (OOB: false) and live updates (OOB: true).

Step 3: Upgrade the Inventory List

Open internal/ui/game/inventory.templ and replace its contents. We need each item row to know whether it’s equipped, and to offer equip/unequip and drop buttons. Start with a plain Go helper function, Templ files can mix Go functions and template definitions:

package game

import (
	gamepkg "github.com/<your-name>/adv-htmx/internal/game"
	"github.com/<your-name>/adv-htmx/internal/utils"
)

func equipCmd(equipped bool) string {
	if equipped {
		return "unequip"
	}
	return "equip"
}

This returns the right command verb based on current state. We’ll use it in the hidden form input.

Now the list component. InventoryList passes an equipped boolean into each row by comparing the item ID against the weapon and offhand slots:

templ InventoryList(data ListPartialData) {
	<ul
		id="inventory-list"
		if data.OOB {
			hx-swap-oob="true"
		}
	>
		for _, item := range data.Inventory {
			@BackpackItem(item, item.ID == data.WeaponID || item.ID == data.OffhandID)
		}
		if len(data.Inventory) == 0 {
			<li class="text-terminal-dim/50 italic">(empty)</li>
		}
	</ul>
}

The expression item.ID == data.WeaponID || item.ID == data.OffhandID is evaluated per item during rendering.

Now the row component. BackpackItem adjusts its styling and button labels based on the equipped flag:

templ BackpackItem(item gamepkg.Item, equipped bool) {
	<li class={ "py-1 border-b border-terminal-dim/20", utils.If(equipped, "text-terminal-highlight") }>
		<div class="flex items-center justify-between gap-2">
			<span>
				{ item.Name }
				if equipped {
					<span class="text-xs text-terminal-dim ml-1">[E]</span>
				}
			</span>
			<div class="flex gap-2">
				if item.Slot != "" {
					<form hx-post="/command" hx-target="#log" hx-swap="beforeend scroll:bottom">
						<input type="hidden" name="cmd" value={ equipCmd(equipped) + " " + item.ID }/>
						<button class="text-xs text-terminal-dim hover:text-terminal-highlight" type="submit">
							if equipped {
								unequip
							} else {
								equip
							}
						</button>
					</form>
				}
				<form hx-post="/command" hx-target="#log" hx-swap="beforeend scroll:bottom">
					<input type="hidden" name="cmd" value={ "drop " + item.ID }/>
					<button class="text-xs text-terminal-dim hover:text-terminal-highlight" type="submit">drop</button>
				</form>
			</div>
		</div>
	</li>
}

There are several things working together here. The class={} on the <li> uses utils.If(equipped, "text-terminal-highlight") to tint equipped items brighter green when equipped is false, utils.If returns an empty string that Templ skips. The [E] badge uses a Templ if block inline inside the <span>. The equip button’s hidden input uses our equipCmd helper. When the sword is equipped, it produces value="unequip sword" and when it’s not, value="equip sword". And the if item.Slot != "" guard ensures the equip button only appears on equippable items. For example, the Brass Lamp has no slot, so it only shows a drop button.

Each button submits to /command, the same endpoint the command bar and log hyperlinks use. Clicking “drop” posts cmd=drop sword, the parser handles it, the handler returns a log entry plus OOB updates. One route, one code path, three interaction styles.

Step 4: Add Equipment and Inventory to the Sidebar

Open internal/ui/game/room.templ and update the right sidebar to include the equipment panel above the backpack, with the weapon/offhand IDs flowing through:

			// --- RIGHT SIDEBAR ---
			<aside class="border-l border-terminal-border p-4 bg-black overflow-y-auto">
				<h3 class="border-b border-dashed border-terminal-border pb-1 mb-4 text-terminal-highlight">
					EQUIPMENT
				</h3>
				@EquipmentPanel(ListPartialData{
					OOB:       false,
					Weapon:    data.Weapon,
					WeaponOK:  data.WeaponOK,
					Offhand:   data.Offhand,
					OffhandOK: data.OffhandOK,
				})

				<h3 class="border-b border-dashed border-terminal-border pb-1 mb-4 mt-6 text-terminal-highlight">
					BACKPACK
				</h3>
				@InventoryList(ListPartialData{
					OOB:       false,
					Inventory: data.Inventory,
					WeaponID:  data.WeaponID,
					OffhandID: data.OffhandID,
				})
			</aside>

Step 5: Set the Flag on Equip

Equipping an item doesn’t move it between containers, so InventoryChanged doesn’t get set by Take or Drop. But the sidebar still needs refreshing, the [E] badge, the button label, and the equipment panel all change. The simplest fix is to set InventoryChanged inside ToggleEquip.

Open internal/game/state.go and find the ToggleEquip method. Add the flag before each return that indicates a state change:

func (s *State) ToggleEquip(itemID string) (EquipResult, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	it, ok := s.Inventory[itemID]
	if !ok {
		return EquipResult{}, ErrNotInInventory
	}
	if it.Slot == SlotNone {
		return EquipResult{}, ErrNotEquippable
	}

	var slotPtr *string
	switch it.Slot {
	case SlotWeapon:
		slotPtr = &s.WeaponID
	case SlotOffhand:
		slotPtr = &s.OffhandID
	default:
		return EquipResult{}, ErrUnknownSlot
	}

	if *slotPtr == itemID {
		*slotPtr = ""
		s.InventoryChanged = true
		return EquipResult{
			Item:     it,
			Slot:     it.Slot,
			Equipped: false,
		}, nil
	}

	var replaced Item
	var replacedOK bool
	if *slotPtr != "" {
		if prev, ok := s.Inventory[*slotPtr]; ok {
			replaced = prev
			replacedOK = true
		}
	}

	*slotPtr = itemID
	s.InventoryChanged = true
	return EquipResult{
		Item:       it,
		Slot:       it.Slot,
		Equipped:   true,
		Replaced:   replaced,
		ReplacedOK: replacedOK,
	}, nil
}

Step 6: Send OOB Updates for Both Panels

Open internal/web/handlers.go and update the OOB block in handleCommand. When inventory changes (from take, drop, or equip), we refresh both the inventory list and the equipment panel:

		if state.InventoryChanged {
			snap := state.Snapshot()

			_ = gameui.InventoryList(gameui.ListPartialData{
				OOB:       true,
				Inventory: snap.Inventory,
				WeaponID:  snap.WeaponID,
				OffhandID: snap.OffhandID,
			}).Render(r.Context(), w)

			_ = gameui.EquipmentPanel(gameui.ListPartialData{
				OOB:       true,
				Weapon:    snap.Weapon,
				WeaponOK:  snap.WeaponOK,
				Offhand:   snap.Offhand,
				OffhandOK: snap.OffhandOK,
			}).Render(r.Context(), w)
		}

Three Templ components rendered into one response. The log entry (primary swap), the inventory list (OOB), and the equipment panel (OOB). HTMX picks them apart and puts each one in the right place.

Step 7: Rebuild and Test

task dev

Navigate to the Atrium (go north or click [north]). Click [Iron Sword] in the log to take it. The sword appears in the BACKPACK panel with “equip” and “drop” buttons. Type equip sword or click the “equip” button. Three things update simultaneously, the log shows “You equip the Iron Sword”, R.HAND shows “Iron Sword”, and the backpack entry shows Iron Sword [E] with “unequip” replacing “equip”. Click “unequip”. Everything reverts. Drop the sword and it vanishes from the backpack, R.HAND clears, and the next time you type look, the sword appears on the ground as a clickable [Iron Sword] link.

Task

Reintroduce the Spellbook modal with HTMX powered live search. This exercise adds a new UI element and a new route.

Plan

  1. Create Templ components for spell search results.
  2. Add a spellbook dialog to room.templ.
  3. Add the /search-spells route and handler.

Step 1: Create spell_list.templ

The spellbook is our first component that doesn’t interact with game state. It’s a read only search UI against the static Grimoire slice from spells.go.

Create internal/ui/game/spell_list.templ:

package game

import (
	"strconv"

	gamepkg "github.com/<your-name>/adv-htmx/internal/game"
)

templ SpellList(results []gamepkg.Spell) {
	for _, spell := range results {
		<div class="mb-3 pb-2 border-b border-terminal-border">
			<div class="flex justify-between">
				<span class="font-semibold">{ spell.Name }</span>
				<span class="text-terminal-dim">{ strconv.Itoa(spell.Cost) } MP</span>
			</div>
			<div class="text-sm text-terminal-dim mt-1">{ spell.Description }</div>
		</div>
	}
}

templ SpellListEmpty() {
	<div class="text-terminal-dim">No spells found matching that incantation.</div>
}

One thing to note: Templ expressions only accept strings, not integers. That’s why we use strconv.Itoa(spell.Cost) to convert the mana cost. If you try { spell.Cost } directly, the compiler will reject it. This is stricter than html/template, which silently calls fmt.Sprint behind the scenes.

We define SpellListEmpty as a separate component rather than handling the empty state inside SpellList. This keeps each component focused on one job, and the handler decides which to render based on the query results.

Step 2: Add the Spellbook UI to room.templ

The spellbook uses an HTML <dialog> element, a native modal that requires no JavaScript framework. We need two things, a button to open it, and the dialog itself.

In internal/ui/game/room.templ, add the button in the left sidebar below the map div. This gives it a natural home alongside other reference panels:

<button
	type="button"
	class="w-full mt-5 border border-terminal-border px-3 py-2 text-terminal-text hover:bg-terminal-text hover:text-terminal-bg"
	onclick="document.getElementById('spellbook-modal').showModal()"
>
	Spellbook
</button>

The hover style inverts the terminal colors (green background, black text), which feels right for a CRT era button. showModal() is the native <dialog> API.

Now add the dialog itself. Place it near the end of the Room component, still inside @layout.Base(...) but after the main grid div. It stays in the DOM but is hidden until showModal() is called:

<dialog id="spellbook-modal" class="bg-[#111] text-terminal-text border-2 border-terminal-text w-[400px] p-0">
	<div class="p-4">
		<div class="flex justify-between border-b border-terminal-border pb-2 mb-4">
			<h3>Grimoire</h3>
			<button type="button" onclick="document.getElementById('spellbook-modal').close()">&times;</button>
		</div>
		<input
			type="search"
			name="search"
			placeholder="Search spells..."
			class="w-full bg-black text-terminal-text border border-terminal-border px-2 py-1"
			hx-post="/search-spells"
			hx-trigger="input changed delay:400ms"
			hx-target="#spell-search-results"
		/>
		<div id="spell-search-results" class="mt-4 h-[220px] overflow-y-auto"></div>
	</div>
</dialog>

The search input uses HTMX’s hx-trigger="input changed delay:400ms" and it fires a POST request 400 milliseconds after the player stops typing, targeting the #spell-search-results div below. This is live search without writing any JavaScript event handlers or managing client side state. Each keystroke (after the debounce) sends the current value to the server, which returns rendered HTML that HTMX swaps into the results container.

Step 3: Add the Route and Handler

Open internal/web/handlers.go. Register the route inside Routes():

mux.HandleFunc("POST /search-spells", s.handleSearchSpells)

Then add the handler. It calls SearchSpells from spells.go, which does a case-insensitive substring match against the Grimoire slice, and renders the appropriate component:

func (s *Server) handleSearchSpells(w http.ResponseWriter, r *http.Request) {
	query := r.FormValue("search")
	results := game.SearchSpells(query)

	w.Header().Set("Content-Type", "text/html; charset=utf-8")

	if len(results) == 0 && query != "" {
		_ = gameui.SpellListEmpty().Render(r.Context(), w)
		return
	}

	_ = gameui.SpellList(results).Render(r.Context(), w)
}

This is a simple request-response cycle. Form value in, rendered HTML out. No state mutation, no OOB swaps, no flags. The handler picks the component based on the result, SpellList for matches, SpellListEmpty for a non empty query with no hits, and implicitly an empty body when the query is blank, clearing the search field clears the results.

Step 4: Regenerate and Test

task dev

Open the game, click Spellbook in the left sidebar. A modal appears with a search field. Type fire, after a brief pause, “Fireball” appears with its mana cost and description. Clear the input and type heal, “Lesser Heal” slides in. Type xyz and “No spells found matching that incantation.” Click the × button or press Escape to close.

Exercise 4: Bring Back the Room Image

Task

Each room in world.yaml has an image field, but our terminal UI doesn’t display it. Bring back the lazy loaded room visual from article 9 and render it using a Templ component.

Plan

If you look at handlers.go, you’ll find a leftover handleRoomImage handler from article 9 that’s no longer connected to any route. It still works, but it builds its HTML with fmt.Sprintf, a raw string with interpolated values, exactly the kind of thing we migrated away from. We need to:

  1. Create a Templ component for the room image.
  2. Add a lazy loading placeholder in the sidebar.
  3. Rewrite the handler to use the new component.
  4. Reconnect the route.

Step 1: Create room_image.templ

Create internal/ui/game/room_image.templ. This is about as minimal as a Templ component gets, a single <img> tag with dynamic attributes:

package game

type RoomImageData struct {
	RoomName string
	ImageURL string
}

templ RoomImage(data RoomImageData) {
	<img
		src={ data.ImageURL }
		alt={ data.RoomName }
		class="w-full h-full object-cover opacity-80 animate-fade-in"
	/>
}

The opacity-80 tones the image down so it doesn’t overpower the monochrome palette. The animate-fade-in class uses the keyframe animation we defined in input.css back in Part 4, giving the image a smooth entrance after the network request completes.

We define a RoomImageData struct rather than passing two bare strings. This is a small thing, but it reads more clearly than positional arguments when you revisit the handler months later.

Step 2: Add the Placeholder to the Sidebar

Open internal/ui/game/room.templ and wrap the existing grid in a flex column and put the image above it. Change:

@layout.Base("Adventure Terminal") {
    <div class="grid grid-cols-[250px_1fr_250px] h-screen gap-[2px] overflow-hidden">

To:

@layout.Base("Adventure Terminal") {
    <div class="flex flex-col h-screen overflow-hidden">
        <div
            id="room-image"
            hx-get={ "/room/" + data.Room.ID + "/image" }
            hx-trigger="load"
            hx-swap="innerHTML"
            class="bg-black border-b border-terminal-border text-terminal-dim text-sm italic text-center max-h-[200px] overflow-hidden"
        >
            Rendering...
        </div>
        <div class="grid grid-cols-[250px_1fr_250px] flex-1 gap-[2px] overflow-hidden">

And add a closing </div> after the grid’s closing </div> to close the new flex wrapper.

The hx-trigger="load" fires a GET request as soon as this element appears in the DOM. The player sees “Rendering…” for a moment, then the image fades in. Notice the URL is built with a Templ expression: { "/room/" + data.Room.ID + "/image" } concatenates Go strings at render time, producing something like /room/hallway/image. In the old HTML template, this was hx-get="/room/{{.Room.ID}}/image".

Step 3: Rewrite the Handler

The old handleRoomImage built HTML with fmt.Sprintf:

html := fmt.Sprintf(`<img src="%s" alt="%s" class="room-image fade-in">`, room.Image, room.Name)
fmt.Fprint(w, html)

Replace the entire function body to use the Templ component:

func (s *Server) handleRoomImage(w http.ResponseWriter, r *http.Request) {
	roomID := r.PathValue("id")
	room, ok := s.Rooms[roomID]
	if !ok {
		http.NotFound(w, r)
		return
	}

	if room.Image == "" {
		w.WriteHeader(http.StatusNoContent)
		return
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	_ = gameui.RoomImage(gameui.RoomImageData{
		RoomName: room.Name,
		ImageURL: room.Image,
	}).Render(r.Context(), w)
}

The 204 No Content response for rooms without images tells HTMX there’s nothing to swap, the “Rendering…” text simply disappears. This is a useful HTMX convention: 204 means “request succeeded, nothing to display.”

Step 4: Reconnect the Route

The route was removed when we simplified Routes() in Part 6. Add it back alongside the other GET endpoints:

mux.HandleFunc("GET /room/{id}/image", s.handleRoomImage)

Step 5: Add the Room Change Flag

Open internal/game/state.go and add a setter:

func (s *State) MarkRoomChanged() {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.RoomChanged = true
}

Then open internal/game/commands.go and add the call in handleGo, right before the return:

	s.SetRoom(targetID)
	s.MarkVisited(targetID)
	s.MarkRoomChanged()
	return handleLook(s, rooms), "system"

Step 6: Create an OOB Image Wrapper

The RoomImage component renders a bare <img> tag. It doesn’t know about OOB swapping. Rather than adding OOB logic to the that component, which the lazy load route still uses without OOB, create a wrapper in internal/ui/game/room_image.templ below the existing RoomImage:

templ RoomImageOOB(data RoomImageData) {
	<div id="room-image" hx-swap-oob="true" class="bg-black border-b border-terminal-border max-h-[200px] overflow-hidden">
		if data.ImageURL != "" {
			@RoomImage(data)
		}
	</div>
}

This replaces the entire #room-image container in one OOB swap. When the room has no image, it renders an empty div, clearing the previous room’s image.

Step 7: Send the OOB Swap in handleCommand

Open internal/web/handlers.go. In handleCommand, add a new block after the InventoryChanged block:

		if state.RoomChanged {
			room := s.Rooms[state.RoomID]
			_ = gameui.RoomImageOOB(gameui.RoomImageData{
				RoomName: room.Name,
				ImageURL: room.Image,
			}).Render(r.Context(), w)
		}

Step 8: Rebuild and Test

task dev

Type go north, the Atrium image should replace the Hallway immediately. Type go south, the Hallway image come back.

What’s Next

We have successfully performed a complete metamorphosis of our application. The terminal is glowing. The world feels real.

But it is a fragile reality.

Right now, the dungeon exists only in the RAM of the server. If you restart the application, every item dropped, every door opened, and every goblin slain is forgotten.

In the next article, we’ll implement persistent storage with a SQL database, and we will build the Gatekeeper, an OAuth2 authentication system that grants every player their own private universe.

The Hallway is finished. It’s time to build the Fortress.

Code

You can find full code on GitHub.

RS
Rob Sliwa

Coder | Book Lover | Lifelong Learner

PT
Pawan Tripathi

Writes about infrastructure, agentic coding, and trying to keep things small.