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:
- The engine calls one of your callbacks (
init,tick,bump,grab, or a custom function reached viasend). - Inside the callback you read the world synchronously (
Board.*,Me.*,blocked(), etc.) and enqueue actions (move,say,set_tile, …). - Enqueued actions go into your object's private output queue. They are not applied while your callback is running.
- 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.
- Some actions (
move) automatically queuedelayactions 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(typeDirection). - 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(x, y) |
Jump to an arbitrary cell. Zero cost. Refused (with a logged error at resolve time) if this object is solid and the destination already holds a 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).