# First person: movement, the map, the camera, shooting and CPUs

The SDK's first-person kit is Frag Island's controller, made reusable: arena-shooter movement that
feels right (air strafing, bunny hops, stairs, jump pads, rocket jumps), collision and line of
sight against a voxel map, the camera and the gun in your hands, the mouse and keys read into one
intent, the maths of shooting, and CPUs that find their way round any map. Start from the template:

```sh
bunx --package https://cdn.voxelparty.io/sdk/voxelparty-sdk-latest.tgz vp init my-shooter --fps
```

It's a small arena deathmatch with every piece below wired together: a map, CPUs, hitscan
netcode, a HUD and sounds. Grow it into what you want: teams and rounds (`vp docs menus` for
`Setup`), bomb sites, more guns, a bigger map.

## Contents
1. The pieces
2. The map: `VoxelGrid`
3. Moving: `FpsBody`, `fpsStep`, `FpsTuning`
4. The view: `FpsCamera` and `readIntent`
5. Shooting: rays, hit boxes, spread, splash
6. Netcode
7. CPUs: `NavGrid` and `PathFollower`
8. Testing
9. Pitfalls

---

## 1. The pieces

| Piece | Where | What |
|---|---|---|
| `VoxelGrid` | `/core` | The solid grid: `fill`, `addVolume`, `boxHits`, `ray`, `sees`, `groundBelow` |
| `FpsBody`, `newFpsBody`, `fpsStep`, `turn`, `stickFor` | `/core` | A body and how it moves one frame |
| `FpsTuning`, `FPS_ARENA` | `/core` | How it moves (Frag Island's tuning, to copy and change) |
| `launch`, `push`, `JumpPad`, `onPad` | `/core` | Jump pads and launchers, knock-back and rocket jumps |
| `rayBox`, `boxDist`, `spreadDir`, `HIT_BOX`, `forwardOf` | `/core` | Shooting maths |
| `NavGrid`, `PathFollower`, `NavLink` | `/core` | CPUs' paths: A* over standing spots, walked like a player |
| `FpsCamera` | `@voxelparty/sdk` | The eyes and the gun: eased steps, landing dip, bob, recoil, shake, zoom |
| `readIntent`, `FpsIntent` | `@voxelparty/sdk` | WASD, `action` (Space), the mouse, 1–9 and the wheel, as one intent; a pad and a touch screen too |
| `blockIsSolid` | `@voxelparty/sdk` | For `addVolume`: blocks you bump into (not plants or water) |

Everything in `/core` runs headless, so rules, bots and netcode test under `bun test`.

**Conventions** (the same everywhere in the kit; mixing others in is the classic bug):
- A body's `(x, y, z)` is its **feet**. `yaw` 0 looks along **+z**, and turning left is **+**.
- Forward is `(sin yaw, cos yaw)`; right is `(−cos yaw, sin yaw)`. `forwardOf(yaw, pitch)` is the
  3D aim; `pitch` + looks up.
- Moving the mouse right turns right: yaw goes **down** (`readIntent` does it).
- The camera turns by `(pitch, yaw + π, 0, 'YXZ')` (`FpsCamera` does it).
- A stick is `{ fwd, side }` (forward +, right +); `input.move()` has forward as `−z`
  (`readIntent` converts). `stickFor(yaw, x, z)` turns a world direction into a stick (bots).

## 2. The map: `VoxelGrid`

Collision, shots and line of sight all ask one grid. Cells default to half a unit (stairs of
0.5, walls of 0.5), and hold a number: 0 is air, anything else solid, so you can keep your own
materials in them and mesh the visuals from the same grid.

```ts
import { VoxelGrid } from '@voxelparty/sdk/core';

const grid = new VoxelGrid({ size: [120, 32, 120], origin: [-30, -2, -30] });  // 60 × 16 × 60 units
grid.fill(-30, -2, -30, 30, 0, 30, FLOOR);   // world units, low corner to high; top of the floor at y 0
grid.fill(-5, 0, -5, 5, 3, 5, WALL);          // a 10 × 3 × 10 block
grid.fill(-1.5, 0, -5, 1.5, 2.5, 5, 0);       // 0 carves a tunnel through it
for (let k = 1; k <= 6; k++) grid.fill(8 - k, 0, -8, 9 - k, k * 0.5, -5, STAIRS);   // stairs: 1 unit per 0.5 step
```
- **One source of truth.** Build the grid, then draw from it (a `Volume` of the solid cells,
  `meshVolume(vol, { voxel: 0.5 })` at the grid's origin; Frag Island's `buildStructures`), or
  stamp what you draw into it: `grid.addVolume(island.top, [island.origin[0], island.y - 2, island.origin[1]], blockIsSolid)`
  puts an `Island`'s top slab in (floor top at `island.y`), leaving its plants out. Share the land
  shape function between the `Island` and your grid when you fill the floor yourself.
- `fill` covers the cells from the low corner's up to (not including) the high corner's; keep
  boxes on half units and it's exactly the box.
- `grid.ray(ox, oy, oz, dx, dy, dz, max, normal?)`: distance to the first solid cell (`max` if
  none), and the face's normal. `grid.sees(a…, b…)`: line of sight, stopped by `grid.opaque` cells (every value but 0 unless you
  clear one: `grid.opaque[GLASS] = 0` sees through a window you still bump into; `grid.solid[BUSH] = 0`
  walks through a bush that still hides you). `ray`'s last argument picks what stops it (`grid.opaque`
  for how far you can see, or a 256-byte table of your own). `grid.groundBelow(x, y, z)`:
  the floor under a point (`-Infinity`: nothing, you'd fall). Outside the grid is air.
- Size: a 150 × 32 × 100 grid is 480 KB and meshes in a moment. Big open maps: raise `cell` to 1.
- A map players build and break (walls to blow open, bridges, cover that gets shot away) is a
  `World`: a `VoxelGrid` of blocks with rules, synced edits and chunked drawing, and `fpsStep`
  and `NavGrid` run on it as they are (`vp docs world`).
- `grid.solid` says which cell values you bump into (every one but 0 by default): clear a
  material there for something drawn that you walk through (a bush, a curtain of vines).

## 3. Moving: `FpsBody`, `fpsStep`, `FpsTuning`

```ts
import { FPS_ARENA, fpsStep, launch, newFpsBody, onPad, push, turn } from '@voxelparty/sdk/core';

const body = newFpsBody(spawn.x, spawn.y, spawn.z, spawn.yaw);
// every frame, for a body you own:
turn(body, it.dyaw, it.dpitch);                 // pitch kept within ±1.5
const r = fpsStep(grid, body, it, dt);          // it: { fwd, side, jump } (an FpsIntent is one)
if (r.jumped) play(SOUNDS.jump);
if (r.landed > 8) play(SOUNDS.land);            // landing speed, units/s
for (const p of pads) if (onPad(body, p)) launch(body, p.to, p.arc);
// a rocket went off near you (you own the body):
push(body, kx, ky, kz);                         // lifts you off the ground if it's upwards
```
- `fpsStep` moves in slices so nothing tunnels through a wall, even at 40 units/s on a 0.1 s
  frame; it climbs `step`-high ledges by itself and glues you to stairs going down. The same
  inputs give the same result.
- `r.stepped` (how far it climbed this frame) and `r.landed` feed the camera (section 4).
- `launch(body, to, arc)` throws the body to `to` (its feet) along an arc `arc` above the higher
  end, and turns air control off for `launchGrace` so steering doesn't eat the arc: jump pads,
  launchers, a knock-up. `onPad(body, pad)`: standing on a `JumpPad` (`{ x, y, z, half, to, arc }`).
- `body.ground`, `body.vx/vy/vz` are yours to read (footsteps, a speed meter, fall damage).

**`FpsTuning`** is how a body moves; `FPS_ARENA` is Frag Island's (fast, floaty, strafe-jumping).
Copy it and change what you need, and pass the same tuning to `fpsStep`, `NavGrid` and `FpsCamera`:
```ts
// A slower, grounded feel (untested here: tune it by playing). No bunny hops, little air control.
const TACTICAL: FpsTuning = { ...FPS_ARENA, run: 6, accel: 8, airCap: 0.3, airAccel: 4, jump: 7, autoHop: false };
```
Fields: `radius`, `height`, `eye`, `step` (the body); `run`, `accel`, `friction`, `stopSpeed`
(ground); `airAccel`, `airCap` (air control; `airCap` caps what a strafe adds at once:
small is what makes strafe-jumping work); `jump`, `gravity` (a jump peaks at jump² / 2·gravity:
1.42 for the arena tuning); `maxSpeed`; `launchGrace`; `autoHop` (holding jump hops again on
landing, keeping your speed). `fpsStep(grid, b, it, dt, tuning, gravity?)` takes a gravity for a
low-gravity mode.

**Crouching** is opt-in (SDK 3.10): give the tuning a crouch and declare a `crouch` button.
```ts
const MOVE: FpsTuning = { ...FPS_ARENA, crouch: FPS_CROUCH };   // or your own { height, eye, run }
// defineGame({ buttons: { crouch: { keys: ['KeyC', 'ControlLeft', 'ControlRight'], pad: 'r3', label: 'CROUCH' } } })
```
`readIntent` then fills `it.crouch`, `fpsStep` ducks the body while it's held (a lower box that
fits under things, `run × crouch.run`) and stands it up once there's room over its head, and
`FpsCamera` eases the eyes down and up. `body.crouch` says whether it's down: shoot from
`eyeHeight(body, tuning)`, stream it (a flag bit) so others draw it lower and trace their shots
against a lower box (`bodyHeight(body, tuning)`), and set it on remote bodies from the stream.
`canStand(grid, body, tuning)` says whether there's room to stand. Bind Ctrl as well as C: players
reach for it, and a Ctrl the game ignores turns Ctrl+W into closing the tab.

