# FirstPersonPlayerController — Extension Plan

> Status: **In progress** — core movement vocabulary + 4 mastery evaluators landed. Slide, Mantle, WallRun, WallJump, and LedgeGrab abilities are implemented; StrideTimingJump, FootAsymmetryTurn, BreathRhythm (multi-decision-point), and SlideInitiationTiming evaluators ship registered in the prototype.
> Sibling to: [DESIGN.md](./DESIGN.md) (base controller).
> Scope of this doc: movement vocabulary (wall run, wall jump, mantle, vault, ledge grab, slide) + a hidden mastery layer (gait-timed bonuses, foot-asymmetric modifiers, curve-driven response).

---

## 1. Vision

Two pillars, layered:

**Pillar A — Movement vocabulary.** The controller graduates from "athlete with juice" to "free-runner". Walls, ledges, and surfaces become traversable surfaces. The base movement still works for everyone — these extensions are *additive*, never required.

**Pillar B — Hidden mastery.** Behind the visible mechanics, a layer of *temporally-aware* modifiers rewards understanding gait phase, foot-side, momentum, and chained timing. A casual player presses jump and gets a jump. A skilled player presses jump *at right-foot push-off, mid-stride, leaning into the turn* and gets ~12% more height. They cover more ground over a long traversal. Compounded across a parkour run, the gap is meaningful.

**Core design rule:** **symmetric in behaviour, asymmetric in magnitude.** The same curve drives both bonuses and penalties — peak timing rewards, anti-peak timing punishes — but the penalty side is *smaller* than the bonus side (typical +12% peak / −5% trough). This makes mastery genuinely valuable while making mis-timed inputs feel "slightly off" rather than punitively bad. A flat per-evaluator multiplier (`flatBoost`) lets designers tune overall magnitude without re-authoring curves.

The reasoning: pure-additive bonuses produce odd outcomes (good-timing-or-baseline, no concept of "mistimed"). Symmetric responses are honest — your inputs land somewhere on a continuous response curve, with peaks and troughs.

**Subtlety rule:** typical mastery bonus is +3 to +15%; matching penalty is −2 to −5%. Never enough to be obvious in a single instance. Compounds across chains.

**No glitch-mastery rule.** Bunny-hopping, tap-strafing, and similar exploits arise from *unintended* interactions between systems (or, charitably, from gaps designers chose to leave). We don't want those. Every mastery effect in our system is **physically motivated** — stride timing, foot-side asymmetry, breath rhythm, momentum carry, contact angles. If a "discovery" can't be explained as a real-body effect, it's a bug, not a feature.

Inspiration for *layered depth*: Apex Legends, Mirror's Edge, Titanfall 2. Inspiration for *grounded-in-body*: Inman/Perry gait research, sports biomechanics. Inspiration for *what NOT to do*: Quake bunny-hop (depth from glitches, not from physics).

---

## 2. Architectural extensions

### 2.1 Layered model (revised)

```
L0 Intent             external (input/AI)
L1 Locomotion         base movement (unchanged from DESIGN.md)
L1.5 Mastery hooks    NEW — observe locomotion, compute timing-based bonuses
L1.6 Ability override NEW — wall run / mantle / slide can override L1 entirely
L2 Pose state         unchanged
L3 Camera             unchanged (abilities push to EyeOffsetStack)
L4 Events             unchanged + new ability-specific signals
```

