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.
idis the bumper'sObjectId(ani64).id == -1means 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. |
Black … White (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_IDconstant any more — useMe.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_keybut 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 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.
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 asObjectInfo(.id,.x,.y,.name,.tags) which has nohas_tag()and noglyph(). 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 thePlayerconstant (stats only). Player position lives onBoard, player stats live onPlayer— 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_gemsvsalter_health. - The implicit subject of a write is unpredictable.
set_tile/set_fg/say/dieact on self;add_gems/alter_health/set_keyact on the player;set_tag/sendact on an arbitrary target;push/shift/teleportact on cells. Theset_*prefix in particular means three different subjects. - Mixed addressing for movement.
move/blocked/pushtake a typedDirection, whileteleport/passable/can_push/can_shift/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
- Queue introspection is stringly-typed.
peek/popreturn untyped maps keyed by a"type"string, andScroll/Shiftaren'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
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. - 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 supportshift/swapedge cases. - Read/write asymmetry on player keys.
set_keywrites keys, but no read exists —Playerexposesgems/health/max_healthonly (there is a literalTODOto expose keys). - Doc/name drift across the codebase. The function is
shift([[x, y], …]), but the engine's own module docs andCLAUDE.mdstill describe aswap([[src_x, src_y, dst_x, dst_y], …]). The two concepts (rotate-a-cycle vs. batch directed moves) are conflated under both names.