## 4. The view: `FpsCamera` and `readIntent`

`game.json`: `"input": ["mouse", "pointerLock", "keyboard"]`; `index.ts`: `pointerLock: true` (the
platform locks the pointer on "Click to play" and on clicks after Escape).

```ts
import { FpsCamera, arenaStage, readIntent } from '@voxelparty/sdk';

this.view = arenaStage(ctx.engine, { fov: 95, tilt: 0, near: 0.04, pollen: false });
this.view.scene.add(this.view.camera);          // the held gun is the camera's child
this.fp = new FpsCamera(this.view.camera, { fov: settings.fov });
this.fp.hold(gunGroup);                          // in front of the eyes; null holds nothing

// every frame, first person:
const it = readIntent(ctx.input, { sens: settings.sens, invert: settings.invert, scale: this.fp.lookScale });
turn(body, it.dyaw, it.dpitch);
const r = fpsStep(grid, body, it, dt);
this.fp.zoom = it.alt && scoped ? 30 : null;    // a zoomed field of view, eased
this.fp.update(dt, body, r, it);                 // after moving: eyes, bob, sway, kick, fov
// events:
this.fp.recoil(0.12, 0.16);                      // on your shot: push the gun back, tip the view up
this.fp.shake(0.5);                              // a blast nearby (0..1, fades)
this.fp.lower = switching ? 1 : 0;               // the gun dips (weapon switch)
this.fp.reset();                                 // on respawn: nothing left to ease
```
- `update`'s `moved` is any `{ stepped, landed }`: `fpsStep`'s result, or those two numbers
  passed on from a headless core that moves your body.
