---
title: Project architecture for a game
summary: Read BEFORE structuring any game bigger than one file — where code goes (aspect / system / plain struct / scene file), devices → input struct → simulation → events → listener systems, one boot, one config, and four checks that the shape holds.
area: core
globals: []
sources: [src/core/Aspect.ts, src/core/StateMachine.ts, src/core/time.ts, src/runtime/input.ts, src/scene/defineScene.ts]
---

# Project architecture for a game

How to lay out a game so that it keeps growing: where a piece of code goes, what is allowed to know
about what, and how to tell — before the project is large — whether the structure is holding. The
pieces are the reference's ([aspects](/docs/api/core/aspects.md), [systems](/docs/api/core/systems.md),
[state machines](/docs/api/core/state-machine.md), [scene files](/docs/api/3d/scene-files.md)); this page is how
they fit into ONE project. Enemies and bots: [Game AI with state machines](https://le.codes/docs/api/cookbook/game-ai-state-machines.md).

- decide where a piece of game code goes: aspect, system, plain struct or scene file
- structure a game so that a new feature is one new file
- separate input from the player so a bot or the network can drive it
- play sounds, effects and gamepad rumble without the player knowing about them
- keep the HUD out of the game logic
- keep every tunable number in one config file
- check that the project structure still holds

## When

- Starting any game that will not fit in one file — decide the shape before the first `setLoop`.
- A project where features keep getting added (by you or by an assistant) and each one touches more
  files than the last.
- You see the symptoms: a `game.ts` that imports everything and is imported by everything; a
  hand-written `tick()` that calls the parts in order; `audio.play(…)` inside the player; the same
  number in three files; a "pause" boolean threaded through function arguments.

Not this recipe: a UI app (screens, a router, forms) has a different shape; a one-file toy needs
none. For enemies and bots, read [Game AI with state machines](https://le.codes/docs/api/cookbook/game-ai-state-machines.md) next — it
is this shape applied to characters.

## The shape: where each piece of code goes

One rule decides where everything goes: **state flows DOWN as plain structs, facts flow UP as
events, and nobody in the middle knows who is on the other end.**

```
devices ─▶ Controls ─▶ PlayerInput ─▶ Player (simulation) ──emit──▶ Audio · Fx · Hud · Rumble · Telemetry
 (system)               (plain struct)   (aspects on the rig)        (systems that only LISTEN)
                                              ▲
scene files ─▶ placed aspects ──Aspect.all──▶ GameMode (system: builds the player, wires level objects,
 (data)         (Barrel, Spawn, Target)                 owns the rules — a state machine)
```

| File | Kind | The rule it keeps |
|---|---|---|
| `main.ts`, `range.ts`, … | entry | ONE line: `run(level, name)`. A level is an entry point, not a menu branch |
| `src/boot.ts` | function | the ONLY place that lists the systems; two phases: attach all → `setup()` → `wire()` → `start()` |
| `src/config.ts` | plain data | every tunable number, with its unit and the reason it has that value |
| `src/input.ts` | plain struct | the seam between devices and the simulation (what a bot or the network would fill instead) |
| `src/controls.ts` | `System` | devices → the struct. The only file that calls `Input.*` for gameplay |
| `src/player.ts` | `Aspect`s | simulation (`Player`, early / fixed phase) and presentation (`PlayerView`, late) as TWO aspects on one node |
| `src/level/*.ts` | `Aspect`s | things a level PLACES: spawn points, barrels, targets, use-points — found by `Aspect.all` |
| `src/*.scene.ts`, `*.level.ts` | `defineScene` data | what is where. No logic; the editor and the importer rewrite these files |
| `src/game.ts` | `System` | `GameMode`: builds the player into the level, subscribes level objects, owns the rules as a machine |
| `src/audio.ts`, `fx.ts`, `hud.ts`, `rumble.ts` | `System`s | LISTENERS and services: they react to events or to what the rules hand them, and change nothing in the game |
| `tests/*.flow.json` | scenarios | assert on `[evt] …` console lines, one flow per feature |

The four kinds, and how to choose (the first two are [systems.md](/docs/api/core/systems.md#what-belongs-in-a-system-vs-an-aspect)'s table):

| It is… | …when | Example |
|---|---|---|
| an **aspect** | the state belongs to ONE node | `Player`, `ExplosiveBarrel`, `RangeTarget` |
| a **system** | it is scene-wide: a service, a pool, a rule set, a listener | `Controls`, `Fx`, `GameMode`, `Audio` |
| a **plain struct** | it CROSSES a boundary — it must be fillable by someone else | `PlayerInput`, `HudState`, `Hit` |
| a **scene file** | it answers "what is where" | the level, a weapon's sockets, a prefab |

Three consequences worth stating, because each one is a habit from elsewhere:

- **The simulation never calls presentation.** `Player` does not play a sound, spawn a particle or
  touch the HUD — it `emit('shot', …)`s and whoever cares subscribes. The import graph shows it:
  the simulation files import no listener, the listeners import the simulation.
- **The HUD is written to, not read from.** `GameMode` fills a `HudState` struct once a frame
  (`static before = [Hud]`); `Hud` only renders it. The HUD never reaches into the player.
- **Order is declared, not called.** There is no loop that calls the parts. A class says
  `static after = [Controls]` and ticks in its own phase — `updateBefore` feeds the physics step,
  `updateFixed` is the simulation's clock, `update` is presentation.

## Steps

The skeleton below compiles and runs as one project (checked headless, 2026-09-30). Reference
project: `fps-demo` (not published — the steps are self-contained; a first-person shooter, ~38 k
lines; verified 2026-09-19 by adding a whole feature, gamepad rumble, as one new file — see
[the test of the shape](#the-test-of-the-shape-a-feature-is-a-file)).

### 1. The entry and the boot

```ts
// main.ts — the whole file
import { run } from './src/boot'
import yard from './src/yard.scene'
run(yard, 'yard')
```

```ts
// src/boot.ts
import { Audio } from './audio'
import { Controls } from './controls'
import { Fx } from './fx'
import { GameMode } from './game'
import { Hud } from './hud'
import { Rumble } from './rumble'

export const boot = async (level: SceneHandle<any>, name: string): Promise<void> => {
  const { scene } = await level.load()
  console.log(`[evt] level ${name}`)
  const s = scene
    .system(Controls).system(Fx).system(Hud)          // services first…
    .system(GameMode)                                  // …the rules…
    .system(Audio).system(Rumble)                      // …then the listeners
  await s.game.setup()      // builds the player and the pools INTO what the level placed
  s.audio.wire()            // listeners subscribe AFTER the things they listen to exist
  s.rumble.wire()
  s.game.start()            // opens the scene, starts the rules
}
export const run = (level: SceneHandle<any>, name: string): void => {
  boot(level, name).catch((e) => console.warn(`boot failed on ${name}`, e))
}
```

The list is the same for every level even where a system has nothing to do (a drill system that
finds no targets returns on its first line) — cheaper than a list per level. `level.load()` only
builds the scene; `GameMode.start()` opens it once everything is wired.

::: note Note
`asset()` is collected statically from the ENTRY's import graph, so a level per entry
(`lecodes desktop run --entry range.ts`) also means a build of one level does not ship the
others' models. Keep every `.ts` reachable from an entry — an unimported file is treated as an
entry candidate of its own.
:::

### 2. The input seam

```ts
// src/input.ts — plain data, no SDK in it
export type PlayerInput = {
  moveX: number, moveY: number, lookX: number, lookY: number    // continuous — polled
  trigger: boolean, aim: boolean                                 // held
  jump: boolean, reload: boolean                                 // EDGES — true for one read
}
export const NO_INPUT: Readonly<PlayerInput> = Object.freeze({
  moveX: 0, moveY: 0, lookX: 0, lookY: 0, trigger: false, aim: false, jump: false, reload: false,
})
```

```ts
// src/controls.ts
import { NO_INPUT, type PlayerInput } from './input'

export class Controls extends System<'controls'> {
  input: PlayerInput = NO_INPUT
  private edges = { jump: false, reload: false }
  private pending = { jump: false, reload: false }
  private onKey = (e: InputKeyEvent): void => {        // one-shots come from EVENTS, never an edge-poll
    if (e.repeat) return
    if (e.code === 'Space' || e.code === 'GamepadSouth') this.edges.jump = true
    if (e.code === 'KeyR' || e.code === 'GamepadWest') this.edges.reload = true
  }
  onAttach(): void { Input.on('keydown', this.onKey) }
  onDetach(): void { Input.off('keydown', this.onKey) }

  updateBefore(dt: number): void {                     // early phase: before the physics step
    const pad = Input.gamepad(0)
    this.input = {
      moveX: (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0) || pad.axis('leftX'),
      moveY: (Input.key('KeyW') ? 1 : 0) - (Input.key('KeyS') ? 1 : 0) || -pad.axis('leftY'),
      lookX: Input.mouse.locked ? Input.mouse.delta.x * 0.12 : 0,
      lookY: Input.mouse.locked ? Input.mouse.delta.y * 0.12 : 0,
      trigger: Input.key('MouseLeft') || Input.key('GamepadR2'),
      aim: Input.key('MouseRight') || Input.key('GamepadL2'),
      jump: this.edges.jump, reload: this.edges.reload,
    }
    if (this.edges.jump) this.pending.jump = true
    if (this.edges.reload) this.pending.reload = true
    this.edges = { jump: false, reload: false }
  }

  /** The SIMULATION's read: continuous state + every edge latched since the last call. */
  consume(): PlayerInput {
    const out = { ...this.input, ...this.pending }
    this.pending = { jump: false, reload: false }
    return out
  }
}
```

Everything below this line reads the struct and nothing else, so the same `Player` is driven by a
bot's brain or by the network without a change — and a test can write the struct directly.

### 3. A simulation aspect that tells the world

```ts
// src/player.ts
import { PLAYER } from './config'
import { Controls } from './controls'

export type Hit = { amount: number }                                      // damage taken, hp
export type HitReport = { kind: 'world' | 'miss', point: Vec3 | null }
export type PlayerEvents = {
  shot: (hit: HitReport, killed: boolean) => void
  action: (what: 'jump' | 'land' | 'reload' | 'dry', amount?: number) => void   // `land`: fall speed, m/s
  damaged: (hit: Hit) => void
}
export const evt = (...parts: Array<string | number>) => console.log('[evt] ' + parts.join(' '))

/** The simulation. Attached with `{ scene }` in the opts — a node aspect has no scene of its own. */
export class Player extends Aspect<'player', With<Node, CharacterController>, PlayerEvents> {
  static after = [Controls]
  hp = 100
  private controls!: Controls
  private cooldown = 0
  onAttach(): void { this.controls = this.scene.get(Controls)! }

  updateFixed(dt: number): void {                      // the simulation's clock: always 1/60 s
    const input = this.controls.consume()
    const body = this.node.controller
    body.move(input.moveX * PLAYER.speed, -input.moveY * PLAYER.speed)
    if (input.jump && body.grounded) { body.velocityY = PLAYER.jump; evt('jump'); this.emit('action', 'jump') }
    this.cooldown -= dt
    if (input.trigger && this.cooldown <= 0) {
      this.cooldown = PLAYER.fireInterval
      const hit = this.fire()
      evt('shot', hit.kind)
      this.emit('shot', hit, false)                    // no sound, no flash, no HUD here
    }
  }

  hurt(hit: Hit): void { this.hp -= hit.amount; this.emit('damaged', hit) }

  private fire(): HitReport {
    const ray = Physics.raycast(this.node.worldPosition, [0, 0, -1], PLAYER.range)
    return ray ? { kind: 'world', point: ray.point } : { kind: 'miss', point: null }
  }
}

/** The presentation — a second aspect on the same node, in the late phase. */
export class PlayerView extends Aspect<'playerView', Node> {
  update(): void { this.scene.camera.position = this.node.worldPosition.add([0, 0.7, 0]) }
}
```

Carry on the event what the listeners would otherwise recompute: `land` carries the fall speed,
a `Hit` carries its damage (in the reference project, also the momentum it throws a body with).
A rule lives in one place.

### 4. A thing the level places

```ts
// src/level/barrel.ts
import { BARREL } from '../config'

export class ExplosiveBarrel extends Aspect<'barrel', Node, { explode: (b: ExplosiveBarrel) => void }> {
  hp = BARREL.hp
  blastRadius = BARREL.radius
  hit(amount: number): void { this.hp -= amount; if (this.hp <= 0) this.emit('explode', this) }
}

/** Where the player starts — a marker the level places. */
export class PlayerSpawn extends Aspect<'spawn', Node> {
  yaw = 0                                              // degrees
}
```

```ts
// src/yard.scene.ts — data. The editor and importers own this file
import { ExplosiveBarrel, PlayerSpawn } from './level/barrel'

export default defineScene({
  nodes: {
    ground: { mesh: { kind: 'box', size: [40, 0.5, 40] }, position: [0, -0.25, 0], aspects: [use(Shape), use(Physics, { motion: 'static' })] },
    spawn:  { position: [2.5, 1, -9], aspects: [use(PlayerSpawn, { yaw: -112 })] },
    barrel: { model: asset('../assets/props/barrel.glb'), position: [4, 0, 2], aspects: [use(Shape), use(Physics, { motion: 'dynamic' }), use(ExplosiveBarrel)] },
  },
})
```

The aspects have no FX and no sound of their own. They are found by `Aspect.all(ExplosiveBarrel)`,
never through a registry the level has to fill in.

### 5. The game mode: build, wire, rule

```ts
// src/game.ts
import { PLAYER } from './config'
import { Fx } from './fx'
import { Hud } from './hud'
import { ExplosiveBarrel, PlayerSpawn } from './level/barrel'
import { Player, PlayerView, evt } from './player'

const Waves = defineStates<GameMode>({                 // the RULES are a table, not if-chains
  initial: 'range',
  on: { begin: 'warmup' },
  states: {
    range:  {},
    warmup: { after: [3, 'wave'] },
    wave:   { enter: (g) => g.spawnWave(), to: { break: (g) => g.alive === 0 } },
    break:  { after: [5, 'warmup'] },
  },
})

export class GameMode extends System<'game'> {
  static after = [PlayerView, Fx]
  static before = [Hud]
  player!: Player
  wave = 0
  alive = 0
  private fsm!: StateMachine<GameMode>

  onAttach(): void {                                   // systems have no constructor
    this.fsm = new StateMachine(Waves, this)
    this.fsm.on('change', (to, from) => evt('mode', from || '-', '>', to))
  }

  async setup(): Promise<void> {
    this.player = this.buildPlayer(Aspect.all(PlayerSpawn)[0])
    for (const b of Aspect.all(ExplosiveBarrel)) b.on('explode', (x) => this.onExplode(x))
    this.player.on('shot', (hit) => this.scene.get(Fx)!.impact(hit))
  }

  start(): void { this.scene.open(); this.fsm.send('begin') }

  update(dt: number): void {
    this.fsm.step(dt)
    const hud = this.scene.get(Hud)!
    hud.state.hp = this.player.hp                      // the HUD is WRITTEN to
    hud.state.wave = this.wave
  }

  spawnWave(): void { this.wave++; evt('wave', this.wave) }   // the reference project spawns its bots here

  private buildPlayer(spawn: PlayerSpawn): Player {
    const rig = new Node()
    rig.position = spawn.node.worldPosition
    const built = rig
      .aspect(Shape, { capsule: PLAYER.capsule }).aspect(CharacterController)
      .aspect(Player, { scene: this.scene })           // node aspects get the scene in the opts
      .aspect(PlayerView, { scene: this.scene })
    this.scene.add(built)
    return built.player
  }

  private onExplode(b: ExplosiveBarrel): void { evt('explode', b.blastRadius) }
}
```

`GameMode` is the one place allowed to know both sides. If it grows past a screenful of wiring,
what grew is a RULE SET — give it its own system (`TargetDrill`, `WaveDirector`) with `static before =
[GameMode]`, not a bigger `game.ts`.

### 6. A listener: sound, effects and rumble that react to events

```ts
// src/rumble.ts — the whole idea of the file
import { RUMBLE } from './config'
import { GameMode } from './game'
import { ExplosiveBarrel } from './level/barrel'
import { evt } from './player'

export class Rumble extends System<'rumble'> {
  static after = [GameMode]
  private game!: GameMode
  private cause = ''; private strong = 0; private weak = 0; private ms = 0   // this frame's loudest
  onAttach(): void { this.game = this.scene.get(GameMode)! }

  wire(): void {                                        // called by boot after game.setup()
    const player = this.game.player
    player.on('shot', () => this.pulse('shot', 0.5, 0.6, 60))
    player.on('damaged', (hit) => this.pulse('hit', 0.45 + hit.amount / 60, 0.25, 200))
    for (const b of Aspect.all(ExplosiveBarrel)) b.on('explode', () => this.pulse('blast', 1, 0.7, 600))
  }

  private pulse(cause: string, strong: number, weak: number, ms: number): void {   // causes MIX: the loudest wins
    if (strong * RUMBLE.gain <= this.strong) return
    this.cause = cause; this.strong = strong * RUMBLE.gain; this.weak = weak * RUMBLE.gain; this.ms = ms
  }

  update(): void {                                      // one command per frame, whatever happened in it
    if (this.strong <= 0) return
    const pad = Input.gamepad(0)
    const sent = pad.connected && pad.rumble(this.strong, this.weak, this.ms)
    evt('rumble', this.cause, this.strong.toFixed(2), this.weak.toFixed(2), this.ms, sent ? 'pad' : 'no-pad')
    this.strong = 0
  }
}
```

A listener's public surface is `wire()` and nothing else — no other file calls into it. The
services the rules call and the other listener, at their smallest:

```ts
// src/fx.ts — a service: the rules call it, it calls nobody
import type { HitReport } from './player'

export class Fx extends System<'fx'> {
  private sparks!: Particles
  onAttach(): void {
    this.sparks = new Particles({ blend: 'add', rate: 0, lifetime: 0.3, size: 0.05 })
    this.scene.add(this.sparks)
  }
  impact(hit: HitReport): void {
    if (!hit.point) return
    this.sparks.position = hit.point
    this.sparks.spawn(12)
  }
}

// src/hud.ts — renders a struct it is given; reaches into nothing
export type HudState = { hp: number, wave: number }

export class Hud extends System<'hud'> {
  state: HudState = { hp: 0, wave: 0 }                 // written by GameMode
  private label = UIText('').style({ color: '#ffffff', fontSize: 18 })
  onAttach(): void { this.scene.widgets.append(UIWidget(this.label).style({ left: 16, top: 16 })) }
  update(): void {
    const text = `HP ${this.state.hp} · WAVE ${this.state.wave}`
    if (this.label.text !== text) this.label.text = text
  }
}

// src/audio.ts — a listener, like rumble.ts
import { GameMode } from './game'
import { ExplosiveBarrel } from './level/barrel'
import { evt } from './player'

export class Audio extends System<'audio'> {
  wire(): void {
    const player = this.scene.get(GameMode)!.player
    player.on('shot', () => evt('sound', 'shot'))     // the reference project plays a Sound here
    for (const b of Aspect.all(ExplosiveBarrel)) b.on('explode', () => evt('sound', 'blast'))
  }
}
```

### 7. `config.ts`: numbers with a reason

```ts
/** The SHOT is read off `cartridgeMomentum` — the quantity the recoil, the bullet holes and the
 *  ragdolls already run on — so a new weapon rumbles correctly the day it gets its impulse.
 *  `ms` of an automatic is capped at `autoDuty` × its fire interval: the gap between the pulses IS
 *  the feel of automatic fire; longer, and a burst fuses into one flat buzz. */
export const RUMBLE = {
  gain: 1,                                              // master, 0 = off
  shot: { light: 2, heavy: 14, curve: 0.6, ms: [70, 260] as const, autoDuty: 0.7 },   // N·s, ms
} as const

export const PLAYER = {
  speed: 4.5,                                           // m/s, walking
  jump: 5,                                              // m/s, take-off speed
  fireInterval: 0.1,                                    // s between shots
  range: 100,                                           // m, how far a shot reaches
  capsule: { halfHeight: 0.5, radius: 0.35 },           // m
} as const

export const BARREL = { hp: 30, radius: 4 }             // hit points; m, blast radius
```

One file for every tunable, each with its unit and WHY. Derive from a quantity the game already
has before authoring a new table per object: one number that means the same thing to the recoil,
the holes, the ragdolls and the pad cannot drift apart.

## The test of the shape: a feature is a file

Add something the game never had and count the files. Gamepad rumble in the reference project
(2026-09-19): shots scaled by calibre, hits, blasts, landings, reload ticks, death.

| | |
|---|---|
| `src/rumble.ts` | new — 127 lines, the whole feature |
| `src/config.ts` | one `RUMBLE` table appended |
| `src/boot.ts` | `.system(Rumble)` + `s.rumble.wire()` |
| `src/player.ts` | 2 lines — the `land` event grew an `amount` (the fall speed the listener needed) |
| the player's logic, weapons, game mode, HUD, audio, FX | untouched |

That last row is the architecture working. When a feature of this kind needs edits inside the
simulation, the usual cause is a fact the simulation knows and does not announce — add the EVENT
(or the missing argument on it), not a call.

## Verify

- **`[evt]` lines are the assertion channel.** Every gameplay event logs one line
  (`[evt] shot miss`, `[evt] mode warmup > wave`); a flow asserts with `expect.log`.
  One `tests/<feature>.flow.json` per feature:

  ```json
  { "name": "rumble", "settle": 2500, "steps": [
    { "frames": 30, "expect": { "log": "[evt] level yard" } },
    { "gamepad": "connect" },
    { "key": "GamepadR2", "ms": 80, "expect": { "log": "[evt] rumble shot" } },
    { "frames": 200, "expect": { "log": "[evt] mode warmup > wave" } }
  ] }
  ```

- **The delete test.** Remove one listener from `boot.ts` (its `.system()` and `wire()` lines): the
  project must still compile and play, minus that sense. If it does not, something depends on a
  listener — find the import.
- **The import test.** No simulation file imports a listener:
  `grep -ln "from './\(audio\|fx\|hud\|rumble\)'" src/*.ts src/*/*.ts` should name only `boot.ts`,
  the game-mode / rule systems and other listeners.
- **The struct test.** Drive the player without devices: write a `PlayerInput` by hand (or from a
  flow's `stick` / `key` steps). If that needs a mock of `Input`, the seam leaked.
- Headless (`lecodes test`) runs the logic and the logs; pixels of a 3D game need `--desktop`.
  Keep the run deterministic: the simulation in `updateFixed`, randomness from a seeded generator.

## Traps

- **Subscribing before the thing exists.** Systems attach before `GameMode.setup()` builds the
  player, so a listener that subscribes in `onAttach` subscribes to nothing, silently. Hence the
  two phases in `boot`: `onAttach` takes references to other SYSTEMS, `wire()` subscribes to
  OBJECTS.
- **A node aspect with no scene.** An aspect attached by code has no `this.scene` (it reads
  `undefined`): `buildPlayer` hands it over in the opts, `.aspect(Player, { scene: this.scene })`.
  Systems always have theirs.
- **A one-shot lost above 60 fps.** The simulation runs in `updateFixed`, and on a fast machine
  most frames contain no fixed step — an edge that lives for one frame (`input.jump`) is never
  seen. Latch edges until the simulation takes them (`consume()` in step 2); keep the per-frame
  `input` for per-frame readers.
- **A key that is both held and a one-shot.** "Q aims" and "Q swaps weapon" on one key swaps on
  every aim. A code is either polled (`Input.key`) or an event — never both.
- **A listener that owns an exclusive resource must MIX.** `pad.rumble()` replaces the running
  effect, so a blast in the middle of a burst cuts the burst short if each event just calls it.
  Collect causes and send the loudest once a frame (`pulse()` / `update()` in step 6). The same
  goes for a music ducker or a full-screen flash.
- **Effects that outlive a pause.** While `Time.paused` no updater runs (except the
  `updateWhilePaused` ones), so whatever a listener started keeps going. Make long effects
  self-expiring (re-issue a short command every frame) rather than remembering to stop them.
- **A registry the level must fill.** `static all: Barrel[] = []` plus a `register()` call is a
  list that goes stale on `node.destroy()`. `Aspect.all(Ctor)` is the registry.
- **A per-object table that should be a formula.** Four guns, four rumble rows, four recoil rows,
  four hole sizes — and the fifth gun gets three of them. Find the quantity they share.
- **A number without a unit.** `speed: 12` — m/s? degrees per second? per frame? The unit and the
  reason go next to the number in `config.ts`, or the next reader tunes the wrong thing.

## See also

- [Aspects](/docs/api/core/aspects.md) · [Systems](/docs/api/core/systems.md) · [Time](/docs/api/core/time.md) — the pieces
- [State machines](/docs/api/core/state-machine.md) — the rules (`GameMode`, weapons) as tables
- [Scene files](/docs/api/3d/scene-files.md) — levels and prefabs as data, `use()` to place aspects
- [Input](/docs/api/runtime/input.md) — polling vs events, gamepads, rumble
- [Game AI with state machines](https://le.codes/docs/api/cookbook/game-ai-state-machines.md) — this shape, applied to characters