L1.5 and L1.6 are the new layers. L1.5 runs *during* normal L1 (it modifies coefficients used by L1's math). L1.6 *replaces* L1's body integration when an ability is active.

### 2.2 The Ability framework

An ability is a behaviour module that:
- Polls per fixed update for activation
- Once active, runs its own `tick(dt)` to override locomotion / vertical integration
- Resolves its own exit conditions (timer, contact, intent, etc.)
- Releases control back to base locomotion on exit

```js
class Ability {
    /** Priority for activation. Higher wins ties when multiple can fire. */
    priority = 0;

    /**
     * @param {FirstPersonPlayerController} controller
     * @param {PerEntityRuntime} runtime
     * @param {Object} sensors  See § 2.4
     * @returns {boolean} true if this ability wants to take control now
     */
    canActivate(controller, runtime, sensors) { return false; }

    /** Called once when this ability acquires control. */
    onActivate(controller, runtime) {}

    /**
     * Runs each fixed step while active. Owns the body's motion during
     * this window — writes to runtime.velocity*, bodyTransform.position
     * directly. Returns true to stay active, false to release.
     */
    tick(controller, runtime, dt) { return false; }

    /** Called once when releasing control (either by tick returning false
     *  or by being preempted by a higher-priority ability). */
    onDeactivate(controller, runtime) {}
}
```

**Activation policy** — at most one ability is active at a time. Each fixed update:
1. If an active ability exists, its `tick()` runs first. If it returns `false`, fall through to step 2 in the SAME tick.
2. Poll all abilities by descending priority. First that says `canActivate` becomes active, with `onActivate` called immediately.
3. If no ability activates, base L1 locomotion runs.

**Preemption** — an active ability can be preempted by a higher-priority one that wants in (e.g., mid-slide, hit a vaultable obstacle → mantle takes over). The current ability's `onDeactivate` fires before the new one's `onActivate`.

**Ability set is config-driven** — a controller has an array of `Ability` instances. Removing wall-running from a particular game/mode is just leaving that ability out of the array. No code changes.

### 2.3 Sensors

Multiple abilities share spatial queries. Wall run, wall jump, and ledge grab all need to know "is there a wall to my left/right". Mantle and vault need "is there an obstacle in front of me, and how tall is it". Building one shared "sensor bundle" beats each ability re-raycasting.

```js
class FirstPersonSensors {
    /** {hit: bool, distance: number, normal: Vec3, surfaceTag: string?} */
    wallLeft = null;
    wallRight = null;
    wallFront = null;

    /** Forward-and-up probe: information about a vaultable obstacle ahead.
     *  Includes the surface top altitude relative to the player's feet. */
    obstacleAhead = null;

    /** Ledge detection: result of "forward hits, but a step-up to a low
     *  ceiling, then nothing forward" — i.e., a grabbable ledge edge. */
    ledgeAhead = null;
}
```

The sensors update once per fixed step (in their own subsystem, runs before the ability poll). Cost: a small constant number of raycasts per player. Cache the results on `runtime.sensors`.

Sensors use the engine's existing physics raycast facility (or, for the prototype-without-physics, a simple AABB world model).

### 2.4 The Mastery layer

A *mastery evaluator* observes controller state and returns a multiplier for a specific decision point. Decision points include:

- Jump impulse magnitude (event: jump-fire)
- Ground acceleration (every fixed step on ground)
- Slide initiation velocity preservation (event: slide-start)
- Wall-jump impulse (event: wall-jump-fire)
- Mantle exit speed (event: mantle-end)
- Wall-run gravity factor (each fixed step while wall-running)

```js
class MasteryEvaluator {
    /**
     * @param {number} decisionPoint  one of the DecisionPoint enum values
     * @param {FirstPersonPlayerController} controller
     * @param {PerEntityRuntime} runtime
     * @returns {number} multiplier; 1.0 = no opinion, >1 = bonus, <1 = penalty
     */
    evaluate(decisionPoint, controller, runtime) { return 1.0; }
}
```

Each decision point queries every registered evaluator and composes *multiplicatively*:
```
finalMultiplier = evaluators.reduce((acc, e) => acc * e.evaluate(point, c, r), 1.0)
```

**Symmetric response.** Evaluators can return values both above and below 1.0 — the same curve drives bonus and penalty regions. Default convention: curve `y` range is `[1 - penaltyMag, 1 + bonusMag]` where typically `bonusMag ≈ 2.5 × penaltyMag`. Behaviour is symmetric (a curve evaluation at every decision point); magnitude is asymmetric (the bonus side is bigger than the penalty side). Mis-timing feels "slightly off", peak timing feels good.

**Flat multiplier per evaluator.** Each evaluator has a `flatBoost: number` (default 1.0) that scales its entire output. Lets designers tune overall magnitude without re-authoring the curve. `final = pow(curveValue, flatBoostExponent)` — exponentiation rather than multiplication preserves the curve's range bracketing around 1.0.

**Composition is multiplicative**, not additive, so evaluator contributions are independent. A 1.10× and a 1.05× compose to 1.155×. A 0.95× (penalty) and a 1.10× (bonus) compose to 1.045× — penalty partially offset by bonus. This is what gives the system its "honest" feel: mistiming costs something even when other parts went well.

### 2.5 Mastery score (developer-facing telemetry)

The system maintains a per-controller *mastery score* — a normalized exponential moving average of all evaluator outputs over time:

```js
class MasteryScore {
    /** EMA of (multiplier − 1.0). Positive = trending bonus, negative = trending penalty. */
    aggregate = 0;
    /** EMA per decision point. Keyed by DecisionPoint. */
    byPoint = new Map();
    /** EMA half-life (seconds). */
    halfLife = 8.0;
}
```

Every time any evaluator fires, the score updates. The game/HUD can read these values to drive whatever feedback it wants (a subtle UI hint, post-match summary, leaderboard score, achievement triggers, training-mode coaching, etc.). The controller itself doesn't *use* the score — it's a side-channel for downstream systems.

This satisfies the "subtle but discoverable" requirement without putting mastery-feedback directly on the camera. The dev/game decides what to surface.

### 2.6 Curve-driven response

Every mastery evaluator's input→output mapping is an `AnimationCurve` (engine's `engine/animation/curve/AnimationCurve.js` — exists and has `.evaluate(t)`). Curves are *symmetric around 1.0*: above 1.0 in peak-timing regions, below 1.0 in worst-timing regions.

