A script does **not** mutate the world directly. Instead:
1. The engine calls one of your **callbacks** (`init`, `tick`, `bump`, `grab`, or a custom
function reached via `send`).
2. Inside the callback you **read** the world synchronously (`Board.*`, `Me.*`, `blocked()`,
etc.) and **enqueue actions** (`move`, `say`, `set_tile`, …).
3. Enqueued actions go into *your object's* private output queue. They are not applied while
your callback is running.
4. 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.
5. Some actions (`move`) automatically queue `delay` actions 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](#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. |
| `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 |
```rhai
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
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`. |
```rhai
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(id, x, y)` | Jump entity `id` to an arbitrary cell (pass `me.id` for self; `id == -1` is the player). Zero cost. Refused (with a logged error at resolve time) if that entity is solid and the destination already holds a *different* solid. |
<a name="send"></a>`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.
```rhai
// 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.)
```rhai
// Two-cell swap: exchange the solids at (3,3) and (3,4).