posts / go

Adventures in Go and HTMX - Part 6

In the previous article, we enhanced our world with optimistic updates, allowing us to pick up, drop, and equip items with a satisfying instant UI update.

But right now, our hero is a bit of a brute who can walk into a room and poke things with a stick.

To be a true adventurer, one needs knowledge. One needs a library of arcane arts. One needs… a Spellbook.

But here is the problem. A high level wizard might know hundreds of spells. We can’t just dump a list of 500 spells onto the screen and hope the player scrolls until they find “Fireball.”

We need a search bar.

And not just any search bar. We want that snappy, modern “Live Search” experience where the results filter down as you type.

In the old days, you’d type “Fire”, hit Enter, wait for a reload, see the results, realize you meant “Flare”, type “Flare”, hit Enter, wait…

In a modern Single Page App, you typically build this by listening for input events, debounce so you don’t fire a request on every keystroke, send an async request, and make sure only the latest response updates the UI (by canceling older requests or ignoring stale results).

With HTMX, we’re going to find the happy medium: Active Search.

We will let the user type. We will wait for them to pause. Then we will ask the server, “What matches ‘Fir’?”, and the server will return just the HTML for those rows.

The Mental Model: Debouncing

The most critical part of Active Search isn’t the search itself. It’s knowing when not to search.

If a user types “Lightning Bolt” quickly, they press about 14 keys. If we fire a request for every keystroke (L, Li, Lig, Ligh, …), we are spamming our server with 14 requests, 13 of which are instantly obsolete.

We need the concept of Debouncing.

Debouncing is like a waiter at a restaurant. When you start saying your order, they don’t run to the kitchen after the first word. They wait until you stop talking for a second, then they write it down.

HTMX gives us this behavior with a simple attribute modifier delay:500ms.

Define the Spells

First, we need something to search. Let’s create a library of spells in our game package.

Each spell will be defined with following properties:

  • ID: A machine-friendly key used by game logic (e.g., "fireball").
  • Name: The human-readable display name shown in the Grimoire.
  • Description: Flavor text that gives the spell personality.
  • Cost: The mana required to cast it, so resource management has teeth.

Open internal/game/model.go and add the Spell struct.

type Spell struct {
	ID          string
	Name        string
	Description string
	Cost        int // Mana cost
}

Now, let’s populate the world with magic. Since spells are global knowledge (for now), we can just create a static list.

Create a new file internal/game/spells.go:

package game

import "strings"

var Grimoire = []Spell{
	{ID: "light", Name: "Light", Description: "Illuminates dark places.", Cost: 1},
	{ID: "missile", Name: "Magic Missile", Description: "Always hits. Always annoying.", Cost: 3},
	{ID: "fireball", Name: "Fireball", Description: "Solves most problems, creates new structural ones.", Cost: 10},
	{ID: "ice", Name: "Ice Spike", Description: "Cool off your enemies.", Cost: 5},
	{ID: "heal", Name: "Lesser Heal", Description: "Stops the bleeding, mostly.", Cost: 4},
	{ID: "levitate", Name: "Levitate", Description: "Walk over traps with style.", Cost: 6},
	{ID: "silence", Name: "Silence", Description: "Good for librarians and evil wizards.", Cost: 8},
	{ID: "identify", Name: "Identify", Description: "What does this button do?", Cost: 2},
}

// SearchSpells is our "database query"
func SearchSpells(query string) []Spell {
	if query == "" {
		return nil
	}
  // normalize string to search to lower case
	q := strings.ToLower(query)
	var results []Spell

  // substring search
	for _, s := range Grimoire {
		if strings.Contains(strings.ToLower(s.Name), q) {
			results = append(results, s)
		}
	}
	return results
}

We include a helper SearchSpells. In a real app, this would be a database query. Here, a loop works just fine.

The Search Handler

We need an endpoint that accepts a search term and returns a list of matching spells as HTML.

Open internal/web/handlers.go and add this near other page data structs:

type SpellListPartial struct {
	Results []game.Spell
}

