Controllers

CharacterController, CameraRig and Vehicle from @react-three/jolt/controllers.
import { CharacterController } from '@react-three/jolt/controllers';

Characters, cameras and vehicles are things Jolt does well and physics wrappers usually don't. They sit behind their own subpath because they are opinionated, they bring input handling with them (via @react-three/jolt/addons), and they are the least settled part of the library — but they ship inside @react-three/jolt, so there is nothing extra to install.

Warning

These components work, but they are the roughest corner of the API: several props are not wired up yet, and the components are thin wrappers over systems that are much more capable than the props suggest. Where a prop is missing, reach for the system.

CharacterController

A Jolt CharacterVirtual — a capsule that walks, runs, crouches, jumps, climbs stairs and slides — driven by keyboard/gamepad commands out of the box.

import { Physics } from '@react-three/jolt';
import { CameraRig, CharacterController } from '@react-three/jolt/controllers';

export function Player() {
    return (
        <Physics>
            <CharacterController radius={0.5} height={2}>
                <mesh>
                    <capsuleGeometry args={[0.5, 1]} />
                    <meshStandardMaterial color="white" />
                </mesh>
            </CharacterController>
            <CameraRig />
        </Physics>
    );
}
proptypedefault
radiusnumber1capsule radius, applied at creation and on every change
heightnumber2capsule height, same
debugbooleantruedraws the capsule. Note the default is on
positionVector3 | tuplethe character's real position — sets it at creation, teleports on change
innerBodybooleanfalsegive the character a body the world collides with — see below
innerBodyLayernumberLayer.MOVINGobject layer for that body
refRef<CharacterControllerSystem>hands back the system itself

Default bindings (from the commander): WASD/left stick to move, Space to jump, Shift to run, C to crouch. The move direction is rotated by the camera's horizontal rotation, so "forward" means forward on screen.

Being collided with

A CharacterVirtual is not part of the simulation. It collides against the world — it walks on floors, it is stopped by walls — but the world does not collide against it. Push a crate into an NPC built this way and the crate goes straight through.

innerBody fixes that. Jolt gives the character a real kinematic body, keeps it glued to the capsule, and everything else in the world collides with that:

<CharacterController innerBody radius={0.5} height={2}>
    <mesh>
        <capsuleGeometry args={[0.5, 1]} />
        <meshStandardMaterial color="white" />
    </mesh>
</CharacterController>

This is what Jolt's standard (non-virtual) Character class is usually reached for. That class isn't exposed by jolt-physics, and its author considers it inferior to CharacterVirtual — an inner body gets you the property it was wanted for without giving up stair walking, crouching or any of the rest.

It costs one extra body and the narrowphase work that implies, so it is off by default. Turn it on for characters other things bump into (NPCs, other players) and leave it off for a first-person player that nothing ever collides with.

Note

innerBody and innerBodyLayer are read once, when the controller is built: Jolt creates the inner body inside the CharacterVirtual constructor and has no API to add, remove or re-layer one afterwards. Changing either prop remounts the controller. setCapsule does keep the inner body's shape in step, so resizing works normally.

CharacterControllerSystem exposes hasInnerBody and innerBodyId (the Jolt BodyID, or undefined). The body belongs to the character — don't remove it through BodySystem, and don't hold the id past destroy().

CharacterControllerSystem

The component creates one and shares it through CharacterControllerContext:

import {
    CharacterControllerContext,
    type CharacterControllerSystem
} from '@react-three/jolt/controllers';
import { useContext } from 'react';

function Stamina() {
    // the context is untyped today, so annotate it yourself
    const { characterSystem } = useContext(CharacterControllerContext) as {
        characterSystem?: CharacterControllerSystem;
    };

    // characterSystem?.isRunning, .isExhausted, .hangtime, ...
    return null;
}

Movement and state:

