posts / go

Adventures in Go and HTMX - Part 5

The Sword and Shield

You’ve looted the dungeon. You’ve got a backpack full of mysterious objects. And now, deep in the atrium, you spot it:

A sword.

Not a letter opener. Not a decorative butter knife. A sword.

So you take it…

…and then you remember something somewhat embarrassing about our game.

We don’t have an Equip button yet.

Right now, our game can collect loot, but can’t actually use it. Which is a bit like running a restaurant where the chef is allowed to buy ingredients but not allowed to turn on the stove.

When the player clicks Equip, the UI should immediately show that the item is equipped. Even before the server finishes processing the request. We want the UI to reflect the expected outcome of an action before the round trip completes, while still letting the server make the final call.

That pattern is called an Optimistic Update:

  • The browser updates the UI as if the action succeeded.
  • The request fires to the server in the background.
  • If the server agrees, everything continues smoothly.
  • If the server disagrees, we roll the UI back to reality.

Now you might be thinking, do we really need this? Take and Drop already feel instantaneous.

And you’re not wrong… but only because we’ve been playing on easy mode:

  • The actions are simple.
  • The server is local.
  • The network latency is basically zero.

So before we build anything new, let’s make the problem visible.

Open Chrome DevTools:

  1. Go to the Network tab.
  2. Find the Throttling dropdown.
  3. Select Slow 3G.

Now try to Take an item.

You’ll see it. A small delay where the click happens… and the UI just sits there. Nothing is broken. The game is still correct. But the feeling changes. That pause is what optimistic updates are for. So when we introduce Equip, we’ll do it with style:

Click Equip -> immediate feedback -> server confirms -> UI stays consistent.

Let’s forge the sword. And this time, when you click, it’s going to feel like you’re holding it now, not after the paperwork clears.

Make Items Equippable

Right now, our Item is just loot you can take and drop. To equip something, the server needs to know if the item can be equipped and what kind of slot it belongs to.

A Sword is a weapon. A Shield is an offhand item. A brass lamp is… emotionally supportive, but not equippable.

Update the Item Model

Let’s define the concept of a Slot which will tell the server what goes where.

In internal/game/model.go add a new Slot type and update the Item to include it:

package game

import "time"

type Exit struct {
	Label string
	To    string
}

type Room struct {
	ID          string
	Name        string
	Description string
	Exits       []Exit
}

type Slot string

const (
	SlotNone    Slot = ""
	SlotWeapon  Slot = "weapon"
	SlotOffhand Slot = "offhand"
)

type Item struct {
	ID          string
	Name        string
	Description string
	Slot        Slot
}

type LogEntry struct {
	Turn    int
	At      time.Time
	Command string
	Output  string
	Kind    string // "system", "error" etc.
}

With this the server can enforce if the item can be equipped, which slot it goes to, and one item at a time in a slot mechanics.

Teach the Game State About Equipment

Our equipment system is just two extra bits of state. What’s equipped in weapon slot and what’s equipped in offhand slot. We’ll store the equipped item IDs in the state.

In internal/game/state.go add following fields to State:

type State struct {
	mu sync.Mutex

	RoomID string
	Turn   int
	Log    []LogEntry

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

	WeaponID  string
	OffhandID string
}

type Snapshot struct {
	RoomID    string
	Log       []LogEntry
	Inventory []Item
	ItemsHere []Item

	WeaponID  string
	OffhandID string

	Weapon    Item
	WeaponOK  bool
	Offhand   Item
	OffhandOK bool
}

Also update Snapshot to include those fields:

