We’ve backed ourselves into a bit of a corner.
Take a look at main.go. It’s becoming a mess of hardcoded strings, coordinate, and manual object placements. Every time you want to add a room, you have to recompile the server. If you want to move the Rusty Key from the Hallway to the Atrium, you have to stop the world, rewrite the code and restart the universe.
This is not how game engines work.
When id Software built Doom in the early 90s, one of their greatest architectural achievements wasn’t just the 3D (well, 2.5D), it was the WAD (Where’s All the Data) file format. By separating the engine code from the map data, level designers could build out worlds without ever needing to touch or recompile the C code.
A true game engine separates the Engine (Logic) from the Assets (Data). The Engine knows how to parse a command or calculate a path, but it shouldn’t know about the Atrium or the Rust Key. That information belongs in a data file that can be edited, expanded, and swapped out without touching a single line of Go code.
In this chapter, we’ll implement a YAML-based loader and build a “Fail Fast” validation system to ensure our world is logically sound before the server even starts.
The Blueprint
First, we need to move our data out of Go and into YAML. We’ll use YAML format since it’s easy for humans to read and write.
Create a new file in your root directory called world.yaml. This file represents the “Source of Truth” for the game:
title: "Adventures in HTMX"
start_room: "hallway"
rooms:
- id: "hallway"
name: "The Hallway"
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"
x: 0
y: 0
exits:
north: "atrium"
east: "basement"
items:
- "lamp"
- "key"
- id: "atrium"
name: "The Grand Atrium"
description: "You step into a grand atrium. The ceiling is glass, revealing a grey, indifferent sky."
image: "https://placehold.co/600x200/2a2a2a/c5a059?text=THE+ATRIUM&font=roboto"
x: 0
y: 1
exits:
south: "hallway"
items:
- "sword"
- "shield"
- "coin"
- id: "basement"
name: "The Basement"
description: "It is dark here. The smell of mold is overpowering."
image: "https://placehold.co/600x200/2a2a2a/ff3333?text=THE+BASEMENT&font=roboto"
x: 1
y: 0
exits:
west: "hallway"
items:
- id: "lamp"
name: "Brass Lamp"
description: "A heavy brass lamp. It feels warm."
- id: "key"
name: "Rusty Key"
description: "It looks like it opens something important."
- id: "coin"
name: "Ancient Coin"
description: "A coin stamped with a face you don't recognize."
- id: "sword"
name: "Iron Sword"
description: "A rusty blade. Better than nothing."
slot: "weapon"
- id: "shield"
name: "Kite Shield"
description: "A large wooden shield."
slot: "offhand"
Mapping Data to Code
Now we need to teach Go how to read this file. We do this using Struct Tags.
Struct tags are metadata attached to struct fields that tell libraries (like the YAML parser) how to map data to you code. If you look at the YAML above, the keys are lowercase (e.g., start_room), but Go exported fields must be Uppercase (e.g., StartRoom). Struct tags bridge this gap.
We’re also going to change how Exits are stored, from list of structs to a map, which matches our YAML representation.
Update internal/game/model.go:
package game
import (
"html/template"
"time"
)
// World represents the entire game state as loaded from the file
type World struct {
Title string `yaml:"title"`
StartRoom string `yaml:"start_room"`
Rooms []Room `yaml:"rooms"`
Items []Item `yaml:"items"`
}
type Room struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Image string `yaml:"image"`
X int `yaml:"x"`
Y int `yaml:"y"`
// We changed Exits from []Exit to a map for easier data entry
Exits map[string]string `yaml:"exits"`
Items []string `yaml:"items"` // List of Item IDs
}
// We can remove the old 'Exit' struct entirely, as we now use a simple map.
type Slot string
const (
SlotNone Slot = ""
SlotWeapon Slot = "weapon"
SlotOffhand Slot = "offhand"
)
type Item struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Slot Slot `yaml:"slot"`
}
// ... LogEntry and Spell structs remain unchanged ...
The Loader & Validation
The most important thing about reading data from external file is integrity. When data was hardcoded in Go, the compiler ensured that if you referenced a variable, it existed. In YAML, you could easily type exits: north: atriuum (typo), and the compiler wouldn’t know. The player would just click “North” and crash the server.
To fix this, we’ll write a Validate step in our main loader.
In cmd/adventure/main.go:
package main
import (
"log"
"net/http"
"os"
"github.com/robjsliwa/adv-htmx/internal/game"
"github.com/robjsliwa/adv-htmx/internal/web"
"gopkg.in/yaml.v3"
)
func main() {
// Load the raw data
data, err := os.ReadFile("world.yaml")
if err != nil {
log.Fatal("Failed to read world.yaml: ", err)
}
// Parse YAML into our World struct
var world game.World
if err := yaml.Unmarshal(data, &world); err != nil {
log.Fatal("Failed to parse YAML: ", err)
}
// We convert lists to maps for fast lookup during gameplay
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
}
// Verify that every exit points to a real room
for _, room := range roomsMap {
for dir, targetID := range room.Exits {
if _, exists := roomsMap[targetID]; !exists {
log.Fatalf("DATA ERROR: Room '%s' has exit '%s' pointing to unknown room '%s'", room.ID, dir, targetID)
}
}
// Verify items exist
for _, itemID := range room.Items {
if _, exists := itemsMap[itemID]; !exists {
log.Fatalf("DATA ERROR: Room '%s' contains unknown item '%s'", room.ID, itemID)
}
}
}
// Place items into their starting rooms
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
}
// Start the server
templates := web.MustLoadTemplates()
sessions := game.NewSessionStore(seedItems)
srv := &web.Server{
Rooms: roomsMap,
Sessions: sessions,
Tmpl: templates.T,
}
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()))
}
Note: You’ll need to get YAML library:
go get gopkg.in/yaml.v3
Fixing Logic
When we changed Room.Exits from a list []Exits to a map map[string]string, we broke our Goblin’s brain. The compiler is complaining because we are trying to index a map with a random integer, which is not allowed.
We need to update the MoveGoblin function in internal/game/state.go. Since we can’t pick a random entry directly from a map, we need to gather the valid room IDs into a temporary list first:
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
}
// NEW: Convert the map of exits to a slice so we can pick a random one
var validExits []string
for _, targetRoomID := range currentRoom.Exits {
validExits = append(validExits, targetRoomID)
}
// Pick a random exit from our list
if len(validExits) > 0 {
s.Goblin.RoomID = validExits[rand.Intn(len(validExits))]
}
}
Updating the View
At this point code compiles but the UI is not doing to well.
We changed the Exits structure from a list of structs [{label: "North", To: "atrium"}] to a simple map {"north": "atrium"}. This breaks our HTML template because the template expects .Label and .To fields, which no longer exist.
We need to update room.html to iterate over the map keys and values. In Go templates, iterating over a map gives up the Key (Direction) and the Value (Target Room ID).
In templates/room.html, find the <h2>Exits</h2> section and replace the <ul> block with this:
<ul class="exits">
{{range $dir, $target := .Room.Exits}}
<li>
<a href="/room/{{$target}}" class="capitalize">Go {{$dir}}</a>
</li>
{{else}}
<li>(There are no exits. You are trapped.)</li>
{{end}}
</ul>
Run It
Let’s break the world on purpose first. Open world.yaml and change the Atrium’s ID to atriuum (a typo). Now start the server and you should see error:
DATA ERROR: Room 'hallway' has exit 'north' pointing to unknown room 'atrium'
Fix the typo, save the file, and run it again. This time:
- Watch the terminal. You should see a clean startup announcing the world title and how many rooms and items were loaded.
- Open your browser to
http://localhost:4040. - You’re in the Hallway. The Brass Lamp and the Rusty Key should be on the ground.
- Go North. The Atrium should have the Iron Sword, the Kite Shield, and the Ancient Coin.
- Check the map. The goblin should still be wandering the dungeon, its red dot pulsing in visited rooms.
Exercise: Add a Room
The real test of a data driven engine is how easy it is to extend. Try adding a fourth room to world.yaml:
- id: "library"
name: "The Forgotten Library"
description: "Shelves stretch into the darkness. Most books have crumbled to dust."
image: "https://placehold.co/600x200/2a2a2a/9966ff?text=THE+LIBRARY&font=roboto"
x: 1
y: 1
exits:
west: "atrium"
south: "basement"
Don’t forget to add the matching exits in the Atrium east: "library" and the Basement north: "library". Restart the server.
What’s Next
We have refactored our engine to separate code from data. The code describes how the world works and the YAML describes what the world is. We also ensured safety, if you make a typo in the YAML, the server refuses to start, printing a helpful error message.
But the engine still have one weak link.
Did you notice what happened when we changed the Exits structure from a list to a map? The Go code compiled perfectly. The server started without errors. But when you opened the browser, the UI was broken.
This happened because standard Go HTML templates are parsed at runtime. The compiler has no idea that {{.Label}} inside room.html refers to a field that no longer exists. You only find out you broke the UI when you (or your users) actually try to play the game.
In the next article, we’re going to eliminate this entire class of bugs. We’ll introduce Templ, a library that lets us write HTML as strongly typed Go code. If we had been using Templ, the compiler would have stopped us the moment we changed the Exits definition, guaranteeing that our UI is always in sync with our data.
Code
You can find full code on GitHub.