Builtin Library
README.md
CharacterController
Kinematic mover. Integrates velocity against gravity, walls, ceilings and ground, and writes the result to the entity's position every frame.
Driving it
The character moves on a public input bus: inputX, inputZ and
inputJump. Each frame the controller reads the bus, moves on what it found,
and then refills it for the next frame.
Only the local player's body answers the player's controls. The character
the local player is bound to (layers.active.players.localPlayer.avatar)
refills its bus from the move axis and the sprint / jump actions of its
input map, so the map drives it whenever nothing else does. Every other
character holds no map and refills its bus with no movement: an NPC, a
mannequin, a preview or another player's body stands where it is until
something writes its bus. The player's spawn binds the body for you; to hand
the controls to a character you made, bind it:
layers.active.players.localPlayer.avatar = entity(bodyId)
The character takes the map up on the frame after the bind and gives it back when the binding moves to another body.
Anything else takes the character over by writing those three fields from its
own update() — an AI brain, a networked peer, a replay, or gameplay code
that wants the character committed during an attack. The write works from
any executionOrder, because the refill happens after the read: whatever
is in the bus when the controller next reads it is what the character does.
- A writer ahead of the controller (
executionOrderbelow-100) is acted on the same frame. - A writer behind it is acted on the next frame.
- Stop writing, and the character stops one frame later; on the local player's body the map has it back instead.
- Pausing gameplay empties the bus, so a character frozen mid-stride stands where it was paused and a writer that had it committed says so again.
inputX / inputZ are a world-space velocity in units per second — the
controller applies them as they are, so scale them yourself rather than
expecting moveSpeed to. inputJump asks for one jump and the next update
consumes it.
A component that walks the character due north, whatever the input map says:
-- Northbound.component/init.luau
declare { executionOrder = 50 } -- anywhere works; 50 runs after the controller
public = {
speed = Field.number(4.0, Sync),
}
function update(_dt: number)
local cc = public.entity.component.get("CharacterController")
if cc == nil then return end
cc.inputX = 0
cc.inputZ = -public.speed
end
WASDInput is the same pattern shipped as a component: it reads an input map
of its own and writes the bus of whatever component targetComponent names,
which is how a mover that reads no controls of its own gets driven. On a
CharacterController — which reads move, sprint and jump from its own
inputMap — a WASDInput writes the bus every frame at its own speed, and
that is the speed the character walks at. CharacterController.install adds
one when input = true asks for it.
The control scheme
inputMap is the scheme the character answers to. It reads three controls from it, each on its own:
move— an axis2, projected against the active viewport camera so "forward" is into the screen.sprint— held, multipliesmoveSpeedbysprintMultiplier.jump— pressed, asks for one jump.
A map declaring only some of the three drives the character with those: a scheme with no sprint holds one gait, a scheme with no jump stays on the ground. The character logs which controls the map left out and names the map, so a game with three verbs ships a three-control scheme. A field naming no map leaves the character answering nothing, and logs that too.
Repointing it on a live character takes on the next frame. The map the character was holding is stood down — its controls leave the live set and its touch buttons leave the screen — and the map named here is taken up in its place:
entity(bodyId).component.get("CharacterController").inputMap = asset.resolve("mygame.input.run", "inputMap")
Assigning the map the character already holds changes nothing, so re-stating the scheme leaves the live set where it is. While gameplay is paused the character holds no map at all (the editor's camera has the controls), and it takes whatever inputMap names when play resumes. A character that is not the local player's body holds no map either, and takes whatever inputMap names when it is bound as that body.
Platforms, belts and the contact filter
The character meets the surfaces content declares on colliders the way a
rigid body does. Its ground, wall and ceiling queries are cast for the
character's own entity as their mover (Physics.raycast(..., { mover = id })
and the same option on the shape casts), so the contact rules the physics
step applies to bodies apply to it too:
-
One-way platforms. A collider whose
oneWaypoints up holds the character from above only: it jumps up through the platform from below and lands on top of it, and walks through its sides. -
Dropping through.
Physics.passThrough(character)drops it through the one-way colliders it stands on (or, with a second argument, only those that entity owns). It falls through under its own gravity and meets each again once it is clear of it.Physics.passThrough(player) -- drop through what it stands on Physics.passThrough(player, platform) -- drop through this platform alone -
Moving surfaces. Standing on a collider whose
surfaceVelocitymoves (a conveyor belt, a treadmill, an escalator) carries the character at that velocity on top of its own walk. It keeps the carry as momentum when it jumps or walks off, and air control (airControlFactor) takes it back toward the walk it asks for. -
The contact filter. Give the character a collider with a
contactTag(a trigger capsule is enough) andPhysics.setContactFilteranswers its pairs: a collider whose tag the filter answers"ignore"or"sensor"with the character's tag is one the character walks through.player.component.add("CapsuleCollider", { isTrigger = true, contactTag = "player" }) Physics.setContactFilter("player", "ghostWall", "ignore")
Telemetry
These read back what the mover did on the frame just simulated:
grounded— standing on ground no steeper thanmaxSlopeAngle.falling— in the air and descending.sliding— on ground too steep to stand on, being carried down it.blocked— the character asked to move horizontally and the wall pass left it with none of that movement: pressed into a wall, or wedged where two surfaces meet. This is what tells a character standing still apart from one whose walk is being refused.slopeAngle— the angle, in degrees, of the ground under it.velocityX/velocityY/velocityZ/horizontalSpeed— its velocity.jumpCount— jumps used since it last landed.