You have an entity in your game, say an enemy, and you want to give
it a clear shape, a state machine for its behaviour, and a rule that
says what counts as a valid one. This page builds that up from three
pieces: a record for the shape, an enum for
the state machine, and a contract for the validity rule.
Each step runs on its own, and the end is one program you can copy
whole.
#The shape: a record
A record is a named type with a fixed set of fields.
Give a field a default and you can leave it out when you construct a
value.
record Enemy {
name: string,
hp: int,
level: int = 1, -- a default: omittable when you construct one
}
let goblin: Enemy = { name = "goblin", hp = 40 }
print(`{goblin.name}: hp {goblin.hp}, level {goblin.level}`)
goblin.hp = goblin.hp - 15
print(`{goblin.name}: hp {goblin.hp}`)
Run it:
$ scua enemy.scua
goblin: hp 40, level 1
goblin: hp 25
level was never written in the literal, so it came out
as its default of 1. Fields are checked when you construct the value:
every required field has to be present, and nothing unknown is allowed.
A record is also sealed, so you can update a declared field but you
can't bolt a new one on later. For the full story see Records and gradual types.
#The state machine: an enum
An enum is a closed set of named variants. A variant can carry data, which is exactly what you want for a state machine where some states have parameters (a patrol has a waypoint, a chase has a target).
enum EnemyState {
Idle,
Patrol(int), -- a waypoint id
Chase(target: int, dist: int), -- who it chases, and how far
Dead,
}
fn describe(name, state)
match state
EnemyState.Idle -> return `{name} stands idle`
EnemyState.Patrol(wp) -> return `{name} patrols toward waypoint {wp}`
EnemyState.Chase(t, d) -> return `{name} chases #{t} at range {d}`
EnemyState.Dead -> return `{name} is dead`
end
end
print(describe("goblin", EnemyState.Idle))
print(describe("goblin", EnemyState.Chase(1, 6)))
Run it:
$ scua state.scua
goblin stands idle
goblin chases #1 at range 6
The match pulls the data back out of each variant:
Patrol(wp) binds the waypoint, Chase(t, d)
binds both fields by position. The match is exhaustive. Drop the
Dead arm and the compiler tells you which case you forgot,
so adding a new state later forces you to handle it everywhere. More on
both in Enums and Pattern matching.
#The rule: a contract
A contract is a named predicate. The simplest kind is
one clause over one value, and you can attach it to a record field with
where. That makes it a refinement: the field is checked
whenever data crosses into the type.
contract InHpRange(n) n in 0:101 end -- 0..100
record Enemy {
name: string,
hp: int where InHpRange,
}
let ok: Enemy = { name = "goblin", hp = 40 }
print(`{ok.name}: hp {ok.hp}`)
let incoming: any = { name = "wisp", hp = 250 }
try
let bad: Enemy = incoming
rescue err
print(`rejected {err.record}.{err.field} = {err.value}`)
end
Run it:
$ scua rule.scua
goblin: hp 40
rejected Enemy.hp = 250
The range 0:101 is half-open, so it means 0 through 100.
When the loose any value tries to become an
Enemy, the refinement runs and the bad hp is caught right
at the crossing. The fault you catch is introspectable:
err.record, err.field, and
err.value tell you what failed.
One sharp edge to know: the refinement is checked at the boundary,
not on every later write. After a value is a trusted Enemy,
you can still assign enemy.hp = 999 in typed code and it
won't re-check. The guarantee is "valid when it crosses in," not "valid
at every point forever." Contracts
covers this in depth.
#Putting it together
The same contract type also works as a multi-clause check you call
directly. Here IsThreat gates behaviour, and the rest of
the program constructs an enemy, drives it through its states, and
checks the rule.
-- A small enemy: a shape, a state machine, and a validity rule.
enum EnemyState {
Idle,
Patrol(int), -- a waypoint id
Chase(target: int, dist: int), -- who it chases, and how far away
Dead,
}
contract InHpRange(n) n in 0:101 end -- 0..100
contract InLevelRange(n) n in 1:51 end -- 1..50
record Enemy {
name: string,
hp: int where InHpRange,
level: int where InLevelRange = 1,
state: EnemyState,
}
contract IsThreat(e)
alive: e.hp > 0 else "must be alive"
active: e.state != EnemyState.Idle else "must not be idle"
end
fn describe(e)
match e.state
EnemyState.Idle -> return `{e.name} stands idle`
EnemyState.Patrol(wp) -> return `{e.name} patrols toward waypoint {wp}`
EnemyState.Chase(t, d) -> return `{e.name} chases #{t} at range {d}`
EnemyState.Dead -> return `{e.name} is dead`
end
end
-- Construct one. `level` is omitted, so it defaults to 1.
let goblin: Enemy = {
name = "goblin",
hp = 40,
state = EnemyState.Idle,
}
print(`{goblin.name}: hp {goblin.hp}, level {goblin.level}`)
print(describe(goblin))
-- Update fields. The state machine moves by reassigning `state`.
goblin.state = EnemyState.Chase(1, 6)
goblin.hp = goblin.hp - 15
print(describe(goblin))
-- Check a rule. A multi-clause contract returns a checklist.
let r = IsThreat(goblin)
print(`threat? {r.ok} ({r.done} of {r.total})`)
-- The hot-path form: `matches` inlines to a plain bool, no allocation.
if goblin matches IsThreat then
print("the goblin will attack")
end
-- A loose value crossing into an Enemy slot is checked once, at the crossing.
let incoming: any = { name = "wisp", hp = 250, state = EnemyState.Idle }
try
let bad: Enemy = incoming
rescue err
print(`rejected {err.record}.{err.field} = {err.value}`)
end
Run it:
$ scua game-state.scua
goblin: hp 40, level 1
goblin stands idle
goblin chases #1 at range 6
threat? true (2 of 2)
the goblin will attack
rejected Enemy.hp = 250
The IsThreat contract gives you two ways to ask the same
question. Called as IsThreat(goblin) it returns a checklist
with ok, done, and total, which
is what you want when you're showing the player why something did or
didn't happen. Used with matches it collapses to a plain
bool with no allocation, which is the form to reach for in a per-frame
check.
Notice the division of labour. The record fixes the shape, so a typo
in a field name is a compile error. The enum makes illegal states
unrepresentable and the exhaustive match makes sure you
handle every case. The contract states the rule once and enforces it at
the edges. None of these change how the program runs; drop the
annotations and it behaves the same, you just lose the checks.