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).
diris 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/Westconstants. - 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.
diris the [Direction] the entrant came from — the same convention asbump. For a one-cell move or push it is exactly the opposite of the entrant's travel direction. For ateleportorshiftthat 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)—argis the value passed tosend(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. |
Black … White (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/stateas parameters, they are local to the hook. To use them in a helper function you define, pass them down explicitly:fn dance(me) { … }called asdance(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 one1.0 sdelay. - 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 (amove) is released per object per cycle. me.waitingistruewhile a delay sits at the front of the queue — the idiom for "don't issue a new action until the last one finished" isif !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.playernow (.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 anObjectInfo. - 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 onstate.boardvs. self queries onme) 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_gemsvsalter_health. - The implicit subject of a write is unpredictable.
set_tile/set_fg/say/dieact on self;alter_gems/alter_health/set_keyact on the player;set_tag/sendact on an arbitrary target;push/shiftact on cells andteleporton an arbitrary entity. Theset_*prefix in particular means three different subjects. - Reads are methods/getters on handles, but writes are free functions.
me.blocked(dir)reads, butmove(dir)(the matching write) is a bare global that acts onmeimplicitly — the symmetry is invisible. - Mixed addressing for movement.
move/pushtake a typedDirection, whileteleport/passable/can_push/shifttake rawi64coordinates. There's no cell or position value type. - Self-mutation can't go through
me.meis read-only, so writing your own tag isset_tag(me.id, …)— you pass your own id back to a free function instead ofme.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.
sayhas a duration overload,logdoes not;delayhas 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 bydt, 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
Registryare separate stores with separate APIs; cross-board state must pick one. - Stringly-typed dispatch.
sendand scroll choices route by function name; a scroll choice key is silently also a function name. Renaming a handler breaks callers with no signal. Registryalone is a scope constant. Now thatme/stateare parameters,Registryis 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_keywrites) — but reads are per-color getters while the write is a stringly-named color, so the two sides still don't mirror each other.