---
name: incanto-behaviors-and-scripts
description: Attach vibe-coded TypeScript to JSON nodes via the Behavior system — registerBehavior, script props, JSON connections to behavior methods, Timer, CharacterController2D. Use when adding game logic.
---

# Behaviors & Scripts

> Shipped inside the `incanto` npm package — this document always matches the
> installed engine version. Sibling skills live in `node_modules/incanto/skills/`.

> **For ready-made behaviors, see `incanto-gameplay-behaviors.md` first.** Health,
> score, pickups, contact damage, lifetimes and interaction ship built-in and
> auto-register in `createGame` — wire them from JSON with no code. Write a custom
> behavior (this doc) only for game-specific logic the library doesn't cover.

JSON owns STRUCTURE; TypeScript owns BEHAVIOR. A node carries at most ONE script:

```json
{ "name": "Level", "type": "Node2D",
  "script": { "name": "CoinCounter", "props": { "target": 10 } } }
```

```ts
import { Behavior, registerBehavior } from 'incanto';

class CoinCounter extends Behavior {
  static readonly props = { target: { default: 0 } }; // same schema model as nodes
  target = 0;
  collected = 0;

  override onReady(): void {}                  // once per instance, after the node's own
  override update(dt: number): void {}         // every frame
  override fixedUpdate(dt: number): void {}    // every fixed step
  onCoinCollected(other: unknown): void {      // JSON connections call this
    this.collected += 1;
  }
}
registerBehavior('CoinCounter', CoinCounter);  // EXPLICIT — before loadScene
```

**A behavior that holds progress should be able to save it.** `serialize()` /
`deserialize(data)` are two more optional hooks in the same list, and they are
what makes "continue where you left off" possible — see
`incanto-save-slots.md`. Save only what a fresh `onReady` could not recreate.

**A prop that holds a node path must say so.** Mark it `nodePath: true` and the
editor rewrites it when the target is renamed; leave it off and the rename
silently breaks the link (`getNodeOrNull` returns null, nothing throws).

```ts
static readonly props = {
  target: { default: '', nodePath: true },   // fed to getNode/getNodeOrNull
  home: { default: '', nodePath: true, required: true },   // and you MUST set it
  damage: { default: 10 },
};
```

`""` is the "not set" value of a node-path prop, and `getNodeOrNull('')` returns
`null` for it — so `const t = this.node.getNodeOrNull(this.target)` is safe to
write without a guard.

**`required: true` when the prop is not optional.** It turns "left empty" into a
LOAD error naming the prop and the node (`'script:Archer' needs a "home" — it is
empty. (at '/Level/Enemies/Slime3')`) instead of a behavior that silently does
nothing all game. It costs one word and it is the best message the engine has
for an unset prop.

## The three-artifact contract (keep them in sync!)

1. scene JSON `"script": {"name": "CoinCounter"}`
2. the TS class
3. `registerBehavior('CoinCounter', CoinCounter)` in the game entry

A missing registration is a hard `UNKNOWN_BEHAVIOR` at load listing registered names.
Script `props` validate against `static props` (`UNKNOWN_PROP` / `PROP_TYPE_MISMATCH`).
Hot reload: `registerBehavior(name, ctor, { replace: true })` swaps the implementation
under an existing name (re-registering a different class without it → `DUPLICATE_BEHAVIOR`).

## What a Behavior can reach

- `this.node` — the host node (cast to its type when needed)
- `this.getNode(path)` / `this.getNodeOrNull(path)` / `this.emit(...)` / `this.on(...)` — node delegates
- `this.engine` / `this.input` — via the scene tree (TREE_VIOLATION if the scene isn't
  set on an Engine)
- Engine access in `onReady` (`this.engine` / `this.rng` / `this.log`) works under
  `createGame2D/3D` — they pass `{ engine }` to `loadScene`, attaching it BEFORE
  `onReady` fires. Under a manual boot it throws `TREE_VIOLATION` until
  `engine.setScene(scene)`: prefer `createGame` (or `loadScene(json, { engine })`),
  or defer engine-dependent work to the first `update`. Caveats even with the
  engine attached: `engine.scene` is still null (setScene runs after), and the
  scene's `input` actions are not declared yet — query input from
  `update`/`fixedUpdate`, never `onReady`. The scene's `strings` and its
  `connections[]` ARE both in place by then, so `engine.t(...)` resolves and a
  signal you emit from `onReady` reaches its JSON handler.