func (s *State) Snapshot() Snapshot {
	s.mu.Lock()
	defer s.mu.Unlock()

	logCopy := append([]LogEntry(nil), s.Log...)
	inv := sortedItems(s.Inventory)

	var here []Item
	if roomInv, ok := s.RoomItems[s.RoomID]; ok {
		here = sortedItems(roomInv)
	}

	var weapon Item
	var weaponOK bool
	if s.WeaponID != "" {
		if it, ok := s.Inventory[s.WeaponID]; ok {
			weapon = it
			weaponOK = true
		}
	}

	var offhand Item
	var offhandOK bool
	if s.OffhandID != "" {
		if it, ok := s.Inventory[s.OffhandID]; ok {
			offhand = it
			offhandOK = true
		}
	}

	return Snapshot{
		RoomID:    s.RoomID,
		Log:       logCopy,
		Inventory: inv,
		ItemsHere: here,

		WeaponID:  s.WeaponID,
		OffhandID: s.OffhandID,

		Weapon:    weapon,
		WeaponOK:  weaponOK,
		Offhand:   offhand,
		OffhandOK: offhandOK,
	}
}

Why both WeaponID and Weapon/WeaponOK? Because they serve different UI needs. WeaponID is used for checking if the row is equipped in the template. Weapon/WeaponOK is for showing a friendly name in the Equipment panel.

Toggle Equip on the Server

We want one clean operation:

  • Click Equip on an item.
  • If it’s already equipped -> unequip it.
  • If it’s not equipped -> equip it (and replace whatever was in that slot).

What we’re building is basically a single atomic state transition. A toggle on a particular slot. And the server should return enough information for the web layer to narrate the result (“you equip the sword”, “you swap out the dagger”,…) without the handler doing a second round of lookups or inventing UI logic.

Add the following in internal/game/state.go:

package game

import (
	"errors"
	// ...
)

var (
	ErrNotInInventory = errors.New("you don't have that item")
	ErrNotEquippable  = errors.New("that item cannot be equipped")
	ErrUnknownSlot    = errors.New("unknown equipment slot")
)

type EquipResult struct {
	Item       Item
	Slot       Slot
	Equipped   bool // true if equipped after the toggle, false if unequipped
	Replaced   Item
	ReplacedOK bool
}

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 already equipped -> unequip.
	if *slotPtr == itemID {
		*slotPtr = ""
		return EquipResult{
			Item:     it,
			Slot:     it.Slot,
			Equipped: false,
		}, nil
	}

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

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

Now, let’s unpack what’s happening in this code.

You can only equip what you own:

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

If it’s not in your inventory, you can’t equip it. Simple rule.

Not everything is wearable:

if it.Slot == SlotNone {
	return EquipResult{}, ErrNotEquippable
}

We made the slot explicit in the model earlier (SlotWeapon, SlotOffhand, etc). That’s the whole trick that keeps the domain logic clean:

  • the sword knows it belongs in weapon slot
  • the shield knows it belongs in offhand slot
  • the lamp knows it is just emotional support

Toggle semantics (click equip) is a reversible action

if *slotPtr == itemID {
	*slotPtr = ""
	return EquipResult{ ... Equipped: false }, nil
}

This is what makes the UI dead simple. The UI doesn’t need an equip endpoint and an unequip endpoint. It only needs:

POST /equip/{id}

and the server decides.

Replacement detection

If we’re equipping an item into a slot that’s already occupied, we want to know what got replaced. So, we return replaced and replacedOK:

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

We return replaced because the domain knows if something was swapped out. The handler shouldn’t have to recompute that after mutation. This is the part that the handler will later narrate:

  • “You equip the sword.”
  • or “You equip the sword, putting away the dagger.”

Returning EquipResult make the handler’s job simple.

We also use bool replacedOK because Item is a struct with a valid zero value. If we returned Item{} and tried to infer “is this real?” we’d be guessing. So we go with pattern: value + ok boolean

Dropping an Equipped Item Should Unequip It

If the player drops the sword they’re holding, they probably shouldn’t keep holding it. Unless your dungeon runs on cartoon physics, in which case… respect.

Update Drop() in internal/game/state.go so it clears equipment:

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 the item was equipped, unequip it.
	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

	return it, true
}

Add an Equipment Panel to the Sidebar

We’re going to show the player what they’re actively wearing/holding. Create a new named template inside templates/partials.html (we’ll keep all sidebar fragments together):

