Skip to content
Back to skills

Fps Controller

ASecurity

First-person controller: WASD, mouse look, jump, coyote time, jump buffering, air control.

  • 77 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 16, 2026
ai-agentsgoshellreactnodeapi

Works with

  • claude code
  • cursor
  • terminal
  • cli
  • api
  • mcp

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 16, 2026

npx -y skills add SummerEngine/summer --skill fps-controller --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Fps Controller?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Fps Controller
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/summerengine-fps-controller/badge)](https://www.skillsdirectory.com/skills/summerengine-fps-controller)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: fps-controller
description: "First-person controller: WASD, mouse look, jump, coyote time, jump buffering, air control."
license: MIT
compatibility: [Cursor, Claude Code, Windsurf, Codex]
category: character-controllers
user-invocable: false
template-id: template-3d-fps-old-school
allowed-tools: Read Grep summer_get_scene_tree summer_run_script summer_add_node summer_set_prop summer_set_resource_property summer_input_map_bind summer_save_scene summer_get_script_errors
paths: ["**/*.gd", "**/*.tscn"]
---

# FPS Controller for Summer Engine

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
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.

<!-- shell-commands: generated from the operation catalog by npm run generate:registry; do not edit by hand -->
## 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` |
<!-- /shell-commands -->

Files in this skill

  • SKILL.md21.6 KB
  • resource.yaml775 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.