## When your script throws

A throw is CONTAINED, not fatal — but only after the scene has loaded, and the
two halves are different on purpose:

- **at load** (`loadScene`, including every `onReady` in the initial pass) a
  throw is a hard failure. That is where an authoring mistake belongs: you want
  to hear about it before the game runs, not to play a game with a piece
  missing.
- **at runtime** — a spawned prefab, a clone, a node a behaviour adds — a throw
  from `onEnterTree`, `onReady`, `onExitTree`, `update` or `fixedUpdate`
  quarantines THAT script and nothing else. The node keeps its state, the rest
  of the scene keeps running, and `engine.log` names the node, the script and
  the hook.

The runtime half used to cover only `update`/`fixedUpdate`, so a spawned
prefab's `onReady` bug unwound into the `update()` of whatever spawned it: the
innocent Spawner was quarantined, spawning stopped permanently, and the log
named the wrong node.

**A signal listener is quarantined the same way** — the one that throws is
disconnected, everything else keeps running, and the log names the node that
OWNS the listener rather than the one that emitted:

```
[incanto] a 'scoreChanged' listener owned by behavior 'Hud' on /World/Hud threw
— THIS LISTENER is now off; /World/Score, which emitted it, and the rest of the
scene keep running.
```

Both spellings, `node.on(...)` and a JSON `connections` wire. Only the JSON
half used to be covered, so a cosmetic HUD handler with a typo in it disabled
the SCRIPT OF THE NODE THAT EMITTED — on the five shipped examples that do
`controller.on('movementStateChanged', …)`, that stops the player moving.
- `this.rng` — seeded engine randomness: `next()` [0,1), `range(min,max)`,
  `int(min,max)` (inclusive), `pick(arr)`. **Never `Math.random()` in game logic** —
  with `new Engine({ seed: 42 })` a run replays identically (scripted verification).
- `this.log` — the engine log channel (`debug/info/warn/error(...parts)`; debug overlay
  and headless harnesses tail it via `entries()` / the live `added` signal)
- `this.physics` — the physics world, for the queries AI asks:
  `castRay(origin, dir, maxLen, exclude?)` for line of sight, `castSphere(...)`
  (3D) for a probe that must not skim past a wall. `null` in a game with no
  physics. Pass `this.node` as `exclude` when casting from your own body.
  See `incanto-physics-and-input.md`.
- `this.enabled` — whether this behavior's `update`/`fixedUpdate` run, with
  `enable()` / `disable()` to flip it. Every behavior has it, it is authorable
  as a `script` prop, and because they are METHODS a connection can call them:

  ```jsonc
  { "signal": "spotted", "from": "Eyes", "to": "Hunt", "handler": "enable" }
  ```

  That is what makes an enemy STATE MACHINE expressible in JSON — a node carries
  one behavior, so each state gets a child node and only one of them ticks. Full
  recipe: "An enemy that changes its mind" in `incanto-gameplay-behaviors.md`.

  Asleep is not detached: props, accumulated state, signal connections and
  `serialize()` all survive; only the per-frame hooks pause. The lifecycle hooks
  are never gated, so a behavior authored `enabled: false` is fully initialized
  and simply idle.

## Runtime API (the imperative half)

What behavior code actually calls at runtime — all instance methods, no globals:

