Tour: Text Adventure

Build the pieces of a tiny text adventure across 5 steps — rooms, actions, a map, and a protocol that ties them together. Showpieces: dictionaries, Optional, and how Kestrel handles things that might not be there.

Step 1 — Values & functions

module Main import std.io.stdio.println func describe(location: String) { println("You stand in \(location)."); } @main func main() { describe("a damp cave"); }

A function and a positional call. location is the bind name with no label, so the call site is just describe("a damp cave"). Run it, see the output. That's the loop you'll repeat for every step.

Step 2 — Structs & methods

<!-- sample: continue --> struct Room { let name: String let description: String var exits: [String] } extend Room { func describe() { println("\(self.name): \(self.description)"); println("Exits: \(self.exits.joined(", "))"); } }

let fields are fixed at construction; var lets you change them later (we'll need that when the player picks up an item). The implicit memberwise initializer means you can write Room(name: "Cave", ...) without writing an init. joined glues the exit names into one string.

Replace main to try it:

<!-- sample: continue --> @main func main() { let cave = Room(name: "Cave", description: "A damp cave.", exits: ["north", "east"]); cave.describe(); }

Step 3 — Enums & pattern matching

The player needs to do things — go places, look around, take items, quit. That's an enum with payloads.

<!-- sample: continue --> enum Action { case Go(direction: String) case Look case Take(item: String) case Quit } func handle(action: Action, in room: Room) { match action { .Go(direction) => { println("You head \(direction)."); }, .Look => { room.describe(); }, .Take(item) => { println("You pick up the \(item)."); }, .Quit => { println("Goodbye."); } } }

Every payload variant unpacks inline in the match. Exhaustiveness means the compiler refuses to compile if you forget a case — add a new Action, every match over it lights up red until you handle the new variant. One subtlety: arms are expressions and must all agree on a type. println returns a Result (printing can fail) while room.describe() returns nothing, so each arm is wrapped in a block ending in ; — that makes every arm ().

Add to the end of main:

<!-- sample: continue --> handle(Action.Look, in: cave); handle(Action.Go(direction: "north"), in: cave);

Step 4 — Collections

A real adventure has more than one room. We'll keep them in a dictionary keyed by name.

<!-- sample: continue --> import std.collections.Dictionary func play(rooms: Dictionary[String, Room], start: String) { var current = start; let lookup = rooms(current); if let .Some(room) = lookup { room.describe(); } else { println("You wandered off the map."); } }

rooms(current) returns an Optional[Room] — the key might not exist. The if let .Some(room) = ... shape unwraps it; the else branch handles the missing case. Optional is just an enum (.Some(T) and .None), but the compiler treats unwrap patterns specially so you can't forget to handle absence.

Add to main:

<!-- sample: continue --> var rooms = Dictionary[String, Room](); rooms.insert("cave", cave); play(rooms, "cave"); play(rooms, "throne room"); // exercises the .None branch

Step 5 — Protocols

Right now describe only works on Room. But the dungeon has shrines and vaults too. Abstracting over "places you can describe" is what protocols are for.

<!-- sample: continue --> protocol Place { func title() -> String func describe() } extend Room: Place { public func title() -> String { self.name } // describe() from Step 2 already satisfies the requirement } struct Shrine { let deity: String let blessing: String } extend Shrine: Place { public func title() -> String { "Shrine of \(self.deity)" } public func describe() { println("A shrine to \(self.deity). It offers: \(self.blessing)."); } } func visit[P](place: P) where P: Place { println("— \(place.title()) —"); place.describe(); }

Now any function that takes a Place works on either type — visit is generic over any P that conforms (where P: Place; the generics chapter has the full story). Add a Vault later, conform it to Place, and visit picks it up for free. (The requirement is called title rather than name so it doesn't collide with Room's name field.)

Add to main:

<!-- sample: continue --> visit(cave); visit(Shrine(deity: "Mola", blessing: "luck"));

What you saw

StepFeature
1Functions, positional parameters, println
2Structs, methods via extend, memberwise init
3Enums with payloads, exhaustive match
4Dictionaries, Optional and if let unwrap
5Protocols, generic functions with where bounds

The Optional story is the takeaway. There are no null pointers — null is just literal sugar for Optional.None — so anything that might be absent is an Optional[T] the compiler forces you to unwrap. Whole categories of bug just don't exist.