DEMIENGINEDOCUMENTATION Everything is free
3D gameplay · Game systems

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

NeedCharacterController3DRigidbody3D
Responsive stick/keyboard movementYesOnly with heavy tuning
Knockback, tumbling, stacking cratesNoYes
Step up small ledges automaticallyYesNo
Slope limit (slide or stop on steep faces)YesFriction only
Pushed around by explosionsNo (react in gameplay)Yes
Driven by forces and massNoYes

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 Rigidbody3D on 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 CapsuleCollider3D is the capsule the controller sweeps through the world.
  • radius and height size the capsule; offset [0, 0.85, 0] lifts it so the base sits at the entity origin (the feet).
  • is_trigger: false is required — a trigger shape cannot block movement.
  • layer participates in layer filtering like any other collider.

Controller fields

FieldMeaningTypical value
slope_limitSteepest walkable slope in degrees. Steeper faces are slid down rather than climbed.45
step_heightMaximum ledge height climbed without jumping, in world units.0.35
skin_widthSmall gap kept between the shape and obstacles. Prevents sticking in corners and jitter against walls.0.02
gravityDownward acceleration applied each step, world units per second squared. Negative pulls down.-18.0

Tuning notes:

  • A skin_width too small lets the capsule catch on seams; too large and the character floats visibly above floors.
  • step_height above knee height makes stairs feel like ramps — usually not what you want.
  • gravity is 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
CallArgumentsReturns
set_velocityid, desired vx, vy, vzBoolean: true when the controller accepted the value. The current 3D movement step reads the horizontal X/Z values; gravity and jumping control Y.
jumpid, positive upward speed in world units/secondBoolean: true when the request was queued. The physics step applies it only when grounded; acceptance is not proof of a jump.
stateidTable 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_update samples the action values and stores the jump edge so a fixed step can consume it.
  • on_fixed_update applies horizontal intent; the controller maintains vertical gravity and jump velocity.
  • Character.state reports grounded state and a one-based velocity array. Character.jump takes 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_height are climbed; faces steeper than slope_limit are 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_number and set_bool feed state-machine parameters; call Animation.trigger only when a jump is actually started.
  • Blend spaces pick idle/run from speed; transitions react to grounded.

Checklist

  • One non-trigger primitive or convex collider, no rigidbody.
  • Capsule sized to the character with offset lifting it to the feet.
  • slope_limit, step_height, skin_width, and gravity tuned for the level’s stairs and ramps.
  • Movement applied in on_fixed_update through set_velocity.
  • Jump presses buffered in script when needed; native jump applies only while grounded.