Venus Lua API › Physics
DynamicCharacter
Two-way, massy character capsule in the deterministic physics world.
lua/venus_lua/src/lua_dynamic_character.rs
DynamicCharacter
A dynamic character controller: a real rigid-body capsule in the app-owned FixedXpbdWorld whose
orientation is locked (it cannot tip) and whose horizontal motion is a force-capped velocity
motor — light crates yield to it, heavy crates block it. Gravity and contacts own vertical
motion; Jump is an impulse. It is the two-way, massy counterpart to
KinematicController (which sweeps a shape and never pushes anything).
Because its state is fixed-point and lockstep-deterministic, drive it from Script.FixedUpdate at
the fixed cadence, not from Update. Shape and mass are baked once, at attach time, via Add.
Static calls use the type table (DynamicCharacter.Add(...)); instance methods use colon syntax
(dc:Move(...)).
Types
DynamicCharacter — an opaque handle (userdata) to a dynamic-character component on a GameObject.
Passing a value that is not a DynamicCharacter to any method (including Exists) raises a type
error; a genuine stale handle never raises — it returns the documented default (nil /
false) or no-ops. The handle is safe to hold and pass around. There is no custom __tostring, so
tostring(dc) is Lua’s default userdata string.
The body is registered on on_enable, not on Add. Until it is registered, every
world-touching method — Move, Stop, Jump, IsGrounded, Velocity/SetVelocity,
Position, SetFriction, SetStepOffset — no-ops or returns its default. Guard with
IsRegistered() (the demo script does). Registration is torn down on on_disable, so deactivating
the GameObject removes the body from the simulation; reactivating re-registers it and re-applies
the cached friction and step offset.
The GameObject’s rotation is owned by gameplay: the simulation leaves it alone (the body’s
orientation is locked, and update writes position only), so animate facing/yaw freely — it never
feeds back into physics. There is no SetRotation binding (unlike KinematicController); rotate the
GameObject directly.
Creation & Destruction
DynamicCharacter.Add( GameObject gameObject, number radius, number halfHeight, number mass ) : DynamicCharacter
Attaches a dynamic-character capsule to gameObject. radius, halfHeight, and mass are
optional, defaulting to 0.3, 0.5, and 80 (kg). The push-force budget is not a parameter —
it defaults to 400 N; raise it with SetMaxForce. Shape and mass are baked when the body
registers (on_enable), which seeds it at the GameObject’s world position — so set the spawn
position before Add (as dynamic_player.lua does). Returns nil and logs an error if the
component can’t be added (invalid GameObject, already has one, or the pool is exhausted).
DynamicCharacter.Get( GameObject gameObject ) : DynamicCharacter
Returns the character on gameObject, or nil if it has none (no warning).
DynamicCharacter.Exists( DynamicCharacter character ) : boolean
Returns true if the handle refers to a live component. Safe on a genuine stale handle (returns
false), but raises a type error if character is not a DynamicCharacter (including nil) — see
Types.
There is no Destroy binding — the character is removed with its GameObject; disabling the
GameObject unregisters its body from the simulation.
Functions
Read-only queries. Each returns its default (false, or nil) when the body is unregistered or no
physics world is wired.
dc:IsGrounded( ) : boolean
true if the body had a ground-like contact (contact normal y ≥ 0.7, ≈ 45°) during the last
fixed step — voxel terrain, a static collider, or standing on a crate all count. false when
unregistered.
dc:Position( ) : Vector3
The body’s current world position — the authoritative fixed-point capsule center converted to
f32, not the interpolated render transform. nil when unregistered.
dc:IsRegistered( ) : boolean
true once the body exists in the world (after on_enable); false on a stale handle. Use it to
gate the other methods (see Types).
The two lifecycle getters below read the scene component, not the world body, so they answer
normally on an attached-but-unregistered character (unlike everything else in this section).
dc:Active( ) : boolean or nil
The component’s own enabled flag — what Activate()/Deactivate() write. nil (+ a logged
NotFound) on a stale handle. This is intent, not effect: a character whose own flag is enabled
but whose GameObject is deactivated still reports true.
dc:ActiveInHierarchy( ) : boolean or nil
true only when the own flag is enabled and the owning GameObject is active in hierarchy —
i.e. the component is actually running. This is the predicate the engine uses to gate update
dispatch. Returns nil on a stale handle.
Properties (get / set)
Velocity
dc:Velocity( ) : Vector3 — the body’s current linear velocity (units/second), or nil when
unregistered.
dc:SetVelocity( Vector3 velocity ) : void — sets the full 3D linear velocity directly.
velocity must be a native Vector3 (loose numbers raise). No-op when unregistered. An active
Move drive re-steers the horizontal velocity every step, so this is mainly for vertical kicks or
teleport-stops.
Methods
Locomotion and tuning.
dc:Move( number vx, number vz ) : void
Sets a persistent, force-capped horizontal velocity motor toward (vx, vz) in units/second.
Note the arguments are two loose numbers (not a Vector3) and there is no delta-time: this
is not a per-step advance — it upserts a drive that the solver re-applies every fixed step until
you change it or call Stop(), so calling it once keeps the character moving. (Contrast
KinematicController:Move, which takes a velocity Vector3 plus dt
and advances a single step.) The force cap is the push budget (SetMaxForce). Vertical motion is
untouched — gravity, contacts, and Jump own it. Idempotent; call each fixed step or on input
change. No-op when unregistered.
dc:Stop( ) : void
Removes the drive entirely — the character then slides freely under friction and contacts.
(Move(0, 0) is different: active braking toward zero under the force budget.) No-op when
unregistered.
dc:Jump( number impulse ) : void
Applies a vertical impulse of impulse = mass·Δv (e.g. mass * 6 for a ~6 units/second take-off).
Does not check grounded — gate on IsGrounded() yourself. No-op when unregistered.
dc:SetMaxForce( number newtons ) : void
Sets the locomotion/push force budget (default 400 N). Stored on the component and applied by the
next Move — it is not pushed to the world immediately. Works before registration; no-op on a
stale handle.
dc:SetFriction( number mu ) : void
Sets the body’s Coulomb friction (characters default a deliberately low 0.05: the drive owns
horizontal control, and a high μ fights it — μ·m·g rivals the force cap). Applied live when
registered and cached on the component so a re-enable keeps it. No-op on a stale handle.
dc:SetStepOffset( number height ) : void
Sets the max auto step-up (stair-climb) height in units; 0 disables (the default, and the
byte-identical off path). Terrain only — the character cannot step onto crates or other dynamic
bodies. Applied live when registered and cached for re-enable. No-op on a stale handle.
Lifecycle
Both silently no-op on a stale handle. Because registration follows the component’s enable state
(see Types), these are the component-level lever on whether the body is in the simulation
at all.
dc:Activate( ) : void
Sets the component’s own enabled flag. Its enable callback runs only if the owning GameObject is
also active — and that callback is what registers the body.
dc:Deactivate( ) : void
Clears the own enabled flag; the disable callback runs only if the component was actually active.
Example
-- A deterministic dynamic (massy) character. Adapted from data/scripts/dynamic_player.lua.
-- Driven from FixedUpdate: Move sets a persistent velocity motor the solver applies each
-- fixed step, so this must run at the fixed cadence for lockstep determinism.
local Script = {}
local SPEED = 5.0 -- target horizontal speed (units/second)
local JUMP_SPEED = 6.0 -- take-off speed; impulse = mass * this
local MASS = 80.0 -- must match the DynamicCharacter.Add mass
local MAX_FORCE = 3000.0 -- push budget (N); the 400 N default is gentle
function Script.OnCreate(self)
-- Set the spawn position BEFORE Add — the body seeds at the world position on_enable.
self.object:SetWorldPosition(0, 7, 0)
DynamicCharacter.Add(self.object, 0.4, 0.9, MASS) -- radius, half-height, mass
local dc = DynamicCharacter.Get(self.object)
if dc then dc:SetMaxForce(MAX_FORCE) end
end
function Script.FixedUpdate(self)
local dc = DynamicCharacter.Get(self.object)
if dc == nil or not dc:IsRegistered() then return end
-- WASD → world-axis direction (forward is -Z).
local ix, iz = 0, 0
if Input.Key("move_forward") then iz = iz + 1 end
if Input.Key("move_back") then iz = iz - 1 end
if Input.Key("move_right") then ix = ix + 1 end
if Input.Key("move_left") then ix = ix - 1 end
local move = Vector3.new(ix, 0, -iz)
if move:sqrMagnitude() > 1 then move = Vector3.Normalize(move) end
move = move * SPEED
-- Horizontal velocity motor (two loose numbers, no dt). (0,0) = active braking.
-- Vertical is owned by gravity/contacts — we do NOT fold gravity into the drive.
dc:Move(move.x, move.z)
-- One impulse per press, only when grounded (Jump itself does not check).
if Input.KeyDown("jump") and dc:IsGrounded() then
dc:Jump(MASS * JUMP_SPEED)
end
end
return Script