# FirstPersonPlayerController — Design

> Status: **Draft** — design doc. No implementation yet.
> Scope: one entity, one body, one camera. No skeleton, no avatar mesh, no sound, no input wiring.
> Companion files (planned): `FirstPersonPlayerController.js` (component), `FirstPersonPlayerControllerSystem.js` (system), serialization adapter, spec.

---

## 1. Goals

The controller has two intertwined goals. Every design decision below should be justified against at least one of them.

1. **Feel alive.** Convey weight, inertia, breath, exertion, and consequence. The player should sense a body even when the screen shows only the world.
2. **Be configurable.** Every parameter that contributes to "feel" must be exposed and tunable per-entity, with sensible defaults. Different characters (heavy soldier vs. light scout vs. drunk NPC) must be expressible by data alone.

### Non-goals (explicitly out of scope)

- Visual representation of the body (arms, weapon, third-person mesh, shadow).
- Input wiring (keyboard, gamepad, touch). The controller exposes an **intent surface**; binding is the job of `InputController` or a script.
- Collision and ground-truth physics resolution. This document assumes a separate physics or capsule-controller layer reports `grounded`, `groundNormal`, and resolves penetration. The controller writes desired velocity / impulses.
- Skeletal pose evaluation. The controller exports **pose channels** (numeric outputs) intended for a future skeleton system to consume.
- Sound. The controller exports **events** intended for a future audio system to consume.
- Networking / rollback. The component is designed to be deterministic per fixed update so this is possible later, but no replication is included.

---

## 2. Research notes

A condensed reading of public sources informed this design:

- **3Cs framework** (Camera, Character, Controls) — these three are interdependent; tuning one warps the others. Treat them as one design surface even though they live in separate components. Joel Nilsson (Massive, *Avatar*) and the gamedevpills writeup are the canonical references.
- **Mirror's Edge Catalyst camera** (DICE / Erik Söderholm) — the hardest problem is "not feeling like a box that glides around". Solution: animate the camera with body weight, not just track a capsule. Inform jump anticipation, land recovery, breathing, head tilt with the same data the body uses.
- **Star Citizen FPS stances & breathing** — breathing is an *active subsystem*, not decoration. Breath rate scales with stamina/exertion. Camera sway has 1s rest pauses at full stamina and shrinks to ~0.2s pauses when depleted. Heavy gear amplifies it.
- **Playtank "First-Person 3Cs: Camera"** — a layered transform hierarchy (Root → Head → Noise → Camera) lets effects compose without coupling. Anti-motion-sickness: keep horizon stable by default, make roll/shake opt-in.
- **NeoFPS additive transforms** — effects are an additive stack; translation and rotation compose independently so layer order doesn't matter for one but does for the other.
- **Spring-roll-call** (Daniel Holden) — critically damped springs with half-life parameterization beat lerp for camera smoothing. Under-damped springs are the right tool for landing recovery (they overshoot and ring slightly, which reads as compression).
- **Coyote time + jump buffer + variable jump height** — three small forgiveness tricks that single-handedly elevate a "stiff" controller. Coyote ~80–120 ms, buffer ~80–150 ms, fall gravity multiplier 1.5–2.5×.
- **Gait research** — a walk/run cycle is two footfalls per stride; cadence scales roughly with √(g·L) where L is leg length (the pendulum approximation). Heel-strike events happen at the bottom of the vertical bob.

---

## 3. Architectural overview

```
┌────────────────────────────────────────────────────────────────────┐
│  ENTITY                                                            │
│                                                                    │
│  ┌──────────────────────┐  ┌──────────────┐  ┌────────────────┐    │
│  │ FirstPersonPlayer-   │  │  Transform   │  │  Motion        │    │
│  │ Controller           │  │  (BODY)      │  │  (velocity)    │    │
│  │                      │  │              │  │                │    │
│  │ - config             │  │              │  │                │    │
│  │ - intent (RW)        │  └──────────────┘  └────────────────┘    │
│  │ - state (RO)         │           ▲                              │
│  │ - signals (RO)       │           │ written by System            │
│  │ - pose channels (RO) │           │                              │
│  └──────────────────────┘           │                              │
│            ▲                        │                              │
│            │              ┌─────────┴────────┐                     │
│            │              │ Camera entity    │                     │
│            │              │  (child or       │                     │
│            │              │   referenced)    │                     │
│            │              │ - Transform (EYE)│                     │
│            │              │ - Camera         │                     │
│            │              └──────────────────┘                     │
│            │                                                       │
│            └── intent set by: InputController binding / AI script  │
│                pose channels read by: future skeleton system       │
│                signals consumed by: future audio / FX systems      │
└────────────────────────────────────────────────────────────────────┘
```

