Text Adventures and State Machines
Six hundred-line text engines, dissecting how state machines carry narrative
Text Adventures and State Machines
Text games are the genre best suited to understanding state machines: the whole world is one state transition graph, every player input is a transition condition, and the log scrolling up the screen is the state history itself. This post uses six hundred-line text engines to watch how state machines carry narrative.
Text Adventures: The State Machine as Narrative
These six engines run from a hundred to two hundred lines each and happen to form a textbook that goes from simple to complex: a Zork-style command parser and its interactive-fiction mode (two shapes of the same component), a MUD-style turn-based RPG, two random dungeons built on different ideas, plus a detective game that turns elimination into an interface and a TTRPG log where the log is the save file. Hands-on versions: Text Adventure, MUD-style RPG, Dungeon Generator, Mini Dungeon, Detective Deduction, TTRPG Session Log.
The Parser
The World Model
The text adventure’s default mode is a tiny Zork-style engine.
The world model has only four rooms: a cabin, a hall, a garden, and a vault. Each room carries three pieces of data: a name, a description, an exits table exits (a mapping from direction to room id), and an item list items.
The rooms live in a Record<string, Room>, and the game state is down to four variables. here (current room id), inv (backpack array), log (output log), won (endgame flag).
The map is equally restrained: north of the cabin is the hall, east of it the garden, and the hall’s east exit leads to the vault. A key sits in the cabin, a flower in the garden, the treasure at the center of the vault.
Worth stopping on: the key and the flower are pure atmosphere props.
The parser applies no mechanical check to either. Pick them up, drop them in the backpack, that is all. The door’s description says “locked tight”, yet mechanically you can walk straight into the vault.
The only thing that actually drives the ending is the treasure itself: take treasure triggers won, and the input box is disabled along with it.
This mismatch between atmosphere and mechanics is a common trade-off in minimal engines. Even when the inventory takes no part in the checks, its mere presence is enough to shape the motivation loop of “explore, collect, get stronger” — which is exactly the evidence that narrative props and mechanical props can come apart.
From Verb Dispatch to a Transition Table
The parser’s core fits in a dozen-odd lines. cmd() trims, lowercases, splits on spaces; the first word is the verb, and the rest are stitched back into the object.
Then a long if-else chain compares in turn: look (aliases l, 看), go/move/direction words, take/get/拿, inv (i, 背包), help (帮助), and if nothing matches it falls back to “I don’t understand that command”.
The Chinese aliases are normalized through a mapping table. 北/南/东/西 are translated into north/south/east/west before they reach the logic.
A parser’s first step is always synonym normalization, never anything resembling natural language understanding. The action set is a small closed set, and the failure mode of keyword matching is entirely predictable to the player (a mistyped command yields a fixed error) — controllability matters more than intelligence.
Restate it in the language of state machines.
look is a self-loop. It prints the description and the state does not change.
go is a cross-room transition; when the target does not exist it prints “there is no path that way” and keeps the original state.
take changes item ownership, moving an entry between the room’s items and the backpack.
Once won is set, the whole input surface enters an absorbing terminal state. No input has any effect any more.
The row of quick buttons on the interface (look, go north, go east, take key, inv, help) is an explicit hint at the legal transitions, drawing every state’s outgoing edges for newcomers and saving them the trial-and-error cost of guessing commands.
Submitting the input with Enter, and preventDefault inside onsubmit to stop the page refreshing — the fundamentals of form events were covered in the earlier JavaScript notes, so they are not repeated here.
The log panel is this genre’s only “screen”. The player’s raw input is echoed with a > prefix, interleaved with the system’s replies in the same monospace area.
That layout is a direct inheritance from the MUD era. Server and client shared one scrolling buffer, and the human-machine conversation was, physically, the same single log.
The Semantics of Reset
The reset button tucked in the corner makes “reset” a state-machine problem for the first time.
Flipping here, inv, and won back to their initial values is not enough. take has already moved the treasure from the vault into the backpack; if the vault’s items are not restored, a fresh game hands the player a hollowed-out maze.
So the reset function carries a dedicated loop that re-seeds the item arrays of the three rooms, rolling player state and world state back together.
That is the full semantics of a state-machine reset: the entire reachable state space returns to its initial state, not just the half the player can perceive.
The “the world didn’t refresh after respawn” family of bugs, where the world’s half is missing, is everywhere in games with saves, and the root is always a reset function that was only half written.
The interactive-fiction side’s reset is symmetric: node, the history stack, and the path badges are cleared together. Because there, history itself is the world.
The Dialogue Tree
The same component’s second mode is interactive fiction (CYOA): no input box, only buttons.
The data structure becomes a node graph. Each node carries a piece of text, a set of {label, next} options, and an optional ending flag.
The story graph itself has only five nodes: the start splits two ways, into the cave or up the mountain. Light the lamp in the cave and push into the depths for the victory ending; back out and you return to the start. Crossing the rope bridge up the mountain is the failure ending.
Note the back edge from the cave to the start. This is no longer a tree in the strict sense — “dialogue tree” is a colloquialism, and the implementation has always been a graph with cycles. The back edge makes “chose wrong, start over” a behavior inside the story rather than a UI operation.
Terminal nodes (victory/failure) have empty option arrays, and the render layer uses that to swap the option list for a “restart” button. Even the presentation of a terminal state is data-driven; the template does not need to know what the story looks like.
This mode’s most valuable feature is the undo stack.
A history array records the ids of visited nodes, pushing on each step forward; the back button pops it and returns to the previous node. A parallel path array records the option text of every choice, shown as badges for “the road you walked”.
Stacked together, the two arrays turn a one-shot read into a retraversable exploration. You can flip back through a paper book, but you cannot “return to chapter three and choose again” while keeping a visual trail of your footprints — which is exactly what CYOA stands on as an electronic genre.
A five-node graph is of course only a demo, but it incidentally exposes the quantity economics of branching narrative.
Every two-option node opens out both ways, and a full binary tree of depth has leaves. Ten layers of “true branching” is over a thousand ending nodes, and no author can afford to write that.
So the universal technique in real works is confluence: let different branches converge on the same downstream node, pressing the tree back toward a near-linear size.
In this piece both the cave and the snow mountain have a “head back the way you came” edge to the start — the smallest possible confluence unit. It justifies “going back” inside the story and saves node budget at the same time.
The real craft of an interactive-fiction author is controlling expenditure between tree-like expansion and graph-like confluence. Open the branches at the climax so the player feels the world answering; tighten the confluence in the transitions so the cost stays contained.
Set against the parser mode, it becomes clearer. The same component shell, the same mode-switch bar, and two input devices push the state machine’s presentation to two opposite extremes.
The buttons enumerate every possible transition of the current node on screen; what the player sees is “the complete outgoing edge set”. The parser hides the outgoing edges inside the world model, and the player has to interrogate them with look.
The former serves narrative pacing: choice, consequence, emotion. The latter serves the feel of exploration: the unknown, trial and error, discovery.
Neither is more advanced; they are two projections of the same transition graph.
The MUD Skeleton
The text RPG tightens the loose room graph into an explicit three-state machine.
Explore state (movement allowed), combat state (an enemy is present, only attack or heal), terminal state (downed, everything disabled).
The first line of move() is the guard if (gameover || mob) return — state mutual exclusion. No moving during combat, not one step.
State visibility is complete as well. The top bar permanently shows HP, attack, level, and the experience bar, and every button’s disabled attribute flips with the state in real time, so the player always knows which state they are in and which transitions are available.
The world is four rooms (village, forest, cave, temple) arranged in a ring, with a 50% encounter roll on every step.
Enemies draw a name from a five-entry pool (slime, goblin, skeleton, wolf, bat), with stats scaled by level. HP , attack .
Combat is a turn exchange. Each swing deals random damage, and felling an enemy grants 5 XP. If the enemy is still alive it strikes back immediately for .
The levelling formula deserves a look of its own. Fill the bar to to level up: +8 max HP, a full heal, +2 attack.
Monster strength grows linearly, and the player’s capacity grows linearly with it. Eight HP and two attack per level, so the number of combat rounds stays roughly constant.
The sense of difficulty comes from the numerical contrast of “the monsters got stronger”, not from the stuckness of “I can’t hurt it any more”. In numeric design this is called synchronized inflation: all the curves shift together, the feel of combat stays stable, and the sense of growth is handed entirely to the numbers.
Between the encounter rate and the levelling curve there is also a temporal structure.
A 50% encounter chance per step means the number of steps between consecutive battles follows a geometric distribution. Expectation two steps, but with large variance: four empty steps in a row and three battles back to back are both normal.
The value 0.5 means “neither predictable nor plannable”. The player cannot bank resources and wait for a milestone the way they would in a deterministic game; they can only stay battle-ready at all times.
Meanwhile the kills needed per level are (the bar is , each kill a flat 5). Two kills at level 1, four at level 2, six at level 3 — levelling slows visibly. At the same time, +8 HP and +2 attack per level guarantee the felt length of each battle never changes.
Slower pacing, constant feel: this is the classic “time dilution”. Within the same play time, the absolute numbers of the level climb more and more slowly, but what the player perceives is always steady progress.
Healing is a neat resource decision. +10 HP costs no currency, but healing mid-combat means giving up your swing and eating a free counter-attack. One turn’s action rights traded for health.
Minimal rules, real decisions. At low HP, “heal once then fight” or “gamble that this swing kills first” — a minute of expectation arithmetic settles it, but the feel of pressing the button is always tenser than the expected value.
The log’s auto-scroll is handled by a $effect: watch the log array for changes and pull scrollTop to the bottom. This amounts to camera follow. There is no picture — the log is the picture, and the newest line is always the one under the spotlight.
Add the four button groups — direction keys, attack, heal, restart — and the whole component has not a single image asset; all the storytelling is pressed onto text and two emoji.
Random Dungeons
The Room Chain
The dungeon generator is a pure generation component. No gameplay, just a canvas — but it is the cleanest sample for understanding “generation is narrative”.
Two algorithmic steps. First randomly scatter 6 to 9 rectangular rooms (3–7 cells wide, 3–6 cells tall) with an overlap check that keeps a one-cell gap; if a room will not fit, retry, up to 300 attempts. Then connect neighboring rooms pairwise with corridors: from the previous room’s center to the next room’s center, horizontal first then vertical, carving an L-shaped passage.
The connection strategy decides the topology. What is used here is a sequential chain (room connects only to room ), so the result is necessarily connected and naturally forms one main path.
A chain is the cheapest guarantee of connectivity. No union-find, no spanning tree, one for loop does it. The price is no cycles: the dungeon can only be “a line with rooms strung on it”.
Want more dimension, add a few extra corridors between random room pairs to create cycles — but that also means the possibility of getting lost.
From the standpoint of narrative pacing, rooms and corridors are two entirely different kinds of space.
Rooms are open ground, suited to lingering, encounters, events. Corridors are narrow passages suited only to movement, carrying an inherent sense of pressure.
The ratio of total room area to corridor area is this dungeon’s “breathing ratio”. In this generator the rooms take at most nine blocks of at most 42 cells each, and the corridors hug the centers along the shortest path, so most of the player’s travel happens in the narrow passages and the rooms become the breathing points of the rhythm.
BFS Validation
The mini dungeon stitches generation and gameplay together.
A grid ( in advanced mode); each floor first scatters walls at 25% probability with the border sealed, then places the player, the down stairs, and three piles of gold on open ground at random.
The crucial step: the stairs and all the gold must be reachable from the player’s position. A BFS floods out from the start marking every reachable cell, then checks that all the targets are marked; fail and the whole map is regenerated, up to 50 attempts.
“Random generation + reachability validation” is the standard package for Roguelike dungeons. Randomness supplies freshness, validation supplies the floor.
A random map without validation produces dead states where treasure is walled in for good. The player cannot tell design from bug, but the experience is frustration either way.
The details of BFS itself were written up in the earlier algorithm notes; here you only need to remember its role: backstopping the randomness.
The cost is worth a quick calculation. A map has only 81 cells, one BFS is at most a few hundred elementary operations, and a 50-retry cap means the worst case is still only tens of thousands of operations. Validation-based generation is nearly free on small maps.
Once the map grows, this “reroll the whole map” strategy has to become incremental repair (dig a corridor when something turns out unreachable), otherwise the validation cost runs away with the area.
Floors are the chapters. Step on the stairs and the next floor generates a whole new map. Monster count (plus 2 more in advanced), HP , attack — the numbers climb monotonically with depth. Going downstairs, meanwhile, restores only 5 HP; supplies are fixed and meagre.
The deeper you go, the stronger the monsters and the relatively thinner your health line; the tension is manufactured entirely by the scissor gap between those two curves. Not one word of traditional story text — the story of “each floor deeper, each floor deadlier” is told entirely by numbers.
Advanced mode adds one more progression line: slain monsters have a 40% chance to drop a sword or armour, directly adding attack or reducing damage; equipment persists across floors and clears on a new run. The progress of “this run” and “this floor” are explicitly layered.
The monster AI has exactly one rule: greedy approach. Move first along the axis with the larger difference; when a wall blocks it, try the other axis. No pathfinding, no memory, no vision.
But combined with corridor terrain, that one rule already produces “blockade” pressure. The monster wedges itself onto the narrow passage between player and stairs and forces a head-on exchange.
One detail you only find by reading the source: the code contains complete branches for a monster hitting the player, the player losing HP, even dying — but the movement function simultaneously forbids monsters from stepping onto the player’s cell. Stacked together, that counter-attack branch can almost never fire in the current implementation.
A textbook edge case of “a guard condition makes an ending unreachable”, and worth pausing on when reading someone else’s game code: where damage is settled is often more bug-prone than the damage formula.
The control surface is three input schemes into one move(dx, dy): arrow keys, WASD, on-screen direction buttons. The keyboard listener hangs off window and the framework cleans it up on unmount.
The log keeps only the last four entries. Small screen, dense information — the other extreme of narrative compression: the mini dungeon gives up even “complete history” and keeps only the moment currently happening.
Elimination and the TTRPG Log
Detective Deduction
The detective game’s case space is combinations. Four suspects (Mr. Green, Mrs. Plum, Colonel Mustard, Professor Plum), four weapons (candlestick, dagger, rope, wrench), four rooms (study, kitchen, greenhouse, ballroom) — drawing one from each makes the truth.
After the case is fixed at random comes its most interesting design decision: hand the player all nine “non-truth” items at once as clues — three non-killers, three non-weapons, three non-rooms — each worded “{name} has been ruled out”.
That is, the clues carry only “is not” information, and they give plenty of it.
Run the information arithmetic: each clue cuts a quarter of the candidates on one dimension, three dimensions each eliminating three quarters, and the remaining intersection is exactly . One unique truth.
It is not a brain-burner of a puzzle but an interactive teaching aid for elimination. The interface itself is a three-column, four-row truth table: the clue list scrolls its elimination verdicts down one side, the three check columns in the middle hand the elimination actions back to the player (tick a cell and it is struck through and grayed out), and the three dropdowns on the right make up the accusation.
Executing the convergence “from 64 to 1” with your own hands sticks better than ten handouts on elimination.
The ending feedback serves the teaching too. Guess wrong and the truth is simply announced: “who did it, with what, and where”.
In an educational game, failure feedback is a remedial lesson rather than a punishment: the player loses no progress, and the next round (a restart rolls a new random truth) starts from the top.
By contrast, hardcore deduction games in the Professor Killer mould hide some clues and force the player to gamble on probability. That is another design stance, betting on “decision-making under insufficient information”, which sits at the opposite end of the spectrum from this work’s “executing a procedure with sufficient information”.
The TTRPG Log
The TTRPG log serves a different tradition: a game master’s assistant for tabletop role-playing.
Three modules, each with its job. An entry log with timestamps for manually recording what happened. A dice module supporting seven sizes from d4 to d100, up to 20 dice at a time, writing each result and the total into the log. An initiative tracker that takes character names and modifiers, rolls for everyone in one click, sorts descending, advances a pointer cyclically to mark the current actor, with a “next” button stepping round by modulo. The minimal implementation of turn-based “rounds”.
Its save mechanism is the only persistence among the six components: one fixed key in localStorage, with a $effect serialising and writing back the log and character data on every change, and reading it back on mount. There is also a manual save button that confirms “saved”, plus a one-click wipe.
A session’s log is the game state itself. Where the last session got to, who took damage, which cliffhanger the story hangs on — reading the log is loading the save.
In the text adventure the log is pure output and the real state hides in variables. In the TTRPG log the log is the state itself, and the variables are the log’s projection.
Both are a scrolling column of text; one is a story, the other a save file.
The state-machine skeleton has finished its story of how structure carries narrative. The synchronized inflation, the geometrically distributed encounter gaps, and the time dilution from the MUD skeleton section are all entry-level samples of manufacturing feel with numbers. The full RPG numerics — combat formulas, growth curves, drops, and resource gates — are taken apart in Game Numerical Design.