Building Your Own Keep
A guide to lux crawl — how the little keep works, and how to make it yours.
Type lux crawl and lux writes you a folder with a text adventure in it. You play it by running it, which is ordinary enough. What is not ordinary is that the whole world — every room, the locked door, the torch in the cellar — is written out in one file called world.lux, in the same language you are learning, with nothing hidden away in an engine somewhere. There is no level editor and no data format. There is a program, and you can read it.
That is the point of the thing. Most games let you play them. This one lets you open it up, and the moment you change a line and run it again, you have stopped being a player and started being a builder. This page is the guide to that second half: what the file is doing, why it is shaped the way it is, and what to try first.
You can start here without having read anything else. Each idea is explained from the beginning as it comes up, and nearly every section ends with two pointers — the terse card in your terminal, like lux learn enums, and the slower walkthrough of the same idea in An Introduction to Programming — so you can go shallower or deeper at any point without losing your place here.
world.lux is also commented throughout, and those comments are the other door into the same building. They tell you what each piece of the file does, standing right where it does. This page explains why the pieces are shaped that way and what to carry off to the next program you write. Read them in either order; neither one assumes the other.
Here and there you will find a boxed note headed a name for this. Those give you the word other programmers use for something you have just met, so that you recognize it when somebody says it out loud. Skip every one of them on your first pass if you like — nothing else on the page depends on them, and they will still be there when you want them.
The page does assume one thing, though, and it matters more than the rest.
What's inside
- Play it first
- The world is a value
- A fixed set of places
- What's true right now
- When there's no answer
- One job each
- Words you build
- The four moving parts
- Skip to what you're building
- Exercise: add a room
- Exercise: hide a thing
- Exercise: remember something
- Planning a wing
- When lux stops you
- Mistakes lux can't catch
- More to try
- Where you'll feel the edges
- It's your keep
Play it first
Do not read the file yet. Play the keep the way anyone would, and get through the locked door — the keep has something to tell you when you do, and it lands far better from inside the game than from a web page. It takes about five minutes.
$ lux crawl
$ cd crawl
$ lux run world.lux
Type help if you get stuck, and quit when you are done. Nothing below depends on your having done it, but it all lands better once you have.
If lux is not on your machine yet, Getting lux in the introduction has the one-line install for macOS and Linux, and the way to remove it again if you decide it is not for you.
Here is the map you will have drawn in your head by then, handy to have in front of you for the rest of this page. North is up. Each passage shows the word that takes you out first and the word that brings you back second, so from the hall, north reaches the chamber and south brings you home again.
[chamber]
|
north / south | locked iron door — needs the brass key
|
[hall] ——— east / west ——— [cellar]
| |
south / north | down / up |
| |
[entrance] [vault]
dark — needs the torch
Five rooms, one lock, two things to pick up, and a hoard of gold nobody told you about — the whole game, in about three hundred lines of lux.
The world is a value, not a place
Everything else in the keep rests on this one idea, so take this section slowly. It is also the idea most likely to still be useful to you in twenty years, in languages nobody has written yet.
First, the thing the whole section is about. Everything the keep knows lives in one value called a World, and a World is a struct — a value with named fields:
struct World {
room: Room
items: [string]
doorOpen: bool
playing: bool
}
Where you are standing, what is in your pack, whether the iron door has been opened, and whether the game is still going. That is the whole memory of the game — there is nothing else, anywhere. Room is the closed list of places you can stand, and the next section is about why it is a list rather than plain text.
Numbers and boxes
Think about the number 7 for a second. You cannot change 7. There is no operation that makes the 7 itself become an 8 — you can only compute 8 from it by adding 1, and 7 carries on being 7. Nobody finds this frustrating, because a number is a value: a thing that simply is what it is.
A box is the other kind of thing. It holds a value now and might hold a different one later, and here is the part that matters: any code that can find the box can change what is inside it, and it does not have to tell you.
In lux, a World behaves like the number rather than the box. Every action in world.lux builds a whole new world instead of adjusting the one it was handed, and two facts about the language keep it that way.
Why a function can't reach back
Try the obvious thing inside a function and you meet the first fact:
func openDoor(w: World) {
w.doorOpen = true
}
error: cannot change `w.doorOpen` — `w` is a parameter,
and a parameter never changes
note: a parameter can't be a var; copy it into a local var
first — `var copy = w` — and change that
What a function is handed, it may read and may not alter. So the door stays shut, and lux says why rather than letting it slide — and it tells you the way round before you have to ask.
You can get around that in one line, by copying what you were given into something you are allowed to change:
var next = w
next.doorOpen = true
Now the second fact arrives, and it is the one holding everything else up. That copy is a genuine copy. next and w are two separate worlds from the moment you write that line, so opening the door on next leaves the world outside this function as shut as it ever was. Two names never share one world in lux, and there is no way to make them.
Put those two together and a function cannot alter the world it was handed, however hard it tries. That leaves one way to make a change that outlives the function: work out what the world would look like with the door open, and hand that back.
return World(room: w.room, items: w.items, doorOpen: true, playing: w.playing)
The world that went in still has its door shut, off in the past where you left it. The world that came out has it open. Once that clicks, the rest of the file stops looking strange and starts looking inevitable.
What a signature tells you
Here is the function the whole keep turns on. Read it slowly, as if the arrow means something:
func step(w: World, cmd: string) -> World
Everything this function needs arrives through the parameters, and everything it produces leaves through the return. That also tells you what step cannot do. It cannot quietly change a world somebody else is holding, and it cannot leave anything behind for the next call to trip over, because nothing is in scope except what you handed it.
Get into the habit of reading that one line carefully before you read the body underneath it. It often tells you more about what a function can get up to than the body does.
One turn after another
If each turn produces a new world, then a game is a chain of them, one per command:
world 0 world 1 world 2 world 3
entrance hall hall hall
pack: — pack: — pack: key pack: key
door: shut door: shut door: shut door: OPEN
\ \ \ \
---north---> ---take key---> ---open door--->
Each arrow is one call to step. The loop at the bottom of the file is that chain being built, one link at a time:
while world.playing {
world = match readLine() {
some(let line) => step(world, line)
none => leave(world)
}
}
Three things in that loop have not been explained yet, and each gets a section of its own further down, so read past them for now. readLine hands back whatever the player typed. match picks one way forward out of several, and it is what A fixed set of places is about. some(let line) and none are how lux says "there was a line" and "there was not" — the subject of A question that may have no answer, and the reason the keep stops cleanly when the player has nothing more to say. Read the whole block as: while the game is still going, take what was typed and step to the next world; when there is nothing left, leave.
The only real work in that loop is the =. It does not modify the world; it points the name world at the newer one. The previous world is not damaged or destroyed, merely no longer the one we are looking at.
What you get for it
All of this would be a nuisance if it did not pay. It pays three times over, and each one is a few lines you can go and try right now.
You can test the game without playing it. Because step needs nothing but a world and a command, you can invent a world, hand it over, and inspect what comes back — no walking, no typing, no getting to the hall first. Put this at the bottom of your file:
let start = World(room: Room.hall, items: ["key"], doorOpen: false, playing: true)
let after = step(start, "open door")
print("door open after?", after.doorOpen) // true
print("still shut in the world we started from?", start.doorOpen) // false
Two things happened there. You checked a rule of your game in three lines, and you watched the original world sit there unharmed while the new one carried the change. That second line is the idea, printed out. (The // true at the end of a line is a comment — lux ignores everything after // — so those are notes about what you should see, not part of the program.)
A list of commands works the same way, which is how you replay a whole session without touching the keyboard:
var w = World(room: Room.entrance, items: [], doorOpen: false, playing: true)
for cmd in ["north", "take key", "open door"] {
w = step(w, cmd)
}
print("carrying:", w.items, "door:", w.doorOpen) // carrying: [key] door: true
Undo is one extra variable. This is the one that convinces people. Keep the previous world before you overwrite it, and going back is a matter of pointing the name at the older value again:
var previous = world
while world.playing {
let line = match readLine() {
some(let l) => l
none => "quit"
}
if line == "undo" {
world = previous
print("You take back your last move.")
world = describeHere(world)
} else {
previous = world
world = step(world, line)
}
}
describeHere in there is the keep's own function for printing where you are standing — undo calls it so you can see where you have landed. Take the key, type undo, and the key is back on the floor — not because anything put it back, but because you went back to a world where you had never picked it up. Written the other way, undo means inventing a careful opposite for every single action in your game, and getting one of them subtly wrong. Here it is a variable.
And time travel is an array. One spare variable buys one step back. Keep them all, and you have every state your game has ever been in:
var history: [World] = [w]
for cmd in ["north", "take key"] {
w = step(w, cmd)
history += w
}
print("worlds kept:", length(history)) // 3
print(history[0].room == Room.entrance) // true — the beginning, still there
That is not a metaphor. history[0] is the world at the start of the game, intact, ready to be handed to step again to take the story a different way. Programs that let you scrub backwards through their own history are built on this.
There is a fourth payoff that is harder to demonstrate but turns up every time something breaks. If the door is open when it should not be, only a function that returned a world could have opened it, and the signatures tell you which ones those are. The list of suspects is short and it is written down.
The bug this style has
Every design has a characteristic failure, and this one has just the one. You will hit it, probably today, so meet it here first:
var v = World(room: Room.hall, items: [], doorOpen: false, playing: true)
takeThing(v, "key", Room.hall)
print("carrying:", v.items) // carrying: []
Run that and it prints You take the key. — and then shows you an empty pack. Both are true. takeThing did its work perfectly and built you a world with a key in it, and then nobody caught the thing it handed back, so that world went straight in the bin. The message was printed on the way past.
The rule that saves you: if a call is not on the right-hand side of an =, or after a return, its work is being thrown away. When a change you made refuses to stick and you cannot see why, this is nearly always it. Look for the call whose result you dropped.
And if you would rather see it than take my word for it, run the same three lines under lux trace, which narrates a program as it executes:
333 takeThing(v, "key", Room.hall)
213 var pack = w.items pack = []
214 pack += thing pack = ["key"]
215 print("You take the " + thing + ".")
You take the key.
216 return World(...) → World(room: Room.hall, items: ["key"], ...)
334 print("carrying:", v.items)
carrying: []
There it is, in the open. A world containing the key gets built, handed back — and then the next line prints an empty pack, because nobody caught it. Watching a value get created and then evaporate is a much faster way to understand this than reading about it, and there is more on lux trace further down.
What it costs
Two things, and it is only fair to name them. The first you have already seen: building a fresh world means writing out every field, including the ones that did not move, and adding a field means visiting every place a world gets built. That is a real cost in typing, and it is one you can decline. Copy into a var, change the one field, hand it back, and the other three are never mentioned:
var next = w
next.doorOpen = true
return next
That is the same shape — a world in, a world out — written the short way, and for a keep with a dozen fields it is the sensible choice. What you give up is small but real: the long form shows the entire next world on one line, where you can see at a glance what it consists of, and it makes lux tell you about every site that needs attention when the struct grows. Use whichever fits. The keep uses the long form because it is a thing to be read.
The second is that copying a whole world every turn is more work for the machine than flipping one bit would be. For a keep with five rooms this is beneath noticing. For a program holding a hundred thousand things it starts to matter, and there are clever ways around it that you can go and find when you need them.
This section leans hardest on the difference between let and var, and on the while loop at the bottom of the file. In your terminal: lux learn variables · lux learn functions · lux learn structs · lux learn while. The slower version: Variables, Functions, Structs, and the while loop in the introduction.
A fixed set of places
Your game has to record where the player is standing. The obvious way is to write it down as text — room = "hall" — and for about an hour that works fine.
Then you type "Hall" somewhere, or "hal", or "hall " with a trailing space. To you those are all the same room. To the program they are four unrelated pieces of text, and the comparison that should have matched quietly returns false. Nothing crashes. The door simply never opens, and you spend an evening staring at code that looks correct. Text can hold anything, which means it cannot tell a place from a typo.
An enum is the fix. It is a type whose values you list out, and the list is closed — those values and no others:
enum Room {
entrance
hall
cellar
vault
chamber
}
Now Room.hall is either a real member of that set or the program stops and tells you it is not. Misspell it and you find out immediately, at the spot where you made the mistake, instead of an hour later in a room that will not open. The trap is not that you might make a typo; it is that with text, a typo and a place look identical. With an enum, one of them is impossible to write down.
The second gift: match has to cover everything
Because the set is closed, lux knows exactly how many possibilities exist — so when you take a Room apart with match, it can check that you handled all of them. Add a sixth room and leave any match without an arm for it, and lux refuses to guess:
error: this match on `Room` doesn't handle every case
| return match room {
note: add an arm for: tower (or a `_` catch-all)
help: `lux learn match` — covering every case is what makes match safe
The real message also points at the file and line, so you can find it.
Read that as a to-do list you did not have to write. Adding a room to a keep means describing it, and putting it on the map, and possibly three other things you would have to remember on your own in a language that lets a half-finished change slide. Here the work cannot be half-done, because the parts you have not done yet announce themselves.
One detail is worth having straight, because it decides when you find out. The check happens before the program runs at all. lux reads the whole file first, so a match inside a function nobody calls is checked the same as one on the first page, and a keep with a missing arm anywhere in it will not start.
And it is the match that gets checked, not the path your program would have taken. lux can see that describe has no arm for the room you just added without ever calling describe, so you do not have to go and stand in your new room to flush the error out. What comes back is one match at a time — fix the one it names, run again, and it names the next — and for each one the note lists every case you have not covered yet.
In your terminal: lux learn enums · lux learn match. The slower version: Enums and Match in the introduction.
What a thing is, and what's true of it right now
A room in the keep carries two very different kinds of information. There is what the room simply is — a wide hall with rotted banners, and that is true every time you stand in it. And there is what happens to be true at the moment — the key is still on the floor, the door is still locked, you are carrying a torch.
Almost everyone writes those as one function the first time. It works, and it stays working until the first day you want the room's description somewhere else: a map command that lists the places you have been, or a line about glimpsing the hall through an open door. Now you cannot have the description without dragging "a brass key lies on the dusty floor" along with it, because the two were never separable.
So the keep keeps them apart. One function answers only the fixed question:
func describe(room: Room) -> string
and a second one answers the situational question, which needs the whole world to answer:
func describeHere(w: World) -> World
The test that sorts them is a question you can ask about any function you ever write: what does this actually depend on? The hall's description depends on nothing but which room it is. The key on the floor depends on your pack. Two different dependencies, two different functions — and the one with the smaller dependency is the one you will reuse.
Return it, don't print it
Notice the other decision hiding in that signature: describe hands back a string rather than printing one. That is deliberate, and a habit to steal.
A function that prints has made a decision on your behalf — it has decided the text goes to the screen, now, on its own line. A function that returns the text leaves that decision to you. You can print it, or put it in the middle of a sentence, or write it to a save file, or compare it in a test, or show it in italics through a door. Same function, five uses.
The general rule: push the doing to the edges of your program, and keep the middle made of functions that just work things out and hand them back. Programs built that way are far easier to change later, because the parts that decide what are not tangled up with the parts that decide where it goes.
In your terminal: lux learn functions · lux learn strings. The slower version: Functions in the introduction.
A question that may have no answer
"What is north of the hall?" has an answer. "What is west of the hall?" does not — there is only wall that way. Every program eventually has to answer a question that sometimes has no answer, and how a language handles that is one of the real differences between them.
The old approach is to invent a stand-in for nothing: return an empty string, or -1, or a special value called null that means "there is nothing here." It sounds reasonable. The problem is that the stand-in looks exactly like a real answer to whatever code receives it. Nothing forces that code to check, so eventually somebody does not, and the program marches on carrying a nothing where a room should be, until it falls over somewhere far away from the actual mistake.
lux has no null at all. Instead the type tells you the answer might be missing:
func exit(room: Room, dir: string) -> Option<Room>
Option<Room> means "a room, or nothing," and it is not a room. You cannot walk into it. The only way to get the room out is to open it up with match, and opening it up means writing both cases, because a match must cover everything it might be:
return match exit(w.room, dir) {
some(let r) => tryEnter(w, r)
none => cantGo(w)
}
Those two arms are the two possible futures, side by side, where anyone reading the code can see both. You did not remember to handle the missing case out of discipline. You handled it because it was the only way to get at the value you wanted, and that is the trick: the language moved a thing you must not forget out of your memory and into the type, where forgetting is not an option.
The map is a function, not a file
The same few lines carry another idea. The keep's map is not a data file that some engine reads — it is a function you can read, made of nested matches: which room you are in, then which direction you asked for.
The lovely part is what is not written down. Walls do not exist anywhere in the keep. There is no list of the directions you cannot go. Every direction that is not named falls through to _ => none, which is why a wall costs zero lines. You describe the world by naming what is there, and everything else is absent by default.
Deciding what your program should hold explicitly and what should just fall out of the default is a design judgement you will make over and over. Five rooms of exits are worth writing; the infinity of directions that lead nowhere is not.
In your terminal: lux learn option. The slower version: Missing values in the introduction, which also tells the story of null — the idea Option was invented to replace.
One job each
Type north in the hall and the keep answers three questions in a row, though it looks like one action from the outside.
Is there anywhere north of here? That is geography, and the answer does not depend on you at all. Am I allowed through? That is permission, and it depends entirely on you — whether the door is open, whether you hold the key. And finally: put me there, and tell me what I can see. That is the effect.
The natural first draft answers all three in one function, and for one door in one keep, it is genuinely fine. The trouble arrives with the second locked door. The lock rule now has to be checked in two places, and then a third when you add a trapdoor, and one day you fix the rule in two of the three and ship the third one broken. The bug is not that the rule was wrong. It is that the rule lived in more than one place.
So the keep gives each question its own small function. exit is pure geography and knows nothing about locks — the chamber is north of the hall whether or not you have the key. tryEnter is the bouncer, and every lock in the game lives there. enter builds the new world and describes it.
Which gives you a test to apply whenever you add a rule to anything: which of these questions is my rule an answer to? A new door is permission, so it goes in the bouncer. A new passage is geography, so it goes in the map. Get that right and adding your second locked door touches exactly one of the three functions, and a split that clean is one that earned its keep.
In your terminal: lux learn functions. The slower version: Functions and Scope in the introduction.
Words you build, and words the player types
Your pack is an array — a list of strings that grows as you pick things up. The keep asks it one question constantly: is the key in there? the torch? the lantern you added yesterday? And lux, out of the box, has no way to ask an array whether it contains something. There is a contains, but it works on strings — it asks whether one piece of text sits inside another, not the question your pack needs answered.
That sounds like a gap. What it actually is, is the most ordinary situation in programming: the language does not have the word you need, so you define the word.
func has(items: [string], thing: string) -> bool {
for it in items {
if it == thing {
return true
}
}
return false
}
Six lines, written once. From then on the rest of the file gets to say has(w.items, "key"), which reads like a sentence, instead of spelling out a loop every time it wants to know. That is the trade: a little work now buys you a word you can use forever.
Look at what has takes, though, because this is the part people miss. It takes an array and a string. It does not take a World. It knows nothing about the keep, nothing about rooms, nothing about keys. That is why it works for the lantern you have not invented yet, and why nothing you do to your game can break it. A helper that knows less is a helper that survives more.
The habit underneath: every program worth writing slowly grows its own vocabulary. You add the words your problem needs, and then you write the rest of the program in those words. Done well, a big program reads less like machine instructions and more like a description of the thing it does — and it gets clearer as it grows rather than muddier, which is the opposite of what most people expect.
The other vocabulary: the one the player has
There is a second kind of word in the keep, and it belongs to whoever is playing. step is where the words a player types get connected to the things your program can do:
return match cmd {
"look" => describeHere(w)
"north" => walk(w, "north")
"take key" => takeThing(w, "key", Room.hall)
"open door" => openDoor(w)
_ => huh(w, cmd)
}
A list of words, each paired with what it means. You will meet this shape everywhere something has to turn an outside request into an action — a menu, a web address, a button, a chat command. Whenever a program accepts input it did not choose, there is a table like this somewhere inside it.
Two things make it work. The _ at the bottom catches every word you did not plan for, which is not politeness — it is required, because a player can type anything at all and your program has to have an answer for all of it. And every arm has the same shape: hand back the next world. Even help, which changes nothing, hands one back. That uniformity is what lets step stay a plain list instead of a thicket of special cases, and it is why adding a command to your keep is one line rather than a project.
In your terminal: lux learn arrays · lux learn for · lux learn match. The slower version: Arrays and the for loop in the introduction.
The four moving parts
Those ideas collapse, in practice, into four things you edit — and knowing which one you are touching tells you where in the file to go. Almost every change you will ever make to a keep is one of these, or two of them together.
| To change this | Edit this | What it is |
|---|---|---|
| the places | enum Room and describe | where you can stand, and what you see there |
| the map | exit | which directions lead where |
| the vocabulary | step and showHelp | the words a player can type |
| what's remembered | struct World | everything the game knows about right now |
A new room touches the first two. A new thing to pick up touches the vocabulary and adds a line to describeHere. Anything the keep must remember — a lever pulled, a torch lit, a monster's temper — touches the fourth, the expensive one, and the third exercise below walks through why.
You can also skip all of this and ask lux for a working example of the edit you want. Each one is a spell — a tiny, complete program you copy the shape of:
lux magic room add a new place
lux magic exit connect two rooms
lux magic thing hide something to pick up
lux magic command teach the keep a new word
lux magic save keep something between runs
In your terminal: lux learn tour — the whole language in one pass, if you would rather see all of it at once than meet it a piece at a time. The slower version: An Introduction to Programming from the top.
Skip to the part you're building
Before the exercises, the single most useful thing anybody can tell you about building a keep — and the thing that decides whether you enjoy this or quietly give up on it.
Say you are building a tower above the hall. You make a change, run the game, and then you have to type north, up to get there and see it. That is fine once. It is fine the fifth time. By the twentieth time — and twenty is a slow afternoon — you are spending more of your life walking to your tower than working on it. Build something in the vault and it is worse: north, east, take torch, down, every single time, before you can look at the one line you just changed.
You do not have to. Look at the bottom of world.lux:
var world = World(room: Room.entrance, items: [], doorOpen: false, playing: true)
That is the world the game starts in, and it is just a value you wrote down. There is nothing sacred about the entrance, or about starting empty-handed. You can invent any world you like and begin there instead:
// var world = World(room: Room.entrance, items: [], doorOpen: false, playing: true)
var world = World(room: Room.vault, items: ["torch", "key"], doorOpen: true, playing: true)
Run that and you are standing in the vault, holding a lit-up view of the gold, with the iron door already open behind you. No walking. Change a line in the vault, run, look, change it again — the loop that used to take thirty seconds now takes two.
Each field is a dial you can turn. room is where you appear, so set it to whatever you are working on. items is what you have in your pack before the game even starts, so you can skip the entire fetch quest and just have the key. doorOpen and any other flag you add later can start already flipped, so you never have to unlock the same door twice in one afternoon. Leave playing as true unless you want a very short game.
Notice the old line is still there, commented out with // so lux ignores it. Keep it that way. When you are done building and want to play your keep properly — or hand it to somebody else — you swap which line is commented, and you are back to the real beginning. Forgetting to swap back is the classic embarrassment: your friend opens your keep and starts in the treasure vault holding everything.
Stop retyping the same commands
The same trick has a second half. When you do need to walk a real path — to check that your new exit works from the actual entrance, say — you can hand the commands to the game instead of typing them:
$ printf 'north\nup\nlook\n' | lux run world.lux
Each line goes in as though you had typed it. If it is a path you walk often, put the lines in a file and feed that in instead:
$ lux run world.lux < walkthrough.txt
The file does not even need a quit at the end. When the commands run out, readLine has nothing left to hand back, the loop takes its none branch, and the keep says goodbye on its own. That is the same missing-value idea from earlier doing something quietly useful.
Keep a walkthrough file next to your keep and you have something better than a shortcut. Run it after every change and you will know immediately if you broke the path through your own game — which, once you have twelve rooms and three locked doors, you will, and you would much rather find out in two seconds than a week later.
Ask the keep what it is thinking
When something behaves oddly and you cannot see why, stop guessing and look. Give yourself a command that prints the entire world — a helper shaped like every other action, plus one arm in step:
func showState(w: World) -> World {
print(w)
return w
}
// and in step:
"debug" => showState(w)
Type debug anywhere in the game and you get the whole thing at once:
World(room: Room.hall, items: [key], doorOpen: false, playing: true)
Printing a struct prints all of it, labelled, so the whole tool costs three lines. Half of all debugging is the moment you find out that the value you were sure about is not what you thought — the door you believed you had opened is still false, and now you know to go looking at openDoor rather than at the room. Leave the command in while you build and take it out before you hand the keep to anybody, or leave it in as one more secret.
Watch it run
When a printed value tells you what is wrong but not where it went wrong, there is a bigger instrument. lux trace runs your program just like lux run, but narrates every line as it executes and shows what each one changed:
$ lux trace world.lux
312 var world = World(room: Room.entrance, ...) world = World(room: Room.entrance, ...)
63 print(describe(w.room))
50 return match room { → "You stand at the mouth of an old stone keep..."
64 if w.room == Room.hall { (no)
76 if w.room == Room.cellar { (no)
188 return enter(w, r) → World(room: Room.hall, items: [], doorOpen: false, ...)
Three things in that listing are worth having in your pocket. The number on the left is the line in your file, so you can follow along in your editor. A → shows what a function handed back, which is how you catch a helper returning something other than what you assumed. And every condition is annotated (yes) or (no), which answers the single most common building question — "why is my new room's description not printing?" — in one glance, because there is the if that guards it, saying (no).
Two practical notes. The narration goes to stderr and your game's own output stays on stdout, so the two can be separated: lux trace world.lux 2> trace.txt keeps the game readable on screen and parks the whole trace in a file you can scroll. And it is thorough — two commands in the keep produce about fifty lines — so trace a couple of turns, or a small file with just the function you are suspicious of, rather than a whole play session.
Between the three tools you now have, the loop is: change the starting world so you begin where you are working, type debug when you want to see the state, and reach for lux trace when you need to watch the program get there.
In your terminal: lux learn variables · lux learn io. The slower version: Variables and The outside world in the introduction — the second of those is where reading input is explained, and feeding a file to your keep is that same idea from the other end.
None of this is a hack, by the way. It works because of what you read earlier: the game is a function from a world and some commands to a new world. Change the world you start with, or change the commands you feed it, and you are simply running the same function on different inputs. Being able to start in the middle is what it looks like when state is a value you can write down.
Exercise: add a room
Exercise one
We will hang a tower over the hall, reachable by going up. It is four small edits, and you should run the game after each one, because the mistakes are more instructive than the successes.
One. Add the room to the enum:
enum Room {
entrance
hall
cellar
vault
chamber
tower
}
Run it now. Nothing happens at all — no title, no entrance. lux reads the file, sees that describe has no arm for tower, and refuses to start. Good. That is the closed set of rooms handing you your to-do list, and it did not make you go looking.
Two. Give it something to say. Put a line in describe alongside the others — the arrows there are lined up in a column, so pad yours out to match — and mention its exits in the prose, because that is how the keep tells you where you can go:
tower => "A high tower above the hall. Worn stairs drop into the dark."
Three. Open the way up. In exit, the hall's arm gains a direction:
hall => match dir {
"south" => some(Room.entrance)
"east" => some(Room.cellar)
"north" => some(Room.chamber)
"up" => some(Room.tower)
_ => none
}
Four. Open the way back down. The tower needs an arm of its own in that same match, and until it has one exit is not covering every room. You have met this error already: it is the one that appeared the moment you fixed describe, because lux checks every match in the file before it runs any of them:
tower => match dir {
"down" => some(Room.hall)
_ => none
}
Now play it: north, then up, and you are somewhere that did not exist ten minutes ago.
Worth doing once on purpose: leave out step four and run it. You never get a prompt to type into, and the room it stops you over is one you were not going to visit. lux checks the whole match rather than the arm your first move would have taken, and it checks it without waiting for anything to call exit at all. That is the job, start to finish, and far less frightening once you have watched it happen deliberately.
A one-way exit, by the way, is perfectly legal and occasionally exactly what you want. Give the hall an up with no matching down and you have built a trap. The keep will not stop you; it is your keep.
The matching spells: lux magic room · lux magic exit
Exercise: hide a thing
Exercise two
Now put a brass lantern in your new tower. It is shorter than the last one.
One. In describeHere, add a block for the tower — the same shape as the key in the hall and the torch in the cellar. It says one thing if the lantern is still hanging there, another if you already took it:
if w.room == Room.tower {
if has(w.items, "lantern") {
print("An empty hook swings by the window.")
} else {
print("A brass lantern hangs on a hook by the window.")
}
}
Two. Teach the keep the words. One arm in step:
"take lantern" => takeThing(w, "lantern", Room.tower)
You are done, and you wrote no new function, because takeThing never knew anything about keys or torches in the first place — you hand it a name and a room, and it handles the rest, including refusing to give you a second lantern and refusing to give you one anywhere else. This is what a well-shaped function buys you: the second thing you add is nearly free, and so is the twentieth.
Add it to showHelp too, or leave it out and let somebody find it. The keep already does one of each.
The matching spell: lux magic thing. If the nested if and else in step one are new to you: lux learn if, or Making decisions in the introduction.
Exercise: remember something
Exercise three
The first two exercises added stuff. This one adds state — a different kind of change, and the one to take slowly. Right now the vault lights up the instant you are carrying the torch, which is a bit magical. Let us make you light it first.
Carrying the torch is already remembered — it is in your pack. But whether it is burning is a new fact about the world, and there is nowhere for it to live, so we make somewhere.
One. Add a field to the struct:
struct World {
room: Room
items: [string]
doorOpen: bool
playing: bool
torchLit: bool
}
Two. Run it, and lux immediately objects, pointing at a line that builds a world:
error: missing field `torchLit` for struct `World`
| var world = World(room: Room.entrance, items: [], doorOpen: false, playing: true)
note: `World` has a field `torchLit: bool`
help: `lux learn structs` — every field gets a value when you build a struct
Every place in the file that builds a World now has a hole in it, and there are six of them. Fix the one lux is pointing at, run it again, fix the next one it points at, and keep going until it stops complaining. The new world starts with torchLit: false; everywhere else just carries the old value forward with torchLit: w.torchLit. Six edits and about two minutes, with lux walking you to each one.
Do it the long way once, because being marched through every affected line is the point of the exercise. Afterwards you know the shortcut from earlier — copy into a var, set the one field, hand it back — and you can use it freely. The trade is the one you just felt: the short form saves you the six edits, and gives up the list lux hands you when the struct grows.
That cost is the lesson. Adding a fact to your world means touching every place a world gets built, and lux makes you pay it out in the open rather than letting a half-updated world slip through. Programs in every language have this problem; most of them just let you find out later.
Three. Write the action. Same shape as every other action in the file — check that it makes sense, say something, hand back the next world:
func lightTorch(w: World) -> World {
if has(w.items, "torch") {
if w.torchLit {
print("The torch is already burning.")
return w
}
print("The torch catches with a soft whump, and the dark backs off a step.")
return World(room: w.room, items: w.items, doorOpen: w.doorOpen, playing: w.playing, torchLit: true)
}
print("You have nothing to light.")
return w
}
Four. Add the word to step:
"light torch" => lightTorch(w)
Five. Make the vault care. In describeHere, the test changes from carrying a torch to having a lit one:
if w.room == Room.vault {
if w.torchLit {
print("Your torch throws back the dark: gold coins spill from a split chest...")
} else {
print("It is pitch black...")
}
}
There is a second place asking the same question, inside takeGold, and lux will not tell you about that one, because it is not an error — it is just a keep that now reads a bit oddly, letting you scoop up coins in the dark. Search the file for torch and fix it too. Getting caught by this once is useful: the language guards the shape of your program, and you guard what it means.
Play it through. Take the torch, go down into the black, type light torch, and watch the vault appear. You just added a rule to the world.
In your terminal: lux learn structs · lux learn booleans. The slower version: Structs and True and false in the introduction — torchLit is a bool, a value that is only ever true or false, and most of what a keep remembers ends up being one.
Planning a wing
The exercises were single edits: one room, one thing, one rule. Building a whole wing — six rooms, a couple of locks, something to find at the end — is a different job, and the tempting way to do it is the wrong one. Finishing each room completely before starting the next feels productive and leaves you, four rooms in, rewriting descriptions because you moved a corridor.
Work in the other order. Get the shape right while the shape is still cheap to change.
Draw it on paper
Boxes and arrows, ten minutes, before a line of code. You are deciding how the place fits together, and paper lets you rub out a passage in a second. Mark which ways are one-way, which are locked, and where the thing that opens the lock is hiding — that last one is the puzzle, and it is much easier to see on a sketch than spread across three hundred lines.
Name the rooms before you write them
Fill in the enum first, and take the naming seriously, because it is design rather than bookkeeping. cistern, rookery, the long stair already tell you how those places feel, and half your descriptions will write themselves once the names are right. Vague names — room2, area3 — will still be vague when you come back tomorrow.
Build a walkable shell
Now put in every room, every exit, and a placeholder line for each description. Stubs are perfectly legal — rookery => "TODO: the rookery" satisfies match and runs — and the point is to have something you can walk through within about twenty minutes:
rookery => "TODO: high, cold, full of birds. Ways down and east."
Leave yourself the note about the exits in the stub. When you come back to write the real prose, the thing you most need to know is which ways out this room has, and now it is sitting right there.
Walk it empty, then fill it
Play your shell before it has a word of real writing in it. This is where you find the passage you wired one way by accident, the room nothing leads to, and the fact that your locked door is on the wrong side. All of those are two-second fixes right now and genuine annoyances after you have written five descriptions that refer to them. Set your start room to the new wing while you do it, so you are not walking across the whole keep each time.
Then fill it in, in this order: prose first, then the things to pick up, then the rules that govern them. Each one depends on the one before — you cannot write "a heavy door faces north" until you know there is a north, and you cannot write the rule about the door until you know what opens it.
Where new code goes
lux does not care. A function can be called before the line that defines it, so nothing forces you to keep any particular order — which means the order is entirely for your benefit, and worth keeping tidy for that reason. Follow the shape the file already has: types at the top, then helpers, then descriptions, then the map, then the actions, then step, then the loop at the bottom. Put your new action next to the actions rather than wherever you happened to be scrolling, and the file stays readable at six hundred lines.
When lux stops you
You will see more errors building a keep than you saw learning the language, and that is not a sign anything is wrong. Every error here is lux catching something before a player could, and each one is written in the same four parts: what went wrong, where it happened, a note with the specific fact you need, and a pointer to the card that explains the idea.
One thing to know before the list, because it saves real confusion: the arrow points at where lux noticed, which is not always where you made the mistake. Add a field to World and the error points at the line that builds a world — but the change that caused it was up in the struct. Read the message, not just the line number.
These are the ones you will actually meet.
"doesn't handle every case"
error: this match on `Room` doesn't handle every case
note: add an arm for: tower (or a `_` catch-all)
You added a room and one of the matches has not caught up. The note names every case that match is missing. lux finds this before the program runs, so you do not need to visit the new room or even start the game — but it reports one match at a time, so fix the one it names and run again to find the next.
"missing field"
error: missing field `torchLit` for struct `World`
note: `World` has a field `torchLit: bool`
You added a field to World and some of the places that build one have not been updated. There are six in the starter keep. Fix the one it names, run again, fix the next.
"has no case"
error: enum `Room` has no case `tower`
note: cases are: entrance, hall, cellar, vault, chamber
Either a typo, or you used a room before adding it to the enum. The note lists everything that does exist, which usually makes the answer obvious.
"expects 3 values but got 2"
error: function `takeThing` expects 3 values but got 2
Nearly always a new take command where the room argument got left off. takeThing wants the world, the name of the thing, and the room it lies in.
"is not defined"
error: `w` is not defined
A misspelled name, or code that uses w outside a function that was handed one. Every function names what it receives, and nothing else is in scope.
"out of bounds" and "type mismatch"
error: index 3 is out of bounds for an array of length 0
error: type mismatch: annotated `int` but the value is string
The first is reaching past the end of an array — counting starts at 0, so the last position is one less than the length. The second is a value that is not the type you said it would be, and it is worth reading twice, because it is often telling you something true that you did not mean.
In your terminal: lux learn errors. The slower version: When something breaks in the introduction. For the rest of it — what to do when there is no message at all and the keep is simply wrong — When It Doesn't Work is the method.
Mistakes lux can't catch
Everything above, lux found for you. Now the other half, which matters more as your keep gets bigger: the mistakes where your program is perfectly valid and your world is nonetheless broken. lux checks the shape of what you wrote. Whether it means anything is your department, and no language will ever take that over.
These are the ones that actually happen.
The description that lies. The most common bug in any text adventure by a wide margin. You write "ways lead south and east" and there is no east arm in exit — or you add the exit and forget to mention it, so a real passage stays invisible because nobody would ever guess. Your prose and your map are two separate things that have to agree, and only you can check that they do. Whenever you touch one, look at the other.
The accidental one-way passage. You added "up" => some(Room.tower) to the hall and never gave the tower a way down. The program is fine. The player is stuck in your tower forever. One-way exits are a legitimate thing to build on purpose — a chute, a collapsing floor — which is why nothing can warn you about the ones you did not mean.
The room nobody can reach. It is in the enum, it has a description, it may have something wonderful in it, and no exit anywhere leads to it. lux is perfectly happy. Nobody will ever see it. Walking your shell before you furnish it catches this one nearly every time.
The half-changed rule. You changed the vault to need a lit torch and updated the description, but the check inside takeGold still asks whether you are merely carrying one, so the coins can be scooped up in the dark. Nothing is wrong with either line by itself; they just disagree. When you change a rule, search the file for the thing it mentions and read every hit.
The command nobody knows about. You added light torch and left it out of showHelp. Sometimes that is a delightful secret. Sometimes it is a puzzle nobody can solve, and the difference is whether the player has any reason to guess it.
The keep that cannot be finished. The one that will really get you: the key that opens the door is in the room behind that door. Every line is correct, and the game is unwinnable. This is a design bug rather than a coding one, and adventure games have shipped with them for forty years.
How to find them anyway
Three habits catch nearly all of it. Keep a walkthrough file — the list of commands from the testing section — and run it after every change, so you find out immediately when the path through your own keep stops working. Walk your world as a stranger now and then, reading only what the game prints and doing only what it tells you is possible, which is harder than it sounds when you know where everything is. And best of all, hand it to somebody else and watch them play without helping, which is agony for about four minutes and then tells you more than an hour of your own testing.
That boundary — the machine checks the shape, a person checks the meaning — is not a lux limitation. It is where the line falls in every language there is. Knowing which side a mistake lives on tells you whether to expect help finding it, and that is worth more than any particular error message.
More to try
Everything from here is yours to design. What follows is a pile of ideas rather than a syllabus — some are an evening's work, some are ten minutes, and all of them are built from the four moving parts you already have. Take whichever one you actually want to play.
Make it a game
A way to lose. The keep currently cannot be lost, only left, and that is a strange kind of adventure. You already have the machinery: playing: false is how the game ends. Wander into the pitch-dark vault without a light and let something in the dark take you on the third turn — or let a floor give way, or a torch burn out in the wrong room. A game with no way to lose has no way to be brave.
A way to win properly. Right now walking out of the entrance is not an ending, it is just a room. Make leaving the keep with gold in your pack finish the game, with different last words depending on what you are carrying — the coins, the torch, nothing at all. One check, several endings, and suddenly everything the player picked up along the way meant something.
xyzzy. In Colossal Cave Adventure, the very first text adventure, typing that word teleported you across the map, and it was in no help text anywhere — people passed it around like a rumour. Add it to your keep. It is one arm in step, and it is a fine thing to know that your game contains a secret only you and one friend know about.
A pack that fills up. Refuse to take anything once length(w.items) reaches three. It is a two-line change and it transforms the game, because now the player has to choose — the torch or the lantern, the gold or the crown — and every choice they agonize over is one you created with an if.
Coins you can spend. Give the world a coins: int, put a shopkeeper somewhere unlikely, and let the gold buy something. Counting things up and down is the most ordinary job in programming and it is what most real software spends its day doing, so it is a good one to have built once for fun.
A room that remembers you. Add a counter that goes up each time the player enters a particular room, and change what they see on the third visit. Somebody who walks in a circle out of boredom and finds the room has been paying attention will remember your keep for a long time.
Somebody to talk to. Put a character at a door who asks a question with input and matches on the answer. It is the same dispatch idea as step, at a smaller scale — and be generous with what counts as right, because the person playing cannot see your list of accepted words.
Make it not a keep at all. Nothing in the code cares that these rooms are stone. Rename the enum and rewrite the descriptions and you have a wrecked spaceship where the iron door is an airlock and the torch is a power cell, or a school after everyone has gone home, or the inside of a whale. The structure you have learned holds every one of them.
Take the machinery further
A second locked door, needing two things. You have seen how one lock works: a bool in the world, a check in the bouncer, and a command that flips it. Make one that needs both a key and something else, and you will write your first compound condition — a small taste of how rules get interesting when they combine.
A world that survives quitting. The keep already writes a file when you reach the chamber, so the machinery is in front of you. Write the room you are in and your pack out when you leave, read them back when you start, and you have save games. lux magic save has the shape.
Time. This one is the fun one. Because a turn is a function from one world to the next, you can wrap it — apply your own tick to whatever step hands back, and something now happens every turn whether the player asked for it or not.
some(let line) => tick(step(world, line))
Give the world a fuel: int, subtract one in tick whenever the torch is lit, and put the vault back into darkness when it hits zero. Suddenly there is a clock in your game and the player has to hurry. The idea cost you one function and one word in the loop, and it only works so neatly because every turn already hands back a whole world.
A wing for somebody else. Build three or four rooms nobody has seen, hand the file to a sibling or a friend, and let them find their way through. Then take theirs. This is where a construction kit stops being an exercise.
One thing you cannot do yet
If you went looking for a way to roll dice — a monster that hits for a random amount, a trap that springs half the time — you will not find one. lux has no random numbers at all, so anything you build has to be beaten by working it out rather than by luck.
That is a real limit, and it is a decision rather than an oversight — somebody asked for dice, and the answer was no. The reason is the idea this whole page is built on. If a program can roll a die, then running it twice with the same commands stops giving the same result, and everything that depended on that goes with it: no replaying a session by folding the commands through step, no trusting that a walkthrough file still proves anything. Randomness would cost the property that makes a world a value.
Which leaves the trade in your hands, where it belongs. Luck is the easy way to make a game feel alive, and doing without pushes you toward the thing text adventures were always best at: the right item, in the right room, in the right order, found by somebody paying attention.
In your terminal: lux magic input · lux magic save · lux learn io. Worth knowing for the talking character: the keep's main loop uses readLine rather than input, because it has to tell a blank line apart from the input running out. Asking a question mid-turn is what input is for.
And when the keep stops being the thing you want to build, Starting Something of Your Own is the same method applied to a blank file and an idea of your own.
Where you'll feel the edges
Build long enough and you will run into places where the keep is stiffer than you want it to be. These three are the ones everybody hits. Two of them you cannot fix by writing better lux, because they are the edges of the language itself. The first one you can — and the story of how it stopped being an edge is the most useful thing in this section.
The commands are matched whole. take key works, and get key does not, and neither does go north or take the key, because match compares the entire line against the words you listed. To do better you need to break a line into pieces — to notice that it starts with take and treat the rest as a name. lux can do that now:
func verb(cmd: string) -> string {
let words = split(cmd, " ")
return words[0]
}
func noun(cmd: string) -> string {
let words = split(cmd, " ")
return words[length(words) - 1]
}
split breaks a string into an array at a separator, so split("take the brass key", " ") hands back four pieces. The first is what the player wants to do and the last is what they want to do it to, enough to accept take the brass key and take key as the same command. Match on verb(cmd) instead of on cmd and your keep gets noticeably less fussy for about six lines of work.
This is also the first time a for loop in your keep will do something you could not have done by hand. Everything you have looped over so far you wrote yourself, so you knew how many there were. A line somebody types has no such promise — two words or nine, you cannot know until it arrives — and a loop over that is the real version of the thing you have been practising.
The map grows an arm per room. Five rooms fit comfortably in exit. Thirty would not, and what you would want by then is a way to look a room up directly rather than matching your way down a list. That is a real thing languages have, and lux does not have it yet either.
And a keep is one file, always. There is no way to split a big world across several files, and no way to pull somebody else's rooms into yours — import is not a word lux knows. So your keep grows in a single file however large it gets, and when you and a sibling each build a wing and want to join them into one world, the only route is opening both files and copying by hand. That one bites hardest precisely when you are doing the best thing the crawl has to offer.
Now the part to take seriously. Write those wishes down as you hit them — not "the keep is broken" but "I wish lux could split a line into words." That list is a specification, and how lux decides what to build next. The keep is stiff on purpose, because a wish you arrived at yourself, from a wall you actually hit, is worth more than a feature handed to you before you knew you wanted it.
That example is not hypothetical. "I wish lux could split a line into words" is the wish this page used to end on, and it is the reason split exists — it was added because people building keeps kept walking into the same wall, in the same place, for the same reason. So the two edges left above are not a list of things lux will never do. They are the next two wishes, and nobody has written them down convincingly enough yet.
And some of those wishes will be bigger than lux is ever meant to be. Two people exploring the same keep at once, over a network, is not a missing lux feature — it is a different kind of program, and wanting it is the signal that you have outgrown this language and should go pick a larger one. lux was built for that day, and it does not make you start over. Your keep comes with you:
$ lux convert swift world.lux # your keep, as Swift
$ lux convert go world.lux # as Go
$ lux build world.lux # or straight to a native binary you can hand to anyone
All three translations compile and play the same keep, and the binary runs on a machine with no lux installed at all. That is not a gesture — it means the first real program you wrote is also your first Rust program, and your first Swift program, waiting for the day you want to read it in a bigger language. Getting to the point where lux feels small was always the plan.
When that day comes, When lux Feels Small is the guide to it: how to tell you are there, what you take with you, and how to choose between the three by reading your own keep in each of them.
In your terminal: lux learn beyond — what you keep after you outgrow lux. The slower version: The same ideas in other languages and Beyond at the end of the introduction.
It's your keep
Look back at how little there was to learn. An enum, a struct, a few functions, a match, and a loop — the same handful of ideas from any first tutorial — and out of them comes a world with rooms and locks and a dark vault and a secret. There is no second, harder set of ideas underneath that you have not been shown yet.
So go break it. Rewrite the entrance so it is a shipwreck instead of a keep. Delete the cellar. Add nine rooms and a monster with a temper and a shop that sells lanterns. Nothing you do to that file can hurt anything, and lux crawl will always hand you a fresh one to start over from.
In your terminal: lux learn crawl for how a world is put together · lux magic for every spell · lux learn for the rest of the language