Up to this point, our game relied entirely on request and response interaction model. You click “north” or you click “take sword,” the server sends you to the north room or puts the sword in your bag. If you walk away from your keyboard for an hour, the goblins patiently freeze in time, holding their breath until you return.
But a living world doesn’t wait.
What we want is a dungeon where things happen while the player is thinking. Enemy patrols move around and the map gets updated with their movements even when the player is not interacting with the game.
To implement this we could use WebSocket or SSE but for now, let’s learn how to use a much simpler tool: Polling.
In this chapter, we are going to build a live, mini map of the dungeon using server generated SVGs, and we are going to release a monster into the maze.
Mapping the Territory
So far, our dungeon has been what mathematicians call a graph. Our rooms were just floating bubbles of data, tethered together by ID strings. While this works perfectly for logic, the engine knows that “North” leads to “Atrium”, it lacks a sense of physical space. Without a coordinate system, the computer has no idea if the Atrium is ten feet away or ten miles. It just knows they are connected.
To draw a map that feels intuitive to a human player, we need to move from an abstract graph to a Cartesian Grid. Why? Because a map is essentially a visual translation of relationships into distances. By assigning each room an X and Y value, we provide the “anchor points” necessary for our SVG render to know where to place a room’s icon on the screen. This geometry also opens the door for the future mechanics, like calculating distance for ranged spell or, as we’ll see shortly, tracking the movement of a wondering goblin across a physical layout.
Let’s ground our abstract world in physical reality. Open internal/game/model.go and add these coordinates to our Room struct. We’ll treat (0,0) as our starting “center” point.
type Room struct {
ID string
Name string
Description string
Image string
X, Y int // Grid coordinates: X (East/West), Y (North/South)
Exits []Exit
}
With the Room updated, we need to actually “survey” our existing rooms. We’ll lay them out on a simple grid where moving North increases the Y value and moving East increases the X value.
Open cmd/adventure/main.go and update the world data. We’ll place the Hallway at the origin (0,0), and since the Atrium is to the North, it naturally sits at (0,1).
rooms := map[string]game.Room{
"hallway": {
ID: "hallway",
Name: "The Hallway",
X: 0, Y: 0, // The anchor point of our world
Description: "You are standing in a dimly lit hallway. Dust motes dance in the air.",
Image: "https://placehold.co/600x200/2a2a2a/33ff00?text=THE+HALLWAY&font=roboto",
Exits: []game.Exit{
{Label: "Go North", To: "atrium"},
{Label: "Go East", To: "basement"}, // Let's add that exit now!
},
},
"atrium": {
ID: "atrium",
Name: "The Atrium",
X: 0, Y: 1, // Directly North of the Hallway
Description: "You step into a grand atrium. The ceiling is glass, revealing a grey sky.",
Image: "https://placehold.co/600x200/2a2a2a/c5a059?text=THE+ATRIUM&font=roboto",
Exits: []game.Exit{
{Label: "Go South", To: "hallway"},
},
},
"basement": {
ID: "basement",
Name: "The Basement",
Description: "It is dark and smells of mold.",
Image: "https://placehold.co/600x200/2a2a2a/ff3333?text=THE+BASEMENT&font=roboto",
X: 1, Y: 0, // Directly East of the Hallway
Exits: []game.Exit{
{Label: "Go West", To: "hallway"},
},
},
}
Now that every room has its place in the physical world, we have the mathematical foundation needed to start drawing our automated cartographer.
Keep Track of Visited Rooms
We also need to keep track of the rooms we previously visited. Open internal/model/state.go and add Visited:
type State struct {
mu sync.Mutex
RoomID string
Visited map[string]bool // Which rooms has the player seen?
Turn int
Log []LogEntry
Inventory map[string]Item
RoomItems map[string]map[string]Item
WeaponID string
OffhandID string
}
And then add helper function to mark the room visited:
func (s *State) MarkVisited(roomID string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.Visited == nil {
s.Visited = make(map[string]bool)
}
s.Visited[roomID] = true
}
Programming SVG in Go
We have our coordinates, but a pair of integers like (0,1) isn’t a map, it’s just data. To see our dungeon, we need to translate that data into a visual format. We could use JavaScript to manipulate DOM with complex CSS, but there is a much more elegant way: Scalable Vector Graphics (SVG).
Why SVG?
An SVG is not a bitmap image like a JPEG or PNG. It’s a text based XML file that describes shapes. Because it’s text, it’s incredibly small to send over the wire. Because it’s vector based, it looks crisp at any zoom level. But most importantly for us, it’s a perfect match for HTMX. Since HTMX loves swapping HTML fragments, and SVG is essentially just “special HTML” for shapes, we can swap out entire map as easily as we swap a line of text.
The Coordinate Transformation
Before we write the code, we need a plan for the match. Out grid is centered at (0,0). However, SVG screens start at (0,0) in the top-left corner. To prevent the map from disappearing off screen, we need to offset our coordinates.
If our map canvas is 200x200 pixels:
- We’ll treat the pixel coordinate
(100,100)as our grid origin(0,0). - Every grid unit will represent 40 pixels.
- The math:
PixelX = 100 + (GridX * 40)andPixelY = 100 - (GridY * 40). We substract Y because in SVG, a larger Y value moves you down the screen, but in our game, a larger Y means you went North.
Implementing the Generator
Create a new file internal/web/map.go and let’s build RenderMap function.
package web
import (
"fmt"
"strings"
"<your github id>/adv-htmx/internal/game"
)
// RenderMap generates a raw SVG string based on the player's knowledge of the world.
func RenderMap(currentRoomID string, visited map[string]bool, rooms map[string]game.Room) string {
var sb strings.Builder
// Start the SVG canvas
sb.WriteString(`<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">`)
// Draw a dark background
sb.WriteString(`<rect width="100%" height="100%" fill="#1a1a1a" />`)
// Draw the rooms
for id, room := range rooms {
// If the player hasn't seen it, it doesn't exist on the map!
if !visited[id] {
continue
}
// Apply our Coordinate Transformation
x := 100 + (room.X * 40)
y := 100 - (room.Y * 40)
// Determine the color: Bright green for "You are here", grey for "Visited"
color := "#555"
if id == currentRoomID {
color = "#33ff00"
}
// Draw a room box centered on our calculated point
// (We offset by -15 so the 30x30 box is centered)
sb.WriteString(fmt.Sprintf(
`<rect x="%d" y="%d" width="30" height="30" rx="4" fill="%s" stroke="#000" stroke-width="2" />`,
x-15, y-15, color,
))
}
sb.WriteString(`</svg>`)
return sb.String()
}
Let’s break this down.
We use strings.Builder as it’s the most efficient way in Go to build a long string. It avoids creating dozens of temporary string objects in memory as we append shapes.
We iterate over every room in our world data. We use the visited map (which we’ll update in the handler) to ensure the player only sees where they’ve been.
We then turn the abstract coordinates into literal screen pixels. If you move North to (0,1), the math turns that into Y = 100 - 40 = 60, moving the square up the screen exactly as the player expects.
Integrating the Handler
Now, we just need a way to serve this SVG to HTMX. Open internal/web/handlers.go and add the handleMap function. Note how clean this is. We aren’t even calling a template. We’re just sending the raw string.
func (s *Server) handleMap(w http.ResponseWriter, r *http.Request) {
state := s.Sessions.Get(w, r)
state.MarkVisited(state.RoomID)
// Generate the SVG string using our new Go function
svgContent := RenderMap(state.RoomID, state.Visited, s.Rooms)
// Tell the browser this is an image/svg, not just plain text
w.Header().Set("Content-Type", "image/svg+xml")
fmt.Fprint(w, svgContent)
}
Don’t forget to register the route in your Routes() function:
mux.HandleFunc("GET /map", s.handleMap)
Showing the Map
We have the backend that keeps track of the map and visited rooms. Now, we need to add the map to our interface to show the map in the sidebar. This map will refresh itself to keep the map up to date.
Open templates/room.html. Add a new panel to the sidebar column. This is where the magic of HTMX polling comes alive.
<div class="panel map-panel">
<h3>Map</h3>
<div id="dungeon-map"
class="map-display"
hx-get="/map"
hx-trigger="load, every 2s"
hx-swap="innerHTML">
<div class="scanning-text">Initializing sensor array...</div>
</div>
</div>
hx-trigger="every 2s" is the feature that makes the world feel alive.
load fires the moment the HTML hits the DOM. This ensures the player sees the map immediately without waiting for the first interval.
After that, every 2 seconds HTMX issues a GET /map request.
hx-swap="innerHTML swaps out the old SVG for the new one.
Styling the Map
Let’s add a little CSS to templates/room.html:
.map-panel {
background: #000;
border: 1px solid #333;
padding: 10px;
margin-bottom: 20px;
}
.map-display {
width: 200px;
height: 200px;
margin: 0 auto;
background: #111; /* Matches the SVG background */
border-radius: 4px;
overflow: hidden;
position: relative;
}
.scanning-text {
color: #0f0;
font-family: monospace;
font-size: 0.8rem;
text-align: center;
padding-top: 90px;
animation: blink 1s infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
Here There Be Monsters
Let’s create a monster that wanders the dungeon.
In internal/game/state.go, let’s add a simple global monster for the server.
type Monster struct {
RoomID string
Name string
}
type State struct {
// ... existing
Goblin Monster
}
And add this to NewState:
s := &State{
RoomID: "hallway",
Inventory: make(map[string]Item),
RoomItems: make(map[string]map[string]Item),
Log: nil,
Turn: 0,
Goblin: Monster{RoomID: "basement", Name: "Snarl"},
}
The Simulation Loop
We need logic to move the goblin. Since we are using HTMX polling, we can cheat a little. Instead of running a complex background goroutines with mutexes, we can calculate the goblin’s movement every time the map is requested.
It effectively ties the Game Speed to the Map Refresh Rate.
Open internal/game/state.go and add a method:
func (s *State) MoveGoblin(rooms map[string]Room) {
s.mu.Lock()
defer s.mu.Unlock()
// 20% chance to move every tick
if rand.Intn(10) > 2 {
return
}
// Find current room
currentRoom, ok := rooms[s.Goblin.RoomID]
if !ok || len(currentRoom.Exits) == 0 {
return
}
// Pick a random exit
exit := currentRoom.Exits[rand.Intn(len(currentRoom.Exits))]
s.Goblin.RoomID = exit.To
}
Update the Map Generator
Since we aren’t using HTML template for the map, we need to update Go rendering logic to draw the threat.
Open internal/web/map.go. We need to update RenderMap to accept the goblin’s location and draw a scary red dot if he’s visible.
// Update signature to accept goblinRoomID
func RenderMap(currentRoomID string, visited map[string]bool, rooms map[string]game.Room, goblinRoomID string) string {
var sb strings.Builder
// ... (SVG Header and Background logic remains the same) ...
sb.WriteString(`<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">`)
sb.WriteString(`<rect width="100%" height="100%" fill="#1a1a1a" />`)
for id, room := range rooms {
// Only draw visited rooms
if !visited[id] {
continue
}
x := 100 + (room.X * 40)
y := 100 - (room.Y * 40)
// Draw Room Box
color := "#555"
if id == currentRoomID {
color = "#33ff00"
}
sb.WriteString(fmt.Sprintf(
`<rect x="%d" y="%d" width="30" height="30" rx="4" fill="%s" stroke="#000" stroke-width="2" />`,
x-15, y-15, color,
))
// NEW: Draw the Goblin if he is here!
if id == goblinRoomID {
// A scary red circle with a CSS animation
sb.WriteString(fmt.Sprintf(
`<circle cx="%d" cy="%d" r="8" fill="#ff0000" class="pulse-danger" />`,
x, y,
))
}
}
sb.WriteString(`</svg>`)
return sb.String()
}
Add animation CSS to room.html style block:
.pulse-danger {
animation: pulse-red 1s infinite;
}
@keyframes pulse-red {
0% { r: 8; opacity: 1; }
50% { r: 10; opacity: 0.5; }
100% { r: 8; opacity: 1; }
}
The Handler
Now we tie it all together in internal/web/handlers.go:
func (s *Server) handleMap(w http.ResponseWriter, r *http.Request) {
state := s.Sessions.Get(w, r)
// We tick the simulation forward every time the map refreshes.
state.MoveGoblin(s.Rooms)
// Generate the View
svgContent := RenderMap(state.Location, state.Visited, s.Rooms, state.Goblin.RoomID)
w.Header().Set("Content-Type", "image/svg+xml")
// Send the SVG
fmt.Fprint(w, svgContent)
}
Run It
Restart the server.
- Go to the Hallway.
- Wait.
- Watch the map.
- Eventually, you will see a red dot appear in the Basement, if you visited it, or move into the Hallway.
You are doing absolutely nothing and your hands are off the keyboard. Yet, the browser is talking to Go, Go is moving the goblin structs in memory, generating a new SVG string, and HTMX is diffing the DOM to show the movement.
Extra Credit: Reactive Description
There is one flaw. If the Goblin walks into your room, the map shows a red dot on top of you. But the log doesn’t announce anything.
We can fix this with a powerful HTMX pattern: Response Headers.
When handleMap runs, if it detects the goblin is in the same room as the player, it can tell the Game Log to refresh itself too.
Update handleMap to add the checks for goblin’s location:
func (s *Server) handleMap(w http.ResponseWriter, r *http.Request) {
state := s.Sessions.Get(w, r)
state.MarkVisited(state.RoomID)
wasWithPlayer := (state.Goblin.RoomID == state.RoomID)
// The World Moves
state.MoveGoblin(s.Rooms)
// Narrative Logic: Did the goblin just arrive?
isWithPlayer := (state.Goblin.RoomID == state.RoomID)
// Generate the SVG string using our new Go function
svgContent := RenderMap(state.RoomID, state.Visited, s.Rooms, state.Goblin.RoomID)
// Tell the browser this is an image/svg, not just plain text
w.Header().Set("Content-Type", "image/svg+xml")
if isWithPlayer && !wasWithPlayer {
// The goblin just walked into your room!
// We manually add an entry to the game log.
state.AddLogEntry(fmt.Sprintf("You hear a guttural growl. %s the Goblin has arrived.", state.Goblin.Name), "system")
// Signal the UI to refresh the log
w.Header().Set("HX-Trigger", "goblin-nearby")
}
fmt.Fprint(w, svgContent)
}
Also in internal/game/state.go and log helper function:
func (s *State) AddLogEntry(message string, style string) {
s.mu.Lock()
defer s.mu.Unlock()
s.Turn++
s.Log = append(s.Log, LogEntry{
Turn: s.Turn,
Output: message,
Kind: "system",
})
}
When the server detects the goblin is nearby, it sends HX-Trigger: goblin-nearby. We just need to tell Game Log to listen for it.
Open templates/room.html and update the log container:
<div id="log"
class="log"
hx-get="/log-history?before=0"
hx-trigger="goblin-nearby from:body"
...>
Now, the map poll triggers a chain reaction that refreshes your game log, revealing a goblin breathing heavily next to you!
What’s Next
We have a game that looks good and feels alive but as we add more features, our interface is getting crowded. We need a way to manage settings, help screens, and administrative tasks without cluttering the screen.
In the next article, we’ll explore Dialogs, Toggles, and Inline Editing to give the user control over their experience.
Code
You can find full code on GitHub.