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.
2026-07-05 23:41:19 -05:00
Every hook receives the same first two parameters — `me` (this object) and `state` (the world) —
through which all reads happen. Writes go through free functions (`move` , `say` , `set_tile` , …) that
implicitly act on the calling object.
2026-06-25 19:16:46 -05:00
> **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
2026-07-05 23:41:19 -05:00
number of parameters is not recognized as a hook. Every hook takes `me` (an [`ObjectInfo` ](#self-and-other-objects-objectinfo )
for the object running the script) and `state` (a [`State` ](#the-world-state ) handle to the world),
in that order, before any hook-specific argument.
2026-06-25 19:16:46 -05:00
2026-07-05 23:41:19 -05:00
### `fn init(me, state)`
2026-06-09 22:38:17 -05:00
2026-07-05 23:41:19 -05:00
Called once for every scripted object after the entire map is loaded, before the first frame (and
again after a board transition re-enters this board's script host).
2026-06-09 22:38:17 -05:00
```rhai
2026-07-05 23:41:19 -05:00
fn init(me, state) {
2026-06-09 22:38:17 -05:00
set_tile(42);
log("ready");
}
```
2026-07-05 23:41:19 -05:00
### `fn tick(me, state, dt)`
2026-06-09 22:38:17 -05:00
2026-07-11 12:04:33 -05:00
Called each frame **only when this object's output queue is empty** — i.e. once its previous
actions (and any pacing `Delay` ) have fully drained. While actions from an earlier tick are still
pending, the engine just advances the queue by `dt` and does **not** re-run `tick` . This means you no
longer need to guard the body with `if me.queue.length == 0` / `if !me.waiting` — the engine does it
for you, so a plain `move(North)` naturally paces itself one step per drain. `dt` is the elapsed time
since the last tick, in seconds (a `f64` ). (All other hooks — `bump` /`enter` /`grab` /`send` /`init` —
still fire regardless of queued actions.)
2026-06-09 22:38:17 -05:00
```rhai
2026-07-05 23:41:19 -05:00
fn tick(me, state, dt) {
2026-07-11 12:04:33 -05:00
if !me.blocked(North) {
2026-06-09 22:38:17 -05:00
move(North);
}
}
```
2026-07-08 13:21:27 -05:00
### `fn bump(me, dir)`
2026-06-09 22:38:17 -05:00
2026-07-08 13:21:27 -05:00
Called when a solid presses into this object's cell — the player, another object, or a **crate** being
pushed into it (the push chain is walked through to reach the object it presses against).
2026-06-09 22:38:17 -05:00
2026-07-08 13:21:27 -05:00
- `dir` is the [`Direction` ] the bump **came from** : it points from this object toward the bumper, so
the bumper occupies the cell at `(me.x + dir.dx, me.y + dir.dy)` .
- Compare it against the `North` /`South` /`East` /`West` constants.
- The hook can no longer tell *who* did the bumping (there is no id) — a crate bumps just like the player.
2026-06-09 22:38:17 -05:00
```rhai
2026-07-08 13:21:27 -05:00
fn bump(me, dir) {
if dir == West { say("Something shoved me from the west."); }
else { log(`bumped from ${dir}`); }
2026-06-09 22:38:17 -05:00
}
```
2026-07-11 11:47:45 -05:00
### `fn enter(me, dir)`
The **non-solid** counterpart to `bump` . Called when a solid — the player, another object, or a
**crate** being pushed/shifted — relocates *onto* this object's cell. Only makes sense on a non-solid
object (a trigger, or a decorative `solid = false` object); a solid object blocks the entrant instead
of being entered, so its `bump` fires rather than `enter` .
- `dir` is the [`Direction` ] the entrant **came from** — the same convention as `bump` . For a one-cell
move or push it is exactly the opposite of the entrant's travel direction. For a `teleport` or `shift`
that jumps a solid an arbitrary distance it is **best-effort** : the dominant axis of the displacement
(a longer horizontal jump reads as East/West, a longer vertical one as North/South).
- Fires for *every* solid that lands on the cell: a player/object step, and each crate a push or shift
moved onto it.
```rhai
fn enter(me, dir) {
say("Who goes there?");
log(`something stepped on me from the ${dir}`);
}
```
2026-07-05 23:41:19 -05:00
### `fn grab(me, state)`
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()` /
2026-07-07 23:58:51 -05:00
`alter_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
2026-07-05 23:41:19 -05:00
fn grab(me, state) {
2026-07-07 23:58:51 -05:00
alter_gems(1);
2026-06-25 19:16:46 -05:00
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
2026-07-05 23:41:19 -05:00
function call of that name.
The host picks the handler's parameter list by arity, in this order:
2026-06-09 22:38:17 -05:00
2026-07-05 23:41:19 -05:00
- **3 params** → `(me, state, arg)` — `arg` is the value passed to `send` (or unit for a scroll choice).
- **2 params** → `(me, state)` .
- **1 param** → `(arg)` .
- **0 params** → `()` .
If no matching function name 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"
2026-07-05 23:41:19 -05:00
fn open(me, state) { set_tile(47); }
fn set_level(me, state, 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-07-05 23:41:19 -05:00
Unlike the `me` /`state` parameters (which every hook receives), these are injected directly into
every object's scope.
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
| Name | Type | Description |
|------|------|-------------|
| `Registry` | `Registry` | Board-scoped key→value store; persists across board transitions. |
| `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-07-05 23:41:19 -05:00
`Registry` is a **scope constant** : it is only visible at the top level of an engine-called hook, not
inside helper functions you define (those run at a deeper call depth — see [Pitfalls ](#pitfalls-and-sharp-edges )).
The direction/color constants come from a global module and *are* visible at any call depth (including
Rhai→Rhai calls).
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
---
2026-06-09 22:38:17 -05:00
2026-07-05 23:41:19 -05:00
## Self and other objects: `ObjectInfo`
`me` is an `ObjectInfo` — a snapshot of one board object. The same type comes back from
[`state.board.get` / `.named` / `.tagged` ](#board-reads ), so an object you look up has exactly the
same members as `me` .
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
| Member | Kind | Returns | Description |
|--------|------|---------|-------------|
2026-07-05 23:41:19 -05:00
| `.id` | getter | `i64` | The object's stable `ObjectId` . |
| `.name` | getter | `String` or `()` | Name, or unit if unnamed. |
| `.x` / `.y` | getter | `i64` | Current cell position. |
| `.tags` | getter | `Array` | All tag strings on this object. |
| `.glyph` | getter | `Glyph` | The object's glyph (`.tile` , `.fg` , `.bg` ). |
| `.waiting` | getter | `bool` | Whether the front of the object's queue is a delay (i.e. it is "busy"). |
| `.queue` | getter | `Queue` | The object's [pending-action queue ](#queue-object ). |
| `.has_tag(tag)` | method | `bool` | Whether the object carries `tag` . |
| `.delay(secs)` | method | — | Append a pause to the object's queue. |
| `.blocked(dir)` | method | `bool` | Whether moving the object one step in `dir` is impossible (off-board or a solid). |
| `.can_push(dir)` | method | `bool` | Whether the pushable chain ahead of the object in `dir` can be shoved. |
2026-06-09 22:38:17 -05:00
```rhai
2026-07-05 23:41:19 -05:00
fn tick(me, state, dt) {
if me.has_tag("asleep") { return; }
let here = me.glyph;
log(`my tile is ${here.tile}`);
if !me.waiting && !me.blocked(East) { move(East); }
}
2026-06-09 22:38:17 -05:00
```
2026-07-05 23:41:19 -05:00
A `Glyph` value (from `me.glyph` or a looked-up object) exposes `.tile` (`i64` ), `.fg` , `.bg` (both
`"#RRGGBB"` strings).
> Because hooks receive `me`/`state` as **parameters**, they are local to the hook. To use them in a
> helper function you define, pass them down explicitly: `fn dance(me) { … }` called as `dance(me)`.
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-07-05 23:41:19 -05:00
## The world: `state`
`state` is a handle bundling the two read surfaces:
| Member | Type | Description |
|--------|------|-------------|
| `state.player` | `Player` | The player's game-global stats and position. |
| `state.board` | `Board` | Read-only handle to the current board. |
### Player stats: `state.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 |
|----------|------|-------------|
2026-07-05 23:41:19 -05:00
| `state.player.gems` | `i64` | Gems collected. |
| `state.player.health` | `i64` | Current health. |
| `state.player.max_health` | `i64` | Health ceiling. |
| `state.player.x` / `.y` | `i64` | Player's current cell. |
| `state.player.keys` | `Keyring` | The player's key inventory (see below). |
2026-06-09 22:38:17 -05:00
```rhai
2026-07-05 23:41:19 -05:00
if state.player.health < 2 { say("You look hurt."); }
if state.player.keys.red { say("You have the red key."); }
2026-06-09 22:38:17 -05:00
```
2026-07-05 23:41:19 -05:00
The `Keyring` from `state.player.keys` exposes one `bool` getter per color: `.red` , `.orange` ,
`.yellow` , `.green` , `.blue` , `.cyan` , `.purple` , `.white` .
2026-06-09 22:38:17 -05:00
2026-07-05 23:41:19 -05:00
### Board reads
2026-06-09 22:38:17 -05:00
2026-07-05 23:41:19 -05:00
#### Properties
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
| Property | Type | Description |
|----------|------|-------------|
2026-07-05 23:41:19 -05:00
| `state.board.width` | `i64` | Board width in cells. |
| `state.board.height` | `i64` | Board height in cells. |
2026-06-09 22:38:17 -05:00
2026-07-05 23:41:19 -05:00
#### Object lookups
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
| Call | Returns | Description |
|------|---------|-------------|
2026-07-05 23:41:19 -05:00
| `state.board.tagged(tag)` | `Array` of `ObjectInfo` | Every object carrying `tag` . |
| `state.board.named(name)` | `ObjectInfo` or `()` | The object with that name, else unit. |
| `state.board.get(id)` | `ObjectInfo` or `()` | Look up by id; an invalid (`id <= 0` ) or unknown id logs an error and returns unit. |
2026-06-09 22:38:17 -05:00
```rhai
2026-07-05 23:41:19 -05:00
let enemies = state.board.tagged("enemy");
2026-06-25 19:16:46 -05:00
log(`there are ${enemies.len()} enemies`);
2026-07-05 23:41:19 -05:00
let door = state.board.named("door");
2026-06-25 19:16:46 -05:00
if door != () { send(door.id, "open"); }
2026-06-09 22:38:17 -05:00
```
2026-07-05 23:41:19 -05:00
#### Cell queries
2026-06-09 22:38:17 -05:00
2026-06-25 19:16:46 -05:00
| Call | Returns | Description |
|------|---------|-------------|
2026-07-05 23:41:19 -05:00
| `state.board.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. |
| `state.board.passable(x, y)` | `bool` | Is `(x, y)` on-board and free of any solid (an enterable hole)? Distinguishes a hole from a blocked solid. |
2026-06-09 22:38:17 -05:00
2026-07-05 23:41:19 -05:00
For queries about the calling object itself, use `me.blocked(dir)` and `me.can_push(dir)`
([above ](#self-and-other-objects-objectinfo )).
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-07-05 23:41:19 -05:00
fn tick(me, state, dt) {
// Per-object counter that survives across ticks and board transitions.
let key = `count_${me.id}`;
let n = Registry.get_or(key, 0);
Registry.set(key, n + 1);
}
2026-06-09 22:38:17 -05:00
```
2026-07-05 23:41:19 -05:00
The Registry is shared by the whole board, so namespace per-instance keys by `me.id` as above.
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
2026-07-05 23:41:19 -05:00
Scripts never mutate the world directly. Each write appends an `Action` to the calling object's
2026-07-10 01:20:26 -05:00
**output queue** ; the moment the hook returns, that object's ready actions are drained and applied
by the engine — before the next object's hook runs. The *subject* of a write is implicit and varies
2026-07-10 23:16:28 -05:00
by function (see the table). The one exception is `log(msg)` : it bypasses the queue and writes to the
game log immediately, so a diagnostic line is never paced by (or stuck behind) the object's delays.
2026-06-25 19:16:46 -05:00
| 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. |
2026-07-08 10:06:25 -05:00
| `teleport(id, x, y)` | arbitrary entity | 0 | Jump entity `id` to an arbitrary cell (pass `me.id` for self; `id == -1` is the player). Refused (error logged at resolve) if that entity is solid and the destination already holds a *different* solid. |
2026-06-25 19:16:46 -05:00
| `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. |
2026-07-05 23:41:19 -05:00
| `set_tag(target_id, tag, present)` | any object | 0 | Add (`true` ) / remove (`false` ) a tag. Use `me.id` for self. |
2026-06-25 19:16:46 -05:00
| `say(msg)` / `say(msg, secs)` | self | 0 | Speech bubble above this object; default 3 s, or `secs` . Replaces any current bubble. |
2026-07-10 23:16:28 -05:00
| `log(msg)` | log | 0 | Append a plain-text line to the game log. **Immediate** — unlike the other writes it is *not* queued, so it appears the instant it's called, even behind a pending `move` /`delay` . |
2026-06-25 19:16:46 -05:00
| `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. |
2026-07-07 23:58:51 -05:00
| `alter_gems(n)` | player | 0 | Change the player's gem count (clamped at 0). |
2026-06-25 19:16:46 -05:00
| `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. |
2026-07-05 23:41:19 -05:00
`delay` /`now` also exist as methods on `me` and on a queue handle: `me.delay(secs)` and
`me.queue.delay(secs)` are equivalent to the free `delay(secs)` for the calling object.
2026-06-25 19:16:46 -05:00
### `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
2026-07-05 23:41:19 -05:00
function call named `choice_key` (see [custom handler functions ](#custom-handler-functions ) for the
arity rules — a choice handler is called with no `arg` ).
2026-06-09 22:38:17 -05:00
```rhai
2026-07-08 13:21:27 -05:00
fn bump(me, dir) {
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-07-05 23:41:19 -05:00
fn eat(me, state) { say("Yeah it was poisoned."); alter_health(-2); }
fn ignore(me, state) { log("Wise."); }
2026-06-09 22:38:17 -05:00
```
---
## `Queue` object
2026-07-05 23:41:19 -05:00
Inspection and control of an object's own pending-action queue, reached via `me.queue` . Most scripts
do not need it directly — `me.waiting` covers the common "am I busy?" check.
2026-06-09 22:38:17 -05:00
2026-07-05 23:41:19 -05:00
| Member | Kind | Returns | Description |
|--------|------|---------|-------------|
| `me.queue.length` | getter | `i64` | Number of pending actions. |
| `me.queue.clear()` | method | — | Discard all pending actions (including delays). |
| `me.queue.delay(secs)` | method | — | Append a pause to the queue. |
2026-06-09 22:38:17 -05:00
```rhai
2026-07-05 23:41:19 -05:00
fn tick(me, state, dt) {
if !me.waiting {
if !me.blocked(East) { move(East); }
else if !me.blocked(West) { move(West); }
2026-06-09 22:38:17 -05:00
}
}
```
---
## 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-07-05 23:41:19 -05:00
- `me.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-07-05 23:41:19 -05:00
- `me.waiting` is `true` while a delay sits at the front of the queue — the idiom for "don't issue a
new action until the last one finished" is `if !me.waiting { … }` .
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
2026-07-08 13:21:27 -05:00
// A guard that patrols east/west and greets whatever bumps into it.
2026-06-09 22:38:17 -05:00
2026-07-05 23:41:19 -05:00
fn init(me, state) {
2026-06-09 22:38:17 -05:00
set_tile(2);
2026-07-05 23:41:19 -05:00
log(`${me.name} standing watch`);
2026-06-09 22:38:17 -05:00
}
2026-07-05 23:41:19 -05:00
fn tick(me, state, dt) {
if me.waiting { return; } // still moving
if !me.blocked(East) { move(East); }
else if !me.blocked(West) { move(West); }
2026-06-09 22:38:17 -05:00
}
2026-07-08 13:21:27 -05:00
fn bump(me, dir) {
say(`Halt! Who approaches from the ${dir}?`); 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
2026-07-05 23:41:19 -05:00
- **Player position/state is reachable two ways.** Player *stats* and *position* both live on
`state.player` now (`.gems` /`.health` /`.x` /`.y` /`.keys` ), which is an improvement — but a looked-up
player no longer exists (`state.board.get(-1)` is gone), so code that wants "the player as an
object" has nothing to look up, while every other entity is an `ObjectInfo` .
- **Reads are split across three handles** (`me` , `state.player` , `state.board` ) with no single
"query" entry point; which handle owns a given fact (e.g. cell queries on `state.board` vs. self
queries on `me` ) has to be memorized.
2026-06-25 19:16:46 -05:00
### Inconsistency — naming and conventions
- **No consistent verb convention.** Setters by name (`set_tile` , `set_tag` , `set_fg` , `set_bg` ,
2026-07-07 23:58:51 -05:00
`set_color` , `set_key` ), delta verbs (`alter_gems` , `alter_health` ), and bare verbs (`move` , `die` ,
2026-06-25 19:16:46 -05:00
`teleport` , `push` , `shift` , `scroll` , `say` , `log` ) all coexist. Worse, the two "change by a
2026-07-07 23:58:51 -05:00
delta" operations use *different* verbs: `alter_gems` vs `alter_health` .
2026-06-25 19:16:46 -05:00
- **The implicit subject of a write is unpredictable.** `set_tile` /`set_fg` /`say` /`die` act on
2026-07-07 23:58:51 -05:00
**self** ; `alter_gems` /`alter_health` /`set_key` act on the **player** ; `set_tag` /`send` act on an
2026-07-08 10:06:25 -05:00
**arbitrary target** ; `push` /`shift` act on **cells** and `teleport` on an **arbitrary entity** . The `set_*` prefix in particular
2026-06-25 19:16:46 -05:00
means three different subjects.
2026-07-05 23:41:19 -05:00
- **Reads are methods/getters on handles, but writes are free functions.** `me.blocked(dir)` reads,
but `move(dir)` (the matching write) is a bare global that acts on `me` implicitly — the symmetry is
invisible.
- **Mixed addressing for movement.** `move` /`push` take a typed `Direction` , while
`teleport` /`passable` /`can_push` /`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(…)` .
2026-06-25 19:16:46 -05:00
- **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
2026-07-10 01:20:26 -05:00
- **The output-queue + `now()` /`delay()` model is subtle.** `now()` reorders by *enqueue
2026-06-25 19:16:46 -05:00
recency* ("most recently enqueued action to the front"), which is position-dependent and easy to
2026-07-10 01:20:26 -05:00
get wrong; the per-object delay draining (leading delays eaten by `dt` , then the ready run applied)
is still a fair amount of implicit state to reason about for "do X, wait, do Y".
2026-06-25 19:16:46 -05:00
- **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.
2026-07-05 23:41:19 -05:00
- **`Registry` alone is a scope constant.** Now that `me` /`state` are parameters, `Registry` is the
one handle with the "invisible inside helper functions" gotcha — an inconsistency with everything
else a hook can reach.
- **Read/write asymmetry on player keys is now closed** (`state.player.keys.*` reads, `set_key`
writes) — but reads are per-color getters while the write is a stringly-named color, so the two
sides still don't mirror each other.