### Two transforms, not one

The controller drives **two** transforms:

1. **Body transform** — the entity's own `Transform`. Position is the ground-projected foot/capsule origin. Yaw is the body's facing. Pitch and roll on the body are always zero. The body is what a physics system would integrate.
2. **Eye transform** — a separate transform (camera entity, attached via `TransformAttachment` or held as a referenced entity). Position is body + (height − crouchDip) + (bob + breath + landKick + shake). Rotation is body-yaw × eye-pitch × (lean roll). This is where all the "feel" lives.

Why split: body kinematics must remain physics-clean (no jitter into the collider); the camera must be free to lie, dip, and oscillate without affecting collision or AI line-of-sight.

### Five processing layers

Each frame the system walks these in order:

| Layer | Reads | Writes | Cadence |
|---|---|---|---|
| **L0 Intent** | (external) | `intent.*` | event-driven |
| **L1 Locomotion** | `intent`, `state.grounded` | `Motion.velocity`, `Transform` (body) | fixed |
| **L2 Pose** | `state`, `config` | `pose.*` channels | fixed |
| **L3 Camera** | `pose`, `state` | eye `Transform`, `Camera.fov` | variable (per-frame) |
| **L4 Events** | derived from `state` transitions | `signals.*` | fixed (where possible) |

L1, L2, L4 run in `fixedUpdate` so they're deterministic and not framerate-coupled. L3 runs in `update` because camera smoothing must be at render rate to avoid judder.

---

## 4. The component: `FirstPersonPlayerController`

Standard meep component contract: `typeName`, `toJSON/fromJSON`, `copy/clone/equals/hash`. Fields are grouped into five sub-objects so the public API stays legible.

```
class FirstPersonPlayerController {
    static typeName = "FirstPersonPlayerController"

    config   : FirstPersonConfig    // tunables — serialized
    intent   : FirstPersonIntent    // written by input/AI — transient
    state    : FirstPersonState     // read-only outputs — transient
    pose     : FirstPersonPose      // read-only outputs for skeleton — transient
    signals  : FirstPersonSignals   // Signal<...> instances — transient

    /** Reference to the eye/camera entity; created on link if absent. */
    eyeEntity : EntityReference
}
```

Only `config` is serialized. Everything else is rebuilt on `link()`.

### 4.1 `FirstPersonConfig` — the tunable surface

Grouped by feel-axis so designers can browse without scrolling past 80 floats.