This is needed to pass data to the template.

Now add the handler method:

func (s *Server) handleSearchSpells(w http.ResponseWriter, r *http.Request) {
	// Simulate a tiny bit of database latency so we can feel the UI state later
	// time.Sleep(200 * time.Millisecond)

	query := r.FormValue("search")
	results := game.SearchSpells(query)

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

	// If no results and we have a query, tell the user
	if len(results) == 0 && query != "" {
		fmt.Fprint(w, `<div class="search-empty">No spells found matching that incantation.</div>`)
		return
	}

	// Render the list
	if err := s.Tmpl.ExecuteTemplate(w, "spellList", SpellListPartial{Results: results}); err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
	}
}

And register this handler in Routes():

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

Notice we are returning a raw string for the empty state. For small things, fmt.Fprint is sometimes cleaner than a whole new template definition. But for the results, we use a template named spellList.

The Spellbook Template

We need a UI for the Spellbook and a search bar. The HTML <dialog> element is perfect for this. It handles the modal behavior (overlay, focus trapping) natively in the browser.

Open templates/partials.html. Add two things:

  1. The spellList partial (the rows).
  2. The spellbookModal (the container).
{{define "spellList"}} {{range .Results}}
<div class="spell-row">
  <div class="spell-info">
    <span class="spell-name">{{.Name}}</span>
    <span class="spell-cost">{{.Cost}} MP</span>
  </div>
  <div class="spell-desc">{{.Description}}</div>
</div>
{{end}} {{end}} {{define "spellbookModal"}}
<dialog id="spellbook-modal" class="modal">
  <div class="modal-header">
    <h2>Grimoire</h2>
    <form method="dialog">
      <button class="close-btn">×</button>
    </form>
  </div>

  <div class="modal-body">
    <div class="search-control">
      <input
        type="search"
        name="search"
        placeholder="Type to search spells..."
        autocomplete="off"
        autofocus
        hx-post="/search-spells"
        hx-trigger="input changed delay:500ms, search"
        hx-target="#search-results"
        hx-indicator=".search-loader"
      />
      <div class="search-loader htmx-indicator">✨</div>
    </div>

    <div id="search-results" class="search-results">
      <div class="search-hint">Enter a search term to find spells.</div>
    </div>
  </div>
</dialog>
{{end}}

Let’s unpack the <input> tag as there is a lot going on in it.

hx-post="/search-spells"

This tells HTMX that when triggered, send a POST request to this URL. It automatically includes the input’s own value in the body because the input has a name="search".

hx-trigger="input changed delay:500ms, search"

This is the Active Search pattern.

  • input: Fires every time the value is modified (typing, pasting).
  • changed: This is a filter. If the arrow keys are pressed (which triggers keyup but doesn’t change the value), do not send a request.
  • delay:500ms: The “waiter”. HTMX will reset the timer every time the text is entered. It only sends the request once typing stops for half a second.
  • search: This handles the little “X” clear button that appears in some browsers’ search inputs.
hx-target="#search-results"

Take the HTML returned by the server and put it inside this div.

hx-indicator=".search-loader"

While the request is in flight, add the class htmx-request to the element matching .search-loader. This lets us show a spinner easily.

Putting the Book in the Room

We need to render the modal into our main layout so it sits there, hidden, waiting to be opened.

Open templates/room.html and add a button to the sidebar to open the book. Add this inside the <aside class="sidebar">, above the Equipment panel:

<div class="actions">
  <button
    onclick="document.getElementById('spellbook-modal').showModal()"
    class="btn-full"
  >
    Open Spellbook
  </button>
</div>

Wait, onclick? Standard JavaScript?

Yes. HTMX isn’t jealous. For simple client-side interactions like opening a native dialog, vanilla JS is faster and cleaner than a server round trip.

Next, include the modal template itself. Add this at the very bottom of the <body>, just before the closing tag:

{{template "spellbookModal" .}}

Styling the Magic

Active Search feels bad if it looks clunky. Let’s add some CSS to templates/room.html to make the modal look good and handle the loading state.

