Fps Controller logo

Fps Controller

Organization
SummerEngine
fps-controller

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

Overview

PublisherSummerEngine
Repositorysummer
Skill namefps-controller
Stars
91
Forks
8
Bundled files
1
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 1 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by SummerEngine on GitHub. Read the source before you install it.

Installation

Install the Fps Controller AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/SummerEngine/summer/tree/main/library/skills/fps-controller
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/SummerEngine/summer.git /tmp/summer
mkdir -p .claude/skills
cp -r /tmp/summer/library/skills/fps-controller .claude/skills/fps-controller
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Fps Controller in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Fps Controller on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Fps Controller is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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

PropertyDefaultWhy
walk_speed5.0Standard FPS pace; feels right at 80–90° FOV.
sprint_speed8.0~60% faster — noticeable without trivializing distances.
jump_velocity5.5~1.5m apex — clears typical step + crate heights.
gravity20.0Heavier than 9.8 — game-feel default, not realism.
fall_gravity_multiplier1.4Snappier descent than ascent (variable-jump-feel staple).
ground_accel / ground_friction80 / 90Crisp start, near-instant stop.
air_accel / air_friction25 / 5Reduced air control + minimal air drag.
coyote_time0.1Standard 0.08–0.15s grace — invisible to players, removes most "unfair death" complaints.
jump_buffer_time0.1Same window the other direction.
mouse_sensitivity0.002Default raw multiplier; expose via settings.
external_damping8.0Knockback 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 skillCommand
summer_add_nodesummer engine add-node
summer_get_scene_treesummer engine get-scene-tree
summer_get_script_errorssummer engine get-script-errors
summer_input_map_bindsummer engine input-map-bind
summer_run_scriptsummer engine run-script
summer_save_scenesummer engine save-scene
summer_set_propsummer engine set-prop
summer_set_resource_propertysummer engine set-resource-property

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Fps Controller AI skill do?

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

Why use Fps Controller on TypingMind?

Because you install it once and use it with any model. Fps Controller is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Fps Controller in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/SummerEngine/summer/tree/main/library/skills/fps-controller. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Fps Controller?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Fps Controller?

As many as you like. As long as a model supports skills, you can use Fps Controller with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Fps Controller AI skill free?

Yes. It is published on GitHub by SummerEngine under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