SCUA

Tutorial

Tutorial: build a battle game

This is a hands-on tour of SCUA. By the end you'll have written a small turn-based battle — fighters, elemental types, a damage formula, and a loop that runs the fight — and you'll have met most of the language along the way.

Every snippet here runs. Press ▶ Run on any block to execute it right on the page — that's the real compiler, built to WebAssembly, running in your browser. Want room to experiment, or a blank slate? The playground is a full editor. Or, to work locally, install the compiler and put each block in a .scua file.

No setup required. Every code block has a ▶ Run button that runs it right here.

#1. Printing and values

The smallest SCUA program prints something:

print("hello, battle")

SCUA has the value kinds you'd expect: integers and floats (3, 3.5), strings, booleans (true / false), and nil for "nothing". Only nil and false are falsey — 0 and "" are both truthy, which trips up newcomers from other languages.

The friendliest way to build a string is a backtick template. Anything inside {...} is an expression, evaluated and dropped into place:

let name = "Aria"
let level = 3
print(`{name} is level {level}`)
print(`next level needs {level * 100} xp`)

One sharp edge worth learning now: + is arithmetic only. To join strings, use ..:

print("super" .. "effective")   -- supereffective

#2. Functions

Functions are declared with fn ... end. return hands a value back:

fn damage(attack, defense)
  let raw = attack - defense
  return if raw > 0 then raw else 1 end   -- always chip at least 1
end

print(damage(12, 5))   -- 7
print(damage(4, 20))   -- 1

Notice if ... then ... else used as an expression — it evaluates to a value. That's the same if you'd use as a statement; when you use it as a value, the else is required.

No type annotations so far, and the program is complete without them. That's SCUA's default: dynamic, like a scripting language. When you want a guarantee, you add a type — which is the next step.

#3. Records give your data a shape

A record is a named type with a fixed set of fields. This is how you model game entities. You build one as a table literal assigned to a binding with the record's type — that annotation is what turns an ordinary table into a checked Fighter:

record Fighter { name: string, hp: int, atk: int }

let hero: Fighter = { name = "Aria", hp = 30, atk = 7 }
print(`{hero.name}: {hero.hp} hp, {hero.atk} atk`)

The field types (string, int) are checked. Give a field the wrong type — or leave one out — and the compiler stops you before the program runs:

record Fighter { name: string, hp: int, atk: int }

let broken: Fighter = { name = "Glitch", hp = "lots", atk = 7 }
$ scua broken.scua
scua: broken.scua:3: type error: field 'hp' is string, expected int

A record's declared fields are mutable, so a turn is just an assignment. Here one fighter strikes another, lowering the target's health in place:

record Fighter { name: string, hp: int, atk: int }

fn strike(attacker: Fighter, target: Fighter)
  target.hp = target.hp - attacker.atk
end

let goblin: Fighter = { name = "Goblin", hp = 18, atk = 4 }
let hero: Fighter = { name = "Aria", hp = 30, atk = 7 }

strike(hero, goblin)
print(`{goblin.name} is down to {goblin.hp} hp`)

#4. Elemental types with enums and match

Real games have rock-paper-scissors type charts. An enum is a closed set of named variants:

enum Element { Fire, Water, Grass }

let attack = Element.Fire
print(attack == Element.Fire)   -- true
print(attack == Element.Water)  -- false

(Under the hood a variant is just an integer, so print(attack) would show 0, not Fire — you compare and match on them rather than printing them.) The payoff is match, which tests a value against patterns. Because the enum is a closed set, the compiler checks that your match covers every case (or has a _ catch-all). Here's a damage multiplier for a classic triangle:

enum Element { Fire, Water, Grass }

fn multiplier(attack: Element, defend: Element) -> int
  return match [attack, defend]
    [Element.Fire, Element.Grass]  -> 2
    [Element.Water, Element.Fire]  -> 2
    [Element.Grass, Element.Water] -> 2
    _ -> 1
  end
end

print(multiplier(Element.Fire, Element.Grass))   -- 2
print(multiplier(Element.Fire, Element.Water))   -- 1

match matched on a two-element array [attack, defend] and destructured it in each pattern. That same matching works on records, arrays, and Ok/Error values.

#5. The battle loop

Now we put it together. A while loop runs turns until someone drops. We give each fighter an element and fold the type multiplier into the damage.

enum Element { Fire, Water, Grass }
record Fighter { name: string, hp: int, atk: int, element: Element }

fn multiplier(a: Element, d: Element) -> int
  return match [a, d]
    [Element.Fire, Element.Grass]  -> 2
    [Element.Water, Element.Fire]  -> 2
    [Element.Grass, Element.Water] -> 2
    _ -> 1
  end
end

fn strike(attacker: Fighter, target: Fighter)
  let dmg = attacker.atk * multiplier(attacker.element, target.element)
  target.hp = target.hp - dmg
end

let hero: Fighter = { name = "Aria", hp = 30, atk = 7, element = Element.Fire }
let foe:  Fighter = { name = "Leafling", hp = 24, atk = 5, element = Element.Grass }

let round = 1
while hero.hp > 0 and foe.hp > 0 do
  strike(hero, foe)
  if foe.hp <= 0 then break end
  strike(foe, hero)
  print(`round {round}:  {hero.name} {hero.hp} hp   {foe.name} {foe.hp} hp`)
  round = round + 1
end

let winner = if hero.hp > 0 then hero.name else foe.name end
print(`winner: {winner}`)

Press Run and watch Aria's fire chew through the grass type. Change the elements, the stats, the formula — it's your game now.

#6. Make the fight persistent

Here's the part that makes SCUA different. Wrap the battle's state in a partition and it becomes a self-contained block of bytes — one a host can snapshot to move the live game to another machine, or park and bring back later, with no serialization code.

partition Battle
  state hero = "?"
  state foe = "?"
  state round = 0

  on Begin(a, b)
    hero = a
    foe = b
    round = 1
    print(`a wild {foe} appears! {hero} steps up.`)
  end

  on Turn()
    round = round + 1
    print(`...round {round}: {hero} attacks {foe}...`)
  end
end

let game = Battle()
tell game.Begin("Aria", "Leafling")
tell game.Turn()
tell game.Turn()

Battle() creates an instance with its own private state. tell sends it a message; only the partition's own handlers touch its state, one message at a time, so there's nothing to lock. Because that state is a self-contained block of bytes, a host can snapshot the whole battle and bring it back elsewhere — the same isolation that makes message passing safe.

That snapshot is the live image of the running game: ideal for moving a session between machines, or parking it and resuming it later in the same build. For saves that must survive your code changing — new fields, reshaped data, a version bump — SCUA has a separate, typed durable-save format that validates and migrates as it loads. The two are different tools; both are covered in Partitions and the actor model and Save, load, and migrate game state.

#Where to go next

You've now used values, functions, records, enums, match, loops, and partitions — the spine of the language.