```
config = {
    body: {
        height          : 1.80   // m, standing eye height
        crouchHeight    : 1.10   // m, crouched eye height
        radius          : 0.35   // m, capsule radius (for downstream physics)
        mass            : 80     // kg, drives bob amplitude & land impact
    },

    motion: {
        walkSpeed       : 3.0    // m/s
        sprintSpeed     : 6.0    // m/s
        crouchSpeed     : 1.4    // m/s
        airControl      : 0.25   // [0..1] how much intent influences air velocity
        groundAccel     : 35     // m/s² (~ "snap" of start-up)
        groundDecel     : 50     // m/s² when intent zero
        airAccel        : 8      // m/s²
        turnRateMax     : Inf    // rad/s — Inf = instantaneous (mouse-look default)
    },

    jump: {
        peakHeight          : 1.2    // m, the *desired* clean-jump apex
        timeToApex          : 0.40   // s — together with peakHeight → gravity & impulse
        coyoteTime          : 0.10   // s of grace after walking off a ledge
        bufferTime          : 0.12   // s a queued jump remains valid before landing
        fallGravityMult     : 2.0    // gravity ×N once vy < 0 or jump released early
        cutGravityMult      : 1.8    // gravity ×N if jump released before apex (variable height)
        anticipation: {              // squash before take-off
            duration       : 0.08   // s
            dipAmount      : 0.06   // m, eye dips this much
            curve          : "ease-out-quad"
        }
    },

    landing: {
        softThreshold       : 3.0    // m/s vertical — below = soft signal
        hardThreshold       : 7.5    // m/s vertical — above = hard signal
        recovery: {
            dipPerVy         : 0.012   // m of eye dip per m/s impact velocity
            dipMax           : 0.18    // m, clamp
            spring: {
                halfLife    : 0.12   // s — under-damped (zeta < 1) for the ring
                zeta        : 0.55
            }
        }
    },

    crouch: {
        transitionTime  : 0.18   // s to lerp eye height
        mode            : "hold" // "hold" | "toggle" — affects intent semantics
    },

    bob: {
        // Cadence is derived from speed: freq = stepFreqAtWalk * (speed / walkSpeed) ^ stepFreqExp
        stepFreqAtWalk   : 1.8    // Hz (≈ 1.8 footfalls/s, both feet)
        stepFreqExp      : 0.9    // sub-linear so sprint isn't frantic
        verticalAmpAtWalk: 0.045  // m
        lateralAmpAtWalk : 0.025  // m  — figure-8 (vert is 2x lateral freq)
        ampMassScale     : 0.005  // m per kg over reference (80kg)
        rollAtWalkDeg    : 0.6    // deg, sympathetic head roll
        // Pattern: vertical(t) = A_v * |sin(πft)|        (footfall = trough)
        //          lateral (t) = A_l * sin(2πft / 2)     (one full cycle = 2 steps)
        //          roll    (t) = A_r * sin(2πft / 2)     (in phase with lateral)
    },

    breath: {
        // Resting respiration — 14 breaths/min ≈ 0.23 Hz
        rateRestHz       : 0.23
        rateMaxHz        : 0.70   // panting at full exertion
        amplitudeRestM   : 0.004  // tiny — barely visible at rest
        amplitudeMaxM    : 0.022  // visible chest heave when exhausted
        pitchAmpRestDeg  : 0.10
        pitchAmpMaxDeg   : 0.55
        // Optional micro-noise on top of the sine, perlin@0.7Hz, ±20% of amp
        noiseAmount      : 0.20
    },

    exertion: {
        // Single scalar [0..1]. Inputs raise it, time decays it.
        sprintRiseRate   : 0.20   // /s while sprinting
        jumpRise         : 0.08   // per jump
        idleDecayRate    : 0.12   // /s while walking or still
        // Carry-over: after exertion peaks, breath rate decays slower than amplitude
        // (you keep breathing hard for a moment after stopping).
        rateDecayHalfLife: 4.0    // s
        ampDecayHalfLife : 2.0    // s
    },

    lean: {
        enabled          : true   // ACCESSIBILITY — make false to disable roll
        maxRollDeg       : 2.5    // deg per (lateral m/s² / 9.81)
        spring: { halfLife: 0.18, zeta: 1.0 }
    },

    fov: {
        base             : 70     // deg
        sprintAdd        : 6      // deg added when at full sprint speed
        crouchAdd        : -3
        smoothHalfLife   : 0.20   // s
    },

    look: {
        pitchMinDeg      : -85
        pitchMaxDeg      :  85
        invertY          : false
        // Sensitivity itself belongs to the input layer, not here.
    }
}
```

Every nested object is itself a normal object that round-trips through `toJSON`. Serialization adapter just walks the tree.

### 4.2 `FirstPersonIntent` — the control surface

This is what input bindings or AI scripts write to. The system reads it once per fixed update and zeroes the *consumable* bits (`look`).

```
intent = {
    move      : Vector2      // [-1..1, -1..1] — forward+/right+ in body-local (state)
    look      : Vector2      // accumulated delta yaw/pitch in radians (consumed each fixed step)
    jump      : boolean      // HOLD STATE — true while button held; edge detected inside system
    crouch    : boolean      // HOLD STATE — meaning depends on config.crouch.mode
    sprint    : boolean      // HOLD STATE — sprint applies only when moveY > 0 and grounded
}
```

Three input semantics live here:
- **Delta**: `look` — accumulated between fixed updates, zeroed on consume.
- **State (hold)**: `move`, `crouch`, `sprint`, `jump` — latched. Always reflects the current physical button/stick state.
- **Edge**: detected internally by the system from `jump` (and from `crouch` when `config.crouch.mode === "toggle"`). The intent surface never exposes "I just pressed it" — that's the system's bookkeeping. This is what guarantees a held jump button doesn't auto-repeat.

