Files
kiln/kiln-api.md
T
2026-07-08 10:06:25 -05:00

16 KiB

Kiln Rhai Scripting API

This is the complete reference for scripting kiln game objects. Scripts are written in Rhai, live in the world file's top-level [scripts] table, and are attached to board objects by script_name. Each scripted object runs its own copy of the script with its own persistent state.

[scripts]
greeter = """
fn init() { log("hello"); }
fn tick(dt) { if Queue.length() == 0 && !blocked(North) { move(North); } }
fn bump(id) { say("ouch!"); }
"""

[[boards.room1.layers]]
sparse = [ { x = 5, y = 3, ch = "G" } ]
palette = { "G" = { kind = "object", name = "greeter", script_name = "greeter" } }

Mental model: how a script runs

A script does not mutate the world directly. Instead:

  1. The engine calls one of your callbacks (init, tick, bump, grab, or a custom function reached via send).
  2. Inside the callback you read the world synchronously (Board.*, Me.*, blocked(), etc.) and enqueue actions (move, say, set_tile, …).
  3. Enqueued actions go into your object's private output queue. They are not applied while your callback is running.
  4. After the callback returns, the engine promotes queued actions onto a shared board queue and applies them — applying actions until it hits a "delay" action.
  5. Some actions (move) automatically queue delay actions after you add them to your queue.

So writes are deferred and rate-limited, while reads are immediate. This is the single most important thing to internalize (see Pitfalls).


Callbacks (lifecycle hooks)

All are optional. Define only the ones you need. They are matched by name and arity — a tick with the wrong number of parameters silently won't be called.

