---
name: incanto-your-first-game
description: The walkthrough — scaffold, author, verify, hand back. A complete game with a real win and lose in one sitting, the shape of the loop, and the handful of traps that cost every first-time author an hour. Read this FIRST if you have not shipped an Incanto game before.
---

# Your first Incanto game

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

This is the shortest honest path from nothing to a game with a win and a lose in
it. Everything below has been built and measured; the timings are real, and so
are the traps.

**You cannot see this game.** No browser, no GPU, no screenshot. That sounds like
the hard part and it is not — the engine ships the instruments, and §5 is how you
use them. What actually costs first-time authors an hour is §4, so read it even
if you skim.

---

## 1. Three minutes to a running game

```bash
bunx incanto@latest new --list                    # what the starters are
bunx incanto@latest new my-game --template tps-3d # or platformer-2d, star-survivor, …
cd my-game && bun install
bun run check && bun run typecheck && bun run verify
```

`@latest` matters: `bunx` caches the CLI, and a cached one scaffolds a project
pinned to ITS version, so you can quietly get an engine a release behind the
docs you are reading. Check with `grep incanto package.json` if anything below
does not match what you see.

The starter is a **complete game**, green on arrival, and it is meant to be
reshaped rather than read. Four agents building four different games all started
here; the two who hand-authored a scene from scratch still scaffolded first, for
the vite config and the verify harness.

Pick by shape, not by subject: `tps-3d` (third-person combat), `platformer-2d`,
`star-survivor` (top-down survivor), `village-quest-3d` (quest/NPC),
`beacon-isle-3d` (open world), `molehill-2d` (**played with the MOUSE** — no
character, no keyboard: the shape a match-3, a tower defense, a card game, a
point-and-click or an RTS starts from).

---

## 2. What you edit

```
src/game.scene.json   ← the game. Nodes, props, connections. This is most of it.
src/behaviors.ts      ← the parts JSON cannot express. Usually very little.
src/App.tsx           ← boot. The React component that owns the canvas. Rarely touched.
src/main.tsx          ← mounts <App /> into #root. Never touched.
verify.ts             ← the harness that proves it works. You WILL touch it.
```

Every project is **TypeScript + Vite + React**: `main.tsx` mounts the app,
`App.tsx` renders one `<canvas>` and boots the engine into it from an effect.
React is the shell, not the game — the game is still the JSON.

The claim that "all structure is JSON" is not marketing: one of the four builds
shipped a whole 3D world with **zero** gameplay TypeScript, and another needed
90 lines for one melee swing. Reach for a built-in behavior before you write a
class — `incanto-gameplay-behaviors.md` is the list, and it is long.

Read `incanto-scene-json-authoring.md` before you write JSON. It is the format,
the node paths, and the connection grammar, and everything else assumes it.

---

## 3. The spine: a game that can be WON and LOST

This is the part worth copying verbatim. It is four nodes and four connections,
and it is entirely JSON.

```jsonc
{ "name": "Score", "type": "Node3D",
  "script": { "name": "ScoreKeeper", "props": { "scoreToWin": 10, "lives": 3 } } },

{ "name": "Flow", "type": "Node3D", "script": { "name": "GameFlow" } },

{ "name": "HUD", "type": "HudLayer", "children": [
  { "name": "Banner", "type": "UiBanner" },
  { "name": "Hp", "type": "UiBar", "props": { "anchor": "topLeft", "label": "HP" } }
] }
```

```jsonc
"connections": [
  { "signal": "died",     "from": "Player", "to": "Score",  "handler": "loseLife" },
  { "signal": "lifeLost", "from": "Score",  "to": "Player", "handler": "reviveFull" },
  { "signal": "won",      "from": "Score",  "to": "Flow",   "handler": "win" },
  { "signal": "lost",     "from": "Score",  "to": "Flow",   "handler": "gameOver" }
]
```

**`lifeLost → reviveFull` is not optional.** A `Health` that has died stays dead:
`damage`, `heal` and regen all stop, so without that wire the player becomes a
walking corpse after the first death — full HP bar, immune to everything,
`died` never firing again, lives frozen, and the game quietly unlosable. It
looks fine from every check.

`GameFlow` freezes `engine.timeScale`, shows a sticky banner, and waits for the
`restart` action. **Give the Flow its own node** — a node holds one behavior and
your root probably already has the game's director script.

A pause menu is the same trick and also zero TypeScript: declare a `pause`
action, add a `UiPanel` named `PauseMenu` under the HUD, and Escape opens it.

### The trap that eats an hour here

`died` carries **no arguments**. `ScoreKeeper.addScore(n)` needs one. So the
obvious way to score a kill —

```jsonc
// WRONG — this is the mistake, not the fix
{ "signal": "died", "from": "Enemy", "to": "Score", "handler": "addScore" }
```

— sets the score to `NaN` on the first kill, and the win condition is
unreachable forever. The engine reports this the moment the wire fires; do not
ignore that line. Wire `dealtDamage` from the KILLER instead (it carries the
amount and the target), which is also clone-safe — a connection on a spawned
enemy never clones.

`won → GameFlow.win` is fine, by contrast, because `win(text = 'YOU WIN')`
defaults what it is not given. That is the difference, and it is the only one.

---

## 4. Making things hurt (read this one)

An enemy that touches you should drain you. Two props decide whether it does,
and both defaults are the harmless answer.

**Contact fires on ENTRY and EXIT, never per frame.** So `oncePerTarget: false`
means "hurt again on RE-entry" — an enemy that closes and stops deals one hit and
then nothing at all. `repeatEvery` is the knob that makes a resting overlap keep
hurting. Six seconds of unbroken contact at `amount: 10`:

```
oncePerTarget=false  repeatEvery=0     → hp 90   ← one hit, then nothing
oncePerTarget=false  repeatEvery=0.5   → hp 0
```

```jsonc
{ "name": "Hit", "type": "Area3D",
  "props": { "collider": { "shape": "sphere", "radius": 1.5 } },
  "script": { "name": "DamageOnContact",
    "props": { "amount": 12, "targetGroup": "player",
               "oncePerTarget": false, "repeatEvery": 0.5 } } }
```

Pair it with `Health.invulnerableFor` (the real per-frame guard) and keep
`repeatEvery` at or above it — under 0.6 s it just lands on i-frames.

**And they have to be able to REACH you.** A `CharacterBody3D` walks; a chaser
walks a straight line and cannot go around. `stepHeight` (default 0.35 m) is how
high a ledge it climbs — raise it for a world with stairs. You will not notice
this on the player, because `CharacterController3D` rides a hover spring and
floats over small ledges already.

The tell for both is one line of `incanto-playtest` output:

```
danger: nothing in this scene can hurt the player — no DamageOnContact, no hazard group
```

Read it. It is the truth.

---

## 5. Handing it back

Never hand a game back on reasoning. Run the ladder:

```bash
bunx incanto verify          # loads · plays · feels · agrees · draws · says
```

- **`loads`** — the scene is legal and its assets resolve. Warnings print under
  it with `!`; a scene that "renders black" says so here.
- **`plays`** — 8 seeded runs. `error`, `fell` and `stuck` are DEFECTS and fail
  the rung; `won`, `lost` and `unfinished` are gameplay. A random bot cannot
  finish a quest, and that is reported as unmeasured, not failed.
- **`feels`** — the sounds and effects the scene declares, against what actually
  fired. A game whose feedback is wired and never triggered plays perfectly and
  feels dead.
- **`draws` / `says`** — need a dev server with the page open. Unmeasured is not
  failed.

Then the two that answer questions the ladder cannot:

```bash
bunx incanto-playtest src/game.scene.json --behaviors src/behaviors.ts
bunx incanto-feel     src/game.scene.json --behaviors src/behaviors.ts
```

`playtest` gives you a difficulty read (`won 4/20, lost 9/20, 9.7 hits per run`)
that no amount of staring at JSON will. `feel` measures your controls by probing
them — and prints the **held** jump apex next to the tapped one, because with
`jumpCutMultiplier` those differ by 4× and the held number is the one your level
geometry has to match.

Full detail: `incanto-verifying-your-game.md`, `incanto-playtesting.md`,
`incanto-game-feel.md`.

### Write the harness, not just the checks

`verify.ts` in the starter drives the game with `runScript` and asserts what
happened. Extend it as you build; it is the only thing that will catch a
regression you cannot see.

**Assert the game's own physics, not your test's.** A harness that emits
`triggerEnter` by hand proves the handler answers an event and nothing about
whether the game produces one — that exact mistake hid a starter whose enemies
could not reach the player. Let the AI chase, and read the health.

---

## 6. The sticky note

Things that cost real time, in the order you will meet them.

| when | the trap |
| --- | --- |
| a game with LIVES | `died` stops a `Health` for good — wire `lifeLost → reviveFull` or you can spend only one. **Not `revive`**: `lifeLost` carries the life COUNT and `revive(hp?)` reads it as health, so you come back at 2 HP, then 1, then 0. |
| wiring a score | `died` carries nothing; `addScore(n)` wants one → `NaN`. Wire `dealtDamage` from the killer. |
| enemies feel harmless | `repeatEvery` on the contact hitbox, or one hit is all you get. |
| enemies never arrive | a chaser cannot climb — `stepHeight`, and it loses to a large downward velocity you apply yourself. |
| sizing a level | use the **held** jump apex, not the tapped one. |
| a melee weapon | a body with `Health` on the root and a `Hit` child presents TWO colliders; one swing can deal damage twice. Give the weapon a `targetGroup`. |
| a HUD you cannot see | `UiText.setText()` fills a slot; the `text` prop keeps the authored line. Use `describeCapture` to read what a widget PAINTS. |
| framing a scene | `describeFraming(scene)` returns the report object; `framingText(report)` renders it. |
| spawned enemies | connections on a template do NOT clone. Put the wire on something that is not cloned, or emit from a behavior. |
| a scene that swaps scenes | `incanto-playtest` drives one scene; a mid-run swap is out of its reach. Script it in `verify.ts` instead. |

---

## 7. Where to go next

| you want | read |
| --- | --- |
| the JSON format itself | `incanto-scene-json-authoring.md` |
| nodes, props, defaults | `incanto-node-reference.md` (generated — always current) |
| ready-made game logic | `incanto-gameplay-behaviors.md` |
| a 3D character that feels right | `incanto-3d-character.md` |
| terrain, water, trees, sky | `incanto-environment.md` |
| sound | `incanto-audio.md` |
| shake, flash, hit-stop, particles | `incanto-gameplay-behaviors.md` (`CameraShake`, `screenFlash`, `hitStop`, `Particles2D/3D`) |
| is the feel RIGHT? measure it | `incanto-game-feel.md` |
| HUD, menus, inventory | `incanto-hud.md` |
| proving it works | `incanto-verifying-your-game.md` |

And when a game "works" but feels wrong, the answer is almost always in
`incanto-feel` output you have not run yet.
