What it is. The layer between a player’s input and a player’s body: it turns “forward, jump, look left” into movement the same way on the client that predicts it and on the server that checks it.
When to use it. You want a first-person or third-person character that is ready for multiplayer, with movement feel you can tune from a file. For a top-down or thrust-driven game use dot-2d instead.
Smallest setup. A CharacterBody3D with a collider, a head with a camera, and two child nodes. Nothing is wired by hand: the view finds the camera by type and the controller finds the collider and the view the same way.
Player (CharacterBody3D)
├── Collision (CollisionShape3D)
├── Head (Node3D)
│ └── Camera (Camera3D)
├── View (DotFpsView)
└── Controller (DotFpsController)
In detail, dot-player-controller ships
an abstract base, a first-person movement model written to be predicted and
reconciled, a third-person orbit rig, and a switch that hands control from one
to the other.
addons/dot_player_controller/
core/ nodes/ the contract: intent, look, the abstract base, the switch
fp/ first-person movement
tp/ third-person movement, and the camera rig
Requires dot-core and dot-player. dot-net is optional and is never
imported. A game that wants one half can delete the other folder — the plugin
skips a node type whose script is absent.
`fp/` was the `dot-fps-controller` addon
Every class_name is unchanged — DotFpsController, DotFpsMotor,
DotFpsTunables and the rest. class_name is global in Godot, so renaming one
breaks every project that has it installed, and DotFps still says what it is.
The contract every controller shares
Intent
An intent is what the player asked for, not which key they pressed and not what happens as a result. A sampler produces them, a controller consumes them, a replay hands the same ones back, and a server receives them over a wire without ever seeing a keyboard.
var intent := DotPlayerIntent.make(Vector2(0, 1), DotPlayerIntent.Btn.JUMP, tick)
intent.view_yaw = look.yaw
controller.simulate(intent, delta)
diff_from(previous_buttons) fills in pressed and released, because a jump
is an edge and a sprint is a level — and a controller computing edges itself
would need the previous command, which a stateless replay does not have.
The look delta is not on the wire
A server that integrated a client’s deltas would be reconstructing an angle the client already knows, one lost packet away from disagreeing about it for ever. The client sends where it is looking; the server clamps and accepts.
Look
DotPlayerLook holds the view angles and the sensitivity rules.
look.apply(mouse_delta) # sensitivity, inversion, zoom
camera.basis = look.basis() # yaw then pitch
body.basis = look.body_basis() # yaw only
velocity = look.move_direction(intent.move) * speed
It exists to stop three mistakes that are easy to make and hard to see:
- Pitch clamps; yaw wraps. Clamping yaw stops the player turning round; wrapping pitch lets them look through their own feet.
- The wrap is bounded. A yaw that only accumulates loses float precision over a long session, and the symptom is a mouse that gets less accurate the longer the server has been up.
- Sensitivity multiplies the delta, not the angle. Applied to the angle, the view snaps every time the setting changes.
forward_flat() is separate from forward() so a player looking at the floor
still walks forwards, and move_direction lives here so two controllers cannot
disagree about which component is X.
The switch
DotPlayer
DotPlayerControllerSwitch default_controller = &"fp"
DotFpsController controller_id = &"fp"
DotTpsController controller_id = &"tp"
Without it, two controllers both read input and both write the body’s transform, and which one wins depends on child order.
The handover carries exactly four things: position, velocity, yaw and pitch.
Not the outgoing controller’s state — a first-person motor’s state and a
third-person motor’s have nothing in common, and getting out of a vehicle should
not restore the air-strafe you were in the middle of. It runs after the
incoming controller’s _on_activated, because that is where a controller resets
its motor.
set_input_enabled(false) freezes every controller, not only the active one,
and a frozen controller still ticks: simulate() applies an empty intent rather
than skipping, so a frozen player keeps falling and keeps sliding to a stop
instead of hanging in mid-air.
The first-person half
The simulation is a pure function of (state, command, delta, world), which is
what lets a client apply input immediately and a server correct it a round trip
later without the two disagreeing. It does its own collide-and-slide, because
bunny-hopping and surfing are consequences of exactly how the sliding works.
Player (CharacterBody3D)
├── Collision (CollisionShape3D)
├── Head (Node3D)
│ └── Camera (Camera3D)
├── View (DotFpsView)
└── Controller (DotFpsController)
Nothing is wired by hand: the view finds the camera by type and the controller
finds the collider and the view the same way, each through a DotNodeRef you
can override in the inspector.
What is in it
DotFpsCommand |
One tick of player intent. Bit-packed to 49 bits. |
DotFpsState |
Everything the simulation carries between ticks. Capture, restore, compare. |
DotFpsTunables |
Every movement number, layered: defaults < JSON < DOT_FPS_* < --fps-*. |
DotFpsMotor |
The simulation. Deterministic, no engine globals, no input reads. |
DotFpsBody |
The collision queries the motor needs. DotFpsPhysicsBody for real geometry, DotFpsFlatBody for tests. |
DotFpsController |
The node. Drives the motor, writes to the scene, resizes the collider. |
DotFpsView |
Camera, pitch, crouch height, speed FOV. Cosmetic only, runs at frame rate. external_offset eases a prediction correction into the eye; external_angles (degrees: pitch, yaw, roll) adds a weapon’s view punch to the camera without touching the simulated aim. |
DotFpsSampler |
Devices to commands. The only thing that reads the keyboard. |
DotFpsTouchSampler |
Commands from touch, for phones and the browser. No art, no layout. |
DotFpsNetSync |
What to replicate and at what precision, without importing dot-net. |
DotFpsStats |
Strafe quality, sync, airtime — what a movement HUD shows. |
Movement behaviour
Everything below falls out of one acceleration function rather than being
special-cased, and every value is in DotFpsTunables.
| Air-strafing, bunny-hopping | Airborne acceleration is capped at max_air_wish_speed (1 m/s) measured as a projection onto the wish direction, so turning while strafing adds speed. 0 disables it. |
| Surfing | Steep slopes are walls. _slide keeps every plane touched during a move and picks a direction satisfying all of them, so a ramp junction taken at 20 m/s slides along the crease instead of stopping dead — and a surfer pressed into a bank keeps sliding along it rather than hanging in mid-air at full speed. |
| Walkable slopes | Anything under max_slope_angle is ground: it can be walked up, a jump from it still leaves it, landing on it while moving uphill grounds you, and a ramp’s crest does not throw you into the air. |
| Stair stepping | step_height is a hard ceiling measured from the floor you are standing on, not from your feet: a kerb under it is stepped onto, walking or running, without launching you into the air, and a kerb one centimetre over it is a wall at every speed. Also refused when there is nothing to stand on behind the step. |
| Crouching | Resizes the collider, keeps the feet planted on the ground and the head planted in mid-air, and refuses to stand up under a low ceiling. |
| Coyote time, jump buffering | Both configurable, both off at zero. |
| Ground snapping | So walking down slopes and stairs does not give air physics every other tick. |
| Wall running, wall jumping, wall slide | Off by default: wall_run_enabled and wall_jump_enabled in DotFpsTunables. A run needs wall_run_min_speed along the wall and ends under wall_run_end_speed or after wall_run_max_time; gravity drops to wall_run_gravity_scale meanwhile. wall_slide_speed is the controlled descent a finished run decays into, and wall_jump_max_per_wall and wall_jump_max_per_air bound the jumps. A wall beside you wins over a nearer face behind you. It is part of the deterministic motor, so prediction replays it. |
Build level geometry against `step_height`, not around it
Until late September 2026 a fast enough runner could ride the capsule’s rounded
bottom up a kerb’s corner and clear kerbs well over step_height, and even a
low kerb popped a runner half a metre into the air. Both are fixed, so a map
that relied on either — a “wall” that was only just over step_height, a ledge
reachable only at a sprint — behaves differently now. If a barrier must stop
players, make it taller than step_height; if they must climb it, make it
lower.
How far a jump goes is the addon’s question. DotFpsTunables answers it
from its own numbers: jump_apex(), climb_limit(margin),
jump_airtime(rise) and jump_reach(rise, speed), plus static airtime_for
and reach_for for a map that holds constants rather than tunables. An
unreachable rise answers 0.0 / -1.0. Four games had each carried the same
arithmetic over their own copies of three of these numbers, with a check that
their copy agreed — and one of them shipped a flat-jump airtime the motor did
not actually achieve. Ask the tunables when laying out gaps and ledges.
Rules, a second motor and the drawn body
The addon has an API level (LEVEL in the addon’s dot_player_controller_api.gd, currently 5, read through dot-core’s DotAddonApi); a game that needs a newer feature checks it rather than the addon’s version.
mp_forceview(DotPlayerViewRules, API 2): 0 leaves the view key to the player, 1 forces first person, 2 forces third person. The server decides because a third-person camera sees round corners; the camera boom is dot-core’sDotCameraBoom.mp_solidplayers(DotPlayerBodyRules, API 2): other players stop a sweep.DotFpsBody.pass_out_mask, set throughDotFpsController.set_pass_out_layers(), names layers a player stops at but is never held inside, so two players placed on one spot walk apart instead of standing still for good.DotFpsSimulator(API 3): a second motor over a copy of a player’s body, fromDotFpsController.simulator(), for a bot that plans ahead. It never touches the real player.DotFpsViewMotion(API 4): the view’s motion cues without nodes.body_roll()andbody_yaw()with thewall_run_body_roll,wall_run_body_yawandwall_run_body_responsefields give a drawn body its pose on a wall;DotFpsViewexports and forwards them.- Wheel on jump (API 5):
DotFpsSamplerlatches button presses between samples, so one mouse-wheel tick bound to jump is exactly one press of jump in a command. Feed it every event throughhandle_event.
Where a game plugs in
Five hooks, none of which needs a fork. The first three are part of the simulation, so all three replicate and survive a prediction replay.
- DotFpsSurface in a DotFpsSurfaceSet
Per-surface movement: ice, mud, conveyors, unstandable rails. Mark a collider
dot_fps_surface = "ice"or put it in asurface_icegroup.Surfaces hold multipliers, never absolute values, so retuning the base movement carries every surface with it. They resolve from something both machines can see — never a collider’s instance id, which differs between processes.
- DotFpsModifier
A temporary change: speed, acceleration, air acceleration, friction, gravity and jump scales;
deny_jump,deny_crouch,deny_move; and animpulse. Speed pads, slow fields, stuns, launchers.duration_secandrefreshabledecide how it ends.An administrator’s noclip, freeze, speed and gravity are modifiers too —
DotFpsAdminModifiers, behindDotFpsController.admin_abilities, which is what dot-moderation’s live tools drive.forces_noclipoverrides the ability gate, and noclip flight honoursdeny_move, so a frozen player stays frozen in noclip.- DotFpsMoveMode subclass
A whole new way to move — a ladder, water, a grapple — using the motor’s own collision and acceleration.
_name,_enter,_exit,_simulate,_uses_crouch.- DotFpsSampler / DotFpsTouchSampler
Devices to commands, and the only place input is read. Or build a
DotFpsCommandyourself for a bot or demo playback — the motor cannot tell the difference.- DotFpsStyle
A named variation: sideways, half-sideways, backwards, low gravity, prebhop. Jump toggles, gravity and speed scales, air acceleration, a timescale, key blocking, and prespeed and velocity limits.
apply_to(tunables)produces the transformed set; pair it with aDotTimerStyleof the same id for ranked styles.- Signals
simulated,jumped,landed,crouch_changed,mode_changed,surface_changed,stepped_up,modifier_added,modifier_removed.
Networking
The first-person half does not depend on dot-net and does not name it. What it
provides instead is the hard part: a simulation that reproduces itself exactly
when replayed, a command that packs to 49 bits, a state object with nothing left
outside it, and DotFpsNetSync, which describes what to replicate as data —
property and type names as strings, which a bridge resolves. Joining the two is
about thirty lines in your game.
Admin powers must be modifiers, or they rubber-band
The obvious implementation of an admin noclip is a server setting
state.mode = NOCLIP. The client never predicted that, so on every replayed
tick its own noclip gate undoes it and the player snaps back and forth. The
active modifier set is already in the replicated state and already restored by
a rewind, so an admin power expressed as a modifier is predicted like any speed
pad. The test suite keeps the naive version as a control that must fail.
Wire up `DotFpsTunables.fingerprint()` on day one
A client whose movement config differs from the server’s diverges every tick, and the symptom is indistinguishable from packet loss.
`render_state` is what stops the stutter
It interpolates a player’s position between the last two ticks, and it is the cure for the jitter people report as “the netcode feels bad”. Measured at 60 frames against a 128-tick server with it disabled: a 47% change in apparent speed, eight times a second.
Both halves of the fix are needed — either alone measures as no better than doing nothing. The fraction a renderer interpolates at is a fraction through a physics frame, which is a fraction through a tick only while the two rates agree.
The third-person half
An orbit rig on a spring arm, a swappable shoulder offset, camera-relative movement, turn-to-face and strafe-lock, coyote time and a jump buffer.
`tp/` is deterministic, but not rewind-deterministic
DotTpsMotor.step is pure, but it does not do its own collide-and-slide —
move_and_slide resolves the collisions. It therefore reproduces itself given
the same collision results: enough for a replay on one machine, not enough for
bit-exact rewind against a server that resolved them itself.
A third-person shooter that needs the latter drives the first-person motor and
uses tp/ for the camera alone.
Writing a controller of your own
Five methods, of which a simple controller overrides one: the base already reads
and writes a CharacterBody3D or a CharacterBody2D found through the player.
Example: a minimal controller
class MyController extends DotPlayerController:
func _apply_intent(intent: DotPlayerIntent, delta: float) -> void:
var body := player().body() as CharacterBody3D
body.velocity = look.move_direction(intent.move) * 6.0
body.velocity.y = _fall
body.move_and_slide()
# Only needed when the thing being driven is not a character body:
func _read_transform() -> Transform3D: ...
func _write_transform(to: Transform3D) -> void: ...
func _read_velocity() -> Vector3: ...
func _write_velocity(v: Vector3) -> void: ...examples/controller_selftest.gd in the repository has a worked one, driven by
scripted intents with no input device present.
