Lua ScriptingAdvanced

Vehicles, constraints, ragdolls, and navigation

Drive wheeled and tracked vehicles, operate constraint motors, build ragdolls, move navigation agents, and follow a generated track's centreline.

Wheeled vehicles

setDriverInput(forward,right,brake,handBrake) accepts normalized inputs. Positive right steers right at the Lua API boundary. Separate setters are also available: setForwardInput, setRightInput, setBrakeInput, and setHandBrakeInput.

Read state with getVehicleForwardSpeed(), getVehicleRPM(), getVehicleGear(), isWheelInContact(index), and getWheelContactNormal(index). Wheel indices are zero-based and are rejected outside the vehicle's own wheel count.

Steering limit, engine torque, and brake torque are authoring-time fields on the vehicle object, not runtime calls. They are constraint construction parameters, so the physics engine has to rebuild the vehicle constraint to change one: set them on the object and reload the scene. Earlier versions exposed setMaxSteerAngle, setEngineTorque, and setMaxBrakeTorque from Lua. Those calls never took effect and have been removed.

Steering feel

setDriverInput applies the steering you hand it immediately and in full, so a driving script needs two things on top of the raw axis. Without them a car is unpleasant on a touch stick and dangerous at speed.

Ease the steering angle toward the axis; never assign it. Roughly a fifth of a second of lag is enough. Nothing about a thumb holds an exact heading, and a steering angle that tracks one exactly transmits every tremor to the wheels — the car weaves the whole way down a straight the player is holding straight. Read input.moveAxisX rather than input.moveLeft and input.moveRight: the booleans carry no magnitude, so a script built on them can only ask for full lock or nothing.

Scale the available lock down as getVehicleForwardSpeed() rises. Full lock at speed rolls a car over with no collision involved. Tire friction peaks at 1.8, so a chassis whose centre of mass sits higher above the contact patches than half its track width generates more lateral force than it takes to tip itself.

That second failure is also fixable on the object. A vehicle's Center of Mass Y Offset lowers the centre of mass in the chassis's local space, and the built-in presets put it at the bottom of the chassis box — what Jolt's own vehicle sample does. Raise it toward zero for a deliberately tippy vehicle.

local STEER_RATE, RETURN_RATE = 6.0, 10.0
local FULL_LOCK_SPEED, MIN_LOCK_SPEED, HIGH_SPEED_LOCK = 8.0, 30.0, 0.35
local steer = 0

function update(dt)
    local target = input.moveAxisX
    local speed = math.abs(gameObject.getVehicleForwardSpeed() or 0)
    local fade = (speed - FULL_LOCK_SPEED) / (MIN_LOCK_SPEED - FULL_LOCK_SPEED)
    fade = math.min(1, math.max(0, fade))
    target = target * (1 - fade * (1 - HIGH_SPEED_LOCK))

    -- Straightening out is quicker than turning in, so a correction reads as a
    -- correction rather than as the start of the opposite turn.
    local rate = STEER_RATE
    if math.abs(target) < math.abs(steer) then rate = RETURN_RATE end
    steer = steer + (target - steer) * (1 - math.exp(-rate * dt))

    gameObject.setDriverInput(input.moveAxisY, steer, 0, input.jump and 1 or 0)
end

The driving script vehicles_create_wheeled attaches to a new vehicle is written this way. The same reasoning applies to a tracked hull, which slews hard the instant a thumb drifts off centre.

Tracked vehicles

setTrackDriverInput(forward,leftRatio,rightRatio,brake) uses independent track ratios for skid steering. setLeftRatio and setRightRatio update them independently. A ratio is how fast that track turns relative to the engine: equal ratios drive straight, unequal ones turn, and opposite signs pivot on the spot. Both ratios at zero hold the tank still with its brakes on, which is what a tank is built with and what the stock tank script sends when nobody is driving. The track differential ratio is an authoring-time field for the same reason as the wheeled tuning fields above.

setForwardInput, setBrakeInput, getVehicleForwardSpeed(), getVehicleRPM(), getVehicleGear(), isWheelInContact(index), and getWheelContactNormal(index) work on a tracked vehicle as well. setDriverInput, setRightInput, and setHandBrakeInput do nothing on one: a tank steers with its ratios, not a steering angle.

How a vehicle looks

Every new vehicle arrives with a model already on it: a pickup for the wheeled presets, a sport bike for the motorcycle controller, and a tank for the tracked presets. Nothing has to be chosen to get something that looks like a vehicle.