That makes all three common input topologies trivial to wire:
- Mouse + keyboard: mouse writes `look += delta`, WASD writes `move`, space-down/space-up writes `jump`.
- Gamepad: right stick writes `look = stick * sensitivity * dt`, left stick writes `move`, A-down/A-up writes `jump`.
- AI: scripts write the same fields directly, no input device required. Scripts that want a "single jump" set `jump = true` for one fixed step then `jump = false`.

### 4.3 `FirstPersonState` — derived, read-only

```
state = {
    // Grounding
    grounded           : boolean
    timeSinceGrounded  : number   // s — feeds coyote time
    groundNormal       : Vector3  // unit, set by physics layer; default Up
    surfaceTag         : string?  // optional, "grass" | "wood" | ... for FX

    // Locomotion
    speed              : number   // m/s, horizontal magnitude of Motion.velocity
    speedNormalized    : number   // [0..1] vs sprintSpeed — drives bob amp/freq
    moveMode           : enum     // Idle | Walk | Sprint | Crouch | Air

    // Vertical
    verticalSpeed      : number   // signed m/s
    airborneTime       : number   // s since leaving ground
    fallDistance       : number   // m fallen since last grounded

    // Jump bookkeeping
    jumpBufferRemaining: number   // s — set when intent.jump fires; consumed on land
    inJumpAnticipation : boolean  // true during the squash window
    isVariableJumpCut  : boolean  // true if button released before apex

    // Exertion & breath
    exertion           : number   // [0..1], smoothed
    breathPhase        : number   // [0..1) — fraction through current breath cycle
    breathRateHz       : number   // current actual rate
    breathAmplitudeM   : number   // current actual amplitude

    // Stride
    stridePhase        : number   // [0..1) — fraction through 2-step cycle
    stepCount          : integer  // monotonic; useful for "every Nth step" effects

    // Pose
    eyeHeight          : number   // current m above body origin (lerped during crouch)

    // Tilt
    leanRollRad        : number   // current eye roll
}
```

### 4.4 `FirstPersonPose` — channels for a skeleton system

These are the *outputs* a future humanoid rig should drive itself from. Designed so the same controller can later feed:
- a first-person arms rig (arms + weapon),
- a third-person full-body rig,
- a network-replicated remote rig.

```
pose = {
    // World-space body
    rootPosition       : Vector3
    rootYawRad         : number      // body facing

    // Head decoupled from body (the "look")
    headYawRad         : number      // = body yaw for most controllers
    headPitchRad       : number
    headRollRad        : number      // includes lean

    // Locomotion phase — for animation playback
    locomotionPhase    : number      // [0..1), synced to stridePhase
    locomotionSpeed    : number      // m/s, for blend tree
    locomotionStrafe   : number      // [-1..1], for strafe-blend space

    // Action state — for state machine driving
    actionState        : enum        // Grounded | Anticipating | Airborne | Landing
    crouchAmount       : number      // [0..1] interpolation between standing/crouched poses
    aimPitch           : number      // = headPitchRad, surfaced separately for IK
}
```

The pose channels are intentionally *redundant* with `state` — they're a stable contract for downstream systems, even if `state` internals churn.

### 4.5 `FirstPersonSignals` — event surface

```
signals = {
    onFootStep   : Signal<{ side: "L"|"R", speed: number, surfaceTag: string? }>
    onJumpStart  : Signal<{ peakHeight: number }>          // fires at impulse, after anticipation
    onJumpApex   : Signal<{}>
    onLeaveGround: Signal<{ reason: "jump"|"fall" }>
    onLand       : Signal<{ verticalSpeed: number, kind: "soft"|"hard" }>
    onCrouchEnter: Signal<{}>
    onCrouchExit : Signal<{}>
    onSprintStart: Signal<{}>
    onSprintStop : Signal<{}>
    onBreathIn   : Signal<{ amplitude: number, rateHz: number }>  // peak inhale
    onBreathOut  : Signal<{ amplitude: number, rateHz: number }>  // peak exhale
}
```

All signals fire from `fixedUpdate` so listeners see consistent state. `onFootStep` fires twice per stride at the troughs of vertical bob. The `side` flips each footstep.

---

## 5. The system: `FirstPersonPlayerControllerSystem`

