New

dot-player-controller

Everything that drives a player — the intent and look contract every controller shares, a first-person movement model built to be predicted and reconciled, a third-person orbit rig, and the switch that hands one to the next.

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.

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.

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’s DotCameraBoom.
  • mp_solidplayers (DotPlayerBodyRules, API 2): other players stop a sweep. DotFpsBody.pass_out_mask, set through DotFpsController.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, from DotFpsController.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() and body_yaw() with the wall_run_body_roll, wall_run_body_yaw and wall_run_body_response fields give a drawn body its pose on a wall; DotFpsView exports and forwards them.
  • Wheel on jump (API 5): DotFpsSampler latches 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 through handle_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 a surface_ice group.

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 an impulse. Speed pads, slow fields, stuns, launchers. duration_sec and refreshable decide how it ends.

An administrator’s noclip, freeze, speed and gravity are modifiers too — DotFpsAdminModifiers, behind DotFpsController.admin_abilities, which is what dot-moderation’s live tools drive. forces_noclip overrides the ability gate, and noclip flight honours deny_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 DotFpsCommand yourself 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 a DotTimerStyle of 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.

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.

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.