SCUA

How-to

Model game state with records and enums

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.