```
class FirstPersonPlayerControllerSystem extends System {
    dependencies = [FirstPersonPlayerController, Transform, Motion]
    components_used = [
        spec(Transform, RW),
        spec(Motion,    RW),
        spec(Camera,    RW),    // on the eye entity
    ]

    fixedUpdate(dt) { /* L1, L2, L4 — deterministic */ }
    update(dt)      { /* L3 — camera composition at render rate */ }

    link(controller, transform, motion, entity)   { /* spawn eye entity if missing, build signals, snapshot defaults */ }
    unlink(controller, transform, motion, entity) { /* tear down eye entity, clear signals */ }
}
```

### 5.1 `fixedUpdate` — pseudocode

```
for each entity (controller, bodyTransform, motion):
    // ---- L1: Locomotion -------------------------------------------------
    consumeLookDelta(controller.intent.look) → apply to bodyYaw, eyePitch (clamped)
    desiredSpeed = pickSpeed(controller, state.moveMode)
    desiredVel   = bodyLocal(intent.move) * desiredSpeed
    accel        = state.grounded ? config.motion.groundAccel : config.motion.airAccel
    motion.velocity.xz  = stepTowards(motion.velocity.xz, desiredVel, accel * dt)

    // Jump
    if intent.jump consumed:
        state.jumpBufferRemaining = config.jump.bufferTime
    state.jumpBufferRemaining -= dt

    canJump = (state.grounded || state.timeSinceGrounded < config.jump.coyoteTime)
              && state.jumpBufferRemaining > 0
    if canJump and not state.inJumpAnticipation:
        beginJumpAnticipation()    // schedules impulse after anticipation.duration
        signals.onJumpStart.send(...)

    integrateAnticipationAndImpulse(dt)
    applyGravity(dt, with fallGravityMult / cutGravityMult)
    integrate(bodyTransform.position += motion.velocity * dt)

    // Ground detection feedback (set externally by physics layer or assumed flat)
    updateGroundedFromExternalContact()

    // ---- L2: Pose -------------------------------------------------------
    updateExertion(dt)
    updateBreath(dt)            // advances breathPhase, computes amp/rate
    updateStride(dt)            // advances stridePhase when grounded & moving
    updateCrouch(dt)            // lerps eyeHeight
    updateLeanTarget(dt)        // computes target roll from lateral accel; springed in L3

    // ---- L4: Events -----------------------------------------------------
    if stridePhase wrapped past 0 or 0.5:
        signals.onFootStep.send({ side: nextSide(), ... })
    detectGroundedEdges()       // sends onLeaveGround / onLand
    detectJumpApex()
    detectBreathPeaks()         // sends onBreathIn / onBreathOut
    detectCrouchEdges()
    detectSprintEdges()

    // Publish pose channels
    writePoseChannels()
```

### 5.2 `update` (variable rate) — camera composition

```
for each entity:
    eyeT = eye transform
    body = body transform

    // Base eye position (body local)
    base = (0, state.eyeHeight, 0)

    // Additive offsets — composed in the body's local space, then world-transformed
    bobOffset    = computeBob(state.stridePhase, state.speedNormalized, config)
    breathOffset = computeBreath(state.breathPhase, state.breathAmplitudeM, config)
    landOffset   = landingSpring.evaluate(dt)        // under-damped, decays to 0
    anticipOffset= state.inJumpAnticipation ? anticipationCurve.eval() : 0
    shakeOffset  = externalShakeStack.evaluate(dt)   // future: gunfire, explosions

    localEyePos  = base + (bobOffset + breathOffset + landOffset + anticipOffset + shakeOffset)
    eyeT.position = body.position + bodyRotation * localEyePos

    // Rotation: yaw from body, pitch from look, roll from lean + bob
    rollTotal = leanSpring.evaluate(dt) + bobRoll(state.stridePhase, config)
    eyeT.rotation = quatYaw(body.yaw) * quatPitch(state.eyePitch) * quatRoll(rollTotal)

    // FOV
    fovTarget = config.fov.base
                + lerp(0, config.fov.sprintAdd, state.speedNormalized * sprintness)
                + (state.moveMode == Crouch ? config.fov.crouchAdd : 0)
    camera.fov = springTowards(camera.fov, fovTarget, config.fov.smoothHalfLife, dt)
```

### 5.3 Key formulas

**Jump from peak height & time-to-apex** (lets designers think in m and seconds, not Newtons):
```
g           = 2 * peakHeight / (timeToApex²)
initialVy   = 2 * peakHeight / timeToApex
```
On variable-height cut: switch gravity to `g * cutGravityMult` once button released *and* `vy > 0`.
On descent: switch gravity to `g * fallGravityMult` once `vy <= 0`.
This matches the classic Sonic/Mario "fast fall" feel.

