Skip to main content
fps-controller is a skill from the Summer skill library: 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.

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:
Swap claude-code for your agent, such as codex or cursor. See Skills 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

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):
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

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)

Property Reference

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:
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.
  • character-movement: Player controllers for 2D and 3D, plus the standard third-person controller for Summer humanoids.
  • camera-rigs: Camera rigs: Camera2D follow with limits, first-person head, SpringArm3D third-person, top-down, FOV.

Source: library/skills/fps-controller/SKILL.md, rendered from summer-engine@4.3.0 on npm.