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:
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
Step-by-Step Setup via MCP
1. Verify the scene first
Always callsummer_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 onesummer_run_script
call (one undo step):
scenePathis required onsummer_run_scriptand 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_bindis the exception — it editsproject.godot, not a scene, and takes onlyname+events.)- Build and size a shape before you assign it. Tuning one value later is
one
summer_set_resource_propertycall on the assigned resource. Structural failures there are explicit —node not found,property is not a resource, orresource is nullwhen 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 misspelledsubProperty, or a wrong-typed value returnsok:trueyet no-ops or coerces destructively. PassVector3(...)/Color(...)strings and confirm in the saved.tscn, never fromokalone.
3. InputMap actions
4. Attach the controller script
Ask before writing: “May I createscripts/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.
- Subtract last frame’s external velocity contribution. If you skip this, knockback and explosions accumulate forever.
- Apply gravity (asymmetric — heavier when falling).
- Update coyote timer. Reset to full when grounded, tick down when airborne.
- Update jump buffer + fire jump if both timers are active. Clearing the buffer on press-instead-of-fire is the bug everyone makes.
- Read input, build wish-direction in worldspace by rotating the local 2D input through
transform.basis. - Pick acceleration / friction based on grounded state, then
move_towardthe horizontal velocity towardwish_dir * target_speed. - Apply external velocity, save it for next-frame subtraction, decay it via
move_toward(0, damping * delta). move_and_slide()— last, after everything else has settledvelocity.
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. Themove_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.0ground_accel = 90,ground_friction = 100air_accel = 20,air_friction = 4jump_velocity = 5.0,gravity = 22,fall_gravity_multiplier = 1.5coyote_time = 0.08,jump_buffer_time = 0.08
walk_speed = 6.5,sprint_speed = 10.0ground_accel = 60,ground_friction = 60air_accel = 35,air_friction = 2(low air friction = momentum preserved)jump_velocity = 7.0,gravity = 16,fall_gravity_multiplier = 1.2
walk_speed = 4.0,sprint_speed = 6.0ground_accel = 50,ground_friction = 60air_accel = 12,air_friction = 6jump_velocity = 4.5,gravity = 24
@export defaults. That’s the point of typed exports.
Common Issues
- Controller feels stiff or instant — verify
ground_accelandground_frictionare not absurdly high (>500). If they are,move_towardsaturates in one frame and you get the snap-to-target behaviour you were trying to avoid. - Controller feels mushy / slidey —
ground_frictionis 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_timeris 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_appliedisn’t being subtracted. Step 1 of_physics_processMUST run before gravity, or external impulses pile up frame after frame. - Knockback decays too fast / too slow — tune
external_damping.8.0decays a 5 m/s impulse to near-zero in ~0.6s. Lower = longer hangtime. - Air control feels like ice OR weightless —
air_accelis the only knob. The usual range is 20–30% ofground_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 onPlayer. - Camera rolls when looking around — you’re calling
rotate_xon 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_modeisn’t set toMOUSE_MODE_CAPTURED, or another node is consuming_inputbefore this script. Use_unhandled_inputto be a good citizen. - Mouse look feels laggy at low FPS — sensitivity is being applied in
_physics_process. Mouse-motion handling MUST stay in_inputor_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.nearto ~0.05 or shrink the capsule radius slightly. - Player jitters on slopes / stairs — enable
floor_snap_length = 0.5andfloor_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.yfrom a sine ofaccumulated_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_speedwhile crouched. Block standing if a short upward shapecast hits ceiling. - Sprint FOV punch. Tween
Camera.fovfrom 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_processbranch with an earlymove_and_slide()return. Don’t pile them asif/elifin a single function. - Camera shake. Add a
traumafloat on the camera, accumulate viaadd_trauma(amount), decay each frame, apply a randomized rotation offset proportional totrauma * trauma. Subtract the previous frame’s shake offset before applying the new one to prevent drift. Seegame-feel.
Fallback (no MCP — edit .tscn directly)
Save as res://scenes/player.tscn:
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; whensummer --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.
Related skills
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.