**Stride frequency from speed**:
```
f = config.bob.stepFreqAtWalk
    * (max(speed, ε) / config.motion.walkSpeed) ^ config.bob.stepFreqExp
```
At rest `f → 0` and bob amplitude clamps to 0, so the player stops bobbing when they stop.

**Bob shape**:

`stridePhase ∈ [0, 1)` wraps once per stride (= 2 footsteps).

Lateral and roll alternate ONCE per stride (head shifts right during right-foot midstance, left during left-foot midstance):
```
phase    = stridePhase * 2π                       // one full cycle per stride (= 2 steps)
lateral  =  A_l * sin(phase) * intensity          // 1 full L↔R cycle per stride
roll     =  A_r * sin(phase) * intensity          // in phase with lateral
```

Vertical is modeled as an **impulse-driven under-damped spring** rather than a sinusoid — the smooth `|sin|` curve we initially specified didn't match real gait dynamics (head is *highest* at midstance, *dips* at footfall under loading response, then is *pushed back up* by the standing leg). At each footfall event:
```
verticalImpactSpring.velocity -= A_v * impactKickMultiplier * intensity
```
Then each fixed step the spring damps toward zero with `halfLife ≈ 0.14 s`, `ζ ≈ 0.5`:
```
verticalOffset = verticalImpactSpring.value     // negative during impact dip,
                                                // positive during overshoot/push
```

The under-damped response produces the four-phase curve:
1. **Impact** — sharp dip below neutral as the foot arrests the head's fall
2. **Recovery** — value rises back through neutral
3. **Push** — overshoot above neutral, modeling energy injection from the standing leg
4. **Settle** — back toward neutral until the next footfall kicks it again

At fast cadence (sprint) the spring barely settles between footfalls and the head oscillates continuously. At slow cadence (back-pedal) each impact is more punctuated, with visible settling between steps. This emerges from the dynamics — no separate handling needed.

**Intensity envelope.** The `intensity` factor used by the lateral/roll/kick amplitudes is itself a critically damped spring around `max(speedNormalized, backwardness)` (or zero when airborne), with `halfLife ≈ 0.22 s`. This is what prevents the "whiplash" of stopping motion — bob amplitudes fade smoothly to zero instead of being cut off when speed drops below the stride threshold.

> An even earlier draft of this doc used `sin(phase / 2)` for lateral and roll, intending a "half-rate" oscillation. That formula actually produces only the first *half* of a sine wave per stridePhase cycle (range [0, π) of sin → all non-negative), which manifested as a one-sided sustained offset for the duration of one stride. The current `sin(phase)` formulation alternates correctly.

**Footstep detection**: emit `onFootStep` when `stridePhase` crosses `0` (right foot) or `0.5` (left foot), but only if `state.grounded && speed > minStepSpeed`. The boundary check uses the *previous* phase so it fires exactly once even at huge dt.

**Breath cycle**:
```
breathRateHz = lerp(rateRest, rateMax, exertion) decaying with rateDecayHalfLife
breathAmpM   = lerp(ampRest,  ampMax,  exertion) decaying with ampDecayHalfLife
breathPhase += breathRateHz * dt; wrap to [0,1)

verticalBreath = -breathAmpM * sin(2π * breathPhase) * (1 + noiseAmount * perlin(t*0.7))
pitchBreath    =  breathPitchAmp * cos(2π * breathPhase)   // gentle nod, 90° out of phase
```
Negative `vertical` so peak exhale dips the eye slightly (chest collapses). `onBreathIn` fires at phase `0.25`, `onBreathOut` at `0.75`.

**Exertion update**:
```
rise = (sprintActive ? sprintRiseRate : 0) + jumpRiseDelta(this frame)
fall = idleDecayRate * (1 - rise > 0 ? 0 : 1)
exertion = clamp(exertion + (rise - fall) * dt, 0, 1)
```
Then `breathRateHz` and `breathAmpM` track `exertion` through *separate* exponential lags — rate hangs around longer than amplitude, matching how real recovery works (you breathe fast for a while after the chest has stopped heaving).

