Tour: Wizard Duel

Two wizards take turns trading spells. By the end of this tour you'll have built the working core of a duel and seen Kestrel's enums, pattern matching, and protocols earn their keep.

Each step adds one new idea. Copy, run, then move on.

Step 1 — Values & functions

module Main import std.io.stdio.println func cast(spell: String, at target: String, for damage: Int) { println("\(spell) hits \(target) for \(damage) damage!"); } @main func main() { cast("Fireball", at: "Morgana", for: 8); }

cast takes three parameters. spell: String is positional — spell is the bind name, no label, so the call site doesn't write one. The next two have labels (at target, for damage), so the call site uses them: cast("Fireball", at: "Morgana", for: 8). String interpolation uses \(...).

Step 2 — Structs & methods

<!-- sample: continue --> struct Wizard { let name: String var hp: Int } extend Wizard { mutating func takeDamage(amount: Int) { self.hp = self.hp - amount; } func isAlive() -> Bool { self.hp > 0 } }

let fields can't change after init; var fields can. mutating func is required to write to a var field — the caller has to pass the wizard via a var binding. Methods live in extend blocks rather than inside the struct definition.

takeDamage takes a positional amount: Int, so callers write wiz.takeDamage(5).

Replace main to try it:

<!-- sample: continue --> @main func main() { var merlin = Wizard(name: "Merlin", hp: 20); merlin.takeDamage(8); println("\(merlin.name): \(merlin.hp) hp, alive: \(merlin.isAlive())"); }

Step 3 — Enums & pattern matching

This is the moment Kestrel earns its keep.

<!-- sample: continue --> enum Spell { case Fireball(damage: Int) case Heal(amount: Int) case Shield(block: Int) case Counterspell } func describe(spell: Spell) -> String { match spell { .Fireball(damage) => "Fireball (\(damage) dmg)", .Heal(amount) => "Heal (\(amount) hp)", .Shield(block) => "Shield (\(block) block)", .Counterspell => "Counterspell" } }

match destructures payloads inline; each arm produces a value, and the compiler checks every case is handled. Add a new variant and every match lights up red until you cover it.

Add to main:

<!-- sample: continue --> println(describe(Spell.Fireball(damage: 8)));

Step 4 — Collections

Time for an opponent and a deck of spells.

<!-- sample: continue --> func resolve(spell: Spell, mutating by attacker: Wizard, mutating on defender: Wizard) { match spell { .Fireball(damage) => defender.takeDamage(damage), .Heal(amount) => attacker.hp = attacker.hp + amount, .Shield(block) => defender.hp = defender.hp + block, .Counterspell => { println("\(defender.name) is silenced!"); } } }

mutating by attacker and mutating on defender are labeled parameters with the mutating access mode — note that mutating comes before the label, and call sites don't repeat it. The .Counterspell arm is a block ending in ; because match arms must agree on a type: the other three arms are (), and println alone would be a Result.

Add to main[Spell] is array-of-Spell, and for-in walks it:

<!-- sample: continue --> var morgana = Wizard(name: "Morgana", hp: 20); let deck: [Spell] = [Spell.Fireball(damage: 8), Spell.Heal(amount: 4), Spell.Counterspell]; for spell in deck { println("Merlin casts \(describe(spell))"); resolve(spell, by: merlin, on: morgana); } println("\(morgana.name): \(morgana.hp) hp");

Step 5 — Protocols

Spells aren't the only thing wizards can cast. A potion should work too. Abstracting over "things that can be cast" is what protocols are for.

<!-- sample: continue --> protocol Castable { func describe() -> String func apply(mutating to target: Wizard) } extend Spell: Castable { public func describe() -> String { match self { .Fireball(damage) => "Fireball (\(damage) dmg)", .Heal(amount) => "Heal (\(amount) hp)", .Shield(block) => "Shield (\(block) block)", .Counterspell => "Counterspell" } } public func apply(mutating to target: Wizard) { match self { .Fireball(damage) => target.takeDamage(damage), .Heal(amount) => target.hp = target.hp + amount, .Shield(block) => target.hp = target.hp + block, .Counterspell => {} } } }

A type "conforms" to a protocol with : ProtocolName. The describe body is the same match from Step 3 — now it lives on the type, so any Castable can be asked to describe itself. apply(to:) works on any Castable, and adding Potion: Castable later costs nothing in the rest of the program.

Add to main:

<!-- sample: continue --> let potion = Spell.Heal(amount: 5); println(potion.describe()); potion.apply(to: merlin); println("\(merlin.name): \(merlin.hp) hp");

What you saw

StepFeature
1Functions, positional vs labeled parameters, string interpolation
2Structs, let/var fields, mutating methods
3Enums with payloads, exhaustive match
4Arrays, for-in, mutating parameters
5Protocols and conformance

The takeaway: enums with payloads plus exhaustive match give you type-safe dispatch without boilerplate. That same shape — define the cases, match over them — is how Kestrel models commands, messages, ASTs, and anything else where you want the compiler to verify you handled every possibility.