/* Modal Styling */
.modal {
  background: #1a1a1a;
  color: #eee;
  border: 2px solid #555;
  border-radius: 8px;
  width: 100%;
  max-width: 400px;
  padding: 0;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
}
.modal::backdrop {
  background: rgba(0, 0, 0, 0.7);
}

.modal-header {
  background: #2a2a2a;
  padding: 0.75rem 1rem;
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 1px solid #333;
}
.modal-header h2 {
  margin: 0;
  font-size: 1.2rem;
  color: #b6e3ff;
}

.close-btn {
  background: none;
  border: none;
  color: #888;
  font-size: 1.5rem;
  cursor: pointer;
}
.close-btn:hover {
  color: #fff;
}

.modal-body {
  padding: 1rem;
}

/* Search Input */
.search-control {
  position: relative;
  margin-bottom: 1rem;
}

.search-control input {
  width: 100%;
  padding: 0.6rem 2.5rem 0.6rem 0.8rem; /* Space for spinner */
  background: #111;
  border: 1px solid #444;
  color: white;
  border-radius: 4px;
  font-size: 1rem;
  box-sizing: border-box; /* Important for width: 100% */
}

.search-control input:focus {
  outline: none;
  border-color: #b6e3ff;
}

/* Loading Indicator */
.search-loader {
  position: absolute;
  right: 10px;
  top: 50%;
  transform: translateY(-50%);
  opacity: 0;
  transition: opacity 200ms ease-in;
  pointer-events: none;
}
.search-loader.htmx-request {
  opacity: 1;
}

/* Results List */
.search-results {
  max-height: 300px;
  overflow-y: auto;
  min-height: 50px;
}

.search-hint,
.search-empty {
  color: #777;
  text-align: center;
  margin-top: 1rem;
  font-style: italic;
}

.spell-row {
  padding: 0.5rem;
  border-bottom: 1px solid #333;
}
.spell-row:last-child {
  border-bottom: none;
}

.spell-info {
  display: flex;
  justify-content: space-between;
  margin-bottom: 0.25rem;
}
.spell-name {
  font-weight: bold;
  color: #b6e3ff;
}
.spell-cost {
  color: #aaa;
  font-size: 0.85rem;
}
.spell-desc {
  font-size: 0.9rem;
  color: #ccc;
}

.btn-full {
  width: 100%;
  margin-top: 1rem;
  padding: 0.5rem;
  background: #2a2a2a;
  border: 1px solid #444;
  color: #eee;
  cursor: pointer;
  border-radius: 4px;
}
.btn-full:hover {
  background: #333;
}

Notice the .search-loader.htmx-request selector. HTMX toggles the htmx-request class onto the element specified in hx-indicator automatically. We use CSS opacity to fade it in and out.

Run It

Restart your server.

  1. Click the Open Spellbook button. The modal should pop up cleanly.
  2. Type “Fire”.
  3. Wait half a second.
  4. See the indicator fade in.
  5. See the result “Fireball” appear.
  6. Backspace and type “Heal”.
  7. The list updates to “Lesser Heal”.

UX Polish: Focusing the Input

There is one tiny annoyance. When you open the modal, you have to click the input box to start typing. We can fix this with just a bit of HTML/JS.

Update the button in templates/room.html:

<button
  onclick="const m = document.getElementById('spellbook-modal'); m.showModal(); m.querySelector('input').focus()"
  class="btn-full"
>
  Open Spellbook
</button>

Now, you click the button, the box opens, and you can immediately type “Fire”.

What’s Next?

We have a functioning world, a backpack, equipment, and a library of knowledge.

But our game has a problem. As it grows, its functionality is getting heavy.

If we add high resolution illustrations for every room, loading the page will start to feel slow. And as our game log grows to thousands of turns, rendering that list will choke the browser.

In the next article, we’re going to solve performance. We’ll learn Lazy Loading to fetch heavy images only when they are needed, and Infinite Scroll to handle a massive list of data without breaking a sweat.

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.