**Critically damped spring** (used for FOV, lean, crouch height):
```
y = halfLife / 0.6931
α = exp(-y * dt)
β = (x - target)
v = (v - β * y) * α
x = target + (β + v * dt / y) * α
```
For landing recovery, use the same form with `zeta = 0.55` so it overshoots once before settling.

### 5.4 Edge cases and rules

- **Look-pitch clamp** is *non-wrapping*: the pitch is stored as a separate scalar so it cannot accidentally roll over. Yaw on the body wraps freely.
- **Crouch under a ceiling**: if `intent.crouch` releases but the entity cannot stand up (raycast collides), the controller *holds* crouch and exposes `state.crouchBlocked = true`. Standing happens automatically when clear. This rule lives in the controller because it's universal; the raycast itself is a service-injection point.
- **Sprint requires forward intent**: sideways-only or backwards movement should not consume exertion at sprint rates. Specifically `sprint = intent.sprint && intent.move.y > 0.5 && state.grounded`.
- **No bob in air**: stride does not advance while `!grounded`. Phase freezes so landing doesn't re-fire a stale footstep.
- **Jump cancel during anticipation**: if grounded becomes false during anticipation (e.g. ground pulled out), drop the queued impulse and emit `onLeaveGround{reason:"fall"}` without `onJumpStart`.
- **Disable when paused**: the system should respect a `controller.enabled` flag (or scene pause) and freeze `update` but still allow `fixedUpdate` to integrate gravity if desired. This is a configuration concern, not behavior — exposed via the existing engine pause mechanism.

---

## 6. Accessibility & motion sickness

Three settings every player should be able to toggle (defaults shown):

| Setting | Default | Effect when off |
|---|---|---|
| `config.lean.enabled` | true | No roll on strafe |
| `config.bob.verticalAmpAtWalk` | (nonzero) | Set to 0 to disable bob |
| `config.fov.sprintAdd` | 6 | Set to 0 to disable FOV kick (a known nausea trigger) |

The Playtank article calls these out specifically — keeping the horizon flat and the FOV stable accommodates a meaningful fraction of players who otherwise can't play first-person at all. They are exposed as plain config knobs, not buried.

---

## 7. Integration points (deferred work)

These don't ship with the controller but the design preserves a clean attachment surface for each:

| Future system | How it hooks in |
|---|---|
| **Capsule physics / collision** | Reads `Motion.velocity` and resolves; writes `state.grounded`, `state.groundNormal`, `state.surfaceTag`. Suggested via a small `GroundContact` component the physics system populates and the controller reads. |
| **First-person arms rig** | Reads `pose.headPitchRad`, `pose.locomotionPhase`, `state.actionState`. Driven by an animation graph keyed to the same stride phase the bob uses, so feet animate in sync with footstep events. |
| **Third-person body rig** | Reads `pose.rootYawRad`, `pose.locomotionSpeed/Strafe`, `pose.crouchAmount`, `state.actionState`. Built around a standard blend-space. |
| **Sound** | Subscribes to `signals.onFootStep` (modulated by `surfaceTag`), `onLand` (kind = soft/hard), `onJumpStart`, `onBreathIn/Out` (volume = amplitude). |
| **Camera shake from external sources** (gunfire, explosions) | Pushes into the `externalShakeStack` on the controller — an additive transform list. The controller's L3 evaluates it; sources only push. |
| **Stamina / damage / status** | A separate component can write into `controller.config.exertion.*` at runtime — e.g. an injury raises `idleDecayRate` to a negative value (exertion can never recover fully). |
| **Networking** | Because L1+L2+L4 are deterministic in `fixedUpdate` and the intent surface is small, the controller can be rolled back / replayed by writing intent and re-running fixed updates. No design changes needed. |

---

## 8. File layout

```
engine/control/first-person/
    DESIGN.md                                                  (this file)
    FirstPersonPlayerController.js                             (component class)
    FirstPersonPlayerControllerSystem.js                       (system class)
    FirstPersonPlayerControllerSerializationAdapter.js
    FirstPersonPlayerController.spec.js                        (component contract)
    FirstPersonPlayerControllerSystem.spec.js                  (system behavior)
    math/
        criticallyDampedSpring.js
        underDampedSpring.js
        computeJumpFromApex.js
    pose/
        FirstPersonPose.js                                     (the pose-channel struct)
```

Math helpers live as standalone pure functions so they're independently testable and reusable.

---

## 9. Resolved design decisions