member
move(direction)world-space direction; length is ignored, speed comes from the settings
jump()respects jumpLimit, allowJumpWhileFalling and exhaustion
startRunning(speed?) / stopRunning()
setCrouched(crouched, forceUpdate?)swaps to the crouching capsule
position, rotation, linearVelocitythree.js types, get/set
isMoving, isRunning, isCrouched, isSliding, isGrounded, isExhausted, isRotating, hangtimestate
anchorthe BodyState other things (like the camera rig) attach to
on(action, callback)subscribe to one action; returns an unsubscribe
addActionListener(fn)subscribe to every action; returns an unsubscribe
destroy()idempotent; called for you when the component unmounts

Actions, and what they carry:

actionpayload
'falling'true on the first frame of hangtime, then the hangtime in seconds
'jump'the jump counter
'crouched'boolean
'running'the new speed on start, 0 on stop
'exhausted'the exhaustion time limit
'exausted'false — the spelling is a bug in the source; it is a different string from 'exhausted'
import type { CharacterControllerSystem } from '@react-three/jolt/controllers';

function watchJumps(characterSystem: CharacterControllerSystem) {
    const off = characterSystem.on('jump', (_action, count) => console.log('jump', count));
    off();
}

removeActionListener(fn) still exists and is deprecated — keep the unsubscribe instead.

Tuning (all plain properties with sensible defaults):

characterSpeed (6), characterSpeedCrouched (3), characterSpeedExhausted (2), jumpSpeed (15), jumpLimit (2), jumpDegradeFactor (0.5), characterHeightStanding (2), characterRadiusStanding (1), characterHeightCrouching (1), characterRadiusCrouching (0.8), allowAirbornControl (true), enableCharacterInertia (true), enableCharacterRotation (true), enableWalkStairs (true), enableStickToFloor (true), allowSliding (false), allowRunning (true), enableExhaustion (true), allowJumpWhileFalling (true), runningTimeLimit (5000 ms), exauhstionTimeLimit (7000 ms, spelled as in the source), maxRotationSpeed (0.1 rad/frame), lerpFactor (0.4).

Stair and floor behaviour maps onto Jolt's own settings: walkStairsStepUp, walkStairsMinStepForward, walkStairsStepForwardTest, walkStairsStepDownExtra, stickToFloorStepDown. Ground state is readable through groundState, isSupported, isFalling, groundNormal, groundPosition, groundVelocity, groundMaterial and groundBodyHandle.

The character uses Layer.RIG, so it does not collide with other rigs — see layers.

Head and ceiling collision

