# Kiln Script API Reference Scripts are written in [Rhai](https://rhai.rs) 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](#design-notes-rough-edges) 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. ```rhai 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. ```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`). - `id == -1` means the **player** bumped this object. ```rhai 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. ```rhai 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. ```rhai // 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_ID` constant any more — use `Me.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`). | ```rhai 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. | ```rhai if Player.health < 2 { say("You look hurt."); } ``` > The player's **keys** are not readable from scripts yet (you can `set_key` but 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`. ```rhai 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)? | ```rhai 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`. | ```rhai 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`. ```rhai 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. ```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). - `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 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. --- ## 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 ```rhai // 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 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.