The open questions from the previous draft are now answered:

1. **Body pitch** — **No pitch on the body, ever.** Body holds only yaw. Pitch lives on the eye. Standard FPS convention; keeps physics integration clean.
2. **Eye representation** — **Separate entity** with its own `Transform` + `Camera`, parented to the body via `TransformAttachment`. Holds the door open for vehicle-mount / turret-style reparenting later without refactor.
3. **Look intent shape** — **`Vector2` delta** (yaw on `x`, pitch on `y`). Matches mouse semantics; yaw-only sources (turret-style AI) just write `(x, 0)`.
4. **Bob curve evaluation** — **Runtime sine.** Cheap on modern CPUs. No LUT.
5. **Look processing rate** — **Inside `fixedUpdate`.** Determinism wins. Input bindings accumulate look delta into `intent.look` (a `Vector2`) between fixed updates; the system consumes and zeroes it once per fixed step. Cost: at very low fixed rates (e.g. 30 Hz) mouse feel becomes slightly chunky; mitigation if it ever bites is to raise `fixedUpdateStepSize` from default ~60 Hz to 120 Hz for entities with this controller, *not* to move look back to render rate.

### Jump-button repeat (added requirement)

**Holding the jump button must not produce repeated jumps.** A jump must consume an explicit *button-down edge*; holding the button after a jump produces no further jumps until the button is released and pressed again.

Implementation:

- `intent.jump` is a **hold-state boolean** (true while the button is held). The input binding sets it on key-down and clears it on key-up.
- The system tracks `_jumpButtonWasDown` internally. A jump is *requested* only on the false→true edge, not while the value remains true.
- Pressing space then holding it across grounded → airborne → grounded transitions produces exactly **one** jump. The player must release and re-press to jump again.
- Auto-repeat from the OS is already filtered by `KeyboardDevice` (`event.repeat` check), so the edge fires cleanly once per physical press.
- Bonus: the same edge gives us **variable jump height** — the true→false edge (release) cuts gravity on the ascending portion of the jump. Two birds, one signal.

The same hold-state-plus-edge pattern is used for crouch (when `config.crouch.mode === "toggle"`); when in `"hold"` mode the held value is used directly.

---

## 10. Sources

- gamedevpills — [The 3Cs Framework: Character, Camera, Controls](https://www.gamedevpills.com/p/the-3cs-framework-character-camera)
- Playtank — [First-Person 3Cs: Camera](https://playtank.io/2023/05/12/first-person-3cs-camera/)
- Roberts Space Industries — [Design Notes: FPS Stances & Breathing](https://robertsspaceindustries.com/en/comm-link/engineering/14653-Design-Notes-FPS-Stances-Breathing)
- NeoFPS — [Additive Transforms and Effects](https://docs.neofps.com/manual/fpcam-additive-transforms-and-effects.html)
- Daniel Holden — [Spring-It-On: The Game Developer's Spring-Roll-Call](https://theorangeduck.com/page/spring-roll-call)
- Allen Chou — [Game Math: Precise Control over Numeric Springing](https://allenchou.net/2015/04/game-math-precise-control-over-numeric-springing/)
- Ryan Juckett — [Damped Springs](https://www.ryanjuckett.com/damped-springs/)
- GDC Vault — [Animation Bootcamp: Giving Purpose to First-Person Animation](https://www.gdcvault.com/play/1017633/Animation-Bootcamp-Giving-Purpose-to)
- GDC Vault — [The Art of First Person Animation for Destiny](https://www.gdcvault.com/play/1022297/The-Art-of-First-Person)
- Palos Publishing — [Simulating Breathing and Idle Motion Procedurally](https://palospublishing.com/simulating-breathing-and-idle-motion-procedurally/)
- Ketra Games — [Coyote Time and Jump Buffering](https://www.ketra-games.com/2021/08/coyote-time-and-jump-buffering.html)
- Opsive — [First Person Lean](https://opsive.com/support/documentation/ultimate-character-controller/character/abilities/included-abilities/first-person-lean/) and [Character Foot Effects](https://opsive.com/support/documentation/ultimate-character-controller/surface-system/character-foot-effects/)
- CTRL500 — [Mirror's Edge Catalyst: Keeping the camera in your face](https://ctrl500.com/developers-corner/mirrors-edge-catalyst-keeping-the-camera-in-your-face/) (DICE / Erik Söderholm)