- `readIntent` gives `{ fwd, side, jump, dyaw, dpitch, fire, alt, firePressed, altPressed, slot, wheel }`:
  `fire` / `alt` held, `firePressed` / `altPressed` gone down this frame (semi-automatic guns, a scope
  toggle); `fire` only while `input.aiming` (the pointer locked, or a pad or a touch screen: so the
  click that locks doesn't shoot), `jump` is `action` (Space, a pad's A, the touch JUMP button), `slot` 0–8 for keys 1–9, `wheel`
  −1/0/+1. While a menu holds the input it's all idle. `scale: fp.lookScale` makes a zoomed view
  turn slower for the same mouse move.
- **The gun can't poke into walls.** `hold()` puts it at `[0.105, −0.1, −0.17]` at scale 0.31 in
  camera space: inside the body's radius (0.35), so however close you stand to a wall, the gun is
  in front of it. Keep the near plane at about 0.04. Model the gun about 0.5 long along −z (the
  barrel pointing away), then `hold(gun)`; `hold(gun, { at, scale })` to place it yourself.
  Keep the whole gun within the radius from the eye.
- The camera must be in the scene (`scene.add(camera)`) or the held gun isn't drawn. Put the gun on
  its own layer (`mesh.layers.set(2)`, `camera.layers.enable(2)`) to keep it off minimaps.
