Files
kiln/docs/script-api.md
T
2026-07-11 12:04:33 -05:00

21 KiB

Kiln Script API Reference

Scripts are written in Rhai and live in the [scripts] section of a .toml map file. Each scripted object references a script by name via script_name. A script defines lifecycle hooks (any may be omitted) and may define arbitrary handler functions reachable via send() and scroll choices.

Every hook receives the same first two parameters — me (this object) and state (the world) — through which all reads happen. Writes go through free functions (move, say, set_tile, …) that implicitly act on the calling object.

Status: this document describes the API as it currently exists. It has grown organically and has known rough edges — duplication, naming inconsistency, and some unwieldy machinery. Those are catalogued in Design notes at the end, as the starting point for a redesign.


Lifecycle hooks

Hooks are detected by name and arity (parameter count). A function with the right name but wrong number of parameters is not recognized as a hook. Every hook takes me (an ObjectInfo for the object running the script) and state (a State handle to the world), in that order, before any hook-specific argument.

fn init(me, state)

Called once for every scripted object after the entire map is loaded, before the first frame (and again after a board transition re-enters this board's script host).

fn init(me, state) {
    set_tile(42);
    log("ready");
}

fn tick(me, state, dt)

Called each frame only when this object's output queue is empty — i.e. once its previous actions (and any pacing Delay) have fully drained. While actions from an earlier tick are still pending, the engine just advances the queue by dt and does not re-run tick. This means you no longer need to guard the body with if me.queue.length == 0 / if !me.waiting — the engine does it for you, so a plain move(North) naturally paces itself one step per drain. dt is the elapsed time since the last tick, in seconds (a f64). (All other hooks — bump/enter/grab/send/init — still fire regardless of queued actions.)

fn tick(me, state, dt) {
    if !me.blocked(North) {
        move(North);
    }
}

fn bump(me, dir)

Called when a solid presses into this object's cell — the player, another object, or a crate being pushed into it (the push chain is walked through to reach the object it presses against).

  • dir is the [Direction] the bump came from: it points from this object toward the bumper, so the bumper occupies the cell at (me.x + dir.dx, me.y + dir.dy).
  • Compare it against the North/South/East/West constants.
  • The hook can no longer tell who did the bumping (there is no id) — a crate bumps just like the player.
fn bump(me, dir) {
    if dir == West { say("Something shoved me from the west."); }
    else           { log(`bumped from ${dir}`); }
}

fn enter(me, dir)

The non-solid counterpart to bump. Called when a solid — the player, another object, or a crate being pushed/shifted — relocates onto this object's cell. Only makes sense on a non-solid object (a trigger, or a decorative solid = false object); a solid object blocks the entrant instead of being entered, so its bump fires rather than enter.

  • dir is the [Direction] the entrant came from — the same convention as bump. For a one-cell move or push it is exactly the opposite of the entrant's travel direction. For a teleport or shift that jumps a solid an arbitrary distance it is best-effort: the dominant axis of the displacement (a longer horizontal jump reads as East/West, a longer vertical one as North/South).
  • Fires for every solid that lands on the cell: a player/object step, and each crate a push or shift moved onto it.
fn enter(me, dir) {
    say("Who goes there?");
    log(`something stepped on me from the ${dir}`);
}

fn grab(me, state)

Called when the player walks onto this object and the object is a grab thing (gems, hearts — solid + grab). Unlike bump, the player ends up on the object's cell; the hook typically adjusts a player stat and removes the object. It fires followed by an immediate resolve, so any die() / alter_gems() / alter_health() applies before the player's move returns.

fn grab(me, state) {
    alter_gems(1);
    die();
}

Grab is only triggered by the player walking onto the object. A grab thing pushed or shifted into the player is treated as an ordinary solid (no grab()).

Custom handler functions

Any other function can be invoked on an object two ways:

  • Another object calls send(target_id, "fn_name" [, arg]).
  • A scroll() choice is selected: the choice key is dispatched to the source object as a function call of that name.

The host picks the handler's parameter list by arity, in this order:

  • 3 params(me, state, arg)arg is the value passed to send (or unit for a scroll choice).
  • 2 params(me, state).
  • 1 param(arg).
  • 0 params().

If no matching function name exists, the call is silently dropped.

// reached via send(my_id, "open") or a scroll choice keyed "open"
fn open(me, state)         { set_tile(47); }
fn set_level(me, state, n) { set_tag(me.id, "level", true); log(`level ${n}`); }

Constants in scope

Unlike the me/state parameters (which every hook receives), these are injected directly into every object's scope.

Name Type Description
Registry Registry Board-scoped key→value store; persists across board transitions.
North / South / East / West Direction Cardinal directions.
BlackWhite (16) String EGA/VGA palette colors as "#RRGGBB" strings.

Registry is a scope constant: it is only visible at the top level of an engine-called hook, not inside helper functions you define (those run at a deeper call depth — see Pitfalls). The direction/color constants come from a global module and are visible at any call depth (including Rhai→Rhai calls).

The 16 color names, in palette order: Black, Blue, Green, Cyan, Red, Magenta, Brown, LightGray, DarkGray, BrightBlue, BrightGreen, BrightCyan, BrightRed, BrightMagenta, Yellow, White. They are plain hex strings, so they are accepted anywhere a color string is (e.g. set_fg(BrightRed)).


Self and other objects: ObjectInfo

me is an ObjectInfo — a snapshot of one board object. The same type comes back from state.board.get / .named / .tagged, so an object you look up has exactly the same members as me.

Member Kind Returns Description
.id getter i64 The object's stable ObjectId.
.name getter String or () Name, or unit if unnamed.
.x / .y getter i64 Current cell position.
.tags getter Array All tag strings on this object.
.glyph getter Glyph The object's glyph (.tile, .fg, .bg).
.waiting getter bool Whether the front of the object's queue is a delay (i.e. it is "busy").
.queue getter Queue The object's pending-action queue.
.has_tag(tag) method bool Whether the object carries tag.
.delay(secs) method Append a pause to the object's queue.
.blocked(dir) method bool Whether moving the object one step in dir is impossible (off-board or a solid).
.can_push(dir) method bool Whether the pushable chain ahead of the object in dir can be shoved.
fn tick(me, state, dt) {
    if me.has_tag("asleep") { return; }
    let here = me.glyph;
    log(`my tile is ${here.tile}`);
    if !me.waiting && !me.blocked(East) { move(East); }
}

A Glyph value (from me.glyph or a looked-up object) exposes .tile (i64), .fg, .bg (both "#RRGGBB" strings).

Because hooks receive me/state as parameters, they are local to the hook. To use them in a helper function you define, pass them down explicitly: fn dance(me) { … } called as dance(me).


The world: state

state is a handle bundling the two read surfaces:

Member Type Description
state.player Player The player's game-global stats and position.
state.board Board Read-only handle to the current board.

Player stats: state.player

A read-only snapshot of the game-global player state, refreshed before each hook call.

Property Type Description
state.player.gems i64 Gems collected.
state.player.health i64 Current health.
state.player.max_health i64 Health ceiling.
state.player.x / .y i64 Player's current cell.
state.player.keys Keyring The player's key inventory (see below).
if state.player.health < 2 { say("You look hurt."); }
if state.player.keys.red   { say("You have the red key."); }

The Keyring from state.player.keys exposes one bool getter per color: .red, .orange, .yellow, .green, .blue, .cyan, .purple, .white.

Board reads

Properties

Property Type Description
state.board.width i64 Board width in cells.
state.board.height i64 Board height in cells.

Object lookups

Call Returns Description
state.board.tagged(tag) Array of ObjectInfo Every object carrying tag.
state.board.named(name) ObjectInfo or () The object with that name, else unit.
state.board.get(id) ObjectInfo or () Look up by id; an invalid (id <= 0) or unknown id logs an error and returns unit.
let enemies = state.board.tagged("enemy");
log(`there are ${enemies.len()} enemies`);

let door = state.board.named("door");
if door != () { send(door.id, "open"); }

Cell queries

Call Returns Description
state.board.can_push(x, y, dir) bool Does a pushable chain starting at (x, y) end in open space when shoved in dir? false off-board.
state.board.passable(x, y) bool Is (x, y) on-board and free of any solid (an enterable hole)? Distinguishes a hole from a blocked solid.

For queries about the calling object itself, use me.blocked(dir) and me.can_push(dir) (above).


Registry (shared key→value store)

Board-scoped, persists across board transitions. Stores primitive values.

Call Description
Registry.get(key) Stored value, or () if absent.
Registry.set(key, value) Store a primitive; passing () removes the key; unsupported types are silently ignored.
Registry.get_or(key, default) Stored value only if it has the same type as default; otherwise default.
fn tick(me, state, dt) {
    // Per-object counter that survives across ticks and board transitions.
    let key = `count_${me.id}`;
    let n = Registry.get_or(key, 0);
    Registry.set(key, n + 1);
}

The Registry is shared by the whole board, so namespace per-instance keys by me.id as above.


Write functions

Scripts never mutate the world directly. Each write appends an Action to the calling object's output queue; the moment the hook returns, that object's ready actions are drained and applied by the engine — before the next object's hook runs. The subject of a write is implicit and varies by function (see the table). The one exception is log(msg): it bypasses the queue and writes to the game log immediately, so a diagnostic line is never paced by (or stuck behind) the object's delays.

Call Subject Cost Description
move(dir) self 0.25 s Enqueue a one-cell move, plus a rate-limiting delay (~4/s). A blocked move still costs the cooldown.
teleport(id, x, y) arbitrary entity 0 Jump entity id to an arbitrary cell (pass me.id for self; id == -1 is the player). Refused (error logged at resolve) if that entity is solid and the destination already holds a different solid.
push(x, y, dir) arbitrary cell 0 Shove the pushable chain at (x, y) one step in dir.
shift([[x, y], …]) arbitrary cells 0 Rotate cell contents: each listed cell moves to the next coordinate, last→first. Skips non-pushables and won't move into a non-vacated occupied cell.
set_tile(n) self 0 Set glyph tile index (CP437 code point in the default font).
set_fg(color) self 0 Set foreground color (hex string).
set_bg(color) self 0 Set background color.
set_color(fg, bg) self 0 Set both colors.
set_tag(target_id, tag, present) any object 0 Add (true) / remove (false) a tag. Use me.id for self.
say(msg) / say(msg, secs) self 0 Speech bubble above this object; default 3 s, or secs. Replaces any current bubble.
log(msg) log 0 Append a plain-text line to the game log. Immediate — unlike the other writes it is not queued, so it appears the instant it's called, even behind a pending move/delay.
scroll(lines) UI 0 Open a full-screen overlay (see below).
send(target_id, fn_name [, arg]) any object 0 Call a handler function on another object; optional string/number arg.
delay(secs) self Insert an explicit pause; integer and float overloads. Adjacent delays merge.
now() self Move the most recently enqueued action to the front of the queue.
alter_gems(n) player 0 Change the player's gem count (clamped at 0).
alter_health(dh) player 0 Change the player's health (clamped to [0, max_health]).
set_key(color, present) player 0 Give/take a key color ("blue", "green", "cyan", "red", "purple", "orange", "yellow", "white"). Unknown name logs and is ignored.
die() self 0 Remove the calling object from the board.

delay/now also exist as methods on me and on a queue handle: me.delay(secs) and me.queue.delay(secs) are equivalent to the free delay(secs) for the calling object.

scroll(lines)

Opens a full-screen scrollable overlay and pauses game ticks until the player closes it. Each element of lines is either:

  • a string — a plain text line, or
  • a 2-element array [choice_key, display_text] — a selectable choice.

When the player selects a choice, choice_key is dispatched back to the source object as a function call named choice_key (see custom handler functions for the arity rules — a choice handler is called with no arg).

fn bump(me, dir) {
    scroll([
        "The muffin looks delicious.",
        "",
        ["eat",    "Eat it"],
        ["ignore", "Walk away"],
    ]);
}
fn eat(me, state)    { say("Yeah it was poisoned."); alter_health(-2); }
fn ignore(me, state) { log("Wise."); }

Queue object

Inspection and control of an object's own pending-action queue, reached via me.queue. Most scripts do not need it directly — me.waiting covers the common "am I busy?" check.

Member Kind Returns Description
me.queue.length getter i64 Number of pending actions.
me.queue.clear() method Discard all pending actions (including delays).
me.queue.delay(secs) method Append a pause to the queue.
fn tick(me, state, dt) {
    if !me.waiting {
        if      !me.blocked(East) { move(East); }
        else if !me.blocked(West) { move(West); }
    }
}

Rate limiting and timing

  • move(dir) costs 0.25 s of queue delay (~4 moves/second max).
  • delay(secs) adds an arbitrary pause; now() can bypass a delay for a zero-cost action already in the queue.
  • me.queue.clear() cancels everything, including pending delays.
  • Adjacent delays are always merged: two delay(0.5) calls become one 1.0 s delay.
  • All other writes (set_tile, say, push, shift, teleport, stat changes, die, …) are zero-cost: a run of them resolves together, and at most one timed action (a move) is released per object per cycle.
  • me.waiting is true while a delay sits at the front of the queue — the idiom for "don't issue a new action until the last one finished" is if !me.waiting { … }.

Error handling

Script errors (compile-time or runtime) are appended to the game log as plain-text lines and do not crash the game. A script that throws during tick logs the error and resumes on the next frame.


Full example

// A guard that patrols east/west and greets whatever bumps into it.

fn init(me, state) {
    set_tile(2);
    log(`${me.name} standing watch`);
}

fn tick(me, state, dt) {
    if me.waiting { return; }              // still moving
    if      !me.blocked(East) { move(East); }
    else if !me.blocked(West) { move(West); }
}

fn bump(me, dir) {
    say(`Halt! Who approaches from the ${dir}?`); now();
}

Design notes (rough edges)

Collected friction points in the current surface, as input to a redesign. None of these are bugs; they are shape problems.

Duplication — several ways to do one thing

  • Player position/state is reachable two ways. Player stats and position both live on state.player now (.gems/.health/.x/.y/.keys), which is an improvement — but a looked-up player no longer exists (state.board.get(-1) is gone), so code that wants "the player as an object" has nothing to look up, while every other entity is an ObjectInfo.
  • Reads are split across three handles (me, state.player, state.board) with no single "query" entry point; which handle owns a given fact (e.g. cell queries on state.board vs. self queries on me) has to be memorized.

Inconsistency — naming and conventions

  • No consistent verb convention. Setters by name (set_tile, set_tag, set_fg, set_bg, set_color, set_key), delta verbs (alter_gems, alter_health), and bare verbs (move, die, teleport, push, shift, scroll, say, log) all coexist. Worse, the two "change by a delta" operations use different verbs: alter_gems vs alter_health.
  • The implicit subject of a write is unpredictable. set_tile/set_fg/say/die act on self; alter_gems/alter_health/set_key act on the player; set_tag/send act on an arbitrary target; push/shift act on cells and teleport on an arbitrary entity. The set_* prefix in particular means three different subjects.
  • Reads are methods/getters on handles, but writes are free functions. me.blocked(dir) reads, but move(dir) (the matching write) is a bare global that acts on me implicitly — the symmetry is invisible.
  • Mixed addressing for movement. move/push take a typed Direction, while teleport/passable/can_push/shift take raw i64 coordinates. There's no cell or position value type.
  • Self-mutation can't go through me. me is read-only, so writing your own tag is set_tag(me.id, …) — you pass your own id back to a free function instead of me.set_tag(…).
  • Colors are stringly-typed ("#RRGGBB", with the named constants being strings) while tiles are bare integers with no symbolic names; glyph reads hand back colors as hex strings.
  • Overload coverage is uneven. say has a duration overload, log does not; delay has int and float overloads, most numeric fns don't.

Unwieldy — machinery that leaks

  • The output-queue + now()/delay() model is subtle. now() reorders by enqueue recency ("most recently enqueued action to the front"), which is position-dependent and easy to get wrong; the per-object delay draining (leading delays eaten by dt, then the ready run applied) is still a fair amount of implicit state to reason about for "do X, wait, do Y".
  • Two persistence mechanisms. Tags and the Registry are separate stores with separate APIs; cross-board state must pick one.
  • Stringly-typed dispatch. send and scroll choices route by function name; a scroll choice key is silently also a function name. Renaming a handler breaks callers with no signal.
  • Registry alone is a scope constant. Now that me/state are parameters, Registry is the one handle with the "invisible inside helper functions" gotcha — an inconsistency with everything else a hook can reach.
  • Read/write asymmetry on player keys is now closed (state.player.keys.* reads, set_key writes) — but reads are per-color getters while the write is a stringly-named color, so the two sides still don't mirror each other.