```js
class StrideTimingJumpEvaluator extends MasteryEvaluator {
    /** Curve: stride phase [0..1) → multiplier ∈ [1-pen, 1+bonus] */
    curve = null;
    /** Exponent applied to (curve - 1) for overall magnitude tuning. */
    flatBoost = 1.0;

    evaluate(decisionPoint, controller, runtime) {
        if (decisionPoint !== DecisionPoint.JumpImpulse) return 1.0;
        const raw = this.curve.evaluate(controller.state.stridePhase);
        // Preserve symmetry around 1.0 while scaling magnitude:
        // signed deviation × flatBoost, then add back to 1.0.
        return 1.0 + (raw - 1.0) * this.flatBoost;
    }
}
```

Designers author the curve in the engine's existing curve editor. The curve shape encodes the *response profile*:
- Wide bell centred at peak → forgiving window
- Sharp spike + sharp trough → precise timing, big magnitude swing
- Asymmetric (steeper one side) → easier in one direction than the other

The curve's `x` range is decision-specific:
- Stride-timed jump: `x` = stride phase ∈ [0, 1)
- Slide-on-impact: `x` = time-since-footfall ∈ [0, 0.3] (seconds)
- Wall run takeoff: `x` = time-on-wall ∈ [0, 2.5]
- Breath-rhythm action: `x` = breath phase ∈ [0, 1) — peak at exhale (0.75), trough at inhale (0.25)
- Wall-jump angle: `x` = dot(velocity, wall.normal) ∈ [0, 1]

Default curves ship with the system. Designers can override any of them. Default curves are unit-tested for "bonus at peak, penalty at trough" behaviour patterns (not specific magnitudes — those are tunable).

### 2.7 File layout

```
control/first-person/
    DESIGN.md                                 (base — existing)
    DESIGN_EXTENSIONS.md                      (this file)
    FirstPersonPlayerController.js            (existing — extended to hold ability state)
    FirstPersonPlayerControllerConfig.js      (existing — extended)
    FirstPersonPlayerControllerSystem.js      (existing — orchestrates abilities)

    abilities/
        Ability.js                            (interface/base)
        AbilitySet.js                         (the activation/preemption logic)
        Slide.js
        Mantle.js
        WallRun.js
        WallJump.js
        LedgeGrab.js

    sensors/
        FirstPersonSensors.js                 (struct)
        FirstPersonSensorsSystem.js           (populates the struct each tick)

    mastery/
        MasteryEvaluator.js                   (base)
        DecisionPoint.js                      (enum)
        StrideTimingJumpEvaluator.js
        FootAsymmetryTurnEvaluator.js
        SlideInitiationTimingEvaluator.js
        WallRunTakeoffTimingEvaluator.js

    math/                                     (existing)
    pose/                                     (existing)
    composer/                                 (existing)
```

---

## 3. Movement abilities — detail

For each ability: **activation**, **behaviour**, **exit**, **events**, **mastery hooks**, **risks**.

### 3.1 Slide (run → slide)

The simplest ability — pure locomotion override, no spatial sensing needed.

**Activation** — `intent.crouch` rising edge AND `state.speed > slideMinSpeed` AND `state.grounded`. So you can only initiate slide while moving fast.

**Behaviour** — replaces standard L1.b/c/d. Velocity is preserved (no decel during slide) with very low friction. `state.eyeHeight` springs to `body.crouchHeight` (same as crouch). Forward intent has *reduced* steering, lateral intent has slightly more (you can carve the slide). Vertical impact spring kicks once on slide-start for the visual "thud".

**Exit** — speed drops below `slideEndSpeed`, OR intent.crouch released, OR intent.jump (slide-jump → preserves horizontal speed, gives normal vertical impulse), OR collision with wall.

**Signals** — `onSlideStart{entrySpeed}`, `onSlideEnd{reason, exitSpeed}`, `onSlideJump` (special: fires both onSlideEnd AND onJumpStart).

