> ## Documentation Index
> Fetch the complete documentation index at: https://docs.summerengine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# FPS controller skill for AI agents

> First-person controller: WASD, mouse look, jump, coyote time, jump buffering, air control. A Summer Engine skill for AI agents.

`fps-controller` is a skill from the [Summer skill library](/mcp/skills): a guide your AI agent reads when the task matches, so it builds the good version on the first try. First-person controller: WASD, mouse look, jump, coyote time, jump buffering, air control.

| | |
| - | - |
| Status | Stable |
| Domains | Character controllers, Gameplay |
| Version | `1.0.4` |
| Engine version | `>=4.6` |
| Recommended set | Yes, installed by `--recommended` |

## When your agent uses it

* Building an FPS, first-person movement, or a 3D character that walks, jumps, and reacts to external forces
* A first-person prototype needs solid movement feel — coyote time, jump buffering, air control

## Install

`summer setup` installs every skill for your agent. To add only this one:

```bash theme={null}
npx -y summer-engine@latest skills install fps-controller --agent claude-code
```

Swap `claude-code` for your agent, such as `codex` or `cursor`. See [Skills](/mcp/skills#quick-start) for every option.

## The skill

The canonical first-person controller: coyote time, jump buffering, air control and external velocity. It follows the standard shipping FPS movement model (asymmetric gravity, grace timers, separate ground/air acceleration, an external force accumulator), so the player never says "this feels stiff" or "I jumped right at the edge and died unfairly".

## Scene Structure

```
Player (CharacterBody3D)
├── CollisionShape3D          # CapsuleShape3D, body
├── Head (Node3D)             # yaw is on Player, pitch is on Head — keeps camera roll-free
│   └── Camera3D              # at eye level
└── (optional) RayCast3D      # ground / interaction probe
```

The Head/Camera split is the standard: yaw rotates the body, pitch rotates only the head. Don't pitch the body or you get tilted capsules and slope-collision bugs.

## Step-by-Step Setup via MCP

### 1. Verify the scene first

Always call `summer_get_scene_tree` before mutating. If `World/Player`, `World/PlayerOld`, or any existing CharacterBody3D is present, ASK the user how to proceed before adding nodes.

### 2. Build the Player rig in one script

The body, its capsule, the head pivot and the camera are one `summer_run_script`
call (one undo step):

```
summer_run_script(scenePath="res://main.tscn", source="""
func run(ctx):
    var player = ctx.add_node("CharacterBody3D", "Player", ctx.find("World"), {"position": Vector3(0, 1, 0)})
    var capsule = CapsuleShape3D.new()
    capsule.radius = 0.4
    capsule.height = 1.8
    ctx.add_node("CollisionShape3D", "Collision", player, {"shape": capsule, "position": Vector3(0, 0.9, 0)})
    var head = ctx.add_node("Node3D", "Head", player, {"position": Vector3(0, 1.6, 0)})
    ctx.add_node("Camera3D", "Camera", head)
    ctx.save_scene()
""")
```

Eye height of 1.6 sits the camera right at the top of a 1.8-tall capsule (capsule center 0.9, plus 0.7 to the eye). Tweak per art style.

Two rules:

* **`scenePath` is required** on `summer_run_script` and on every scene tool
  (`summer_set_prop`, `summer_set_resource_property`, `summer_save_scene`).
  Omit it and the call never reaches the engine. (`summer_input_map_bind` is
  the exception — it edits `project.godot`, not a scene, and takes only
  `name` + `events`.)
* **Build and size a shape before you assign it.** Tuning one value later is
  one `summer_set_resource_property` call on the assigned resource. Structural
  failures there are explicit — `node not found`, `property is not a resource`,
  or `resource is null` when the property was never assigned — but a bad value
  shape is silent on current engines: a JSON object instead of an engine
  variant string, a misspelled `subProperty`, or a wrong-typed value returns
  `ok:true` yet no-ops or coerces destructively. Pass `Vector3(...)`/`Color(...)`
  strings and confirm in the saved `.tscn`, never from `ok` alone.

### 3. InputMap actions

```
summer_input_map_bind(name="move_forward", events=[{type:"key", key:"W"}])
summer_input_map_bind(name="move_back",    events=[{type:"key", key:"S"}])
summer_input_map_bind(name="move_left",    events=[{type:"key", key:"A"}])
summer_input_map_bind(name="move_right",   events=[{type:"key", key:"D"}])
summer_input_map_bind(name="jump",         events=[{type:"key", key:"Space"}])
summer_input_map_bind(name="sprint",       events=[{type:"key", key:"Shift"}])
```

### 4. Attach the controller script

Ask before writing: "May I create `scripts/player_controller.gd` and attach it to `./World/Player`?" Then `Write` the file with the skeleton in the next section, attach via inspector, and finish with `summer_save_scene(scenePath="res://main.tscn")` + `summer_get_script_errors(path="res://scripts/player_controller.gd")` (that tool checks one file and requires `path`).

## Physics-Process Flow (the order matters)

Each frame, `_physics_process` runs these steps in this exact order. Reordering breaks specific edge cases — they are flagged inline.

1. **Subtract last frame's external velocity contribution.** If you skip this, knockback and explosions accumulate forever.
2. **Apply gravity** (asymmetric — heavier when falling).
3. **Update coyote timer.** Reset to full when grounded, tick down when airborne.
4. **Update jump buffer + fire jump** if both timers are active. Clearing the buffer on press-instead-of-fire is the bug everyone makes.
5. **Read input, build wish-direction in worldspace** by rotating the local 2D input through `transform.basis`.
6. **Pick acceleration / friction** based on grounded state, then `move_toward` the horizontal velocity toward `wish_dir * target_speed`.
7. **Apply external velocity, save it for next-frame subtraction, decay it** via `move_toward(0, damping * delta)`.
8. **`move_and_slide()`** — last, after everything else has settled `velocity`.

The skeleton below implements this exactly.

## GDScript Skeleton (drop into `scripts/player_controller.gd`)

```gdscript theme={null}
class_name PlayerController
extends CharacterBody3D

# Movement tunables — the canonical defaults. Tweak in inspector, don't fork the script.
@export var walk_speed: float = 5.0
@export var sprint_speed: float = 8.0
@export var jump_velocity: float = 5.5         # ~1.5m peak with default gravity
@export var gravity: float = 20.0              # heavier than Earth — feels better
@export var fall_gravity_multiplier: float = 1.4  # snappier descent than ascent

# Acceleration model — separate ground/air so air control feels real, not on/off.
@export var ground_accel: float = 80.0
@export var ground_friction: float = 90.0
@export var air_accel: float = 25.0            # ~30% of ground — Quake-ish, not Mario
@export var air_friction: float = 5.0

# Grace timers — the difference between "feels tight" and "feels broken".
@export var coyote_time: float = 0.1           # jump-after-walking-off-ledge window
@export var jump_buffer_time: float = 0.1      # press-just-before-landing window

# Camera.
@export_range(0.0001, 0.01) var mouse_sensitivity: float = 0.002
@export var pitch_min: float = -1.4            # ~-80°
@export var pitch_max: float = 1.4             # ~+80°

# External-velocity damping — knockback / explosions / conveyor belts decay over time.
@export var external_damping: float = 8.0

@onready var head: Node3D = $Head
@onready var camera: Camera3D = $Head/Camera

# Internal state.
var _coyote_timer: float = 0.0
var _jump_buffer_timer: float = 0.0
var _was_on_floor: bool = false

# External velocity accumulator. Any system can push the player by adding to this;
# we apply it, then decay it. Keeps designer-driven forces from fighting input.
var external_velocity: Vector3 = Vector3.ZERO
var _prev_external_applied: Vector3 = Vector3.ZERO

func _ready() -> void:
    Input.mouse_mode = Input.MOUSE_MODE_CAPTURED

func _unhandled_input(event: InputEvent) -> void:
    if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
        rotate_y(-event.relative.x * mouse_sensitivity)
        head.rotate_x(-event.relative.y * mouse_sensitivity)
        head.rotation.x = clamp(head.rotation.x, pitch_min, pitch_max)
    elif event.is_action_pressed("ui_cancel"):
        Input.mouse_mode = Input.MOUSE_MODE_VISIBLE

func _physics_process(delta: float) -> void:
    # 1. Strip last frame's external contribution so it doesn't compound with input.
    velocity -= _prev_external_applied

    # 2. Gravity (asymmetric — falling faster than rising feels better).
    if not is_on_floor():
        var g := gravity * (fall_gravity_multiplier if velocity.y < 0.0 else 1.0)
        velocity.y -= g * delta

    # 3. Coyote-time bookkeeping. Reset on landing; tick down when airborne.
    if is_on_floor():
        _coyote_timer = coyote_time
    else:
        _coyote_timer = max(_coyote_timer - delta, 0.0)

    # 4. Jump buffer. Holding/tapping jump up to `jump_buffer_time` before landing
    #    queues the jump — fires the instant we touch ground.
    if Input.is_action_just_pressed("jump"):
        _jump_buffer_timer = jump_buffer_time
    else:
        _jump_buffer_timer = max(_jump_buffer_timer - delta, 0.0)

    if _jump_buffer_timer > 0.0 and _coyote_timer > 0.0:
        velocity.y = jump_velocity
        _jump_buffer_timer = 0.0
        _coyote_timer = 0.0

    # 5. Build wish-direction in worldspace from camera yaw.
    var input_v := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
    var wish_dir := (transform.basis * Vector3(input_v.x, 0.0, input_v.y))
    wish_dir.y = 0.0
    wish_dir = wish_dir.normalized() if wish_dir.length() > 0.001 else Vector3.ZERO

    # 6. Pick speed + accel/friction based on grounded state.
    var target_speed := sprint_speed if Input.is_action_pressed("sprint") else walk_speed
    var accel := ground_accel if is_on_floor() else air_accel
    var friction := ground_friction if is_on_floor() else air_friction

    var horiz := Vector3(velocity.x, 0.0, velocity.z)
    if wish_dir.length() > 0.0:
        # Accelerate toward wish_dir at `accel` units/sec — not instant snap.
        horiz = horiz.move_toward(wish_dir * target_speed, accel * delta)
    else:
        # No input — decelerate via friction.
        horiz = horiz.move_toward(Vector3.ZERO, friction * delta)
    velocity.x = horiz.x
    velocity.z = horiz.z

    # 7. Apply external velocity, remember it for next-frame subtraction, decay it.
    velocity += external_velocity
    _prev_external_applied = external_velocity
    external_velocity = external_velocity.move_toward(Vector3.ZERO, external_damping * delta)

    _was_on_floor = is_on_floor()
    move_and_slide()

# Public API for other systems — knockback, explosions, jump pads, conveyor belts, etc.
func add_external_velocity(impulse: Vector3) -> void:
    external_velocity += impulse
```

## Property Reference

| Property | Default | Why |
| - | - | - |
| `walk_speed` | `5.0` | Standard FPS pace; feels right at 80–90° FOV. |
| `sprint_speed` | `8.0` | \~60% faster — noticeable without trivializing distances. |
| `jump_velocity` | `5.5` | \~1.5m apex — clears typical step + crate heights. |
| `gravity` | `20.0` | Heavier than 9.8 — game-feel default, not realism. |
| `fall_gravity_multiplier` | `1.4` | Snappier descent than ascent (variable-jump-feel staple). |
| `ground_accel` / `ground_friction` | `80 / 90` | Crisp start, near-instant stop. |
| `air_accel` / `air_friction` | `25 / 5` | Reduced air control + minimal air drag. |
| `coyote_time` | `0.1` | Standard 0.08–0.15s grace — invisible to players, removes most "unfair death" complaints. |
| `jump_buffer_time` | `0.1` | Same window the other direction. |
| `mouse_sensitivity` | `0.002` | Default raw multiplier; expose via settings. |
| `external_damping` | `8.0` | Knockback decays over \~0.5s. |

## Why Each Feature Matters

**Coyote time (\~0.1s)** — Players judge ledges by what they see, not by frame-perfect collision. A small grace period after walking off a ledge where jump still works fixes the "I jumped right at the edge and died unfairly" complaint. Cost: 3 lines. The usual window is 0.08–0.15s — short enough to be invisible, long enough to mask the imprecision of human reaction time.

**Jump buffering (\~0.1s)** — Pressing jump 50ms before landing should not be punished. Buffer the input for \~0.1s and fire on touchdown. Without this, fast-moving players feel like the controller "ate their input" — they pressed jump, they saw nothing happen, they blame the game. With it, every "near-miss" jump becomes a successful one. Cost: 4 lines.

**Air control with reduced acceleration (\~30% of ground)** — Zero air control feels like ice; the player commits to a direction at jump-takeoff and can't course-correct. Full air control feels weightless; momentum is meaningless and a strafe-jump cancels itself. The shipping FPS standard sits around 25–30% of ground accel: enough to course-correct mid-jump, not enough to cancel realistic momentum. The `move_toward` formulation lets designers tune this with one number — `air_accel` — without forking code.

**External velocity accumulator** — Knockback, explosions, jump pads, conveyor belts, wind volumes, and grapple-yanks must not fight player input. Storing them in a separate `external_velocity`, applying once per frame, and decaying via `move_toward(0, damping * delta)` means an explosion shoves the player and then control returns smoothly — no "stuck at terminal velocity" or "input cancels the punch" bugs. The subtract-previous-contribution trick (line 1 of `_physics_process`) is the production fix that prevents accumulation when the player is also sprinting; it's the kind of detail you only learn by shipping multiplayer. Public API (`add_external_velocity`) means any system — AOE attack, jump pad scene, grapple ability — can shove the player with one call, no coupling.

**Acceleration / friction via `move_toward`** — Setting `velocity.x = direction.x * speed` is the AI-tutorial pattern: instant snap to target, instant snap to zero. Real controllers ramp up and ramp down. `move_toward(target, rate * delta)` gives four separate knobs (`ground_accel`, `ground_friction`, `air_accel`, `air_friction`) so designers can dial in "snappy military shooter" (high both) vs "floaty arena shooter" (high accel, low friction) without rewriting code. This is the single biggest reason a controller built from this skeleton feels like a real shipped FPS instead of a prototype.

## Tuning By Feel — Recipe Cards

Once the skeleton is in, designers tune by feel. Common targets:

**Snappy military shooter:**

* `walk_speed = 5.5`, `sprint_speed = 8.0`
* `ground_accel = 90`, `ground_friction = 100`
* `air_accel = 20`, `air_friction = 4`
* `jump_velocity = 5.0`, `gravity = 22`, `fall_gravity_multiplier = 1.5`
* `coyote_time = 0.08`, `jump_buffer_time = 0.08`

**Floaty arena shooter (longer airtime, more air control):**

* `walk_speed = 6.5`, `sprint_speed = 10.0`
* `ground_accel = 60`, `ground_friction = 60`
* `air_accel = 35`, `air_friction = 2` (low air friction = momentum preserved)
* `jump_velocity = 7.0`, `gravity = 16`, `fall_gravity_multiplier = 1.2`

**Heavy / tactical (slower, more committed):**

* `walk_speed = 4.0`, `sprint_speed = 6.0`
* `ground_accel = 50`, `ground_friction = 60`
* `air_accel = 12`, `air_friction = 6`
* `jump_velocity = 4.5`, `gravity = 24`

The skeleton doesn't change — only the `@export` defaults. That's the point of typed exports.

## Common Issues

* **Controller feels stiff or instant** — verify `ground_accel` and `ground_friction` are not absurdly high (>500). If they are, `move_toward` saturates in one frame and you get the snap-to-target behaviour you were trying to avoid.
* **Controller feels mushy / slidey** — `ground_friction` is too low (under 30). Crank to 80–120 for crisp stops.
* **Jump occasionally fails near ledges** — coyote isn't being reset on landing. Confirm the `is_on_floor()` branch in step 3 runs before the buffer-fires branch in step 4.
* **Pressing jump just before landing does nothing** — `_jump_buffer_timer` is being decremented to zero before the player touches floor. The buffer must NOT be cleared by anything except a successful jump.
* **Knockback compounds forever** — `_prev_external_applied` isn't being subtracted. Step 1 of `_physics_process` MUST run before gravity, or external impulses pile up frame after frame.
* **Knockback decays too fast / too slow** — tune `external_damping`. `8.0` decays a 5 m/s impulse to near-zero in \~0.6s. Lower = longer hangtime.
* **Air control feels like ice OR weightless** — `air_accel` is the only knob. The usual range is 20–30% of `ground_accel`. Zero = ice. Equal = arcade-y.
* **Camera tilts on slopes** — pitch is on the body instead of the Head. Pitch only on `Head`; yaw only on `Player`.
* **Camera rolls when looking around** — you're calling `rotate_x` on something that's already rotated on Y. Fix: only ever rotate Y on Player and X on Head, never combine.
* **Mouse look doesn't work** — `Input.mouse_mode` isn't set to `MOUSE_MODE_CAPTURED`, or another node is consuming `_input` before this script. Use `_unhandled_input` to be a good citizen.
* **Mouse look feels laggy at low FPS** — sensitivity is being applied in `_physics_process`. Mouse-motion handling MUST stay in `_input` or `_unhandled_input` — those fire per-event, not per-physics-tick.
* **Falling through floor** — floor needs a StaticBody3D + CollisionShape3D on the same physics layer as the player.
* **Camera clipping geometry** — push `Camera.near` to \~0.05 or shrink the capsule radius slightly.
* **Player jitters on slopes / stairs** — enable `floor_snap_length = 0.5` and `floor_max_angle = 0.785` (\~45°) on the CharacterBody3D. Out of scope for this skeleton.

## Networking Note

In a Summer multiplayer game this controller stays on the player's own
client: it moves the local body, and the client publishes the resulting pose
for the authority to check (`multiplayer-movement`). Other players' bodies
are drawn from their published poses, never simulated locally. Never sync it
with `@rpc` or `MultiplayerSynchronizer`. Knockback, explosions and jump pads
that the authority decides arrive as an authority `reset()` of the pose, or
as State the owner applies to `external_velocity`; the external-velocity
accumulator above absorbs them without fighting input.

## Beyond This Skeleton

The skeleton is intentionally \~90 lines. When you outgrow it, add features by *layering*, not by stuffing more branches into `_physics_process`:

* **Headbob.** Drive `Head.position.y` from a sine of `accumulated_walk_distance`, not of time — bob disappears when you stop without a fade-out timer. Amplitude scales with current horizontal speed / `walk_speed`. Disable while airborne.
* **Crouch.** Lerp the capsule height (e.g., 1.8 → 1.0) and the Head Y (1.6 → 0.9) over \~0.15s. Halve `walk_speed` while crouched. Block standing if a short upward shapecast hits ceiling.
* **Sprint FOV punch.** Tween `Camera.fov` from 75 → 82 over 0.2s when sprint starts, back when it ends. The single cheapest "feel of speed" effect available.
* **Slide.** When sprint + crouch are pressed together with horizontal speed above a threshold, lock direction to current velocity, set a `slide_timer`, and skip the wish-direction step. Apply slide-specific friction. Restore on timer expiry, ceiling clearance, or speed-below-threshold.
* **Wallrun, dash, double-jump.** Each is its own override state. Build a state machine (see `gdscript-patterns`) and let each state run its own `_physics_process` branch with an early `move_and_slide()` return. Don't pile them as `if/elif` in a single function.
* **Camera shake.** Add a `trauma` float on the camera, accumulate via `add_trauma(amount)`, decay each frame, apply a randomized rotation offset proportional to `trauma * trauma`. Subtract the previous frame's shake offset before applying the new one to prevent drift. See `game-feel`.

Each of these is a separate skill or a separate function, not a fork of this script.

## Fallback (no MCP — edit `.tscn` directly)

Save as `res://scenes/player.tscn`:

```
[gd_scene load_steps=3 format=3]

[ext_resource type="Script" path="res://scripts/player_controller.gd" id="1"]

[sub_resource type="CapsuleShape3D" id="caps"]
radius = 0.4
height = 1.8

[node name="Player" type="CharacterBody3D"]
position = Vector3(0, 1, 0)
script = ExtResource("1")

[node name="Collision" type="CollisionShape3D" parent="."]
shape = SubResource("caps")

[node name="Head" type="Node3D" parent="."]
position = Vector3(0, 1.6, 0)

[node name="Camera" type="Camera3D" parent="Head"]
```

Then patch `project.godot` with the InputMap actions (`input/move_forward = { ... }` etc.) and `Write` the script body from the section above.

## Collaborative Protocol

This skill writes scene nodes, an InputMap, and a `.gd` script. Always ask before applying. Group writes per phase: "May I add the Player + Collision + Head + Camera, bind WASD/jump/sprint, and attach `player_controller.gd`?"

If the user asks for a "third-person FPS", flag the contradiction: FPS = first-person. Either clarify, or hand off to `character-movement`, which has the third-person controller.

## See Also

* `gdscript-patterns` — once movement grows past this skeleton (slide, grapple, hover), refactor into a state machine instead of stuffing more branches into `_physics_process`.
* `character-movement` — 2D and third-person controllers.
* `game-feel` — camera shake and hit feedback.

## From a shell

No Summer MCP tools in this session? Run each one as a command; when `summer --version` is missing or below 4.0, use `npx -y summer-engine@latest` in place of `summer`. Fields are flags in kebab-case (`--scene-path res://main.tscn`) or one `--input '<json>'`, and `summer ops describe <op>` prints the exact input. The `summer-cli` skill has the rest.

| In this skill | Command |
| - | - |
| `summer_add_node` | `summer engine add-node` |
| `summer_get_scene_tree` | `summer engine get-scene-tree` |
| `summer_get_script_errors` | `summer engine get-script-errors` |
| `summer_input_map_bind` | `summer engine input-map-bind` |
| `summer_run_script` | `summer engine run-script` |
| `summer_save_scene` | `summer engine save-scene` |
| `summer_set_prop` | `summer engine set-prop` |
| `summer_set_resource_property` | `summer engine set-resource-property` |

## Related skills

* [`character-movement`](/skills/character-movement): Player controllers for 2D and 3D, plus the standard third-person controller for Summer humanoids.
* [`camera-rigs`](/skills/camera-rigs): Camera rigs: Camera2D follow with limits, first-person head, SpringArm3D third-person, top-down, FOV.

***

Source: [`library/skills/fps-controller/SKILL.md`](https://github.com/SummerEngine/summer/blob/main/library/skills/fps-controller/SKILL.md), rendered from `summer-engine@4.3.0` on npm.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.