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:
@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:
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:
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.
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:
let potion = Spell.Heal(amount: 5);
println(potion.describe());
potion.apply(to: merlin);
println("\(merlin.name): \(merlin.hp) hp");
What you saw
| Step | Feature |
|---|---|
| 1 | Functions, positional vs labeled parameters, string interpolation |
| 2 | Structs, let/var fields, mutating methods |
| 3 | Enums with payloads, exhaustive match |
| 4 | Arrays, for-in, mutating parameters |
| 5 | Protocols 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.