{{define "equipmentPanel"}}
<div id="equipment-panel" class="equip"{{if .OOB}} hx-swap-oob="true"{{end}}>
  <div class="equip-slot">
    <span class="equip-label">Weapon</span>
    {{if .WeaponOK}}
      <span class="equip-item">{{.Weapon.Name}}</span>
    {{else}}
      <span class="equip-empty">(none)</span>
    {{end}}
  </div>

  <div class="equip-slot">
    <span class="equip-label">Offhand</span>
    {{if .OffhandOK}}
      <span class="equip-item">{{.Offhand.Name}}</span>
    {{else}}
      <span class="equip-empty">(none)</span>
    {{end}}
  </div>
</div>
{{end}}

{{define "inventoryList"}}
<ul id="inventory-list" class="inv-list"{{if .OOB}} hx-swap-oob="true"{{end}}>
  {{- range .Inventory -}}
    {{- $isWeapon := eq .ID $.WeaponID -}}
    {{- $isOffhand := eq .ID $.OffhandID -}}
    {{- $isEquipped := or $isWeapon $isOffhand -}}

    <li
      id="inv-item-{{.ID}}"
      class="inv-item{{if $isEquipped}} equipped{{end}}"
      data-slot="{{.Slot}}">

      <span class="item-name">{{.Name}}</span>

      <div class="inv-actions">
        {{if ne .Slot ""}}
          <form
            class="inline equip-form"
            action="/equip/{{.ID}}" method="post"
            hx-boost="false"
            hx-post="/equip/{{.ID}}"
            hx-target="#log"
            hx-swap="beforeend scroll:bottom"
            hx-on::before-request="equipOptimistic(this)"
            hx-on::after-request="if(event.detail.successful) equipCommit(this)"
            hx-on::response-error="equipRollback(this)"
            hx-on::send-error="equipRollback(this)">
            <button type="submit" class="mini mini-equip">
              {{if $isEquipped}}Unequip{{else}}Equip{{end}}
            </button>
          </form>
        {{end}}

        <form
          class="inline"
          action="/drop/{{.ID}}" method="post"
          hx-boost="false"
          hx-delete="/inventory/{{.ID}}"
          hx-trigger="submit"
          hx-target="closest li"
          hx-swap="outerHTML">
          <button type="submit" class="mini">Drop</button>
        </form>
      </div>
    </li>
  {{- end -}}
</ul>
{{end}}

{{define "roomItemsList"}}
<ul id="room-items-list" class="items-list"{{if .OOB}} hx-swap-oob="true"{{end}}>
  {{- range .ItemsHere -}}
    <li class="room-item">
      <span class="item-name">{{.Name}}</span>
      <form
        class="inline"
        action="/take/{{.ID}}" method="post"
        hx-boost="false"
        hx-post="/take/{{.ID}}"
        hx-target="#log"
        hx-swap="beforeend scroll:bottom">
        <button type="submit" class="mini">Take</button>
      </form>
    </li>
  {{- end -}}
</ul>
{{end}}

A couple of things worth noticing. The equipment panel has a stable id="equipment-panel". That makes it an easy OOB swap target. The inventory rows have stable IDs too (inv-item-sword, etc). That will help us with optimistic rollback.

The equip form doesn’t swap into the inventory list. It targets the log like take does, and we’ll handle the inventory visuals optimistically.

Now we need to actually place the equipment panel in the room template.

Show the Equipment Panel

Open templates/room.html and inside <aside class="sidebar">, add this above the Backpack section:

<aside class="sidebar">
  <h2>Equipment</h2>
  {{template "equipmentPanel" .}}

  <h2>Backpack</h2>
  {{template "inventoryList" .}}
</aside>

Add some CSS to make it readable (drop it in <style> block):

.equip {
  margin: 0.5rem 0 1rem 0;
  padding: 0.6rem 0.7rem;
  border: 1px solid #ddd;
  border-radius: 12px;
  background: #fafafa;
}