Callback When it fires
fn init() Once, after the board is fully loaded (and again after a board transition that re-enters this board's script host). Use for setup.
fn tick(dt) Every frame. dt is elapsed seconds since the last frame (a float).
fn bump(id) When another mover steps into this object's cell. id is the bumper's object id, or -1 if it was the player.
fn grab() When the player walks onto this object — only if the object has the grab behavior (e.g. a gem). Typically grants something and die()s.
fn init() {
    log(`I am object ${Me.id} at ${Me.x},${Me.y}`);
}

fn tick(dt) {
    // Patrol north until blocked, then idle.
    if Queue.length() == 0 && !blocked(North) {
        move(North);
    }
}

fn bump(id) {
    if id == -1 { say("Hello, player!"); }
    else { say("Hey, watch it!"); }
}

fn grab() {
    alter_gems(1);
    die();
}

You may also define arbitrary functions and invoke them on another object with send(target_id, "fn_name") — see send. The target's matching function runs with that object's identity and scope.


Scope constants

These four handles are injected into every object's scope. They are only visible at the top level of your callbacks — not inside helper functions you define (those run at a deeper call depth). See the Me/Registry pitfall.

Name What it is
Board Read-only handle to the current board.
Me This object's self-reference (id, name, position, tags, glyph).
Queue This object's pending-action queue.
Registry A board-scoped key→value store that persists across board transitions.

Global constants

These are registered as a global module, so they are visible everywhere, including inside helper functions and Rhai-to-Rhai calls.

  • Directions: North, South, East, West (type Direction).
  • Colors: the 16 EGA/VGA palette names as "#RRGGBB" strings: Black, Blue, Green, Cyan, Red, Magenta, Brown, LightGray, DarkGray, LightBlue, LightGreen, LightCyan, LightRed, LightMagenta, Yellow, White.
move(East);
set_color(Yellow, Black);

Reading the world

Board.* properties

Expression Type Meaning
Board.player_x int Player's X.
Board.player_y int Player's Y.
Board.width int Board width in cells.
Board.height int Board height in cells.

Board lookups

Call Returns
Board.tagged(tag) Array of ObjectInfo for every object carrying tag.
Board.named(name) The ObjectInfo with that name, or () (unit) if none.
Board.get(id) The ObjectInfo for id; Board.get(-1) returns the player. Returns () and logs an error for an unknown/invalid id.

ObjectInfo is a snapshot (read fields, it is not live):

Field Type
.id int (the player's is -1)
.x, .y int
.name string ("" if unnamed)
.tags array of strings
let p = Board.get(-1);
log(`player at ${p.x},${p.y}`);

for g in Board.tagged("guard") {
    if g.x == Me.x { say("aligned with a guard"); }
}

Cell queries (free functions)

Call Returns true when
blocked(dir) Moving the calling object one step in dir is impossible: off-board, an existing solid, or the destination of another mover's pending move this frame.
passable(x, y) (x, y) is on-board and has no solid (an empty cell / hole a mover could enter). Off-board is not passable.
can_push(x, y, dir) (x, y) holds a pushable whose entire chain can be shoved one step in dir (the chain ends in open space). False off-board.
can_shift(x, y, dir) (x, y) holds a pushable and the single next cell is empty or another pushable — does not require the whole chain to clear. The right gate for a rotation/shift via swap.
combinable(x1, y1, x2, y2) The solids at the two cells can share a cell: one is empty, or one is a grab thing and the other is the player. Off-board combines with nothing.

passable vs can_shift: can_shift == false is ambiguous between "the next cell is a wall" and "the next cell is empty but the source isn't pushable." Use passable to detect a genuine hole.

Me.* (self)

Expression Returns
Me.id This object's id (int).
Me.name This object's name, or "".
Me.x, Me.y Current position (int).
Me.has_tag(tag) bool.
Me.tags() array of strings.
Me.glyph() a Glyph: .tile (int), .fg (hex string), .bg (hex string).
if Me.has_tag("asleep") { return; }
let here = Me.glyph();
log(`my tile is ${here.tile}`);

Registry (persistent key→value store)

Board-scoped storage that survives board transitions (unlike script-local variables, which reset whenever the script host is rebuilt). Use it for any state that must outlive a single tick. Values may be primitives (int, float, bool, string); unsupported types are silently ignored.

Call Effect
Registry.get(key) The stored value, or () if absent.
Registry.set(key, value) Store value. Passing () removes the key.
Registry.get_or(key, default) The stored value if present and the same type as default; otherwise default.
fn tick(dt) {
    // Per-object counter that survives across ticks.
    let key = `count_${Me.id}`;
    let n = Registry.get_or(key, 0);
    Registry.set(key, n + 1);
}

Key your entries by Me.id (as above) if each object instance needs its own slot — the Registry is shared by the whole board.


Writing to the world (actions)

Every function below enqueues an action on the calling object's output queue; nothing takes effect until your callback returns. Only move carries a time cost (0.25 s); the rest are zero-cost and may all resolve in the same frame.

Movement & timing

Call Effect
move(dir) Step one cell in dir, shoving any pushable chain (including the player) ahead; a no-op if blocked. Costs 0.25 s before this object can move again.
delay(secs) Insert an explicit pause in this object's queue (accepts int or float). Adjacent delays merge.
now() Promote the most-recently-enqueued action to the front of the queue (jump the delay).
teleport(id, x, y) Jump entity id to an arbitrary cell (pass me.id for self; id == -1 is the player). Zero cost. Refused (with a logged error at resolve time) if that entity is solid and the destination already holds a different solid.

Appearance

Call Effect
set_tile(n) Set this object's glyph tile index (CP437; e.g. 64 is @).
set_fg(hex) Set foreground color ("#RRGGBB" string or a color constant).
set_bg(hex) Set background color.
set_color(fg, bg) Set both at once.

Communication & UI

Call Effect
log(msg) Append a line to the in-game log.
say(msg) Show a speech bubble above this object (default ~3 s). One bubble per object; a new say replaces the old.
say(msg, secs) Same, with explicit duration.
scroll(lines) Open a full-screen scrollable overlay (pauses ticks). See below.

scroll(lines) takes an array whose elements are either a plain string (a text line) or a two-element array [choice_key, display_text] (a selectable option). When the player picks an option, the engine calls send-style back into this object: a function named choice_key is invoked. Define that function to react.

fn tick(dt) {
    if Queue.length() == 0 && near_player() {
        scroll([
            "The old wizard studies you.",
            "",
            ["yes", "Accept the quest"],
            ["no",  "Decline"],
        ]);
    }
}
fn yes() { say("Brave soul!"); alter_gems(5); }
fn no()  { say("Coward."); }

Tags & messaging

Call Effect
set_tag(target_id, tag, present) Add (true) or remove (false) tag on the object with target_id.
send(target_id, fn_name) Call fn_name() on object target_id.
send(target_id, fn_name, arg) Same, passing one string-or-number arg.

send runs the target's function with the target's identity and scope (its Me, its Queue). The target must define a function of that name accepting 0 or 1 params (the engine picks the matching arity; if neither exists the call is silently dropped). This is the building block for object-to-object coordination and scroll choices.

// Object A wakes object B (id from a lookup) and passes it a word.
let b = Board.named("door");
if b != () { send(b.id, "open", "north"); }

Pushing & batch moves

Call Effect
push(x, y, dir) Shove the pushable chain starting at (x, y) one step in dir. Acts on arbitrary cells, not the caller. Zero cost.
swap(pairs) Apply a batch of one-way solid moves simultaneously (read-all then write-all, so cycles and two-cell swaps resolve). Zero cost.

swap(pairs) takes an array of four-int arrays [src_x, src_y, dst_x, dst_y]. Each names a solid (player, object, or terrain) to move from src to dst; all happen at once. A malformed entry is skipped and logged. The player is never destroyed by a swap — a move that would overwrite the player without relocating it is refused and logged. (This is how the built-in spinner rotates its 8 neighbours in one step.)

// Two-cell swap: exchange the solids at (3,3) and (3,4).
swap([[3, 3, 3, 4], [3, 4, 3, 3]]);

Player stats & self-removal

Call Effect
alter_gems(n) Add n to the player's gem count (negative subtracts; clamped at 0).
die() Remove the calling object from the board. Zero cost.

The Queue API

Your object's output queue is inspectable. Most scripts only need Queue.length() to check "am I idle?" before issuing a new move.

Call Returns
Queue.length() Number of pending actions (int).
Queue.clear() Empty the queue (cancel pending actions).
Queue.peek() The front action as a map (see below), or () if empty.
Queue.pop() Same, but removes the front.

A peeked/popped action is a Rhai map with a "type" key ("Move", "SetTile", "Say", "Delay", "Push", "Swap", "Teleport", "AddGems", "Die", "Log", "SetTag", "SetColor", "Send", "Scroll") plus payload keys. Scroll and Swap expose only their type; Log is flattened to its text.

fn tick(dt) {
    // Idle guard: don't pile up moves.
    if Queue.length() == 0 {
        if !blocked(South) { move(South); }
    }
}

Pitfalls and sharp edges

Writes are deferred — you can't read back what you just wrote. After move(North), Me.y is unchanged for the rest of this callback; the move resolves later. Don't write then immediately read the same state expecting the new value.

Moves are rate-limited to one per 0.25 s. If you call move every tick without guarding on Queue.length(), you'll pile up a backlog of moves that drain slowly and ignore newer decisions. The idiom is if Queue.length() == 0 { move(...); }. The built-in pusher/spinner both gate on Queue.length().

Board, Me, Queue, and Registry are not visible inside your own helper functions. They're scope constants injected only at the top level of the engine-called callback. A helper fn step() { move(North); } works (the host functions are global), but fn where_am_i() { return Me.x; } will error — Me is undefined there. Read those handles at the top of your callback and pass the values down as arguments. (The built-in scripts read Me.has_tag(...), Me.x, etc. only inside tick/grab, never in a sub-function.)

Direction and color constants are global, so they're fine inside helpers. Only the four scope handles have this restriction.

Script-local variables reset across board transitions. When the player leaves and the script host is rebuilt, every let in your script is gone. Object state baked into the board (position, tags, glyph) persists; anything else you need to keep must go in the Registry or a tag.

Callbacks are matched by name and arity. fn tick() (no param) is not the tick hook — it must be fn tick(dt). Likewise bump needs exactly one param and init/grab exactly zero. A mismatch fails silently (the hook just never runs).

grab() only fires for objects with the grab behavior. That behavior comes from the archetype (only gem sets it today). A plain object's grab() will never be called; use bump(id) to react to being walked into. Also: grab is only triggered by the player walking onto the thing — pushing/swapping a grab object into the player treats it as an ordinary solid.

bump distinguishes the player by id == -1. Other movers pass their real object id.

Board.get / Board.named return () (unit), not an empty object, when absent. Check if x != () { ... } before reading fields, or you'll error on a missing lookup. Board.get also logs an error for an invalid id (≤ 0 other than the special -1).

ObjectInfo is a snapshot. The .x/.y/.tags you read are the values at lookup time; they don't update if the object moves later in the same frame. Re-query when you need fresh data.

Registry is shared by the whole board. If each object instance needs private storage, namespace your keys (e.g. `count_${Me.id}`). get_or only returns the stored value when it matches the default's type, so a key reused for different types falls back safely.

teleport and swap can be silently refused. A solid can't teleport onto another solid, and a swap never overwrites the player. These log an error rather than throwing — watch the log if a move "didn't happen."

Runtime and compile errors are non-fatal. A script that throws (or fails to compile) is logged and skipped; it won't crash the game, but the object will quietly do nothing. Check the in-game log when an object misbehaves.

Rhai caps expression complexity. Very deeply nested expressions are rejected by the engine; pull sub-expressions into let locals (the built-in spinner does this deliberately).