19 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 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(me, state, dt) {
if !me.waiting && !me.blocked(North) {
move(North);
}
}
fn bump(me, state, id)
Called when another entity walks into this object's cell.
idis the bumper'sObjectId(ani64).id == -1means the player bumped this object.
fn bump(me, state, id) {
if id == -1 { say("Hey! Watch it."); }
else { log(`object ${id} bumped me`); }
}
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; 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(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. |
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, state, id) {
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 the player when bumped.
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, state, 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
- 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 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 delay draining is a lot 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.