ExtendedUpdate's own ground/wall/ceiling classification only tells Jolt how to sweep the character; it does nothing to the character's velocity. Without help, a character jumping into a low overhang keeps its upward velocity until gravity alone brings it back down, which reads as getting stuck under the ceiling for a beat too long (issue #88). headAngle and onHeadHit are the fix:

import { CharacterController } from '@react-three/jolt/controllers';
import type { HeadHitInfo } from '@react-three/jolt/controllers';

<CharacterController
    headAngle={Math.PI / 6} // default: 30 degrees
    onHeadHit={(info: HeadHitInfo) => {
        console.log('bonk', info.normal, info.previousVerticalSpeed);
    }}
/>;
prop / member
headAnglenumber (radians)half-angle of the cone around straight-up within which a contact counts as a head/ceiling hit rather than walkable ground or a wall. Default 30°. A contact whose normal satisfies contactNormal · up < -cos(headAngle) cancels the character's upward velocity
onHeadHit(info: HeadHitInfo) => voidcalled once per new contact inside that cone while the character is moving upward — not every step the contact persists, and not for ground/wall contacts

HeadHitInfo carries normal (world-space contact normal, pointing away from the obstacle towards the character) and previousVerticalSpeed (the up-axis speed at the moment of the hit, before it is cancelled). Both are set on the CharacterControllerSystem directly too (characterSystem.headAngle, characterSystem.onHeadHit = ...) if you're driving the system without the component.

Events

CharacterControllerSystem.events is an Emitter<CharacterEventMap> — the same primitive bodyState.on() and the world's <Physics on*> use. Every movement event is an edge, derived once per pre-step from ground state and velocity relative to whatever is carrying the character, and every one of them is also emitted as an 'action' under the same name, so the older characterSystem.on('jump', fn) (filtered-action) API and characterSystem.events.on('jump', fn) see the same things.

import { useCharacterEvent } from '@react-three/jolt/controllers';

<CharacterController
    onMove={(speed) => console.log('moving', speed)}
    onStop={() => console.log('stopped')}
    onSlide={(speed) => console.log('sliding', speed)}
    onSlideEnd={() => console.log('slide ended')}
    onJump={(count) => console.log('jump', count)}
    onLand={(airtime) => console.log('landed after', airtime, 's')}
    onGround={() => console.log('grounded')}
    onAirborne={() => console.log('airborne')}
    onContactAdded={(payload) => console.log('touched', payload.handle)}
/>;

useCharacterEvent(system, type, handler) is the imperative form the component's on* props are built from — subscribe from your own component once you have a CharacterControllerSystem (from CharacterControllerContext, or one you built yourself):

import { useCharacterEvent } from '@react-three/jolt/controllers';
import type { CharacterControllerSystem } from '@react-three/jolt/controllers';

function Footsteps({ characterSystem }: { characterSystem?: CharacterControllerSystem }) {
    useCharacterEvent(characterSystem, 'move', (speed) => {
        if (speed > 4) playRunSfx();
    });
    return null;
}

Like useBodyEvent, its dependency is whether a handler was passed rather than its identity, so an inline arrow does not resubscribe every render.

CharacterEventMap:

eventpayload
action(name, payload)every action, including all the ones below
movespeed: numberstarted moving under its own power; speed is m/s relative to the ground
stop—stopped moving under its own power
slidespeed: numberstarted sliding down something too steep to stand on
slideEnd—stopped sliding
jumpcount: numbera jump was accepted; which jump of the allowed sequence it was
landairtime: numbertouched down, after being unsupported for airtime seconds
ground—became supported by something (always paired with land)
airborne—stopped being supported by anything
crouch / stand—
contactAdded / contactPersisted / contactRemovedCharacterContactPayloada contact with a body, forwarded from Jolt's CharacterContactListener. Pooled — read it inside the handler, don't keep it. Dispatched after the character update returns, never from inside ExtendedUpdate

CharacterContactPayload carries body/object (undefined for a Jolt body BodySystem never registered), handle, subShapeId, position (zeroed for contactRemoved, which Jolt gives no geometry) and normal (pointing from the character into the other body).

Component props: onMove, onStop, onSlide, onSlideEnd, onJump, onLand, onGround, onAirborne, onCrouch, onStand, onContactAdded, onContactPersisted, onContactRemoved and onAction mirror the event map one-for-one, plus moveThreshold (default 0.5 m/s — the relative speed above which the character counts as moving) and slideThreshold (default 0.5 m/s, along the surface). Nothing subscribes for a prop you don't pass, so an unused contact stream costs one & per contact and nothing else.

Caution

Everything in CharacterEventMap is dispatched after ExtendedUpdate returns — Jolt's CharacterContactListener fires from inside it, where adding or removing a body is illegal and the pointers it hands the callback are into memory Jolt reuses. Never call anything that adds, removes, or otherwise mutates bodies from inside a listener registered here — by the time your handler runs the step has already finished, but the contract still matters for the payload itself: it is pooled (one object reused for every contact in a step), so copy what you need and return.

CameraRig

A physics-aware third-person camera: a boom arm on a chain of spaces (base → collar → camera) that collides with the world instead of clipping through it, and follows an anchor.

<CharacterController>
    <mesh>
        <capsuleGeometry args={[0.5, 1]} />
    </mesh>
</CharacterController>
<CameraRig />

Inside a <CharacterController>, <CameraRig> finds the character through context and attaches itself automatically. Otherwise pass a body:

<CameraRig anchor={body.current ?? undefined} />

Mouse, touch, stick look and wheel zoom are bound through useLookCommand; R resets the camera behind the anchor.

Options

Every option below is also a <CameraRig> prop. They reach the boom before the rig is first stepped, so the camera opens already framed instead of snapping into place on frame one. Changing one afterwards updates the live rig through setOptions() — the manager is memoised and is never rebuilt for a prop change, and a new distance is eased into rather than snapped to.

optiondefault
cameraPosition(0, 0, 0)where the rig's main camera starts, in rig space. A camera anywhere other than the origin defines the boom's length, pitch and yaw
distance5boom length, metres. An explicit value wins over cameraPosition
minDistance / maxDistance0.1 / 100zoom limits
pitch0starting pitch, radians; negative looks down
minPitch / maxPitch-1.5 / 0.5what a look command may reach
yaw0starting yaw about world up
collisionRadius—radius of the camera's collision sphere; left to the shape collider's own default when unset
smoothing0.5lerp factor while the boom changes length
lookSpeed / zoomSpeed1 / 1input multipliers
allowCameraClippingfalseskip the collision, obstruction and shapecast tests entirely
obstructionBuffer0.01gap kept between the camera and whatever obstructs it
target(0, 0, 0)point the boom frames, relative to the pivot
updateMode'demand''demand' recomputes on every command, 'additive' once per frame
followTarget—BodyState to follow, before the first step
anchorOffset(0, 2, 0)where on the body the boom hangs
positionUpdateType'distance'or 'fixed'
debugtruedraw the rig's spaces
<CameraRig cameraPosition={[4, 4, 4]} minPitch={-1.2} maxPitch={0.4} smoothing={0.35} />

Follow modes

A PC-style rig only translates with its anchor, so running off sideways leaves you looking at the character's ear. followMode decides where the rig points:

mode
'free' (default)the boom only turns when the player turns it
'movement'the boom eases round to trail the character's horizontal velocity, so heading off in a new direction swings the camera in behind you
'lookAt'the boom eases round so lookAtTarget stays framed past the character
<CameraRig followMode="movement" rotationSpeed={3} movementThreshold={0.5} />
optiondefault
followMode'free'
rotationSpeed2how fast an automatic mode eases the yaw round, per second. Always takes the short way
movementThreshold0.5ground speed, m/s, the character must beat before 'movement' steers
manualOverrideTimeout1000ms a look command parks the automatic modes for
lookAtTarget—THREE.Object3D | THREE.Vector3, for 'lookAt'
characterSystem—whose velocity drives 'movement'; set for you inside a <CharacterController>

Neither automatic mode fights the player: CameraBoom.move() / rotate() stamp lastLookTime, so the existing look bindings need no changes. Inside a <CharacterController> the rig reads the character's own linearVelocity — the anchor it follows is a kinematic stand-in for a CharacterVirtual and does not carry one. Standalone, it falls back to the followed body's velocity.

Whiskers

The boom already shape-casts backwards and pulls the camera in when a wall gets between it and the player, which reads as a snap. With whiskers on it also fans short rays out either side of the boom every step and rotates the yaw away from whatever they touch, so the camera slides around a corner before the wall ever becomes a problem.

<CameraRig whiskers whiskerLength={3} whiskerStrength={2} />
optiondefault
whiskersfalse
whiskerCount5fanned evenly across the spread
whiskerSpread60°half-angle of the fan either side of the boom, radians
whiskerLength3metres
whiskerStrength2peak yaw rate a fully buried whisker asks for, rad/s
whiskerDamping0.2how fast the steering rate catches up; lower is smoother

boom.isWhiskerSteering says whether anything is currently in the way (it is a field on CameraBoom, not an option), and boom.whiskerYawVelocity is the current rate. The steering is spring damped, so it eases in and decays back to zero once the whiskers come clear.

The whisker raycaster is built lazily the first time whiskers are switched on and is freed by destroy() with the boom's other queries. The per-step sweep allocates on neither side of the WASM boundary.

CameraRigManager

useCameraRig(options) gives you the manager directly. It takes over r3f's default camera — and restores it on unmount — and disables the r3f controls while it is mounted.

member
setOptions(options)change options on a live rig; the rig is not rebuilt
attach(body, offset?) / detach() / reAttach()follow a BodyState
moveBoom(lookVector) / zoom(level)what the look command drives
createCamera(name, options?) / addCamera(name, camera, space?) / setActiveCamera(name) / getCamera(name) / removeCamera(name)multiple cameras, positioned in rig space
onCamera(fn)subscribe to camera changes; returns an unsubscribe
controlsthe CameraBoom: collision, obstruction and whiskers
debugdraw the rig's spaces
destroy()the public teardown
Important

destroy() stops the rig being stepped, frees the boom's raycaster, shapecaster and shape collider, and removes every body and three.js object the rig created. It is idempotent. useCameraRig and <CameraRig> call it on unmount, so mounting and unmounting a rig no longer leaks. attachToLoop() / detachFromLoop() are private on the manager — destroy() is what you call. (VehicleSystem.detachFromLoop() is the one public exception.)

On the boom itself, initialize(options) configures it and snaps to the resulting pose, setOptions(options) eases into it and only touches the options you passed, and destroy() is likewise idempotent.

Bodies marked allowObstruction let the boom pull the camera in (or see through them) rather than getting stuck behind them.

Vehicle

One component for every kind of vehicle: a Jolt vehicle constraint with engine, transmission, differentials and suspension.

import { Vehicle } from '@react-three/jolt/controllers';

<Vehicle type="fourWheel" position={[0, 25, 0]} vehicleSettings={{ vehicleMass: 900 }} />;
proptypedefault
type'fourWheel' | 'twoWheel''fourWheel'a car or a motorcycle
namestring'car' / 'bike'the key it is registered under in its VehicleSystem
positionTHREE.Vector3 | [number, number, number]—reactive
vehicleSettingsVehicleSettingsper typemerged over the defaults — see below
bodyObjectObject3DSource—your own chassis object
childrenAsChassisbooleantrue when children are given and bodyObject is not
wheelsVehicleWheelOptions[]—per-wheel settings, in constraint order
wheelObjectsObject3DSource[]—just the objects, in constraint order
debugbooleantrueshow the generated stand-in meshes
followCamerabooleantruetranslate the r3f camera by the chassis delta each step
onVehicle(vehicle: VehicleManager | null) => void—called when the manager appears and again with null when it goes

Object3DSource is THREE.Object3D | RefObject<THREE.Object3D | null> | null | undefined, so a ref is fine and is resolved after mount.

Driving is bound to the move command (stick/WASD) out of the box.

Injecting your own meshes

<Vehicle type="fourWheel" position={[0, 25, 0]} wheelObjects={[fl, fr, bl, br]}>
    <primitive object={chassisGltf.scene} />
</Vehicle>

Children become the chassis by default. bodyObject does the same with an object or a ref, and wheels / wheelObjects do it for the wheels — position and rotation, steering included. Wheel order is fl, fr, bl, br for a four-wheeler and front, back for a two-wheeler (wheelOrderByType). If both wheels and wheelObjects are given, wheels wins.

Ownership: the manager disposes only the meshes it generated itself. An object you injected is detached on teardown and never disposed.

useVehicle

import { useVehicle } from '@react-three/jolt/controllers';
import { useEffect } from 'react';

function Car() {
    const vehicle = useVehicle({ type: 'fourWheel', position: [0, 25, 0] });

    useEffect(() => vehicle?.onPostStep(() => console.log(vehicle.position)), [vehicle]);

    return null;
}

useVehicle(options) returns the VehicleManager, or null on the first render — it is created after mount. Options are name, type, settings, position, bodyObject, wheels, wheelObjects, debug and addToScene (default true). Settings are read when the vehicle is built: changing name or type rebuilds it, and live changes go through the manager's own setters. The vehicle, its VehicleSystem and everything they own are destroyed on unmount.

VehicleManager

member
move(direction)a Vector3 or Vector2 of driver input
setHandBrake(v) / setBrake(v) / triggerTurbo(extraTime?)
setPosition(position)
setBodyObject(object | null)null puts the generated box back
setWheelObject(index | name, object | null) / setWheelObjects(objects)
getWheel(index | name) / getWheelObject(index | name)
onPreStep(fn) / onPostCollide(fn) / onPostStep(fn)each returns an unsubscribe
onAction(type, fn)ditto
position, bodyObject, wheels, wheelOrder, settings, debug
destroy()idempotent

settings is a fully resolved, typed ResolvedVehicleSettings object rather than any. FourWheelVehicleManager and TwoWheelVehicleManager are the two concrete classes; VehicleSystem holds them by name (addVehicle, getVehicle, removeVehicle, setPosition, detachFromLoop, destroy).

Settings

vehicleSettings is a discriminated union on type. Shared by both:

bodyPosition, castType ('ray' | 'sphere' | 'cylinder'), vehicleLength, vehicleWidth, vehicleHeight, vehicleMass, maxPitchRollAngle, maxEngineTorque, clutchStrength, bodyObject, wheelObjects.

fourWheel adds fourWheelDrive, frontBackLimitedSlipRatio, leftRightLimitedSlipRatio, antiRollbar, splitEngineTorqueFront, splitEngineTorqueRear, frontRollBarStiffness, rearRollBarStiffness and a wheels object with fl / fr / bl / br overrides. Defaults: 4.0 × 1.8 × 0.4 m, 1500 kg, cylinder cast, four-wheel drive, 500 N·m of engine torque, 30° of steering, 0.5 m wheels.

twoWheel adds steerSpeed (radians per second the steering angle may change by), casterAngle and a wheels object with front / back overrides. Defaults: 0.8 × 0.4 × 0.6 m, 250 kg, 30° caster, 0.31 m wheels.

Per-wheel settings cover radius, width, position (the attachment point; when given it overrides the wheelOffsetHorizontal / wheelOffsetVertical layout helpers), suspensionMinLength / suspensionMaxLength / suspensionPreloadLength, suspensionDirection, suspensionForcePoint, suspensionSpring ({ frequency, damping }), steeringAxis, wheelUp, wheelForward, inertia, angularDamping, plus maxSteerAngle / maxBrakeTorque / maxHandBrakeTorque (four-wheel) or posZ / suspensionFreq / brakeTorque (two-wheel).

defaultFourWheelVehicleSettings, defaultTwoWheelVehicleSettings and resolveVehicleSettings(settings) are exported if you want to inspect or pre-merge them. Your settings are merged over the defaults — a motorcycle built with a custom mass or custom wheels no longer silently gets the defaults back.

Secondary physics

Three presentational layers sit on top of the constraint (issue #41) — none of them touch the simulation, they only read what Jolt solved and drive the three.js objects (and the readouts a game needs for particles, skid marks and engine audio) from that. All three are on by default and independently configurable through vehicleSettings, and each can be turned off with false:

<Vehicle
    type="fourWheel"
    vehicleSettings={{
        bodyRoll: { maxAngle: 0.15, referenceAcceleration: 6 },
        wheelSmoothing: false,
        skid: { longitudinalSlip: 2 }
    }}
/>;

bodyRoll — a spring-damped visual tilt of the chassis object (never the physics body), driven by the chassis' own acceleration rotated into its local frame: a steady turn leans the body outwards, exactly like a real sprung mass. Because the manager owns that object's local rotation while this is on, rotate your model inside a wrapper if you need to orient it yourself.

optiondefault
maxAngle0.1 rad (~5.7°)the most the body may lean sideways
maxPitchAnglemaxAngle / 2the most it may pitch under acceleration/braking
referenceAcceleration9.81 m/s²the acceleration that produces the full maxAngle — lower for floatier, raise for stiffer
stiffness120spring constant pulling the tilt towards its target
damping20damping of that spring; 2 * sqrt(stiffness) is critical

wheelSmoothing — easing applied to what the wheels render, so a wheel doesn't snap between two suspension lengths or steering angles the solver happens to land on between frames. Wheel spin is Jolt's own, integrated from its angular velocity, and is never eased.

optiondefault
suspension0.04 stime constant for rendered suspension travel — ~63% of the way to Jolt's value every this many seconds. 0 renders it raw
steering0.05 sthe same, for the rendered steering angle

skid — when a wheel counts as skidding, compared against Jolt's own per-wheel slip. The thresholds are deliberately generous: a longitudinal slip ratio of 3 (the wheel spinning at four times the speed of the ground under it) is an ordinary standing start in a 500 N·m car.

optiondefault
longitudinalSlip1.5the |slipRatio| a wheel has to exceed to be skidding
lateralSlip0.25 rad (~14°)the |lateralSlip| a wheel has to exceed
minLateralSpeed0.5 m/sbelow this speed, lateral slip never counts — Jolt's `atan2(lateral,
release0.7the fraction of those thresholds a skidding wheel has to fall back under
releaseTime0.12 show long it has to stay under release before skidEnd fires
requireContacttruewheels not touching anything never skid

Change any of the three live, on the manager: vehicle.setBodyRoll(settings | false), vehicle.setWheelSmoothing(settings | false), vehicle.setSkid(settings | false). Turning bodyRoll off puts the chassis object back to level; turning skid off ends every skid currently running, so a listener that started a particle effect still gets its skidEnd.

Readouts and events

import { useVehicle } from '@react-three/jolt/controllers';
import { useEffect } from 'react';

function Dashboard() {
    const vehicle = useVehicle({ type: 'fourWheel' });

    useEffect(() => {
        if (!vehicle) return;
        const offSkid = vehicle.onSkidStart((e) => spawnSkidMark(e.position, e.name));
        const offEngine = vehicle.onEngine(({ rpm, gear, throttle }) => {
            engineSound.playbackRate = 0.5 + rpm / 6000;
            engineSound.volume = 0.2 + 0.8 * throttle;
        });
        return () => {
            offSkid();
            offEngine();
        };
    }, [vehicle]);

    return null;
}
member
rpmnumberthe engine's current RPM, 0 with no engine (a destroyed vehicle)
gearnumber0 in neutral, 1..n forward, negative in reverse
shiftingbooleantrue while the transmission is between gears
clutchnumberclutch friction, 0..1
throttle / brakeInputnumberdriver input, 0..1
speed / speedKmhnumberchassis' signed forward speed, m/s / km/h
skiddingbooleantrue while any wheel is over the skid thresholds
onSkidStart(fn) / onSkidEnd(fn)(event: VehicleSkidEvent) => voida wheel crossed (or released) the skid thresholds
onEngine(fn)(state: VehicleEngineState) => voidthe engine readout, once per physics step

Every readout getter reads a Jolt static temporary or a number — none of it allocates. Both event payloads are pooled (the same object every dispatch): VehicleSkidEvent carries wheel, name ('fl' | 'fr' | 'bl' | 'br' or 'front' | 'back'), index, slipRatio, lateralSlip, position (world space, also pooled — copy it if you keep it) and speedKmh. VehicleEngineState carries rpm, gear, throttle, brake, speed, speedKmh, shifting, clutch and skidding. onEngine's handler is skipped entirely (no state object is even filled) unless something is subscribed, so an unused dashboard costs nothing per step.

VehicleFourWheel

Warning

Deprecated. <VehicleFourWheel /> is exactly <Vehicle type="fourWheel" /> and now forwards to it; the type string it used to take is replaced by <Vehicle type="twoWheel">. It keeps working for one release and warns once under setDebug(true). VehicleFourWheelProps is likewise a deprecated alias of VehicleProps.

The managers were renamed to FourWheelVehicleManager / TwoWheelVehicleManager; the old VehicleFourWheelManager and VehicleManagerTwoWheels names are exported as deprecated aliases.

Note

<Vehicle followCamera> translates the r3f camera by the chassis delta on every pre-step (and retargets the default controls if there are any). Pass followCamera={false} if you are managing the camera yourself — with a <CameraRig>, for instance.