Chassis Mesh replaces that model with any GLB, GLTF, FBX, or OBJ asset in the project — on tracked vehicles as well as wheeled ones. Chassis Mesh Fit below it places the model inside the vehicle, and Fit & Center sizes it to the chassis box in one click. That fit moves only the visual: the object's own Scale fields are what resize the physical chassis, its wheels, and its suspension.

Wheels and track belts are never part of the chassis model. They are drawn from the vehicle's Jolt wheel layout, which is what lets them steer, spin, and ride their suspension independently of the hull. A model that ships its own road wheels has them removed as it loads, matched by name (wheel-front-left, Tire_03, and similar), so they are not drawn twice with one copy frozen to the body.

A tracked vehicle's belts carry a tread that moves with the track. Each side scrolls at its own track's speed, so the top and bottom runs travel in opposite directions and a tank turning on the spot shows one belt running forward and the other back. The inspector's Show track belt switch turns the belts off.

Colouring the running gear

A vehicle's Material tints its body. The running gear has its own fields, because rubber and steel should not follow the paint: Tire Color, Rim Color, and — on a tracked vehicle — Track Color. All three are also settable through scene_objects_update, as 0-1 RGB.

They take effect immediately, without rebuilding the vehicle constraint, so dragging a colour picker stays smooth. Leave one unset and it keeps its default: near-black tires, light steel rims, and a dark steel track.

Why a new car is red without you choosing anything

The default material means "keep the model's own materials" on anything loaded from a file, so a bundled or imported body arrives in the colours it was authored in and only a material you pick yourself overrides them. The running gear never followed that rule, which is why it needs colour fields of its own instead of inheriting the body's.

Constraint motors

Hinge and slider constraints expose setConstraintMotorState("off"|"velocity"|"position"), setConstraintTargetVelocity(value), setConstraintTargetPosition(value), and getConstraintCurrentValue().

On a hinge the values are degrees: target velocity in degrees per second, and target position and current value as an angle in degrees. On a slider they are metres and metres per second. A target only drives the joint while the motor is in the matching state, so call setConstraintMotorState("velocity") or ("position") first. On anything that is not a hinge or slider the setters do nothing and getConstraintCurrentValue() returns nil.

Ragdolls

A ragdoll takes a skinned model and simulates its skeleton: one capsule body per bone, joined by Swing-Twist constraints. It is its own object type rather than a switch on a mesh, because what is simulated is the rig rather than the shape.

Point Mesh File at a skinned GLB, GLTF, or FBX asset and the bones are detected from the skeleton automatically. Nothing else is required to get a body that falls over.

The three modes

ModeBehaviour
kinematicThe bodies follow the playing animation and are unaffected by physics. The ragdoll pushes other things and is pushed by nothing — an animated character with a real collider.
dynamicThe bodies fall freely under gravity and collision, and animation input is ignored entirely. This is the classic limp ragdoll.
poweredThe bodies are dynamic, but constraint motors drive them continuously toward the playing animation's pose. The character holds its animation, can be knocked off it by an impact, and fights its way back.

Tracked Animation, with its own speed and loop settings, is the pose source for kinematic and powered. It is ignored in dynamic mode, which has no pose to track.

Joint limits, and what you get without setting any

Each bone joint has four limits in degrees: Normal Half-Cone and Plane Half-Cone bound the swing, and Min Twist and Max Twist bound the rotation along the bone. A joint with a zero normal cone and a wide plane cone behaves like a hinge, which is what an elbow or a knee should do.

A limit is resolved in three steps, and the first one that has a value wins:

  1. A per-bone override, if you set one for that bone. Overrides are keyed by bone name, so they survive re-detecting the skeleton or swapping in a differently ordered rig.
  2. The ragdoll's own default, which applies to every joint at once.
  3. An anatomical preset matched against the bone's name.

That last step is why a standard humanoid rig behaves sensibly before anyone opens the joint fields. Bones whose names contain spine, chest, neck, head, shoulder, clavicle, arm, forearm, hand, wrist, thigh, hip, leg, knee, shin, foot, ankle, toe, or a finger name are each given limits appropriate to that joint — a forearm and a shin get hinge-like limits, a head and a hip get wide cones, a spine gets very little.

Setting a ragdoll-wide default therefore replaces the presets rather than adjusting them: every joint gets the same limits, including the elbows. Prefer per-bone overrides when only one joint is wrong.

