Files
kiln/docs/script-api.md
T
2026-06-25 19:16:46 -05:00

17 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.

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.

fn init()

Called once for every scripted object after the entire map is loaded, before the first frame.

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

fn tick(dt)

Called every frame. dt is the elapsed time since the last tick, in seconds (a f64). Timed actions pace themselves through the queue; you do not track time manually for simple walking.

fn tick(dt) {
    if Queue.length() == 0 && !blocked(North) {
        move(North);
    }
}

fn bump(id)

Called when another entity walks into this object's cell.

  • id is the bumper's ObjectId (an i64).
  • id == -1 means the player bumped this object.
fn bump(id) {
    if id == -1 { say("Hey! Watch it."); }
    else        { log(`object ${id} bumped me`); }
}

fn grab()

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() / add_gems() / alter_health() applies before the player's move returns.

fn grab() {
    add_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 zero-argument function call of that name.

A handler may take zero or one parameter; the host calls the 1-arg form when an arg is supplied, else the 0-arg form. If neither arity exists, the call is silently dropped.

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

Constants in scope

Pre-set in every object's scope. The object handles are scope constants; direction/color constants come from a global module visible at any call depth (including Rhai→Rhai calls).

Name Type Description
Board Board Read-only handle to the current board / world.
Queue Queue This object's own pending-action queue.
Me Me Self-reference (id, name, position, tags, glyph).
Registry Registry Board-scoped key→value store; persists across board transitions.
Player Player Read-only snapshot of the player's game-global stats.
North / South / East / West Direction Cardinal directions.
BlackWhite (16) String EGA/VGA palette colors as "#RRGGBB" strings.

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)).

There is no MY_ID constant any more — use Me.id.


Self-reference: Me

Member Kind Returns Description
Me.id getter i64 This object's stable ObjectId.
Me.name getter String Name, or "" if unnamed.
Me.x / Me.y getter i64 Current cell position.
Me.has_tag(tag) method bool Whether this object carries tag.
Me.tags() method Array All tag strings on this object.
Me.glyph() method Glyph This object's glyph (.tile, .fg, .bg).
if Me.has_tag("locked") { say(`${Me.name} is locked`); }
set_tag(Me.id, "seen", true);   // self-mutation still needs the explicit id

A Glyph value (from Me.glyph() or ObjectInfo lookups) exposes .tile (i64), .fg, .bg (both "#RRGGBB" strings).


Player stats: Player

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

Property Type Description
Player.gems i64 Gems collected.
Player.health i64 Current health.
Player.max_health i64 Health ceiling.
if Player.health < 2 { say("You look hurt."); }

The player's keys are not readable from scripts yet (you can set_key but not query it).


Board reads

Properties

Property Type Description
Board.player_x i64 Player's column.
Board.player_y i64 Player's row.
Board.width i64 Board width in cells.
Board.height i64 Board height in cells.

Object lookups

Call Returns Description
Board.tagged(tag) Array of ObjectInfo Every object carrying tag.
Board.named(name) ObjectInfo or () The object with that name, else unit.
Board.get(id) ObjectInfo or () Look up by id; -1 returns the player; an invalid/unknown id logs an error and returns unit.

An ObjectInfo exposes .id, .x, .y, .name ("" if unnamed) and .tags (an Array). Note it has no has_tag() or glyph() — those exist only on Me.

let enemies = Board.tagged("enemy");
log(`there are ${enemies.len()} enemies`);

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

Cell queries (free functions)

Call Returns Description
blocked(dir) bool Would the caller be unable to move one step in dir (off-board, a solid, or another object's already-queued move targets that cell). The caller's own queued moves don't count.
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.
can_shift(x, y, dir) bool Like can_push but checks only the one cell ahead (pushable source + empty-or-pushable neighbor). The right gate for shift().
passable(x, y) bool Is (x, y) on-board and free of any solid (an enterable hole)? Distinguishes a hole from a blocked solid.
combinable(x1, y1, x2, y2) bool Can the solids at the two cells share a cell (one empty, or a grab thing + the player)?
fn tick(dt) {
    if !blocked(East) { move(East); }
}

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.
let count = Registry.get_or("visits", 0);
Registry.set("visits", count + 1);

Write functions

Scripts never mutate the world directly. Each write appends an Action to this object's output queue; actions drain to a shared board queue between script calls and are resolved by the engine after the batch. The subject of a write is implicit and varies by function (see the table).

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(x, y) self 0 Jump to an arbitrary cell. Refused (error logged at resolve) if self is solid and the destination is occupied.
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.
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.
add_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.

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 zero-arg function call named choice_key.

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

Queue object

Inspection and control of this object's own pending-action queue.

Method Returns Description
Queue.length() i64 Number of pending actions.
Queue.clear() Discard all pending actions (including delays).
Queue.peek() map or () Front action as a map, or unit if empty.
Queue.pop() map or () Same, removing the front.

peek/pop return an untyped map with a "type" key ("Move", "SetTile", "Say", …) plus variant-specific payload keys (e.g. "dir", "tile", "msg"). Scroll and Shift report type only — their payloads are not inspectable through this API.

fn tick(dt) {
    if Queue.length() == 0 {
        if !blocked(East) { move(East); }
        else if !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.
  • 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.

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 the player when bumped.

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

fn tick(dt) {
    if Queue.length() > 0 { return; }     // still moving
    if !blocked(East)      { move(East); }
    else if !blocked(West) { move(West); }
}

fn bump(id) {
    if id == -1 { say("Halt! Who goes there?"); now(); }
    else        { say("Watch it!");             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

  • Object introspection has two parallel types with different members. Self uses Me (Me.has_tag(), Me.tags(), Me.glyph(), plus getters); other objects come back as ObjectInfo (.id, .x, .y, .name, .tags) which has no has_tag() and no glyph(). The same conceptual "an object" is two incompatible types depending on whether it's you.
  • Player position/state is reachable three ways: Board.player_x / Board.player_y, Board.get(-1).x / .y, and the Player constant (stats only). Player position lives on Board, player stats live on Player — the player is split across two surfaces.
  • Tag reads come in three shapes: Me.has_tag(t) (method, bool), Me.tags() (method, array), ObjectInfo.tags (property, array). Method-vs-property is inconsistent even for the same data.

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 (add_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: add_gems vs alter_health.
  • The implicit subject of a write is unpredictable. set_tile/set_fg/say/die act on self; add_gems/alter_health/set_key act on the player; set_tag/send act on an arbitrary target; push/shift/teleport act on cells. The set_* prefix in particular means three different subjects.
  • Mixed addressing for movement. move/blocked/push take a typed Direction, while teleport/passable/can_push/can_shift/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

  • Queue introspection is stringly-typed. peek/pop return untyped maps keyed by a "type" string, and Scroll/Shift aren't introspectable at all (type only). There is no typed action value.
  • The two-tier 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 object-queue → board-queue promotion plus per-object ready timer is a lot 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.
  • Engine internals leak into the script API. combinable(x1,y1,x2,y2) encodes a very specific rule (a grab thing may share a cell with the player) that exists only to support shift/swap edge cases.
  • Read/write asymmetry on player keys. set_key writes keys, but no read exists — Player exposes gems/health/max_health only (there is a literal TODO to expose keys).
  • Doc/name drift across the codebase. The function is shift([[x, y], …]), but the engine's own module docs and CLAUDE.md still describe a swap([[src_x, src_y, dst_x, dst_y], …]). The two concepts (rotate-a-cycle vs. batch directed moves) are conflated under both names.