.equip-slot {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.25rem 0;
  border-bottom: 1px dashed #e3e3e3;
}
.equip-slot:last-child {
  border-bottom: none;
}

.equip-label {
  color: #666;
  font-weight: 600;
}
.equip-item {
  font-weight: 700;
}
.equip-empty {
  color: #888;
  font-style: italic;
}

.inv-actions {
  display: flex;
  gap: 0.4rem;
  align-items: center;
}

.inv-item.equipped {
  background: #f2f2ff;
  border-radius: 12px;
  padding: 0.35rem 0.5rem;
}

.inv-item.pending {
  opacity: 0.65;
  filter: grayscale(0.2);
}

.mini-equip {
  font-weight: 700;
}

At this point, if you reload, you’ll see:

  • Equipment: Weapon (none), Offhand (none)
  • Backpack: items (with Equip showing only on equipable items… well, once we add those items)

Now let’s add the actual sword and shield to the world.

Seed the Sword and Shield

Open cmd/adventure/main.go and add two items to your seed data:

seedItems := map[string][]game.Item{
	"hallway": {
		{ID: "lamp", Name: "Brass Lamp", Description: "A squat brass lamp. It's heavier than it looks.", Slot: game.SlotNone},
		{ID: "key", Name: "Rusty Key", Description: "A key that looks like it has opened exactly one door and regretted it.", Slot: game.SlotNone},
	},
	"atrium": {
		{ID: "coin", Name: "Ancient Coin", Description: "A coin stamped with a face you don't recognize, but it recognizes you.", Slot: game.SlotNone},
		{ID: "sword", Name: "Iron Sword", Description: "It has seen battles. Mostly with rust.", Slot: game.SlotWeapon},
		{ID: "shield", Name: "Kite Shield", Description: "A shield large enough to have opinions.", Slot: game.SlotOffhand},
	},
}

Now you can walk to the atrium, take the sword and shield, and they’ll appear in the backpack with an Equip button.

Optimistic Updates with hx-on

HTMX emits lifecycle events for every request, including:

  • htmx:beforeRequest - right before the request is issued.
  • htmx:responseError - if the server returns an HTTP error.
  • htmx:sendError - if the network fails.

And HTMX lets you attach handlers inline using hx-on. It even supports convenient shorthand for HTMX events: hx-on::before-request, etc.

We already wired these attributes onto the equip form:

hx-on::before-request="equipOptimistic(this)"
hx-on::after-request="if(event.detail.successful) equipCommit(this)"
hx-on::response-error="equipRollback(this)"
hx-on::send-error="equipRollback(this)"

Now we just need to define those three functions.

Open templates/room.html file and put this script in the <head>, after the HTMX script tag is a fine place:

<script>
  function equipOptimistic(form) {
    const li = form.closest("li");
    if (!li) return;

    const slot = li.dataset.slot;
    if (!slot) return;

    const wasEquipped = li.classList.contains("equipped");
    form.dataset.wasEquipped = wasEquipped ? "1" : "0";

    // If we're equipping, remember (and visually clear) the currently equipped item in this slot.
    const currentlyEquipped = document.querySelector(
      `#inventory-list li.equipped[data-slot="${slot}"]`,
    );

    if (!wasEquipped && currentlyEquipped && currentlyEquipped !== li) {
      form.dataset.prevEquippedId = currentlyEquipped.id;

      currentlyEquipped.classList.remove("equipped");
      const prevBtn = currentlyEquipped.querySelector("form.equip-form button");
      if (prevBtn) prevBtn.textContent = "Equip";
    } else {
      form.dataset.prevEquippedId = "";
    }

    // Make it feel instant.
    li.classList.add("pending");

    const btn = form.querySelector("button");
    if (wasEquipped) {
      li.classList.remove("equipped");
      if (btn) btn.textContent = "Equip";
    } else {
      li.classList.add("equipped");
      if (btn) btn.textContent = "Unequip";
    }
  }

  function equipCommit(form) {
    const li = form.closest("li");
    if (li) li.classList.remove("pending");

    delete form.dataset.wasEquipped;
    delete form.dataset.prevEquippedId;
  }

  function equipRollback(form) {
    const li = form.closest("li");
    if (!li) return;

    const wasEquipped = form.dataset.wasEquipped === "1";
    const prevId = form.dataset.prevEquippedId;

    const btn = form.querySelector("button");

    // Restore the clicked item's prior state.
    if (wasEquipped) {
      li.classList.add("equipped");
      if (btn) btn.textContent = "Unequip";
    } else {
      li.classList.remove("equipped");
      if (btn) btn.textContent = "Equip";

      // Restore the previously equipped item in this slot, if we cleared one.
      if (prevId) {
        const prev = document.getElementById(prevId);
        if (prev) {
          prev.classList.add("equipped");
          const prevBtn = prev.querySelector("form.equip-form button");
          if (prevBtn) prevBtn.textContent = "Unequip";
        }
      }
    }

    li.classList.remove("pending");

    delete form.dataset.wasEquipped;
    delete form.dataset.prevEquippedId;
  }