**Mastery hooks:**
- `slideInitiationVelocityCurve`: input = time-since-last-footfall. Sliding *right at footfall* preserves more entry speed (you're pushing off the foot into the slide). Wide window centred around 0–80ms post-footfall.
- `slideJumpTimingCurve`: jumping out of slide near a footfall gives a boost to the jump's horizontal carry.

**Config additions:**
```js
slide = {
    minEntrySpeed: 5.0,        // m/s — gates activation
    endSpeed: 2.0,             // m/s — auto-exit threshold
    friction: 3.0,             // m/s² deceleration while sliding
    steerForwardFactor: 0.3,   // intent.move.y has 30% steering authority
    steerLateralFactor: 1.4,   // intent.move.x has 140% (carving)
    eyeHeight: 0.55,           // even lower than crouch
}
```

### 3.2 Mantle / Vault

Auto-triggered surface clearing. Distinguishes by obstacle height: low → vault (over and continue); medium → mantle (climb onto and stop on top).

**Activation** — `sensors.obstacleAhead.hit` AND obstacle top is reachable (within `mantleMaxHeight`) AND forward intent is non-zero. Vault if the obstacle has *space behind it* (sensor detects a deeper forward probe past the obstacle); mantle if not.

**Behaviour** — scripted Bezier curve through the motion: up + slight slow-down + over/onto. The body's position is on a *path*, not under integration, during the mantle window. Duration is fixed (configurable per height) — typically 0.4–0.8s. Camera dips down on the ascent (looking at hands/wall), then back to level.

**Exit** — when the scripted path completes. The player re-acquires control with their previous (preserved) velocity for vault, zeroed for mantle.

**Signals** — `onMantleStart{obstacleHeight, kind:"mantle"|"vault"}`, `onMantleEnd{kind}`.

**Mastery hooks:**
- `mantleApproachTimingCurve`: input = approach angle (radians off-perpendicular). Hitting a vault dead-perpendicular gives the cleanest, fastest vault. Approaching at a slight angle (≤20°) maintains some momentum carry; beyond that, the vault is slower or aborted.
- `vaultCarrySpeedCurve`: input = pre-vault horizontal speed. Sprinting into a vault carries *more* exit speed than walking into one. The curve is super-linear at the top — sprinting gives a slight EXTRA speed boost on exit, like you're using the vault as a momentum gate.

**Risks** — the scripted path overrides player input briefly. Need careful design so it doesn't feel like control loss. Mitigation: paths are SHORT (<0.5s), and the player can cancel by reverse-intent during the first 25% of the window (drop off the obstacle).

### 3.3 Wall run

Run along a vertical surface for a brief window.

**Activation** — `!state.grounded` AND `(sensors.wallLeft.hit OR sensors.wallRight.hit)` with normal roughly perpendicular to body forward AND `state.speed > walkSpeed` AND `state.airborneTime > 0.1` (so you can't trigger off a jump's initial frames — you have to be IN the air, not transitioning).

**Behaviour** — gravity is reduced to `wallRunGravity` (typically ~30% of normal fall). Velocity is preserved along the wall's tangent. Body yaw is forced to align with the wall (player can adjust, but yaw is steered toward the wall direction). Camera rolls toward the wall (~10–15°).

**Exit** — wall ends (sensor lose contact), OR `state.airborneTime - wallRunStartedAt > wallRunMaxDuration` (timer expires; ~2s default), OR jump pressed (chains to wall-jump), OR move-away intent strong enough.

**Signals** — `onWallRunStart{side:"L"|"R"}`, `onWallRunEnd{reason}`.

**Mastery hooks:**
- `wallRunEntryTimingCurve`: input = time-since-takeoff. Wall-running just after a jump (within ~0.3s) is "clean" and starts at full speed. Wall-running mid-fall costs some speed. Curve has a peak at ~0.1s post-takeoff.
- `wallRunTakeoffTimingCurve`: input = time-on-wall. The wall-jump exit is more powerful if you trigger it within the first second (you have leg energy left to push off). Bell curve peaking at ~0.4s.

**Risks** — the camera roll is a known motion-sickness trigger. Provide `wallRunCameraRoll: number` config with default 0.6 (=60% of "full" tilt) and gate it on `cfg.lean.enabled` so the motion-sickness toggle covers it.

### 3.4 Wall jump

Impulse off a wall — chains naturally from wall run, also fires from near-wall airborne (a brief window where you're close to a wall but not yet running).

**Activation** — `intent.jump` rising edge AND `!state.grounded` AND `(sensors.wallLeft.hit OR sensors.wallRight.hit)` with normal pointing away from body forward AND wall is roughly vertical. Higher priority than base jump (so an in-air jump press near a wall produces wall-jump, not nothing).

**Behaviour** — instantaneous impulse: `wallJumpImpulse` magnitude along (wall.normal + worldUp * `wallJumpUpFactor`). Resets `state.isVariableJumpCut` and `runtime.midJump = true` so subsequent gravity treats it as a fresh jump.

**Exit** — instantaneous (it's an impulse, not a sustained state). Returns control to base airborne integration.

**Signals** — `onWallJump{wallNormal, impulse}`.

**Mastery hooks:**
- `wallJumpEntryAngleCurve`: input = dot(incoming velocity, wall.normal) — how directly the player is *flying into* the wall. Cleanly into-wall (high dot) → bigger off-wall impulse (you have more inbound momentum to flip). Glancing → smaller. Models "you push off harder when you've committed harder".
- `wallJumpRhythmCurve`: input = breathPhase. *Yes, breath phase.* Wall-jumping at exhalation gives a small boost. Subtle but a player who learns it gets a long-traversal advantage. The kind of detail Tony Hawk speedrunners would obsess over.

### 3.5 Ledge grab

Snap to and hang from a ledge edge during a fall (or after an aborted mantle).

**Activation** — `sensors.ledgeAhead.hit` AND `runtime.velocityY <= 0` (descending) AND `state.airborneTime > 0.3` (not just leaving ground) AND forward intent (you have to be aiming at the ledge).

**Behaviour** — body position snaps to ledge-hang position. Vertical velocity zeroed; horizontal motion limited to side-shuffling along the ledge edge. Eye position adjusts to "looking up over the ledge". Stamina/exertion gradually depletes (climbing fatigue).

**Exit** — jump pressed (mantle up), OR crouch/back-intent (drop), OR exertion saturates (slip — soft fall), OR `sensors.ledgeAhead` loses contact (shuffled off the edge).

**Signals** — `onLedgeGrab{ledgeHeight}`, `onLedgeRelease{reason}`.

**Mastery hooks:**
- `ledgeCatchTimingCurve`: input = vertical-velocity at catch. Catching at low-velocity (gentle descent) is forgiving. Catching at high-velocity (a desperate save) is harder, but if successful, the next mantle-up from this position is *faster* (you've used momentum to your advantage). Curve has TWO regions: the "forgiveness zone" at low Vy where everything is easy, and a "skill zone" at high Vy where mantle-up speed scales with how aggressive the catch was.
- `ledgeMantleTimingCurve`: input = breath phase at mantle-up press. Same Tony-Hawk-detail level.

---

## 4. Hidden mastery — worked examples

### 4.1 Stride-timed jump

**Player input:** `intent.jump` rising edge.
**Mastery query:** `StrideTimingJumpEvaluator` at `DecisionPoint.JumpImpulse`.
**Inputs read:** `state.stridePhase`, `runtime.standingFoot`.
**Curve:** authored such that mid-stance (`stridePhase ≈ 0.25` for R-foot, `≈ 0.75` for L-foot) gives 1.10–1.12×; mid-swing gives 1.00×.
**Effect:** jump impulse multiplied by the result. A 1.8m jump becomes a 1.95m jump at peak timing.
**Player-visible:** the jump just goes higher. No UI, no sound cue.

### 4.2 Foot-asymmetric turn momentum

**Player input:** sustained yaw turn (`intent.look.x` non-zero) while running.
**Mastery query:** `FootAsymmetryTurnEvaluator` at `DecisionPoint.GroundAccel`.
**Inputs read:** `runtime.standingFoot`, sign of yaw rate.
**Effect:** turning rightward (negative yawDelta in our convention) while LEFT foot is standing gives slight extra ground acceleration (the right leg is in swing, can push you toward the turn).
**Magnitude:** ~5% accel boost. Composed over a long sprint-turn the player covers more ground.
**Player-visible:** the run feels "smoother" in one direction than the other. Most players never notice why.

> The "handedness asymmetry" knob is a `cfg.mastery.dominantFoot: "R"` setting. By convention right-dominant gets ~3% additional bonus to its asymmetric advantage. Off by default in serious modes; on in arcadey ones.

### 4.3 Slide initiation timing

**Player input:** `intent.crouch` rising edge during sprint.
**Mastery query:** `SlideInitiationTimingEvaluator` at `DecisionPoint.SlideEntryVelocity`.
**Inputs read:** time since last footfall (the controller stores this as `runtime.timeSinceFootfall`, which is new — would be added in this extension).
**Curve:** peaks at ~80ms after a footfall (you've just pushed off, body has full forward momentum). Falls off in both directions.
**Effect:** slide-entry velocity preserved at 100% at the peak, ~85% at the trough.
**Player-visible:** sliding "feels good" when timed right. Players naturally trend toward the good timing.

### 4.4 Breath-rhythm reward

**Player input:** any decision-point event (jump, slide-start, wall-jump, mantle-start).
**Mastery query:** `BreathRhythmEvaluator` — registered against *multiple* decision points.
**Inputs read:** `state.breathPhase`.
**Curve:** symmetric bell with peak at 0.75 (exhale), trough at 0.25 (inhale). At exhale: +3%. At inhale: −1%. Asymmetric magnitude per the design rule.
**Effect:** every decisive action benefits slightly from being timed to exhale. Physically motivated — divers, weightlifters, and martial artists all exhale during exertion. Inhaling mid-effort marginally degrades performance.
**Player-visible:** outcomes are slightly inconsistent until the player intuits the pattern.

A linked variant: `BreathRhythmSpeedBonusEvaluator` against `DecisionPoint.GroundAccel` — same curve over breath phase, slight (~1–2%) speed bonus during the exhale half of the cycle. Players running with breath rhythm covered slightly more ground.

> **Note on intensity.** Breath-rhythm bonuses are the smallest in the system by design. They reward acute body awareness without dominating. If a player is *aware* of breath sync, they get a barely-noticeable advantage; if not, they pay a barely-noticeable cost. This is the floor of the mastery layer.

### 4.5 Wall-run launch chain (compounded mastery)

A player wall-runs along a wall, then wall-jumps off it. Multiple evaluators chain:

| Stage | Evaluator | Skilled-timing | Mis-timing |
|---|---|---|---|
| Approach jump | StrideTimingJumpEvaluator | +12% impulse | −4% impulse |
| Approach jump | BreathRhythmEvaluator (jump) | +3% | −1% |
| Wall-run entry | WallRunEntryTimingEvaluator | +5% speed | −2% speed |
| Wall-run takeoff | WallRunTakeoffTimingEvaluator | +8% impulse | −3% impulse |
| Wall jump angle | WallJumpEntryAngleEvaluator | +6% impulse | −2% impulse |

**Skilled outcome (multiplicative):** 1.12 × 1.03 × 1.05 × 1.08 × 1.06 ≈ **1.39×**.
**Mis-timed outcome (multiplicative):** 0.96 × 0.99 × 0.98 × 0.97 × 0.98 ≈ **0.89×**.
**Casual outcome (all near curve midpoint, all close to 1.0):** ≈ 1.00×.

Note: the asymmetric magnitude means mis-timing produces ~11% loss while peak timing produces ~39% gain. The spread is genuine. Across a 30-second parkour route the gap accumulates into a genuinely different traversal speed — and the "mastery score" EMA will reflect it for game UI.

*That* is what mastery looks like — invisible per-interaction, dominant in aggregate.

---

## 5. Implementation roadmap

Each phase delivers usable value and is testable independently.

**Phase 1 — Mastery scaffold (~1 day)**

`DecisionPoint` enum, `MasteryEvaluator` base, `MasterySet` (registers + composes multiplicatively), `MasteryScore` (EMA tracker). No evaluators yet. The decision-point query path exists and returns 1.0 by default. Plumbing only — no observable behaviour change.

Test: composition is multiplicative; score EMA tracks correctly; default-returning-1.0 leaves baseline intact.

**Phase 2 — Ability framework (~1.5 days)**

`Ability` base, `AbilitySet` with the hybrid preempt-or-queue logic. No abilities yet. Active ability slot exists on runtime; base locomotion is the fallback. Higher-priority new request checks `current.canInterrupt()`; if no → queued, re-tries each tick.

Test: zero abilities = unchanged behaviour; mock ability of higher priority preempts when interruptible, queues when not; queued ability fires the moment current ability releases.

**Phase 3 — Sensor system (~1 day)**

`FirstPersonSensors` struct + `FirstPersonSensorsSystem` that populates it each fixed step. Raycasts against:
- `Terrain.raycastFirstSync` for terrain hits
- `ShadedGeometrySystem.raycast` for static geometry (BVH-backed; cheap)

Initial sensors: `wallLeft`, `wallRight`, `wallFront`, `obstacleAhead`, `ledgeAhead`. NPCs without ability needs use a stub sensor (zero rays).

Test: with mocked geometry, sensor populates expected hit info; with no geometry, sensor reports null.

**Phase 4 — Wire mastery into base controller (~0.5 day)**

Apply mastery composition at the existing jump-impulse fire site as proof-of-concept. Default behaviour: no evaluators registered → 1.0 multiplier → no change. With a single test evaluator returning 1.1 → jump v0 is 10% higher.

Test: jump impulse v0 = base × `MasterySet.evaluate(JumpImpulse)`; default = base; with evaluator = scaled.

**Phase 5 — First evaluator: StrideTimingJumpEvaluator (~1 day)**

The canonical evaluator. Symmetric curve over stride phase. Default curve ships peaks at midstance phases (≈0.25 and 0.75), troughs at footfall transitions (≈0, 0.5). Optional foot-asymmetry: dominant foot's peak is slightly higher.

Test: jump fired at peak stride phase produces > base impulse; at trough produces < base; at midpoint ≈ base.

**Phase 6 — Slide ability (~1 day)**

Simplest ability. No sensors needed. Validates the framework. Includes a "non-interruptible window" at the start (matches user's spec — slide can't be preempted in its first ~0.15s).

Test: slide initiates from sprint+crouch; non-interruptible window blocks preemption; exits on speed loss; slide-jump preserves momentum.

**Phase 7 — Mantle / Vault (~3 days)**

Needs `obstacleAhead` sensor + scripted Bezier-path body integration. Path-driven motion is the new pattern; design carefully so the ECS contract stays clean (body position is *written* during the path, not *integrated*).

Test: low obstacle → vaults; tall obstacle → mantles; uncrossable obstacle → no activation.

**Phase 8 — Wall run + wall jump (~3 days)**

Needs lateral wall sensors. Wall jump is a simple impulse; can fire either from wall-run state or from "near-wall airborne" state. Camera roll goes through the existing `leanSpring` (not a separate effect — consistent with the unification work).

Test: wall-detected + airborne + speed → wall run; jump-during-wall-run produces wall-jump; timer expires correctly; queued ability requests after wall-run release fire correctly.

**Phase 9 — Ledge grab (~2 days)**

Needs `ledgeAhead` sensor (forward + up + over-edge probe). Exertion as a depletion mechanic plugs into the existing exertion system. Mantle-up reuses the mantle ability machinery (priority-preempts ledge-grab when triggered).

Test: ledge caught from descent; exertion depletes while hanging; release on exit conditions; mantle-up from ledge transitions cleanly.

**Phase 10 — Additional evaluators (~2 days)**

`FootAsymmetryTurnEvaluator` (ground accel), `BreathRhythmEvaluator` (multi-point), `SlideInitiationTimingEvaluator`, `WallRunEntryTimingEvaluator`, `WallRunTakeoffTimingEvaluator`, `WallJumpEntryAngleEvaluator`. All ship with default curves.

Test: each evaluator's curve produces > base at peak, < base at trough, with documented magnitudes.

**Phase 11 — Iterate on curves (~ongoing)**

The framework is done; the *feel* is iterated by tuning curves through play. Mastery score EMA is the in-game diagnostic.

Total estimate: ~2.5 weeks of focused work to ship all movement abilities + the foundational evaluators.

---

## 6. Risks and open questions

**Determinism & netcode.** Resolved: the controller is deterministic in `fixedUpdate`, networking is tick-synchronized with rollback, and all intent is delivered through networked actions. Mastery curves sampled per fixed step are pure functions of state, so they replay identically. No mastery-specific networking work needed.

**Ability composition.** Resolved via the hybrid preempt+queue model:
- Higher-priority new request → check `current.canInterrupt()`. If yes → preempt now. If no → queue, re-try every tick until current releases.
- Lower-priority new request → queue.
- Priorities (default): slide=10, mantle=30, ledge=40, wall-run=50, wall-jump=60. Wall-jump highest because it's the most player-input-driven (responding to a press) and should fire reliably mid-air.

**The "uninterruptible window" problem.** Resolved by exposing it. Each ability has its own definition of "can interrupt" — slide includes a non-interruptible startup window; mantle is non-interruptible mid-path; wall-jump is instantaneous so never has the issue. The framework supports this naturally via the `canInterrupt()` hook.

**Mastery layer testability.** Resolved by default-curves contract: each shipped evaluator's default curve is unit-tested for the *behaviour pattern* — output > baseline near peak, output < baseline at trough, output ≈ baseline at midpoint. Not specific magnitudes — those are tunable.

**Sensor cost.** Resolved: meep has a fast BVH; ~100 rays/tick are comfortable, ~1000/frame at 60Hz is fine. We can fire what we need without micro-optimizing. The sensor abstraction still helps because it dedupes queries across abilities (mantle and ledge-grab both need a forward+up probe — compute once, share).

**Raycast targets (interim).** Until the dedicated physics layer arrives, raycast against:
- `Terrain.raycastFirstSync(result, ox, oy, oz, dx, dy, dz)` for terrain
- `ShadedGeometrySystem.raycast(...)` for static geometry (BVH-backed)
Filter both through a small union helper that returns the nearest hit across both sources.

**AnimationCurve.evaluate() perf.** ~100ns per evaluation for typical mastery curves (~5 keys). Per-tick: ~20 evaluator queries × 100ns ≈ 2μs. Negligible.

**Curve authoring.** The engine has an editor at `engine/animation/curve/view/`. Our curves are plain `AnimationCurve` instances — they slot in directly. No tooling work expected.

**Glitch-mastery (anti-bunny-hop).** Active design constraint: every mastery effect must be **physically motivated**. Stride timing → real gait dynamics. Foot asymmetry → real handedness asymmetry. Breath rhythm → real exhalation-during-exertion. Wall-jump angle → real momentum redirection. If a "discovery" can't be explained as a real-body effect, it's a bug to remove, not a feature to keep.

The acid test: "could a sports biomechanist endorse this as a real effect, in principle?" If no → cut it.

**Visual feedback for mastery.** Resolved per the user's instruction: we do NOT have a dedicated "mastery visual tell". Instead, mastery effects feed back through *existing dynamics*. A bigger jump impulse goes through the existing jump system → existing anticipation dip → existing landing dip → existing camera kick. The visual response naturally scales because it's downstream of the same physics. We don't add mastery-aware hooks to the camera — the camera reads what's there. This satisfies "actions should have consequences" without coupling.

**Mastery score for game feedback.** Resolved per the user's instruction: `MasteryScore` is an EMA tracking each evaluator's contribution per decision point, plus an aggregate. Exposed via `controller.mastery.score`. The controller doesn't *use* the score — downstream systems (HUD, post-match summary, training mode, etc.) read it and decide what (if anything) to surface.

**Wall-run camera roll.** Same as base `lean.enabled` — accessibility toggle covers it.

---

## 7. Testing strategy

**Sensor stubs.** Tests pre-populate `sensors.wallLeft` etc., then run ability ticks. Validates ability logic without a physics dependency.

**Ability state transitions.** For each ability: not-active → active → not-active under the documented activation/exit conditions. Pin against regressions.

**Preemption.** A high-priority ability fires while a low-priority one is active → preempt happens, deactivate hooks fire in order. Test mid-tick preemption explicitly (it's the common edge case).

**Mastery composition.** Two evaluators registered, both returning 1.1, on the same decision point → final multiplier 1.21 (not 1.2). Three at 1.05 → 1.158. Confirms multiplicative reduction.

**Mastery determinism.** Two identical controller instances, identical intent sequences → identical state trajectories. Curve sampling is by definition deterministic, but the test catches accidents (random number leaks, non-deterministic Map iteration order if it ever happens).

**Mastery curves.** For each shipped evaluator, a "sanity test" verifies the curve produces ≥ 1.0 everywhere (no penalties), peaks within the documented window, and falls off symmetrically (where applicable). Doesn't test exact magnitudes — those are tunable.

**Roundtrip serialization with curves.** Config that contains an AnimationCurve must round-trip through toJSON/fromJSON cleanly. The existing curve module has its own JSON path; verify it composes with our config.

---

## 8. Out of scope (for now)

These come later if they prove needed; flagging so they don't sneak in:

- **Climbing** (vertical surfaces, not just ledges). Big mechanic; deserves its own design pass.
- **Swimming.** Whole separate locomotion mode.
- **Vehicles / mounts.** Different controller entirely.
- **Crouch-jump** (small extra height from crouch-and-jump combo). Tempting and easy, but adds a "competitive timing" expectation that interferes with the mastery layer's subtlety. Defer.
- **Combat animations / weapon interactions.** Controlled by other systems that read pose channels; controller doesn't need to know.
- **Hard-coded keybindings for any of the new abilities.** Per existing design, abilities are activated by *intent* — input wiring is external.

---

## 9. Sources

For the curated subset relevant to this extension:

- [Mirror's Edge Catalyst — Keeping the camera in your face — DICE / Söderholm](https://ctrl500.com/developers-corner/mirrors-edge-catalyst-keeping-the-camera-in-your-face/) (camera while wall-running)
- Titanfall 2 wall-running references (multiple GDC talks; specific implementation differs but principle is shared)
- Apex Legends movement tech analysis (community-documented bunny-hop, wall-bounce, tap-strafe) — illustrates the "hidden mastery" pattern in a shipped product
- Quake III physics (the canonical "compounding subtle bonuses" reference — air-strafing, bunny-hop)
- [Spring-It-On — Daniel Holden](https://theorangeduck.com/page/spring-roll-call) (for the spring-driven path interpolations used in mantle)

All compound on top of what the base controller already does — the extension is genuinely *layered*, not a rewrite.
