CharacterController3D movement, slopes, and jumps.
Characters
A character controller is a mover built for walking, running, and jumping people and robots — not for crates. This page explains what CharacterController3D is, how it differs from a rigidbody, and every field and call you need to get a Skyforge pilot across a station deck.
What CharacterController3D is
Games need two very different kinds of moving objects. A crate should tumble, get shoved, and settle — the physics simulation owns it. A player should go exactly where the stick says, step over small ledges, stop at slopes that are too steep, and never tip over.
CharacterController3D is the second kind. It is a component that moves an entity every fixed step using the collider shape, while resolving the details that make movement feel good: ground detection, step climbing, slope limits, and wall slide. It is not a rigidbody — nothing knocks it around, and it does not fall in a physically simulated way beyond the gravity you configure.
Use a character controller for players, bot pilots, and anything that should follow intent. Use a Rigidbody3D for props that belong to the simulation.
When to use which
| Need | CharacterController3D | Rigidbody3D |
|---|---|---|
| Responsive stick/keyboard movement | Yes | Only with heavy tuning |
| Knockback, tumbling, stacking crates | No | Yes |
| Step up small ledges automatically | Yes | No |
| Slope limit (slide or stop on steep faces) | Yes | Friction only |
| Pushed around by explosions | No (react in gameplay) | Yes |
| Driven by forces and mass | No | Yes |
A hybrid is allowed: the player uses CharacterController3D, and the crate the player drops uses Rigidbody3D. They collide normally through their colliders.
Required collider setup
A character controller drives a shape — it does not carry one. Validation enforces this exact arrangement:
- Exactly one non-trigger collider on the same entity.
- The collider must be a primitive or convex shape: box, sphere, capsule, or convex hull. Capsule is the usual pick.
- No
Rigidbody3Don that entity. A controller and a rigidbody on one body would fight over who moves it.
{
"CharacterController3D": {
"slope_limit": 45,
"step_height": 0.35,
"skin_width": 0.02,
"gravity": -18.0
},
"CapsuleCollider3D": {
"radius": 0.35,
"height": 1.7,
"offset": [0, 0.85, 0],
"is_trigger": false,
"layer": "player"
}
}
- The
CapsuleCollider3Dis the capsule the controller sweeps through the world. radiusandheightsize the capsule;offset[0, 0.85, 0]lifts it so the base sits at the entity origin (the feet).is_trigger: falseis required — a trigger shape cannot block movement.layerparticipates in layer filtering like any other collider.
Controller fields
| Field | Meaning | Typical value |
|---|---|---|
slope_limit | Steepest walkable slope in degrees. Steeper faces are slid down rather than climbed. | 45 |
step_height | Maximum ledge height climbed without jumping, in world units. | 0.35 |
skin_width | Small gap kept between the shape and obstacles. Prevents sticking in corners and jitter against walls. | 0.02 |
gravity | Downward acceleration applied each step, world units per second squared. Negative pulls down. | -18.0 |
Tuning notes:
- A
skin_widthtoo small lets the capsule catch on seams; too large and the character floats visibly above floors. step_heightabove knee height makes stairs feel like ramps — usually not what you want.gravityis gameplay feel, not planetary truth. Raise its magnitude for snappier jumps.
Driving the controller from Lua
local Character = require("demi.physics.character_controller3d")
Character.set_velocity(id, vx, 0, vz) -- horizontal intent; Y is physics-owned
local queued = Character.jump(id, 7) -- upward speed in world units/second
local state = Character.state(id)
-- state → table or nil: grounded, velocity { x, y, z }, ground_entity
| Call | Arguments | Returns |
|---|---|---|
set_velocity | id, desired vx, vy, vz | Boolean: true when the controller accepted the value. The current 3D movement step reads the horizontal X/Z values; gravity and jumping control Y. |
jump | id, positive upward speed in world units/second | Boolean: true when the request was queued. The physics step applies it only when grounded; acceptance is not proof of a jump. |
state | id | Table with grounded, velocity { x, y, z }, and ground_entity; nil without a controller. |
A complete Skyforge pilot loop:
local Character = require("demi.physics.character_controller3d")
local Input = require("demi.input")
local Player = {}
function Player:on_create()
self.move_x, self.move_y = 0, 0
self.jump_requested = false
end
function Player:on_update(dt)
self.move_x, self.move_y = Input.vector("move")
self.jump_requested = self.jump_requested or Input.pressed("jump")
end
function Player:on_fixed_update(fixed_dt)
local state = Character.state(self.entity_id)
if not state then return end
local speed = 4.0
Character.set_velocity(self.entity_id,
self.move_x * speed, 0, self.move_y * speed)
if self.jump_requested and state.grounded then
Character.jump(self.entity_id, 7)
end
self.jump_requested = false
end
return Player
on_updatesamples the action values and stores the jump edge so a fixed step can consume it.on_fixed_updateapplies horizontal intent; the controller maintains vertical gravity and jump velocity.Character.statereports grounded state and a one-basedvelocityarray.Character.jumptakes an upward speed; the grounded check prevents an airborne request from being discarded by the physics step.
Details the controller handles for you:
- Wall slide — pushing into a wall moves along it instead of stopping dead.
- Moving platforms — standing on a kinematic platform inherits its motion for the step.
- Steps and slopes — ledges up to
step_heightare climbed; faces steeper thanslope_limitare not.
Trigger events still fire from the character’s shape, so zones notice the pilot (Physics events).
Jump buffering and coyote time
A press just before landing can be buffered in script state and consumed on a grounded fixed step. Keep the buffer on self so two characters do not share one timer:
local Character = require("demi.physics.character_controller3d")
local Input = require("demi.input")
local BufferedJump = {}
function BufferedJump:on_create()
self.jump_buffer = 0
end
function BufferedJump:on_update(dt)
if Input.pressed("jump") then self.jump_buffer = 0.12 end
end
function BufferedJump:on_fixed_update(fixed_dt)
local state = Character.state(self.entity_id)
if state and state.grounded and self.jump_buffer > 0 then
if Character.jump(self.entity_id, 7) then self.jump_buffer = 0 end
else
self.jump_buffer = math.max(0, self.jump_buffer - fixed_dt)
end
end
return BufferedJump
Character.jump queues a request but the native step uses it only while grounded. Calling it during an airborne grace period does not produce coyote time. A true late jump off a ledge needs a separate movement policy; this controller API does not expose one.
Character visuals
The controller moves the entity; a model supplies its visual. An animation state machine can read movement speed and grounded state. Add this method to the Player script above and call it after reading state:
local Animation = require("demi.animation")
function Player:update_animation(state)
local vx, vz = state.velocity[1], state.velocity[3]
Animation.set_number(self.entity_id, "speed", math.sqrt(vx * vx + vz * vz))
Animation.set_bool(self.entity_id, "grounded", state.grounded)
end
set_numberandset_boolfeed state-machine parameters; callAnimation.triggeronly when a jump is actually started.- Blend spaces pick idle/run from
speed; transitions react togrounded.
Checklist
- One non-trigger primitive or convex collider, no rigidbody.
- Capsule sized to the character with
offsetlifting it to the feet. slope_limit,step_height,skin_width, andgravitytuned for the level’s stairs and ramps.- Movement applied in
on_fixed_updatethroughset_velocity. - Jump presses buffered in script when needed; native
jumpapplies only while grounded.