</script>

What do these functions do:

  • On beforeRequest, we update the UI immediately:
    • toggle the equipped state for the clicked item.
    • clear the equipped state of any other item in the same slot.
    • mark the row “pending” so the player feels the action registered.
  • On success, we simply clear the pending state.
  • On error, we rollback the UI using the little breadcrumbs we stored in data-*.

Wire the /equip/{id} Handler

We want this request to do two things:

  1. Append a log entry (main swap into #log).
  2. Update the Equipment panel (OOB swap).

Update the Web Layer Types

Open internal/web/handlers.go and extend the view models so templates can see equipment info:

type RoomPageData struct {
	Room game.Room
	Log  []game.LogEntry

	Inventory []game.Item
	ItemsHere []game.Item

	WeaponID  string
	OffhandID string

	Weapon   game.Item
	WeaponOK bool
	Offhand  game.Item
	OffhandOK bool

	OOB bool
}

type ListPartialData struct {
	OOB bool

	Inventory []game.Item
	ItemsHere []game.Item

	WeaponID  string
	OffhandID string

	Weapon   game.Item
	WeaponOK bool
	Offhand  game.Item
	OffhandOK bool
}

Update handleRoom to Pass Equipment Fields

Inside handleRoom, after snap := state.Snapshot():

_ = s.Tmpl.ExecuteTemplate(w, "room.html", RoomPageData{
	Room:      room,
	Log:       snap.Log,
	Inventory: snap.Inventory,
	ItemsHere: snap.ItemsHere,

	WeaponID:  snap.WeaponID,
	OffhandID: snap.OffhandID,

	Weapon:    snap.Weapon,
	WeaponOK:  snap.WeaponOK,
	Offhand:   snap.Offhand,
	OffhandOK: snap.OffhandOK,
})

Add the Route

In Routes():

mux.HandleFunc("POST /equip/{id}", s.handleEquip)

Implement handleEquip

Add this method:

func (s *Server) handleEquip(w http.ResponseWriter, r *http.Request) {
	state := s.Sessions.Get(w, r)
	before := state.Snapshot()

	itemID := r.PathValue("id")

	res, err := state.ToggleEquip(itemID)

	var entry game.LogEntry
	if err != nil {
		entry = state.Append(
			"equip "+itemID,
			"You fumble with your gear. ("+err.Error()+")",
			"error",
		)
	} else {
		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)
			}
			entry = state.Append("equip "+itemID, msg, "system")
		} else {
			entry = state.Append("unequip "+itemID, fmt.Sprintf("You unequip the %s.", res.Item.Name), "system")
		}
	}

	if !IsHTMX(r) {
		http.Redirect(w, r, "/room/"+before.RoomID, http.StatusSeeOther)
		return
	}

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

	// Main swap: append to #log.
	if err := s.Tmpl.ExecuteTemplate(w, "logEntry", entry); err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	// OOB swap: update the equipment panel.
	after := state.Snapshot()
	_ = s.Tmpl.ExecuteTemplate(w, "equipmentPanel", ListPartialData{
		OOB: true,

		WeaponID:  after.WeaponID,
		OffhandID: after.OffhandID,

		Weapon:    after.Weapon,
		WeaponOK:  after.WeaponOK,
		Offhand:   after.Offhand,
		OffhandOK: after.OffhandOK,
	})
}

