This commit is contained in:
2026-06-25 19:16:46 -05:00
parent db8c8e615d
commit db9a5d37b6
8 changed files with 328 additions and 213 deletions
+269 -178
View File
@@ -1,17 +1,25 @@
# 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 may define up to
three lifecycle hooks; any or all may be omitted.
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.
Use it to set up initial tile appearance, log a startup message, etc.
```rhai
fn init() {
@@ -22,9 +30,8 @@ fn init() {
### `fn tick(dt)`
Called every frame. `dt` is the elapsed time since the last tick, in seconds (a `f64`). Movement
and other timed actions pace themselves through the queue; you do not need to track time manually
for simple walking patterns.
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) {
@@ -39,60 +46,155 @@ fn tick(dt) {
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.
- `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`);
}
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
These are pre-set in every object's scope and cannot be reassigned.
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 game world. |
| `Queue` | `Queue` | Handle to this object's own pending-action queue. |
| `North` | `Direction` | Cardinal direction: up (y 1). |
| `South` | `Direction` | Cardinal direction: down (y + 1). |
| `East` | `Direction` | Cardinal direction: right (x + 1). |
| `West` | `Direction` | Cardinal direction: left (x 1). |
| `MY_ID` | `i64` | This object's stable `ObjectId`. Use with `set_tag`. |
| `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`.
---
## Read functions
## Self-reference: `Me`
### `Board` getters
| 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`). |
Access via `Board.<property>`:
```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 |
|----------|------|-------------|
| `Board.player_x` | `i64` | Player's current column (0-indexed). |
| `Board.player_y` | `i64` | Player's current row (0-indexed). |
| `Board.width` | `i64` | Board width in cells. |
| `Board.height` | `i64` | Board height in cells. |
| `Player.gems` | `i64` | Gems collected. |
| `Player.health` | `i64` | Current health. |
| `Player.max_health` | `i64` | Health ceiling. |
```rhai
let dist_x = (Board.player_x - 30).abs();
if Player.health < 2 { say("You look hurt."); }
```
### `blocked(dir) -> bool`
> The player's **keys** are not readable from scripts yet (you can `set_key` but not query it).
Returns `true` if moving in `dir` from this object's current cell would be impossible — because
the target cell is out of bounds, holds a solid, or another object has already queued a move there.
---
Note: the object's *own* queued moves are not yet on the board queue when `blocked` is called, so
an object cannot accidentally block itself.
## 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) {
@@ -100,157 +202,93 @@ fn tick(dt) {
}
```
### `has_tag(tag) -> bool`
---
Returns `true` if this object has the named tag.
## 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
if has_tag("enemy") { say("I'm an enemy!"); }
```
### `get_tags() -> Array`
Returns an array of strings listing every tag on this object.
```rhai
for t in get_tags() { log(t); }
```
### `objects_with_tag(tag) -> Array`
Returns an array of `i64` object IDs for every object on the board that currently carries `tag`.
```rhai
let enemies = objects_with_tag("enemy");
log(`there are ${enemies.len()} enemies`);
```
### `my_name() -> String`
Returns this object's name string, or `""` if the object has no name.
```rhai
log(`I am ${my_name()}`);
```
### `object_id_for_name(name) -> i64`
Looks up an object by its name and returns its `ObjectId`. Returns `0` if no object has that name.
`0` is never a valid id, so it can be used as a null check.
```rhai
let door_id = object_id_for_name("door");
if door_id != 0 {
set_tag(door_id, "open", true);
}
let count = Registry.get_or("visits", 0);
Registry.set("visits", count + 1);
```
---
## Write functions
Scripts do not mutate the world directly. Each write function appends an action to this object's
**output queue**. Actions are drained to the shared board queue between script calls and resolved
by the engine after the batch.
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).
### `move(dir)`
| 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. |
Enqueues a move one cell in `dir`. Also inserts a `Delay` of 0.25 s after the move, limiting this
object to ~4 moves per second regardless of frame rate. A blocked move still costs the cooldown.
### `scroll(lines)`
```rhai
fn tick(dt) {
if !blocked(South) { move(South); }
}
```
Opens a full-screen scrollable overlay and pauses game ticks until the player closes it. Each element
of `lines` is either:
### `delay(secs)`
- a **string** — a plain text line, or
- a **2-element array** `[choice_key, display_text]` — a selectable choice.
Inserts an explicit pause of `secs` seconds into this object's queue. Adjacent delays are merged
(you never get two consecutive `Delay` entries). Use it to slow down an action sequence or pause
between speech bubbles.
```rhai
fn init() {
say("One…");
delay(2.0);
say("Two!");
now(); // promote the last say() past the delay
}
```
### `now()`
Moves the most recently enqueued action to the **front** of the queue, bypassing any pending
delays. Useful after a zero-cost action (like `say` or `log`) to make it visible immediately even
if a move delay is in progress.
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) {
say("Ouch!");
now(); // show the bubble right away
scroll([
"The muffin looks delicious.",
"",
["eat", "Eat it"],
["ignore", "Walk away"],
]);
}
```
### `set_tile(n)`
Sets this object's glyph tile index to `n` (an integer). The tile index corresponds to a CP437
code point when using the default kiln font.
```rhai
fn init() {
set_tile(1); // ☺
}
```
### `log(msg)`
Appends a plain-text line to the game log. The message is a string.
```rhai
log(`player is at ${Board.player_x}, ${Board.player_y}`);
```
### `say(msg)`
Displays a speech bubble above this object containing `msg`. The bubble lasts 3 seconds. If the
object already has an active bubble, it is replaced (text and timer reset). Zero-cost; combine with
`now()` to surface it immediately from a `bump` or `init` hook.
```rhai
fn bump(id) {
say("Hey!");
now();
}
```
### `set_tag(target_id, tag, present)`
Adds (`present = true`) or removes (`present = false`) a tag on any object identified by
`target_id`. Use `MY_ID` to mutate the calling object's own tags.
```rhai
// Add a tag to self
set_tag(MY_ID, "activated", true);
// Remove a tag from another object by name
let lever_id = object_id_for_name("lever");
set_tag(lever_id, "pulled", true);
fn eat() { say("Yeah it was poisoned."); alter_health(-2); }
fn ignore() { log("Wise."); }
```
---
## `Queue` object
Accessed via the `Queue` constant in scope. Provides inspection and control of this object's own
pending-action queue.
Inspection and control of this object's own pending-action queue.
| Method | Returns | Description |
|--------|---------|-------------|
| `Queue.length()` | `i64` | Number of actions currently in the queue. |
| `Queue.clear()` | — | Discard all pending actions. |
| `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. |
Use `Queue.length() == 0` in `tick` to avoid queuing new moves while a previous one is in flight:
`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) {
@@ -266,18 +304,20 @@ fn tick(dt) {
## 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 zero-cost actions already in the queue.
- `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 `Delay(1.0)` entry.
- 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 logged to the game log as plain-text lines and do not
crash the game. A script that throws an exception on `tick` will log the error and resume normally
on the next frame.
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.
---
@@ -288,27 +328,78 @@ on the next frame.
fn init() {
set_tile(2);
log(`${my_name()} standing watch`);
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);
}
// Stuck in a corner — do nothing this tick.
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 where you're going!");
now();
}
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.