- Not in first person (dead, spectating, the title card): place the camera yourself and call
  `fp.easeFov(dt)` instead of `update`. Hide your own avatar in first person. `vp check`'s
  screenshots come from these moments too: give the camera something to look at.
- Settings: offer field of view and invert in a `Menu` and keep them with `storage` (Gun Game's `O`
  menu). Mouse sensitivity is the site's (the gear), for every game at once: `look()` is already
  scaled by it, so don't add a slider of your own.

## 5. Shooting: rays, hit boxes, spread, splash

```ts
import { HIT_BOX, forwardOf, rayBox, spreadDir } from '@voxelparty/sdk/core';

const [ox, oy, oz] = [body.x, body.y + FPS_ARENA.eye, body.z];
const d = spreadDir(body.yaw, body.pitch, 0.02, rand);        // a unit direction in a 0.02 rad cone
const wall = grid.ray(ox, oy, oz, d[0], d[1], d[2], RANGE);  // the map stops it here
let hit: string | null = null, best = wall;
for (const p of others) {                                     // as you draw them
  const t = rayBox(ox, oy, oz, d[0], d[1], d[2], p.x, p.y, p.z, best);
  if (t >= 0 && t < best) [hit, best] = [p.pid, t];
}
```
- `rayBox` tests a standing player's box (`HIT_BOX`: 0.5 wide either side, 1.8 tall, a touch
  bigger than the body so shots that look right count). Pass a radius and height for crouching (`bodyHeight`) or
  a head box (`y + 1.45`, 0.25 tall) for headshots.
- `boxDist(x, y, z, p.x, p.y, p.z)` is the distance from a point to a body's box (0 inside): splash
  damage and knock-back fall off with it.
- `spreadDir` with a seeded rng is repeatable; shotguns call it once per pellet.
- Show the shot at once (tracer, muzzle flash, `fp.recoil`), sounds from the camera
  (`new Sfx({ you, ears: () => camera })`, `sfx.at3d(def, x, y, z)`).

## 6. Netcode

Read `vp docs netcode`, section 9 (**Shooters: the shooter decides what it hit**): everyone moves
their own body; the shooter traces its shots against what it sees and sends claims with
`PlayerSync.event`; the host checks them, keeps HP and scores, and broadcasts with `HostSync`
(with `keep`, so a host change or a reload carries the match on). The template is that recipe.

## 7. CPUs: `NavGrid` and `PathFollower`

Every seat must be playable by a CPU. The kit gives them the map; aiming and tactics are your
game's (the template's `bot.ts`: a reaction time, an aim error that settles as it tracks, and
leading shots for slow projectiles).

```ts
import { NavGrid, PathFollower, stickFor } from '@voxelparty/sdk/core';

const nav = new NavGrid(grid, { tuning: FPS_ARENA, links: pads.map((p) => ({ ...p, forced: true })) });
const follow = new PathFollower(nav);
// on a think (a few times a second), or when idle:
if (!follow.flying(body) && follow.idle) follow.goTo(body, goal.x, goal.y, goal.z);
// every frame:
const s = follow.steer(body);                        // a world direction, and whether to jump
const { fwd, side } = stickFor(body.yaw, s.x, s.z);
fpsStep(grid, body, { fwd, side, jump: s.jump }, dt);
```
- `NavGrid` makes a standing spot on every 1-unit column with headroom, and joins neighbours you
  can walk to (within `step`), jump up to (0.9 × the tuning's jump peak, since a perfect jump is
  rare) or drop down (up to 8). Both, where a spot sits under an overhang whose top is within a
  jump (under a shelf, a bed, a bridge); a climb needs headroom over where it starts. Build it
  once per map (a 150 × 100 map: a few thousand spots, well under a second).
- Walls and doorways that don't sit on whole units: `spacing: 0.5` on a grid of half-unit cells.
  A half-unit column is narrower than a body, so it needs free cells beside it (the tuning's
  `radius`, or pass `radius`): a 1-unit doorway anywhere is a way through, a half-unit slit isn't.
  Four times the spots, so give the follower a smaller `reach` (0.35).
