2026-06-09 22:38:17 -05:00
# Kiln Script API Reference
Scripts are written in [Rhai ](https://rhai.rs ) and live in the `[scripts]` section of a `.toml` map
2026-06-25 19:16:46 -05:00
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](#design-notes-rough-edges) at the end, as the starting point for a
> redesign.
2026-06-09 22:38:17 -05:00
---
## Lifecycle hooks
2026-06-25 19:16:46 -05:00
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.
2026-06-09 22:38:17 -05:00
### `fn init()`
Called once for every scripted object after the entire map is loaded, before the first frame.
```rhai
fn init() {
set_tile(42);
log("ready");
}
```
### `fn tick(dt)`
2026-06-25 19:16:46 -05:00
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.
2026-06-09 22:38:17 -05:00
```rhai
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` ).
2026-06-25 19:16:46 -05:00
- `id == -1` means the **player** bumped this object.
2026-06-09 22:38:17 -05:00
```rhai
fn bump(id) {
2026-06-25 19:16:46 -05:00
if id == -1 { say("Hey! Watch it."); }
else { log(`object ${id} bumped me`); }
2026-06-09 22:38:17 -05:00
}
```
2026-06-25 19:16:46 -05:00
### `fn grab()`
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
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.
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
```rhai
fn grab() {
add_gems(1);
die();
}
```
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
> 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()`).
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
### Custom handler functions
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
Any other function can be invoked on an object two ways:
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
- 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.
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
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.
2026-06-09 22:38:17 -05:00
```rhai
2026-06-25 19:16:46 -05:00
// 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}`); }
2026-06-09 22:38:17 -05:00
```
2026-06-25 19:16:46 -05:00
---
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
## Constants in scope
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
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).
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
| 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. |
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
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)` ).
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
> There is **no** `MY_ID` constant any more — use `Me.id`.
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
---
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
## Self-reference: `Me`
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
| 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` ). |
2026-06-09 22:38:17 -05:00
```rhai
2026-06-25 19:16:46 -05:00
if Me.has_tag("locked") { say(`${Me.name} is locked`); }
set_tag(Me.id, "seen", true); // self-mutation still needs the explicit id
2026-06-09 22:38:17 -05:00
```
2026-06-25 19:16:46 -05:00
A `Glyph` value (from `Me.glyph()` or `ObjectInfo` lookups) exposes `.tile` (`i64` ), `.fg` , `.bg`
(both `"#RRGGBB"` strings).
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
---
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
## Player stats: `Player`
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
A read-only snapshot of the game-global player state, refreshed before each hook call.
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
| Property | Type | Description |
|----------|------|-------------|
| `Player.gems` | `i64` | Gems collected. |
| `Player.health` | `i64` | Current health. |
| `Player.max_health` | `i64` | Health ceiling. |
2026-06-09 22:38:17 -05:00
```rhai
2026-06-25 19:16:46 -05:00
if Player.health < 2 { say("You look hurt."); }
2026-06-09 22:38:17 -05:00
```
2026-06-25 19:16:46 -05:00
> The player's **keys** are not readable from scripts yet (you can `set_key` but not query it).
2026-06-09 22:38:17 -05:00
---
2026-06-25 19:16:46 -05:00
## Board reads
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
### Properties
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
| 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. |
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
### Object lookups
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
| 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. |
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
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` .
2026-06-09 22:38:17 -05:00
```rhai
2026-06-25 19:16:46 -05:00
let enemies = Board.tagged("enemy");
log(`there are ${enemies.len()} enemies`);
let door = Board.named("door");
if door != () { send(door.id, "open"); }
2026-06-09 22:38:17 -05:00
```
2026-06-25 19:16:46 -05:00
---
## Cell queries (free functions)
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
| 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)? |
2026-06-09 22:38:17 -05:00
```rhai
2026-06-25 19:16:46 -05:00
fn tick(dt) {
if !blocked(East) { move(East); }
2026-06-09 22:38:17 -05:00
}
```
2026-06-25 19:16:46 -05:00
---
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
## Registry (shared key→value store)
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
Board-scoped, persists across board transitions. Stores primitive values.
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
| 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` . |
2026-06-09 22:38:17 -05:00
```rhai
2026-06-25 19:16:46 -05:00
let count = Registry.get_or("visits", 0);
Registry.set("visits", count + 1);
2026-06-09 22:38:17 -05:00
```
2026-06-25 19:16:46 -05:00
---
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
## 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` .
2026-06-09 22:38:17 -05:00
```rhai
fn bump(id) {
2026-06-25 19:16:46 -05:00
scroll([
"The muffin looks delicious.",
"",
["eat", "Eat it"],
["ignore", "Walk away"],
]);
2026-06-09 22:38:17 -05:00
}
2026-06-25 19:16:46 -05:00
fn eat() { say("Yeah it was poisoned."); alter_health(-2); }
fn ignore() { log("Wise."); }
2026-06-09 22:38:17 -05:00
```
---
## `Queue` object
2026-06-25 19:16:46 -05:00
Inspection and control of this object's own pending-action queue.
2026-06-09 22:38:17 -05:00
| Method | Returns | Description |
|--------|---------|-------------|
2026-06-25 19:16:46 -05:00
| `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. |
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
`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.
2026-06-09 22:38:17 -05:00
```rhai
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).
2026-06-25 19:16:46 -05:00
- `delay(secs)` adds an arbitrary pause; `now()` can bypass a delay for a zero-cost action already in
the queue.
2026-06-09 22:38:17 -05:00
- `Queue.clear()` cancels everything, including pending delays.
2026-06-25 19:16:46 -05:00
- 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.
2026-06-09 22:38:17 -05:00
---
## Error handling
2026-06-25 19:16:46 -05:00
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.
2026-06-09 22:38:17 -05:00
---
## Full example
```rhai
// A guard that patrols east/west and greets the player when bumped.
fn init() {
set_tile(2);
2026-06-25 19:16:46 -05:00
log(`${Me.name} standing watch`);
2026-06-09 22:38:17 -05:00
}
fn tick(dt) {
2026-06-25 19:16:46 -05:00
if Queue.length() > 0 { return; } // still moving
if !blocked(East) { move(East); }
else if !blocked(West) { move(West); }
2026-06-09 22:38:17 -05:00
}
fn bump(id) {
2026-06-25 19:16:46 -05:00
if id == -1 { say("Halt! Who goes there?"); now(); }
else { say("Watch it!"); now(); }
2026-06-09 22:38:17 -05:00
}
```
2026-06-25 19:16:46 -05:00
---
## 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.