| Call | Semantics |
|---|---|
| `parent.addChild(node)` | attach a DETACHED node (already-parented → `TREE_VIOLATION`); sibling name collisions auto-rename (`Enemy` → `Enemy2`) |
| `duplicateNode(node)` | deep-clone via serialize→rebuild — returns a **detached** node; attach it explicitly with `addChild` |
| `node.queueFree()` | deferred destruction — flushed at the END of the current update pass (queued during a flush = freed next pass) |
| `node.free()` | immediate detach + teardown (children first; the signals it owns AND the ones it subscribed to elsewhere are both disconnected — see below) |
| `root.getNodesByName('Enemy')` | EVERY node with that name in the subtree, document order |
| `node.getNodeOrNull(path)` | like `getNode` but `null` instead of `NODE_NOT_FOUND` |
| `node.getNode('%Player')` | unique-name lookup across the whole tree (≥2 matches → `DUPLICATE_UNIQUE_NAME`) |
| `node.findChild('Skin')` | the first descendant with that name, or `null` — `findChild(name, false)` searches direct children only |
| `parent.removeChild(node)` | DETACH without tearing down: the node keeps its children and its props, and is yours to `addChild` somewhere else. Nothing frees it — a detached node nobody re-attaches is a leak |
| `node.reparent(newParent)` | detach-and-attach in one call, which is the safe order (`addChild` on a still-parented node is a `TREE_VIOLATION`) |
| `node.getPath()` | its absolute path, `/Level/Enemies/Slime3` — what every error message and report prints, and what to log when a lookup surprises you |
| `other.isInGroup('enemy')` | **the question a trigger asks** — the first line of nearly every `triggerEnter` handler. `node.groups` is a SET, not the array the JSON writes it as, so `groups.includes(…)` is a TypeError |
| `node.addToGroup('enemies')` / `node.removeFromGroup('enemies')` | tag at RUNTIME; `groups` in the JSON is the same set, declared |
| `this.node.tree?.getNodesInGroup('enemies')` | group query (also `tree.callGroup(group, method, ...args)`) |
| `engine.stop()` / `engine.start()` | pause / resume the loop — stop resets the clock and accumulator, so no banked sim time leaks into the resume |
| `engine.step()` | advance exactly ONE fixed step + one update (both dt = the fixed step) — the unit of time for headless tests |
| `engine.tick(timestampMs)` | manual frame advance — takes an **absolute** ms timestamp (rAF-style), NOT a dt; the first call after (re)start only primes the clock |
| `engine.setScene(scene)` | swap scenes: the previous root is freed, the input map is cleared and redeclared from the new scene's `input{}`, the clock resets (`time`, `unscaledTime`, and **`timeScale` back to 1** — a level restarted out of a frozen game-over must not boot frozen), then `sceneChanged` fires. It fires with **null** at `dispose()` too — a handler that reads `scene.root` has to check, or teardown says so and continues without it |
| `node.off(signal, fn)` | disconnect ONE listener you connected with `on` — pass the same function reference. (`free()` disconnects everything a node owns, so this is for a listener that must stop while the node lives on) |
| `node.signal('died')` | the `Signal` object itself, for `connect`/`disconnect` by hand; undeclared names throw, like `emit` |
| `node.listenerCount('died')` | how many are listening — a test's way to prove a wire was made, or dropped |
| `engine.stats()` | live perf counters `{ fps, frameMs, nodes, running }` — fps/frameMs average the last ~60 REAL `tick` frames (headless `step()` runs report 0), nodes is the current tree size. GPU counters (triangles/draw calls) live on `renderer.stats()` / the merged `game.stats()` |

The recurring traps: `duplicateNode` does NOT insert the clone anywhere — a
"spawner that does nothing" usually forgot `addChild`.

**The template you clone FROM is a live node.** Its behaviors run, its timers
tick, its turret shoots, its `autoplay` audio plays — `visible: false` hides a
node, it does not switch it off. `Spawner` and `WaveSpawner` sidestep this by
DETACHING their `prefab` at enter. For a shelf of templates you clone yourself,
put `PrefabShelf` on the shelf node and it does the same for all of them:

```json
{ "name": "Prefabs", "type": "Node2D", "script": { "name": "PrefabShelf" },
  "children": [ { "name": "Tower", "type": "Node2D", "children": [] } ] }
```
```ts
const shelf = this.node.getNode('/Game/Prefabs').behavior as PrefabShelf;
const tower = shelf.make('Tower');    // detached, awake all the way down
tower.position = at;                  // set it up BEFORE it readies
this.node.getNode('/Game/Towers').addChild(tower);
```

Its children never ENTER the tree, so nothing has to be hidden and nothing has
to be asleep. `make()` returns the clone DETACHED, like `duplicateNode` — not a
formality: `onReady` fires on attach, and a behavior that banks its node's
position there (`FloatAway`) would bank the wrong one. `names()` lists what the
shelf holds, and asking for anything else fails saying so.