- A big map (a forest, a city): `this.nav = await NavGrid.build(grid, o)` builds it a few ms at a
  time between frames, with no hitch; CPUs idle until it's there (`nav.ready`; before that
  `nearest` finds nothing). To drive it yourself, the same frames on every run (the host, tests):
  `const nav = NavGrid.start(grid, o)` and `nav.work(3)` in each update until it returns true.
- `links` are extra ways across: jump pads (`forced`: standing there always throws you, so it's
  the only way out), teleporters, ladders (`{ x, y, z, half, to, cost? }`).
- `PathFollower` walks a path like a player: it doesn't steer while launched (air control
  would brake the arc), drops its path in the air and plans again from where it lands, and jumps
  only at ledges higher than the graph's `step` (stairs it walks up). `goTo` only plans when the goal changes or the path ran
  out, so call it on every think; it returns false when there's no way there (asking again from
  the same spot is free: pick another goal). `reset()` when stuck (hop and plan again) or respawned.
- Hide the held gun with `fp.held.visible = false` (dead, scoped in).
- `nav.nearest(x, y, z)` / `nav.path(a, b)` / `nav.nodes` for your own choices (the nearest item,
  cover, a bomb site). `nearest(x, y, z, { above, below, radius })` keeps to a height: the ground
  by a tree, not the top of it (`{ above: 1.2, below: 1.2 }`), or at or below you (`{ above: 0 }`).
- Many CPUs on a big map: a search that finds no way looks at every spot it can reach (a whole
  forest, ~20 ms). `path(a, b, { budget: 8000, partial: true })`, or the same options on the
  follower (`new PathFollower(nav, { budget, partial })`), stops after that many spots and heads
  as close as it got (`nav.partial` says so); it plans again from there when the path runs out.
- `stickFor(yaw, x, z, { unit: true })` pushes the stick all the way whatever the direction's
  length (a short push under ground friction is a crawl).

## 8. Testing

- Movement and maps are headless: build the grid in a test, step a body with `fpsStep` and check
  it gets where it should (up the stairs, onto the ledge, not through the wall). Check every spawn
  and item is reachable: `nav.path(nav.nearest(spawn), nav.nearest(item)) !== null`.
- Matches: drive humans with your bot in a `FakeRoom` (the template's `rules.test.ts`), with
  people joining and leaving, the host leaving, and `room.reload(pid)` (a reloaded tab: same
  player, a fresh game). Check nobody is stuck dead or invisible and the scores agree.
- See your first-person view headless: `vp check`'s autopilot run and `bunx vp shot` put your own
  CPU in your seat (`input.autopilot`: `intent()` returns null, the core uses your bot), so the
  camera, gun and HUD are a player's. Scripts can also play by hand (`t.hold('up', 800)`,
  `t.look(300, 0)`, `t.mouse(0, 400)`): the pointer lock is granted as a browser would.
  `t.strip('jump', 8, 700)` shows a jump, recoil or a strafe in one picture (`vp docs testing`).
  Give the camera something to show when you're not in first person (spectators, bots-only rounds).

## 9. Pitfalls

- **Conventions.** A yaw of 0 looking along −z, or right as (cos yaw, −sin yaw), gives mirrored
  strafing or bots that run backwards. Use `forwardOf` and `stickFor`, and the camera's `yaw + π`.
- **The gun clips into walls** when it's bigger or further out than the body's radius. See
  section 4.
- **Tunnelling:** don't move bodies yourself (`body.x += vx * dt`); `fpsStep` does it in slices.
  Rockets and pellets: trace them with `grid.ray` from the last position to the next.
- **Feet, not eyes.** Rays start at `y + eye`; hit boxes stand on `y`.
- **Knock-back belongs to the body's owner:** a host that pushes someone else's body gets
  overwritten by their next state. Send the push to them (section 6).
- **Falling off the map:** check `body.y` against a floor (Frag Island: −14) and count it as a
  death; `groundBelow` = `-Infinity` means there's nothing under you.
- **Don't call `view.rig.update`** in first person: the rig would move the camera.