Bone Radius Scale sets each capsule's radius as a fraction of its bone's length, so one number thickens or thins the whole body proportionally.

Limbs that jitter against each other

Adjacent bodies overlap at every joint, and a dynamic ragdoll will buzz as they push apart. Put the ragdoll's parts in a shared collision group and disable the neighbouring pairs — see Collision groups.

Navigation agents

Pathfinding moves an object across a navigation mesh baked from the scene's static, solid geometry: cubes, spheres, cylinders, capsules, meshes, and height fields that are static, are not sensors, and (for meshes) have a collider. Dynamic props, water, triggers, and meshes with collisionType "none" are left out.

  • The mesh is baked the first time a run asks for it, by registerForPathfinding or any navigation query, so scenes without agents never pay for it. It is discarded at Stop, so the next Play reflects your edits.
  • To see what agents will walk on before you press Play, use Bake and Show mesh in the Navigation section of Scene Settings. The line beneath them reports how many objects went into the bake, or that it found no walkable geometry. That bake is a preview; a run still bakes its own.
  • Agent size comes from the scene's Navigation settings (Scene Settings panel, or the navigation argument of scenes_update): radius 0.4 m, height 1.8 m, step 0.4 m, and slope 45 degrees by default.
  • An agent must be a character or a dynamic body. Agents are moved by velocity, which a static object ignores.
  • Steering is horizontal: an agent keeps its own falling speed and turns to face its path.
  • In a multiplayer room only authority scripts may call registerForPathfinding, moveTo, followPath and stopMovement. The queries work in any script.
MethodBehavior
registerForPathfinding(speed)Makes this object an agent moving at speed metres per second and starts the bake. Calling it again only changes the speed, so one agent can walk a patrol and run a chase.
moveTo(x,y,z)Plans a path to the point and follows it. Until the mesh has baked the agent waits (isMoving() is already true); an unreachable point stops it. A new moveTo replaces whatever it was doing.
followPath(waypoints)Walks straight lines through a list of {x, y, z} points without consulting the navigation mesh.
stopMovement()Ends the current route and brings the agent to rest where it stands. Gravity still applies, and the next moveTo or followPath starts it again. Calling it on an agent that is already idle does nothing, so a script can keep driving its own velocity afterwards.
isMoving()False on arrival, after stopMovement(), and when a moveTo target could not be reached.
getPathToTarget(x,y,z)A path from this object to the point, or nil.
getRandomNavPoint(radius)A random reachable point within about radius metres, for wandering, or nil.

Queries wait for the bake

getPathToTarget and getRandomNavPoint return nil until the mesh has baked, so nil-check them and try again next frame. When chasing, call moveTo again once the target has moved a metre or two rather than every frame.

Scene scripts, and object scripts through scene, can query between arbitrary points with scene.findPath(from, to) and scene.getNearestNavPoint(point), both taking {x=, y=, z=} tables.

local PATROL_SPEED = 2.2
local player

function init()
    player = gameObject.getObjectByName("Player")
    gameObject.registerForPathfinding(PATROL_SPEED)
end

function update(deltaTime)
    local here = gameObject.getPosition()
    local target = player.getPosition()
    local dx, dz = target.x - here.x, target.z - here.z
    if dx * dx + dz * dz < 4 then
        -- Close enough: stand still. Only the first call does anything.
        gameObject.stopMovement()
    elseif not gameObject.isMoving() then
        local spot = gameObject.getRandomNavPoint(8)
        if spot then gameObject.moveTo(spot.x, spot.y, spot.z) end
    end
end

Follow a generated track

A navigation mesh is the wrong tool for a race car. On a generated track mesh, getTrackPath() returns the centreline the road was swept along: {closed = true, points = {{x=, y=, z=, width=}, ...}} in world space, one point about every 5 m. It returns nil for other objects and for tracks generated before the path was recorded; regenerate those with their existing recipe and asset id. Call it once in init.

To drive an AI car along it, find the nearest point, steer toward the point a lookahead distance further along, and lower the target speed where the path bends. Don't copy a track recipe's control points into a script: they are in the model's coordinates and go stale when the track moves.

To put a car that has left the road back on it, use gameObject.resetPhysicsState(x, y, z, qx, qy, qz, qw) rather than setPosition. It moves the vehicle, clears its velocity, spin, and driver input, and wakes it, all in one call; setPosition keeps the momentum the car had when it left. The rotation is optional.