**Why not just author the templates `"enabled": false`?** That was the old
advice and it half-works: `enabled` is a per-behavior pause switch, so
`clone.behavior?.enable()` wakes ONE node — while the shape this same skill
teaches (one behavior per node, so a prefab's parts live on children) leaves
every child asleep, silently and for good. Measured on a shipped tower defense:
the bolt's root `Projectile` was woken and its sibling `Lifetime` was not, so
every bolt that MISSED flew forever — five still in the tree at 69 seconds. And
`enabled` never silenced the template's `autoplay` audio at all, which is how
that game's `feels` rung read `every one of the 4 emitters fired` while two of
the four were templates going off on the shelf. `enabled: false` is for a state
a node deliberately starts in (a `Chase` waiting on `spotted`), not for hiding a
template from the tree. **And look the template up by PATH, not `%Name`**: a clone keeps its
template's name, so `%Tower` is unambiguous exactly until the first one is
placed, and then it throws `DUPLICATE_UNIQUE_NAME` from inside your build
handler — a game that works once. `engine.tick(16)` does
not mean "advance 16ms": tick wants wall-clock timestamps, so scripted loops
should call `engine.step()` instead. And `queueFree` inside `update` is always
safe — the node keeps existing until the pass ends.

## Custom signals (declare before emit)

Signals are part of a node's contract: emitting or subscribing an UNDECLARED signal is a
hard `UNKNOWN_SIGNAL` listing the declared ones. A behavior declares its custom signals
with `static signals` (merged up the class chain) — auto-declared onto its node at load:

```ts
class GameRules extends Behavior {
  static signals = ['gameOver'];               // REQUIRED before this.emit('gameOver')
  onPlayerDied(): void { this.emit('gameOver'); }
}
```

Built-in node signals (`timeout`, `triggerEnter`, `animationFinished`, …) are already
declared by their classes. Escape hatch for runtime one-offs: `node.declareSignal(name)`
(instance-only — ad-hoc declarations do NOT survive `duplicateNode`/serialize; prefer
`static signals`); `node.declaredSignalNames()` lists everything a node may emit.

## Connections → behavior methods

```json
"connections": [
  { "signal": "triggerEnter", "from": "Coins/CoinA", "to": ".", "handler": "onCoinCollected",
    "once": true, "filter": { "group": "player" } }
]
```
- `handler` must exist on the target NODE or its BEHAVIOR — hard `UNKNOWN_HANDLER`
  otherwise, and hard `AMBIGUOUS_HANDLER` when BOTH have it (node methods take
  precedence on invocation, so the script's would never run). Prefix handlers
  with `on` and the question never comes up.
- Handlers receive the EMITTED args only (e.g. the other body for `triggerEnter`) — not
  the emitting node. For per-emitter logic, attach a small behavior to the emitter itself
  (see the `Pickup` pattern: a coin's own `triggerEnter` → its own `onTaken` → `queueFree`).

## Respawn (core node — catch a player who leaves the world)

Hang it off the thing it guards. That is the whole feature:

```json
{ "name": "Player", "type": "RigidBody3D", "props": { "…": "…" },
  "children": [
    { "name": "Controller", "type": "CharacterController3D" },
    { "name": "Catch", "type": "Respawn" }
  ] }
```

With no props it guards its parent, catches it 50 m under the spawn (1000 px in
2D — the same line `incanto-playtest` calls `fell`), puts it back where it
started and zeroes the velocity the fall built up. Works in both dimensions.

| Prop | Default | Meaning |
|---|---|---|
| `target` | `".."` | who is being caught — the parent, normally |
| `below` | `null` | the line, in the scene's own down. `null` = auto (see above) |
| `to` | `[]` | where to put it back. Empty = wherever it started |
| `resetVelocity` | `true` | drop the speed the fall built up — off and it falls straight back through |

Signal `respawned(target, y)`, and a `falls` counter. It deliberately does NOT
decide what falling COSTS: wire `respawned` to a `ScoreKeeper.loseLife` or a
`Health.damage` if it should hurt, and leave it alone if it should not.

**A node, not a behavior**, because a node holds one behavior and the player's
is already spoken for.

Nothing in the engine did this before 0.62, and five shipped examples proved
what that cost: with the `plays` rung finally counting defects,
`basic-3d-sideview` failed 8 seeded runs of 8, `water-lake-3d` 7,
`water-ocean-3d` 5, `water-pool-3d` 4, `water-river-3d` 1 — every one of them
the player walking off the terrain and falling forever. One `Respawn` node each,
no TypeScript, and all five are clean.

## Checkpoint (built-in behaviour — move where the Respawn puts you)

The second most common thing a platformer has. On an `Area3D`/`Area2D`:

```json
{ "name": "Check1", "type": "Area3D", "groups": ["checkpoint"],
  "props": { "position": [7, 4.5, -37], "collider": { "shape": "box", "size": [3, 3, 3] } },
  "script": { "name": "Checkpoint", "props": { "respawn": "/root/Player/Catch", "dropBelow": 0.6 } } }
```

| Prop | Default | Meaning |
|---|---|---|
| `respawn` | `""` (required) | node path of the `Respawn` to redirect |
| `group` | `"player"` | who can light it (`""` = anything) |
| `once` | `true` | light it a single time; `false` re-fires on every entry |
| `dropBelow` | `0` | how far below the area's origin the respawn point sits, so the character lands ON the flag |

Touching it sets the `Respawn`'s `to` to the checkpoint's world position and
emits `activated(other)`; `lit` says whether it has fired. `Respawn.to` is read
at the moment of the catch, so a checkpoint lit mid-run is where the next fall
ends — it used to be read once at ready, which is what a platformer built from
the tarball found. `examples/platformer-3d` has two.

## Timer (core node — never setTimeout in game logic)

```json
{ "name": "Spawner", "type": "Timer", "props": { "waitTime": 2, "autostart": true } }
```
Emits `timeout` every `waitTime` s (`oneShot` for once). API: `start(time?)`, `stop()`, `running`,
`timeLeft` (seconds to the next `timeout`, `0` when stopped — the countdown a HUD draws).
A `waitTime` of 0 is a load error: the update guard stops a timer with no period
on its first frame, so it would never fire and never say so.

## CharacterController2D (JSON-only playable characters)

Child of a `CharacterBody2D` (hard error otherwise):
```json
{ "name": "Player", "type": "CharacterBody2D",
  "props": { "collider": { "shape": "capsule", "radius": 10, "height": 20 } },
  "children": [
    { "name": "Controller", "type": "CharacterController2D",
      "props": { "mode": "platformer", "maxSpeed": 260, "jumpHeight": 146 } }
  ] }
```
- `platformer`: x movement + gravity (scene `physics.gravity[1]`) + jump (`jumpHeight` px)
- `topDown`: full-axis movement, no gravity
- Defaults: `mode 'platformer'`, `maxSpeed 220`, `jumpHeight 64`, `moveAction 'move'`,
  `jumpAction 'jump'`. Timer defaults: `waitTime 1`, `oneShot false`, `autostart false`.
- ⚠️ Connection handler names must not collide with Node API methods (`emit`, `update`,
  `on`, `queueFree`, …) — the NODE method wins. That is `AMBIGUOUS_HANDLER` at load
  now, not a silent wrong call; `incanto check` warns on the same pair from the
  file alone. Prefix handlers with `on`.

Reference: [examples/2d-phaser-sprite-character-gravity](https://github.com/planetarium/Incanto/tree/main/examples/2d-phaser-sprite-character-gravity) — engine nodes + one small PlayerControl behavior
(`CoinCounter`, `Pickup`) wired entirely through JSON connections. Verified in Chromium.

## Spawning nodes at runtime

Nodes you create in code and intend to serialize need uids like everything
else — mint them with the engine's generator, never by hand:

```ts
import { newUid } from 'incanto';
import { Sprite2D } from 'incanto/2d'; // node classes live in their dimension entry

const coin = new Sprite2D('Coin');
coin.uid = newUid(); // n_xxxxxxxxxxxxxxxx (crypto, 16 base36 chars)
```

## Time & frame-rate independence (Unity Time equivalents)

Movement is frame-rate independent when you multiply by `dt` — the seconds
(already `timeScale`-adjusted) your update callbacks receive:

```ts
override update(dt: number): void {
  // 1 meter per second at ANY fps — 30, 60, or 144:
  node.position[0] += 1 * dt;
}
```

| Unity | Incanto |
|---|---|
| `Time.deltaTime` | the `dt` argument of `update(dt)` / `fixedUpdate(dt)` |
| `Time.time` | `engine.time` (elapsed game seconds, scaled; resets per scene) |
| `Time.unscaledTime` | `engine.unscaledTime` (real seconds — UI during slow-mo) |
| `Time.timeScale` | `engine.timeScale` (1 realtime · 0.5 slow-mo · 0 frozen) |
| `Time.fixedDeltaTime` | the `dt` of `fixedUpdate` (1/60 by default) |

`engine.timeScale` scales variable AND fixed updates together — physics,
timers, animations and behaviors all slow down as one. Bullet-time:
`engine.timeScale = 0.3`; freeze frames: `hitStop(engine, 0.08)` from
`incanto/gameplay`.
