Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

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 (executionOrder below -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, multiplies moveSpeed by sprintMultiplier.
  • 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 oneWay points 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 surfaceVelocity moves (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) and Physics.setContactFilter answers 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 than maxSlopeAngle.
  • 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.