---
name: incanto-3d-character
description: The 3D character stack — CharacterController3D (floating-capsule movement with sprint/jump/fall tuning and an animation-driving movement state) and its camera rigs (free orbit / first person / quarter / side). Use when building any playable 3D character.
---

# 3D characters: CharacterController3D

One node turns a dynamic body into a playable character with a camera:

```json
{
  "name": "Player", "type": "RigidBody3D",
  "props": { "fixedRotation": true, "friction": 0,
             "collider": { "shape": "capsule", "radius": 0.32, "height": 0.96 } },
  "children": [
    { "name": "Controller", "type": "CharacterController3D",
      "props": { "view": "free", "camDistance": 4 } },
    { "name": "Skin", "type": "ModelInstance3D",
      "props": { "model": "$avatar", "targetHeight": 1.6 } }
  ]
}
```

MUST sit under a dynamic `RigidBody3D` (hard error otherwise) with a capsule
collider and `fixedRotation: true`. Set **`friction: 0`** on that body — the
character rides the hover spring, not floor contact, so friction would just brake
movement. Declare `move` (vector2) / `jump` /
`sprint` actions in the scene input map, and call
`engine.input.attachPointer(canvas, { lockOnClick: true })` for mouse look.
The move vector is SCREEN space, y-down: `up` is `-y`, so in a harness
`setActionVector('move', 0, -1)` walks the character FORWARD (away from the
camera, -z at yaw 0) and `(0, 1)` walks it toward the camera — the guess that
drops a first probe off the back of the map.

## Movement model (vibe-starter-3d parity)

Impulse-based on the dynamic body: target speed = `maxSpeed ×
(1 + (sprintMultiplier−1)·intensity)` where keyboard intensity is 0.6
(any move key) or 1.0 (+sprint). Defaults: maxSpeed 2.5, sprint ×2 → walk
≈4 m/s, sprint ≈5 m/s. `jumpVelocity` 4 — and `sprintJumpMultiplier` (1.2) scales with
the keyboard's INTENSITY, so a jump while walking is ×1.12 in velocity (+26% in height)
and while sprinting ×1.2 (+44%): standing 0.81 u, walking 1.01, sprinting 1.16 at the
defaults. Size a ledge against the moving jump, not the standing one; `incanto-feel`
prints all three. Falls get
gravityScale 2.5 with a −20 m/s terminal clamp, and a hover spring holds the
capsule BOTTOM `floatHeight` above the ground (5-ray probe) — in principle; the
spring sags about 0.13 m under gravity, so at the default `floatHeight` 0.01 the
capsule rests ON the floor (measured: bottom −0.001 m). Author the Skin y-offset
as `-(halfHeight + radius)` so the feet sit at the capsule bottom (the example
templates do this; adding `floatHeight` is off by that much).

## Game feel — the props a jump needs to stop feeling broken

A jump that only fires while the ground ray says so feels BROKEN, and players do
not report it as "the coyote time is missing" — they report the game as
unresponsive. `CharacterController2D` got these in 0.33.0; the 3D controller
did not until now.

| prop | default | what it buys |
|---|---|---|
| `coyoteSeconds` | `0` | jump this long AFTER walking off a ledge. **The single biggest one** — try `0.12`. |
| `jumpBufferSeconds` | `0` | press jump this long BEFORE landing and still get it. Try `0.15`. |
| `jumpCutMultiplier` | `1` | release early and the rise is cut to this fraction. `0.45` = variable-height jump. |
| `maxJumps` | `1` | `2` = double jump. Extra jumps work in mid-air. |
| `airControl` | `0.2` | how much ground control you keep airborne (0 = committed, 1 = full). |
| `fallGravity` | `2.5` | gravity multiplier while falling. Higher = snappier arc. |

And one that is about the CAMERA rather than the jump:

| prop | default | what it buys |
|---|---|---|
| `pitchStart` | `0` | where the camera RESTS before anyone looks around, in degrees. Negative looks down; `-14` is a good third-person default. On a PHONE it stops being a nicety: a portrait screen is very tall, so a level camera spends its top half on sky and a player with no mouse cannot fix it before they start dragging. |
| `mantleHeight` | `0` | the pull-up: on the way down from a jump, a ledge ahead no higher than this above the capsule's bottom is caught and pulled up onto. A jump a hand short of a roof is the most common near-miss in a platformer; try `0.9`. `0` = a jump near a wall feels exactly as it did. |
| `wallJumpImpulse` | `[0, 0]` | `[away, up]` m/s — airborne and pressing INTO a wall, `jump` kicks off it: away along the wall's normal, up. Try `[6.5, 7]`. A fixed arc (the jump cut never halves it) and air control stands down for 0.18 s so the stick still pointing at the wall cannot steer the kick back into it. Emits `wallJumped(normal)`. |
| `wallSlideSpeed` | `0` | max fall speed while pressing into a wall (`fallGravity` stands down too). Try `2`. |
| `platformCarry` | `true` | ride whatever you are standing on. Off = the controller steers in WORLD space, so standing still on a moving floor brakes you off it (measured: 95.9% of a moving deck's travel kept, 0.0% with this off). |

Every one defaults to OFF (`0` / `1`) or to the previous hard-coded constant, so
a scene that asks for nothing behaves exactly as before.

**Getting hit is the controller's too.** When the body's `Health` (on the body,
or on a child such as `Vitals`) declares `knockback` / `knockUp` /
`staggerSeconds`, the controller drops the stick, the jump and the dash for the
stagger and writes the kick as the body's velocity — `knockUp` is the hop. A
club with `DamageOnContact` and a player Health of `knockback 7, knockUp 2.5,
staggerSeconds 0.2` is a hit that knocks the player half a metre back, off the
ground, and takes the controls for a fifth of a second — no game code. See
"Hit reactions" in `incanto-gameplay-behaviors.md`.

**The controller owns the wall-jump.** A kick written from a behaviour lasted
three frames on a platformer built from the tarball: the jump cut halved its
rise the moment the tap was released (7 → 2.2 m/s) and air control steered
the horizontal straight back into the wall (−6.5 → 0 in three steps). Anything
that fights the controller's own feel props has to live INSIDE them. The wall
is found by a short static-only ray in the input direction, so the character
must be pressing into it — a `RigidBody3D` has no `isOnWall`.

```json
{ "name": "Ctl", "type": "CharacterController3D",
  "props": { "coyoteSeconds": 0.12, "jumpBufferSeconds": 0.15,
             "jumpCutMultiplier": 0.45, "maxJumps": 2 } }
```

All six are composed in `examples/tps-3d` (the shipped third-person starter), and
`bunx incanto-feel` measures what they DO rather than echoing them — on that scene:
tapped apex 0.21 u vs held 0.81 u at the default `jumpVelocity` 4 — tps-3d itself, at 5,
measures 0.32 / 1.26 (`jumpCutMultiplier`), a double jump line
(`maxJumps: 2`), and the coyote and buffer windows still measured with a double
jump declared. It used to give up on both the moment `maxJumps` went above 1.

**`slopeLimitDeg` (55) is the steepest ground the character STANDS on**, and
below it a character does not slide at all — measured, on a hillside, for as
long as you leave it there. It used to slide down everything: 0.18 m in three
seconds on a 6.9° slope, 2.87 m on a 27° one, always exactly downhill. The
cause is the hover spring measuring its clearance STRAIGHT DOWN while the
clearance that decides whether the capsule touches is measured perpendicular
to the surface — negative past ~13° — and every rig setting `friction: 0` (the
right setting: the character rides the spring, so floor friction only brakes
it) then slid on the contact. So the spring aims perpendicular now, and on
ground inside the limit the controller cancels downhill drift it was not asked
for. Steeper than the limit the ground stops being ground — no `grounded`, no
jump reset — and the body slips off the face, which is what a slope limit means
everywhere else. 55 is a person; a goat is 80, something on wheels 25, and `90`
is the old no-limit behaviour. `controller.groundSlopeDeg` reports what it is
standing on, and `controller.groundNormal` which way that faces.

**`stepHeight` steps onto a ledge at the APEX of a jump too.** A fence 1.5 m
high was climbed by a jump that peaks at 1.3 m: at the apex the fence top sat
0.2 m above the capsule's bottom, inside `stepHeight` 0.4, and the step-up
lifted the character over. That is what makes landing on a ledge forgiving; a
wall that must stop a walker is jump apex + `stepHeight` tall (`examples/steed-3d`'s
fence is 2 m).

**Jump reads the button EDGE.** Holding the jump key no longer re-jumps every
frame — that auto-hop was never intended and it made coyote time incoherent (a
held button would re-fire through the whole window).

**Two ground senses, deliberately.** `grounded` stays generous (slack for the
hover spring, so slopes and bumps do not flicker the animation state). The feel
props and the airborne animation use a stricter test: close to the support AND
not moving upward. Without the velocity term the check is one frame stale, which
at launch speed is ~6 cm — enough to still read as standing on the floor.

### The two numbers a level is built against — composed and measured

`stepHeight` (0.35) and `jumpVelocity` (4) decide what a level can ask of the player, and
no example had set either. `tps-3d` now declares `stepHeight: 0.4` and `jumpVelocity: 5`
and its harness asserts each by what the player can DO: walked into a 0.3 m curb it rises
from y 0.84 to 1.14 and keeps going; walked into a 0.9 m ledge it stops at the face;
and the same scene with `jumpVelocity` back at 4 reaches 0.81 u where 5 reaches 1.26 u.
Read the number back from nothing — a curb you can climb is the proof.

### The rig around the character — composed and measured

`cameraCollision` and `platformCarry` are on by default and no example had ever set or
measured them. `tps-3d` declares both and its harness asserts each by an effect: standing
1.25 m in front of a wall with the boom pointing at it, the camera closes from 4.2 m to
0.45 m; standing still on a `Lift` (a `StaticBody3D` walked by `Patrol`) for two seconds,
the lift travels 4 m and the rider drifts under 0.25 m off it.

`floatHeight` was composed too and taken back out. The hover spring holds the capsule
bottom `floatHeight` above the ground in principle, but it SAGS about 0.13 m under gravity,
so below that the capsule simply rests on contact and the prop changes nothing — measured
at rest: 0.01 → y 0.839, 0.05 → 0.840, 0.3 → 1.008, 0.6 → 1.308 for a capsule whose
contact height is 0.84. Leave it at the default and use `stepHeight` for ledges (the
docstring's own advice: a raised `floatHeight` makes the character weightless).

## Swimming (water too deep to stand in)

A `Water3D` is a surface with no bottom, and until now the controller never
asked it anything: a player who walked off a beach into a four-metre lagoon
stood on the bed with the camera underwater and the run clip playing, in every
3D scene with water. Now the body SWIMS:

| prop | default | what it buys |
|---|---|---|
| `swimSpeed` | `1.6` | the pace a plain key gives in water (m/s; `sprint` still multiplies, by the ratio it multiplies a run). **`0` turns swimming OFF** — the body sinks to the bed, which is what a drowning pit wants and what every scene got before. |
| `swimDepth` | `null` | how far the body's ORIGIN rides below the surface. `null` derives it from the capsule so the head rides 15 cm clear and the rest is in the water — which is what treading water looks like, and what a fixed number cannot be for every character size. (It used to default to a flat `0.35`, and on the 1.68 m capsule every template ships that put the head 0.46 m clear: the character floated along on top like a boat.) A number still wins. |
| `diveAction` | `"dive"` | held: swim DOWN at `swimSpeed`. Not in the input map = no diving, silently. |

- **When.** The body swims when floating would put it HIGHER than standing:
  the waterline (`surface − swimDepth`) above where the hover spring would
  hold it on the bed it can see, or no bed within reach. That one comparison
  is the whole wade-in/wade-out — walk down a beach and the water takes you
  where the bed drops away; swim toward a shelf and the spring takes over
  where it rises. No zones, no triggers.
- **What.** Gravity is off, a spring holds the origin at the waterline (a
  swimmer rides the swell the way a `Buoyancy` raft does — the same
  `heightAt`), horizontal control is full (water gives purchase), and the
  state is `swim` for the `animations` map / `movementStateChanged`.
- **Diving.** Hold `dive` to go down; let go and you rise on your own. With
  the head under (`controller.submerged`), holding `jump` swims UP. Air,
  drowning and what is down there are your game's business — read
  `controller.submerged` in a behavior and count.
- **Getting out.** A swimmer's step is measured from the WATER, not from the
  foot: swim into a shelf, a dock or a rock whose top is no more than
  `stepHeight` above the surface and the body CLIMBS out on its own — driven
  up at 2 m/s until its bottom is level with the ledge, then onto it. (The
  capsule hangs `swimDepth + halfHeight` under the surface, so from its bottom
  a shelf a hand under the waterline would be a 0.6 m wall; a swimmer at that
  shelf simply puts their hands on it. The hover spring could not do this —
  it is sized to hold a capsule a hair off the ground and stalled with the
  body's round bottom a third of a metre under the ledge, pressed to its
  face.) Higher than `stepHeight` above the water it is a wall, exactly as on
  land. `jump` at the surface hops — SIZED to clear the surface by
  `stepHeight`, whatever `jumpVelocity` says — and the same climb takes a
  ledge on the way down. So author a dock or a rock a swimmer should reach
  **no more than `stepHeight` above the water**. A beach needs nothing: the
  bed rises, the standing height passes the waterline, and you walk out.
- **Readbacks.** `controller.swimming`, `controller.submerged`.
- **Found on the way (fixed, same release):** the ground probe's side rays
  start `radius` out from the capsule's centre, so pressed against a wall one
  of them started INSIDE the wall and hit it at distance zero — a character
  hugging a wall in mid-air read as standing, the ground jump came back while
  falling, and a hop out of the water at a rock's face was cancelled the frame
  it started. A zero-distance hit is ignored now. And `wallSlideSpeed` held a
  third of a metre a second loose (the solver added one tick of gravity back
  after the clamp); at the cap gravity stands down.

```json
{ "name": "Controller", "type": "CharacterController3D",
  "props": { "view": "free", "swimSpeed": 1.8, "stepHeight": 0.5,
             "animations": { "idle": "$idle", "run": "$run", "swim": "$anims/swim" } } }
```

Composed in `examples/lagoon-3d` (a pearl dive with an air meter); the
mixamorig `swim.glb` clip is on the CDN beside the locomotion set.

## Ladders

Every 3D game with a second floor has one, and until now an `Area3D`
against a wall was a box the player walked into. A ladder is any `Area3D` in
the controller's `ladderGroup` — no node type, no code:

```json
{ "name": "Ladder", "type": "Area3D", "groups": ["ladder"],
  "props": { "position": [-3, 2.2, 0.35], "collider": { "shape": "box", "size": [1, 4.4, 0.7] } },
  "children": [ …rails and rungs as MeshInstance3D… ] }
```

| prop | default | what it buys |
|---|---|---|
| `climbSpeed` | `1.5` | up and down the rungs, m/s, from the stick's OWN vertical (W/S — the camera gets no say). **`0` = no ladders.** |
| `ladderGroup` | `"ladder"` | which `Area3D`s are ladders |

- **Grabbing.** Push UP into a ladder you overlap and you are on it: gravity
  off, held to the rungs, state `climb`. Walking PAST one never grabs it —
  only pushing up does. Falling past one while holding DOWN grabs it too,
  which is how you climb down from a ledge: stand at its edge over the
  ladder, hold back, step off.
- **The top.** The same climb that takes a swimmer out of the water takes the
  body onto the ledge: the ladder should lean on the FACE of the thing you
  are climbing onto (a terrace, a wall with a platform on top, a loft's
  edge) so the ledge is AHEAD of the climber — a ladder up through a hole in
  a floor has its ledge to the sides, and the climb-out looks forward.
- **The bottom.** Down while standing at its foot steps off.
- **Letting go.** `jump` pushes off the rungs, away from the ladder, with a
  moment's lockout so it is not grabbed straight back.
- **Animation.** The state is `climb` — map it in `animations`. The CDN's
  mixamorig set has no climbing clip; `walk` reads as climbing well enough
  on the rungs (`examples/tower-3d` does that).
- **Readback.** `controller.onLadder`.

Composed in `examples/tower-3d` ("Lantern Tower": three terraces, three
ladders, a sweeping beam and a lantern that wants three flasks).

## Mantling (the pull-up)

A jump at `jumpVelocity: 5` rises 1.27 m and the capsule's round bottom rides
a corner up to ~0.3 m more, so a 1.6 m roof is cleared and a 2 m one is a
wall — a hand short. `mantleHeight: 0.9` catches it: on the way down from
ANY jump, while the stick is held toward the ledge, a top ahead within that
height of the capsule's bottom is taken by the same climb that takes a
swimmer out of the water and a climber off a ladder (driven up at 2 m/s,
then onto it; state `climb` meanwhile). Above the reach it is still a wall;
a vertical hop beside a ledge is still a hop. Composed in
`examples/rooftops-3d`.

## Crouching (`crouchHeight`)

A duct to crawl through, a laser to duck under, a desk to hide behind:
every stealth game and shooter has a crouch, and the controller could not
make itself shorter. One prop turns it on:

```json
{ "name": "Ctl", "type": "CharacterController3D",
  "props": { "crouchHeight": 0.3, "crouchSpeedMultiplier": 0.5 } }
```

- **`crouchHeight`** (m, `0` = no crouch) is the capsule's `height` while the
  `crouch` key is held — the standing capsule is `height` + 2 × `radius`
  tall (1.6 m for the default rig), the crouched one `crouchHeight` +
  2 × `radius` (0.94 m), so a duct with a metre of headroom is crawled
  through and stops a standing character at its mouth. The body sinks by
  half the difference in the same step, so the feet stay put and the eye
  drops with them in first person; the Skin's authored offset is lifted by
  the same amount so the model's feet stay on the floor, and restored on
  standing.
- **Hold-to-crouch.** The key let go stands you up — if there is headroom.
  A ray from the capsule's top asks; under a duct you stay down until you
  are out from under it, and `crouching` reads true the whole time. A jump
  press while the key is held is not a hop and not a stand.
- **`crouchSpeedMultiplier`** (0.5) is the pace, as a fraction of the standing
  pace. `state` reads `crouch` (still) and `sneak` (moving), so the
  `animations` map takes clips for both.
- Declare the action: `"crouch": { "type": "button", "keys": ["KeyC", "ControlLeft"] }`
  (`crouchAction` renames it). A missing action is tolerated and reported
  once, like `sprint`.

`examples/vents-3d` is the composition: ducts, a laser grid at head height,
a guard's sightline you crouch under.

## A death that falls — `Ragdoll3D`

Every enemy in every example died the same way: the model played a clip, or
vanished. Nothing crumpled, slid down a slope or hung off the rail, because a
ragdoll is ten bodies and nine joints built from a skeleton nobody wanted to
write. One node under the body, beside the Skin:

```json
{ "name": "Brute", "type": "CharacterBody3D", "children": [
  { "name": "Skin", "type": "ModelInstance3D", "props": { "model": "$base" } },
  { "name": "Hp", "type": "Node", "script": { "name": "Health", "props": { "max": 50 } } },
  { "name": "Ragdoll", "type": "Ragdoll3D", "props": { "target": "../Skin", "lifetime": 8 } }
] }
```
```jsonc
{ "signal": "died", "from": "Brute/Hp", "to": "Brute/Ragdoll", "handler": "activate" }
```

- **Idle it is nothing.** `activate()` reads the skeleton at `target`
  (Mixamo names — `Hips`, `Spine`, `Head`, `LeftArm`… with the forgiving
  lookup `findBone` uses), builds a capsule `RigidBody3D` per limb where the
  bones are — torso, head, upper arms, forearms, thighs, shins — joins each
  to its parent with a spherical `Joint3D`, gives them the velocity of the
  body they fell out of, and from then on writes the bones' world matrices
  from the limbs every frame. The limbs go under the scene root in the
  `ragdoll` group; `limbs()` lists them.
- **Turn the rest off beside it**: the body's collider (`enabled: false`) so
  the capsule stops holding the corpse up, the controller or the chase.
  `Health.freeOnDeath` would delete the model the ragdoll is driving — leave
  it off and let `lifetime` free the limbs, then free the corpse yourself on
  the `reset` signal.
- `mass` (70 kg, shared out by limb), `damping` (3 — how fast the flailing
  dies), `collide` (limbs against each other; adjacent ones never). A rig
  without hands or a `HeadTop_End` still builds — the forearm ends at the
  wrist bone, the head is a ball.
- **Headless there is no skeleton**: `activate(pose)` takes bone positions
  (`{ Hips: [x, y, z], … }`), so a harness can build one where the model would
  be and ask that it fell, that the elbow still joins, that the limbs carried
  the run. `active` reads true while it is up.

## Letting go — `enabled`

The player gets into a car, sits through a cutscene, opens a menu: the
controller has to LET GO, and `enabled: false` is that. For getting into a car or
onto a horse, the whole handoff — this controller off, the body parked and
riding along, the Skin on the saddle, the steed's controller or `Vehicle3D`
on, and back — is the `Mount` gameplay behaviour (`incanto-gameplay-behaviors.md`). Off, it reads no
input, applies no forces (no hover spring either), drives no camera and
sets no animation — the body under it is an ordinary `RigidBody3D` until it
is on again. A game parks the character out of sight beside it:

```ts
ctl.enabled = false;          // the controller lets go
player.enabled = false;       // the body's collider is off — nothing bumps the empty seat
player.visible = false;
player.gravityScale = 0;      // a collider-less dynamic body would fall through the floor
// …and each frame while driving: player.position = car.position
```

and on the way out puts it back where the door is, `enabled`/`visible` on,
`gravityScale` 1, THEN `ctl.enabled = true` — the controller re-finds the
current camera and drives it from the next frame (the camera pops to the
rig; smooth it yourself if the cut shows). `Vehicle3D.enabled` is the same
switch on the car's side, and `examples/errands-3d` flips both with one
`Interactable` press.

## Two players at one keyboard

A second player is a second ACTION SET, and a controller told to read it:

```jsonc
// the scene's input map declares both
"input": {
  "move":  { "type": "vector2", "keys": { "up": ["KeyW"], "down": ["KeyS"], "left": ["KeyA"], "right": ["KeyD"] } },
  "jump":  { "type": "button", "keys": ["Space"] },
  "move2": { "type": "vector2", "keys": { "up": ["ArrowUp"], "down": ["ArrowDown"], "left": ["ArrowLeft"], "right": ["ArrowRight"] } },
  "jump2": { "type": "button", "keys": ["ShiftRight"] }
}
```
```json
{ "name": "Controller", "type": "CharacterController3D",
  "props": { "view": "quarter", "camera": "none",
             "moveAction": "move2", "jumpAction": "jump2", "sprintAction": "sprint2" } }
```

- `moveAction` / `jumpAction` / `sprintAction` have been there since the
  controller shipped and no example had ever set them. They work: measured on
  `examples/coop-3d`, player one's keys move smith one 2.4 m and smith two
  **0.00 m**.
- **Every player's controller needs `camera: "none"`**, and one camera gets a
  `GroupCamera` (see `incanto-gameplay-behaviors.md`) — otherwise two
  controllers and the group camera all write the same camera every frame.
- Anything else a player operates takes the same treatment: `Carry.action`,
  `Interactable.action`, `Shoot`'s action — name the second player's own.
- The playtest bot drives EVERY controller it finds, each on its own action set,
  so a two-player game is exercised rather than half-exercised.

## Views

| view | what it does | key props |
|---|---|---|
| `free` | third-person orbit, mouse yaw/pitch, camera-relative WASD | `camDistance` (4), `pitchMin/Max` |
| `firstPerson` | camera at the eye (`eyeHeight` 0.64), pointer-lock look | `camDistance` ~0.01 |
| `quarter` | fixed isometric pitch 35.264°, NO mouse look | `camDistance` 40, `mouseLook: false` |
| `side` | camera at +z `camDistance`, lock movement to ±x | `mouseLook: false` |

The controller drives the scene's `current` Camera3D every frame (smoothed) —
unless you take it off that duty:

```json
{ "name": "Controller", "type": "CharacterController3D",
  "props": { "view": "quarter", "camera": "none" } }
```

**`camera: "none"` leaves the camera exactly where the scene puts it.** A whole
shelf of games has a FIXED view — a board game, a bomb arena, an isometric
puzzle, a fixed-angle horror — and could not use this controller at all, because
it re-posed their camera every frame. Pair it with an authored `Camera3D` (and
a `FollowCamera` behaviour if the board scrolls); `incanto-check` no longer
calls that pair a fight. The default is `"drive"`, which is what every existing
scene already gets.
Wheel zooms only when `zoomMax > zoomMin`.

In `free` view the camera AIMS at the character every frame (`lookAt`), so the
character stays centered even while the camera is catching up or pulled in by a
collision — no jitter.

**Camera collision (spring arm).** For the orbit views (`free`/`quarter`/`side`)
the camera won't clip through walls or the ground: each frame it sweeps a small
SPHERE (not a thin ray — a ray that skims 10 cm over a wall reports "clear"
while the wall still fills the frame) from the eye toward the camera and, if a
collider blocks the boom, smoothly pulls the camera in front of it (plus a
ground backstop so it never drops below the floor).
Level-design corollary: walls you want the camera to respect must be TALLER
than the eye line (`player y + eyeHeight`, ~1.9 m for a default rig) — a
1.8 m hedge is below the boom entirely and the camera will look over it.
This is on by default (`cameraCollision: true`); set it `false` to get the old raw
orbit. Only the STATIC/fixed world stops it — give walls/floors/terrain a
`StaticBody3D` collider (collider-less meshes are invisible to the ray, same as to
the player). MOVABLE bodies are ignored on purpose: both dynamic ones (projectiles,
`RigidBody3D`) and kinematic ones (enemies/NPCs are `CharacterBody3D`), so a passing
enemy never yanks the camera in. `firstPerson` skips it (the camera is at the eye).

## Animations

`controller.state` is `idle | walk | run | fastRun | airborne | swim | climb | crouch | sneak | dash`; the
`movementStateChanged` signal fires on transitions — map states to clips
(`$animation` assets are retargeted onto the skin's own rig — GLB and VRM alike — so a
clip never changes your character's proportions):

```ts
controller.on('movementStateChanged', (state) => {
  skin.animation = { idle: '$idle', run: '$run', fastRun: '$runFast',
                     airborne: '$jump', walk: '$walk', swim: '$swim',
                     climb: '$climb' }[state];
});
```

Setting `animation` to a new clip **crossfades** (0.2s blend) from the current
one — idle↔walk↔run↔jump transitions are smooth, not a hard cut.

**A state your map does not name falls back — it never leaves the last clip
playing.** `swim → walk → idle`, `dash → fastRun → run → walk → idle`,
`sneak`/`climb → walk → idle`, `crouch`/`airborne → idle`. Every chain ends at
`idle`, so a map with one clip in it still animates. Without this, a scene that
mapped only the original five (which is every scene there was) put a swimmer
across the bay in a frozen JUMP pose, and did the same for `dash`, `climb`,
`sneak` and `crouch`. Map the state itself when you have the clip: the shipped
water templates now declare `swim` (`mixamorig/swim.glb` on the agent8 CDN).

## Reskin one model with `tint` (many variants from one GLB)

`ModelInstance3D` has a `tint` prop (hex, `""` = off) that MULTIPLIES into every
material — turn the one base humanoid into a whole cast WITHOUT extra assets: a
sickly-green zombie, a blue ally, a red boss. It clones each material per instance
(the shared source is never mutated, so the player stays untinted) and preserves the
texture/shading detail (it's a multiply, not a flat repaint). Pair it with a
shambling clip + glowing eyes for an enemy that reads as a different creature.

```json
{ "name": "Skin", "type": "ModelInstance3D",
  "props": { "model": "$avatar", "targetHeight": 1.7, "tint": "#5f8f4a",
             "animation": "$walk", "castShadow": true } }
```

## Two-layer animation: attack WHILE running (`animationUpper`)

A single `animation` clip owns the whole body — setting an attack clip freezes
the legs. The UPPER LAYER fixes that:

```ts
skin.animationUpper = '$anims/attack';   // spine-up plays the attack…
// …while `animation` (idle/run via the controller) keeps driving the legs.
// One-shots AUTO-CLEAR when the clip ends (and emit animationFinished) —
// set it per attack and forget it.
```

- `upperBodyRoot` (default `'Spine'`, Mixamo spellings matched) picks the bone
  subtree the layer owns; the base clip is automatically masked to the rest,
  so there's no half-blended overlap and no stride reset (time stays aligned).
- `animationUpperLoop: true` for sustained upper loops (carry, aim, wave).
- Works with the controller's `animations` map untouched — locomotion logic
  never learns the attack exists.
- Composed in `examples/tps-3d`: `Shoot.fire()` sets
  `skin.animationUpper = '$anims/shoot'` and nothing else — the run clip keeps
  the legs, the shot rides the spine up. Measured on the CDN's
  `mixamorig/shoot.glb` (4.03 s, 54 tracks retargeted onto `mixamorigSpine…`):
  the layer's action is live 3 frames after the shot and still at weight 1 with
  the base clip on `$anims/run`. While the clip GLB is still LOADING the layer
  waits silently and starts when it lands — a shot fired in the first second of
  a cold page plays late, not never.

## Animations without code

Map movement states straight in JSON — no behavior needed:

```json
{ "name": "Controller", "type": "CharacterController3D",
  "props": { "view": "free",
    "animations": { "idle": "$idle", "walk": "$walk", "run": "$run",
                    "fastRun": "$sprint", "airborne": "$jump", "swim": "$swim" } } }
```

The controller writes `skin.animation` on every state change (crossfaded by
the model). The `movementStateChanged` signal still fires for extras.

**Where those clips come from.** Locomotion clips live at
`https://agent8-games.verse8.io/assets/3d/animations/mixamorig/<name>.glb`
(`idle-00`, `walk`, `run-medium`, `run-fast`, `jump`, `swim`) and play on any mixamorig
model as they are — retargeted through the humanoid map for a VRM. You do not
have to assemble any of this by hand: run the character URL your asset MCP gave
you through `bunx incanto-model <url>` and it prints the whole thing — assets,
input actions, body, controller and skin — ready to paste. See
`incanto-3d-models.md`.

The controller also yaw-rotates the sibling at `skinPath` ('../Skin') toward
the move direction at `turnSpeed` rad/s (100 = instant snap) — the body
itself never rotates. The skin MOUNTS at 180° (facing away from the default
camera, original parity) and then faces wherever it moves; `skinYawOffset`
adds a correction for models whose native forward is not +z.

## Mounting things on bones (`BoneAttachment3D`)

A sword in the hand, a hat on the head, sparks on a wingtip — parent them to a
`BoneAttachment3D` and they ride the LIVE animated skeleton:

```jsonc
{ "name": "SwordMount", "type": "BoneAttachment3D",
  "props": { "target": "../Skin", "bone": "RightHand" },
  "children": [
    { "name": "Blade", "type": "MeshInstance3D",
      "props": { "mesh": "box", "size": [0.04, 0.04, 0.9], "position": [0, 0, 0.45],
                 "material": { "color": "#d8dde8", "metalness": 0.9, "roughness": 0.25 } } }
] }
```

- `target` = node path to the ModelInstance3D; `bone` = bone name. Lookup is
  forgiving: `"RightHand"` also matches `mixamorigRightHand` /
  `mixamorig:RightHand` (agent8's base-model is a Mixamo rig — `Head`,
  `Spine2`, `LeftFoot`, `RightHandIndex1`…).
- The attachment's own `position`/`rotation` are a BONE-SPACE offset (grip
  adjustments).
- Purely VISUAL: it follows the rendered skeleton, so headless the node stays
  at its prop transform — keep hit checks range-based from the body, never
  bone-based. The sword MESH rides the bone; the sword HITBOX is an `Area3D`
  on the body, aimed from the skin's yaw and armed for the swing's active
  frames — the recipe, with its numbers, is under `DamageOnContact` in
  `incanto-gameplay-behaviors.md` ("A melee swing"). Do not parent the hitbox
  to the skin: physics composes ancestor offsets only, so it never turns.
- `model.boneNames()` lists every bone at runtime when you need to hunt one
  (the editor's inspector offers the same list as a dropdown on the `bone` prop).

## Heads that WATCH things (`BoneLookAt3D`)

NPCs feel alive when they track you. One node, zero code:

```jsonc
{ "name": "HeadTrack", "type": "BoneLookAt3D",
  "props": { "target": "../Body", "bone": "Head", "lookAt": "%Player" } }
```

- Runs on top of the playing animation each frame; blends in/out smoothly and
  DISENGAGES when the target leaves the `maxAngleDeg` comfort cone (75° —
  no owl necks).
- `weight` (0.85) is how far the head commits; `forwardAxis` defaults to the
  mixamo head convention (+z).
- Also good for chests (`bone: "Spine2"`, small weight) and turrets.
- **`node.engaged`** (0..1) is how far it is committed RIGHT NOW — the whole
  observable effect of this node, and until now nothing could read it. It turns
  a rendered bone, so it stays 0 in a headless run (no GLB, no skeleton): check
  it in the browser, where `attachedBone` tells you the bone was found and
  `engaged` tells you the head is following.

## Facing a direction in 3D — the +Z-FORWARD rule (read before turning ANY skin)

This trips people up REPEATEDLY, so here is the one rule. agent8's `base-model`
(`https://agent8-games.verse8.io/assets/3d/characters/realistic%20style/base-model.glb`
— note the escaped space; it is the reference rig these docs measure against) and
any model you give `skinYawOffset: 0` are **+Z-FORWARD**: its face looks down +Z at
rotation 0. To turn it to face a world heading `(dx, dz)`:

```ts
const dx = targetX - selfX; // heading you want to face: movement delta, OR (target - self)
const dz = targetZ - selfZ; // 3D ground plane: x and z (index 0 and 2), NEVER y
skin.rotation = [skin.rotation[0], Math.atan2(dx, dz) * RAD2DEG, skin.rotation[2]];
// RAD2DEG = 180/Math.PI ; rotations are DEGREES, Euler XYZ
```

That is the EXACT formula `CharacterController3D` uses for move-facing
(`atan2(mx, mz) + skinYawOffset`). Do NOT add 180° to it.

**Same formula to LOOK AT / AIM at any target** (turret, NPC turning to face the player,
an enemy that idles facing you): use the direction from self TO the target —
`dx = target.position[0] - self.position[0]`, `dz = target.position[2] - self.position[2]`,
then `rotation[1] = atan2(dx, dz)*RAD2DEG`. Movement-facing is just this with the
target being "where I'm walking." There is ONE facing formula for anything with a
SKIN; only the direction differs.

**NOT a camera.** A `Camera3D` looks down its own local **−Z**, and — more to the
point — it has an UP that a rod does not. This formula aims a camera perfectly and
rolls it: over 72 headings, dot 1.000 in all 72 and **upside down in 36**. No
two-angle recipe can fix it (Euler XYZ makes `[pitch, yaw, 0]` = Rx·Ry, while a
level camera is Ry·Rx, so its third angle is non-zero). Use
`Camera3D.lookAt: "%Target"` — a node path, re-aimed every frame with world up.
See `incanto-building-3d-games.md`.

- **The 180° is NOT a facing formula.** It is only the AT-REST MOUNT — the character
  starts turned away from the behind-the-shoulder camera until it first moves. Adding
  180° to *move-facing* makes the character run BACKWARDS (it shows you its back while
  charging at you). That is the #1 recurring bug when driving a custom enemy/NPC skin.
- **Don't hand-mix conventions.** `Shoot.faceAim` looks like it omits the 180 because
  its input is a camera *look* vector that already encodes it — a different context.
  For a MOVEMENT/heading vector, the answer is plain `atan2(dx, dz)`, full stop.
- **Model not +Z-forward?** Don't guess a 90/180 — set `skinYawOffset` (controller) or
  add the same constant to your `atan2`, then verify.
- **ALWAYS verify facing — never ship it on reasoning alone.** In the browser, the
  decisive check is a dot product: the skin object's world **+Z basis** (matrixWorld
  columns `[8],[10]` = its front in x,z) dotted with the unit direction it should face
  must be ≈ **+1** (−1 means it's backwards). Or just look: a charging enemy must show
  its FACE, not its back. (Headless, no three.js: with the yaw you set, the model's
  forward is `(sin(yaw), cos(yaw))`; dot it with the desired unit dir — `< 0` = wrong way.)

**NON-player models (enemies/NPCs) face by the SAME rule — but do not hand-write
it if a built-in is moving them.** `Patrol` and `Chase` take `facePath` (+
`turnSpeed`), which applies exactly the formula above to the skin you name:

```jsonc
{ "name": "Hunt", "type": "Node3D",
  "script": { "name": "Chase",
    "props": { "target": "/root/Player", "moveParent": true, "facePath": "../Skin" } } }
```

Only a model driven by YOUR OWN behavior needs the formula written out.
`CharacterController3D` automates this for the player; a custom-driven model does it
itself — face with the formula above, and drive `skin.animation` from your AI state (see
incanto-gameplay-behaviors.md "Driving a model's animation from a custom AI"). Ground a
moving non-controller body yourself — it won't auto-fall (see incanto-3d-models.md
"Grounding a model").

Mouse look is GATED: deltas only accumulate while pointer-locked or a
button is held — a free-roaming cursor never spins the camera. Vertical look is
STANDARD (non-inverted): mouse UP looks up (in free view the camera swings down so
it looks up at the character against the sky), mouse DOWN looks down.

### Check it instead of reasoning about it

`bunx incanto-feel <scene.json>` now ends with the answer:

```
facing: /Game/Player/Skin faces its travel (dot 1.00)
facing: /Game/Husk/Skin RUNS BACKWARDS (dot -1.00) — the skin points away from
        the direction of travel.
```

It drives the character, measures where it actually went, and dots that with the
skin's forward axis. **+1 is right, −1 is backwards**, and a declared
`skinYawOffset` is subtracted first — so a model whose art faces another way is
correctly configured, not a false alarm.

This cannot be an `auditScene` warning: a scene file has no velocity, and the bug
is a behavior writing a heading at runtime. `facingReport()` from `incanto/test`
is the same check for your own tests.

Like the feel numbers above it, this presses your title screen's START before
driving the character — a paused world moves nowhere, and `did not move —
nothing to compare` was the answer it gave about three shipped games whose only
fault was that their menu was up. When nothing on screen starts the game it says
THAT instead.

## Facing the mouse in a top-down view (twin-stick)

In `quarter` view the mouse does not look, and the controller turns the skin
toward the MOVE direction — a twin-stick game wants it toward the CURSOR. Set
`skinPath: ""` so the controller leaves the skin alone (then drive
`skin.animation` yourself from `movementStateChanged`), and each frame put the
cursor on the ground and face it — `engine.toWorld(sx, sy)` is the renderer's
ray to the y=0 plane, `null` headless, so a harness sets the aim directly:

```ts
const at = this.input.pointerPosition();
const w = at && this.engine.toWorld?.(at.x, at.y);        // [x, y, z] | null
const [tx, tz] = w ? [w[0], w[2]] : this.aim ?? [NaN, NaN];
const dx = tx - player.position[0], dz = tz - player.position[2];
if (Math.hypot(dx, dz) > 0.2) skin.rotation = [0, Math.atan2(dx, dz) * RAD2DEG, 0];
```

Measured on `examples/survivor-3d`: a cursor 10 m east is yaw 90°, and a bolt
given `direction: [dx, 0, dz]` flies where the skin looks. Boot with
`pointer: { lockOnClick: false }` — a locked cursor is no aim at all.

## Lock-on (`lockOnAction`) and a dodge

Lock-on used to be a one-line recipe — the camera yaw toward the enemy, every
frame — and the half it never had was the one that matters: the SKIN kept
facing the move direction, so a locked player circling a brute showed it a
shoulder, and a sword hitbox hung off the skin's yaw swung sideways. The
controller owns it now:

| prop | default | what it does |
|---|---|---|
| `lockOnAction` | `""` | the button that toggles the lock (`""` = no lock-on) |
| `lockGroup` | `"enemy"` | who can be locked: the nearest live, visible member within… |
| `lockRange` | `12` | …this many metres; the lock lets go by itself at 1.5 × the range, or when the target dies or leaves the tree |

```json
{ "name": "Controller", "type": "CharacterController3D",
  "props": { "view": "free", "lockOnAction": "lockOn", "lockRange": 14 } }
```

**Locked means the character FACES the target while its feet go anywhere.**
The camera yaw follows player → target every frame, so the stick — which is
camera-relative — strafes round it; the skin turns to the target (at
`turnSpeed`) even standing still; `state` stays `walk`/`run` while circling.
Anything you hang off the skin's yaw — `examples/melee-3d`'s `Edge` hitbox at
`sin/cos(skin yaw)` — swings at the target. Methods `lock(node)`, `unlock()`,
`toggleLock()`; readback `lockTarget`; signal `lockChanged(target | null)`.
`examples/boss-3d` locks onto the colossus with `lockGroup: "boss"`,
`lockRange: 40`.

`incanto-feel` knows: a controller with a `lockTarget` is reported as
`LOCKED ON` rather than measured against its travel — a strafe is the
intended reading, not a defect.

A dodge is a dash plus i-frames that start BEFORE the hit. The dash is the
controller's (`dashSpeed` — see below); the i-frames are `Health.protect`,
not `invulnerableFor` (which opens only after one), hung on the `dashed`
signal:

```json
{ "name": "Ctl", "type": "CharacterController3D",
  "props": { "dashSpeed": 9, "dashSeconds": 0.35, "dashCooldown": 0.5 } }
```
```ts
controller.on('dashed', () => (player.behavior as Health).protect(0.35));
```

## A dash / dodge roll (`dashSpeed`)

Two examples carried the same twelve lines — a timer, a direction, and
`body.linearVelocity = dir × 9` written EVERY frame because the controller
brakes toward its own pace and a single impulse dies — while
`CharacterController2D` had `dashSpeed` since 0.33. Now the 3D controller
has it too:

| prop | default | what it does |
|---|---|---|
| `dashSpeed` | `0` | m/s for the roll; `0` = no dash |
| `dashSeconds` | `0.25` | how long the velocity is held |
| `dashCooldown` | `0` | seconds after a roll ends before the next can start |
| `dashAction` | `dash` | pressed = roll (tolerated when missing, like `sprint`) |
| `dashBackstep` | `false` | with no stick, roll AWAY from where the skin faces — the Souls-like backstep a lock-on game wants |

- The roll goes where the stick points (camera-relative), or with no stick
  where the skin faces — or away from it with `dashBackstep`, which is what
  a neutral roll means while locked on to something.
- `state` reads `dash` for the roll — map a clip to it — and `dashed` fires
  once per roll. Not while crouched, on a ladder or swimming.
- The velocity is written last, every step of the roll, over whatever the
  step's impulses did; gravity still runs, so a roll off a ledge falls.
- `examples/melee-3d` rolls with it and keeps its i-frames on `dashed`.

## First-person weapon viewmodel + the tracer-from-the-muzzle rule

A weapon VIEWMODEL is just a `MeshInstance3D`/group child of the **root** `Camera`
(`/root/Camera/Gun`) — it rides the view for free. Pose it each frame in your Shoot
behavior and add a recoil impulse on fire (kick back +z, flip up −x, ease back); pulse a
barrel `OmniLight` for the muzzle flash. Size it for the near plane: a 0.46 m body at
0.5 m with fov 80 fills half the screen (it's viewed broadside), so keep viewmodel meshes
small (~0.3 m) and tucked lower-right.

**The tracer must visibly fly FROM the muzzle TO the target.** This recurred many times;
there are TWO independent things to get right — the START POINT and the ROD ORIENTATION:

**(1) Start at a real muzzle NODE.** Put a tiny `Node3D` (`Muzzle`) at the front-centre
of the barrel mesh (the barrel cylinder's tip), as a child of the gun so it rides the
view + recoil. Read ITS world position each shot — that IS where the muzzle is on screen,
at any aim angle:

```ts
// muzzle = /root/Camera/Gun/Muzzle ; o = muzzle._ensureObject3D()
o.updateWorldMatrix(true, false);
const e = o.matrixWorld.elements;
const from = [e[12], e[13], e[14]];               // tracer ORIGIN = the barrel tip
```

Never reconstruct the muzzle from `eye + camera-basis × offset` (it drifts from the
rig-driven camera, and the camera `right` basis is easy to invert — correct is
`[-fwd.z, 0, fwd.x]`). The node's matrix is the one source of truth.

**(2) `end` is the TARGET, and orient the rod with the CORRECT Euler.** Draw the tracer
from `from` to the hit point (enemy torso, or `eye + fwd*range` if you missed) — a line
muzzle→target converges on the crosshair. A streak parallel to camera-forward (offset
from the muzzle) never converges and looks like it fires off to the side.

The rod is a +Z-stretched box; orienting it is the subtle trap. `Node3D` rotation is Euler
order **XYZ**, whose +Z basis column is `(sin ry, −sin rx·cos ry, cos rx·cos ry)`. So to
aim the rod's +Z along unit `(Dx,Dy,Dz)`:

```ts
const ry = Math.atan2(Dx, Math.hypot(Dy, Dz));    // NOT atan2(dx, hypot(dx,dz))
const rx = Math.atan2(-Dy, Dz);
t.rotation = [rx*RAD2DEG, ry*RAD2DEG, 0];
```

(This is a ROD: a stretched box has no up, so two angles are enough for it and
`rodAxis · (target − muzzle) ≈ 1` is a complete check. A camera is not a rod —
that same check scores 1.000 on an upside-down one. See the Camera3D note above.)

The "obvious" `[-atan2(dy, hypot(dx,dz)), atan2(dx,dz), 0]` is only correct for a LEVEL
shot — for XYZ order the yaw denominator must be `hypot(Dy,Dz)`, not the ground `hypot(dx,
dz)`. With the wrong form the rod skews off-axis when you aim up/down, so its near end
drifts AWAY from the muzzle (the box is centred on the midpoint, so a tilt swings both
ends). THIS — not the start point — was the actual "fires from the centre" bug; moving
the origin around never fixed it.

**Verify by MEASUREMENT, not eyeballing.** In the browser, project the muzzle node's world
position and the rod's two endpoints to screen with the camera matrices, and assert: near
end ≈ muzzle (a few px), far end on the crosshair, and `rodAxis · (target−muzzle) ≈ 1`.
Do it at a steep look-DOWN angle (a level shot hides the skew because gun and centre
overlap). Pitch sign gotcha when scripting the aim: `fwd.y = −sin(pitch)`, so to aim DOWN
pitch is POSITIVE.

**Make it THIN and make it FADE.** A hitscan tracer lives in WORLD space, so a thick,
opaque, long-lived rod looks like a "wooden stick" left hanging beside you when you fire
while moving (the camera moves, the rod doesn't). Keep the cross-section tiny (~0.02 m), a
white/bright emissive (reads as light, not lumber), and FADE its opacity to 0 over a short
life (~45 ms): each frame set `tracer.material.opacity = start * remain / life` — `syncTree`
re-applies `material.opacity` every frame, so mutating the node's material prop just works.
A snappy fading flash never reads as a stationary plank.

**Best feel for a hitscan shot = a muzzle FLASH + a SHORT dash, not a full beam.** A rod
spanning the whole muzzle→target distance (often tens of metres) is the worst "stick"
offender. Instead: (1) pop a tiny `Particles3D` burst each shot, spawned slightly IN FRONT
of the muzzle (offset ~0.2 m along the aim dir so the gun mesh doesn't clip it) — `burst`
~14, `spreadDeg 360` (a 3D sphere; Particles3D sets `spreadZ`), `lifetime [0.05,0.16]`,
high `drag` so it's a tight pop, `blend:'add'`. Keep `depthTest` at its default `true` —
`depthTest:false` makes the flash glow THROUGH walls and characters; only a first-person
viewmodel (nothing between camera and gun) can justify it — that's the "shot bursting"
punch the player reads as firing; and (2) cap the tracer to
a SHORT segment (~2.5 m) anchored at the muzzle (`centre = from + dir*seg/2`, `size.z = seg`,
`min(fullDist, TRACER_LEN)`), not the full distance. The flash sells the shot; the impact is
sold by the hitmarker + a death burst at the target — the streak doesn't need to reach.