Let’s read this code. The UI already optimistically updated and now the server:

  • Toggles the real state.
  • The response appends a log entry as the narration.
  • The equipment panel updates out-of-band.

Update Drop to Also Refresh Equipment OOB

Because dropping can unequip an item now, the HTMX delete handler should update #equipment-panel. In handleInventoryDelete, after you render the room items list OOB, also render the equipment panel OOB:

after := state.Snapshot()

_ = s.Tmpl.ExecuteTemplate(w, "roomItemsList", ListPartialData{
	OOB:       true,
	ItemsHere: after.ItemsHere,
})

_ = s.Tmpl.ExecuteTemplate(w, "equipmentPanel", ListPartialData{
	OOB: true,

	WeaponID:  after.WeaponID,
	OffhandID: after.OffhandID,

	Weapon:    after.Weapon,
	WeaponOK:  after.WeaponOK,
	Offhand:   after.Offhand,
	OffhandOK: after.OffhandOK,
})

Now the sidebar will stay in sync.

What Exactly Happens When You Equip or Drop

Before we run the game, let’s trace both actions from button click all the way through the code, so there’s no mystery about what the server is doing.

When you click Equip:

  • The item must already be in your inventory. If it isn’t there, the server rejects the request with ErrNotInInventory and the UI rolls back, nothing changes.
  • Not every item can be equipped. Items with Slot: SlotNone like the Brass Lamp return ErrNotEquippable. They don’t even get an Equip button in the template, but the server enforces this anyway.
  • Equip is a toggle. If the item is already equipped, clicking the button again unequips it. It stays in your inventory either way, nothing drops to the floor.
  • If another item is already occupying that slot, for example, you equip the Iron Sword while a dagger is already wielded, the old item is quietly unequipped but stays in your inventory. It doesn’t drop to the room. You simply have two swords in your backpack, one of which is equipped.
  • The inventory row updates appearance before the server responds — that’s the optimistic update via equipOptimistic. The pending class dims the row so the player knows the action is in flight.
  • Once the server confirms, the Equipment panel in the sidebar refreshes via an out-of-band swap and the log gets a narrated entry “You equip the Iron Sword.”.
  • If the request fails (network error or server error), equipRollback restores the row to exactly the state it was in before you clicked.

When you click Drop:

  • The item must be in your inventory. If it isn’t, the handler does nothing and returns a 200 so the row still disappears gracefully on the client.
  • If the item was equipped as weapon or offhand, it is automatically unequipped before being dropped. The slot clears. You don’t drop a sword you’re still technically holding.
  • The item is removed from your inventory and placed in the room you are currently standing in and not the room where you originally found it. If you pick up the sword in the Atrium, walk to the Hallway, and drop it, it lands in the Hallway.
  • The item never goes back to your inventory automatically. Once dropped, it is a room item. You’d have to walk up to it and click Take again.
  • The Equipment panel refreshes via OOB swap (in case what you just dropped was equipped), and the room’s item list refreshes so the item appears immediately at your feet.

Run It

Now play the game:

  1. Go north to the atrium.
  2. Take the Iron Sword and Kite Shield.
  3. Click Equip on the sword.

What you should notice:

  • The sword row highlights immediately (optimistic update).
  • The Equipment panel updates a beat later (server confirmation).
  • The log tells a story line once the response arrives.
  • If the request fails, the UI snaps back cleanly (rollback).

What’s Next

Right now, our backpack is functional and our gear feels snappy.

In part 6, we’re going to build something that feels like magic in different way: Active Search.

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.