Skip to content

Wheeled vehicles ​

WheeledVehicle is a possessable pawn with a dynamic box chassis, Vehicle collision profile, CCD, four host raycast wheels and a heading-following chase camera. Handling is arcade, authority-simulated; there is no client prediction yet. It supports exactly four wheels. asset and wheelAsset are manifest ids; leave them empty for the debug chassis and primitive wheels. Wheel models spin about local X. Wheel order is front left/right, rear left/right in settings order.

Place one in a level (import WheeledVehicle from gameable before loading it):

json
{
  "class": "WheeledVehicle",
  "name": "player-car",
  "position": [0, 1, 0],
  "yaw": 0,
  "props": { "handling": "grippy", "maxSpeed": 30, "asset": "car", "wheelAsset": "wheel" }
}

Bind a player's controller during initialization or login:

ts
import { actors, bindVehicleInput, possess } from 'gameable';
import type { ActorHandle, GameContext } from 'gameable';

function drive(ctx: GameContext, controller: ActorHandle): void {
  const car = actors.named('player-car');
  if (!car) return;
  possess(ctx, controller, car);
  bindVehicleInput(controller, { brake: 'Control' });
}

The seated controller reads axis2('A', 'D', 'S', 'W'): Y is throttle and X is steering. Space is the handbrake; a brake key is optional. Bindings run before every pawn component, regardless of actor spawn order. The camera follows chassis heading; camera look never steers the car. From behind a +Z-facing chassis, right is world −X.

vehicleMovement(options) is the root for custom cars; a selector such as vehicleMovement(pawn => pawn.props) resolves settings once at creation. Presets are arcade, grippy and drift; explicit settings override them; null fields inherit the preset. Replace complete wheels, steeringBySpeed or gripBySlip arrays. Distances are metres, speeds m/s, mass kg, steerAngle degrees and steerRate degrees/second. Mounts use local chassis coordinates. The component catalog lists every setting and its units.

setVehicleInput(pawn, input) accepts finite throttle/steer in [-1,1] and brakes in [0,1], clamps finite values and consumes intent once per component step. AI controllers can submit the same intent. An absent intent parks; unpossessed cars and cars with pawn.state.frozen = true or controller.state.frozen = true always park. Disconnect clears player throttle. Frozen input is discarded, so unfreezing needs fresh intent.

vehicleStateOf(pawn) borrows post-physics telemetry without allocation (undefined for non-vehicles); do not retain it across restore or mutate it. Its four wheels include compression in metres, spin in radians and slip; steer is radians and speed is signed m/s. Call resetVehicle(ctx, pawn, position, yaw) inside init/a system/actor hook to teleport, clear linear/angular motion, controls and handling history, and snap camera lag. Reset yaw is radians; level placement yaw is degrees. Saved settings and host integration history rebind lazily after snapshot restore.

Circuit races automatically reset vehicle handling after choosing a free respawn slot, before the optional onRespawn callback. This keeps accepted gates and laps; callers need not duplicate the vehicle reset. Use racerRadius large enough for the chassis when configuring circuit occupancy. Circuit occupancy remains sphere-based.

AI cars use AIController possession and the same input buffer as player controls:

ts
import { AIController, actors, spawnActor, possess, followRacingLine } from 'gameable';
import type { GameContext } from 'gameable';

function addRival(ctx: GameContext): void {
  const car = actors.named('rival-car');
  if (!car) return;
  const ai = spawnActor(ctx, AIController, { position: car.position });
  possess(ctx, ai, car);
  followRacingLine(
    ctx,
    ai,
    { actor: 'track', component: 'line' },
    {
      speed: 16,
      lookAhead: 5,
      cornerSpeed: 7,
      lateralOffset: 0.8,
      loop: true,
      tuning: { lookAheadTime: 0.45, stuckSeconds: 3, reverseSeconds: 1.5 },
    },
  );
}

Alternatively, pass an explicitly ordered array of TargetPoint instance names. The line is sampled once when requesting the drive; restart it after moving the authored line. Initial acquisition searches the line; subsequent searches stay within three segments behind and six ahead, avoiding jumps to another leg of a hairpin. Lookahead grows with speed and curvature preview reduces speed before bends. Offset is toward local +X of the line tangent. These helpers follow authored routes; they do not provide road navigation or traffic avoidance. createCircuitRace gates remain the authority for lap progress.

driveTowards(ctx, ai, target, {speed:12, acceptanceRadius:1, stopAtTarget:true}) tracks a point or actor, brakes for arrival and completes once through onMoveCompleted. With stopAtTarget:false it keeps driving through the target until replaced or stopped. chaseVehicle(ctx, ai, suspect, {speed:20, leadTime:0.6, maxLead:12, ramDistance:5, ramCooldown:4}) predicts a vehicle target, matches its speed outside contact range and attempts a half-second ram when close. Cooldown starts at ram onset. Physics collisions supply all momentum. stopDriving(ctx, ai) aborts once and parks the old car. Destroyed targets/lines and possession replacement also abort once. Drive helpers require a possessed vehicleMovement pawn and reject missing actors or invalid options immediately.

Intent, sampled points, previous segment and recovery timers are JSON-safe AI state. Request tuning accepts lookAheadMin (5m), lookAheadTime (0.45s), steerGain (9), speedGain (0.5), brakeGain (0.3), maxSpeed (30m/s), stuckSeconds (3s), and reverseSeconds (1.5s). After three failed reverse/retry attempts a drive aborts. Frozen cars park and pause recovery. Driving runs in the control phase before all pawn steps; setFocus cannot rotate a car, and walking moveTo retains its pedestrian behavior.

Advanced controllers can call computeDrive(state, target, tangent, settings, out) and submit out with setVehicleInput. Provide a world position, yaw in radians and signed speed in state, a desired path tangent or null, and DriveSettings built from defaultDriveTuning plus speed, acceptanceRadius, stopAtTarget. Negative speed requests reverse travel. The policy writes caller-owned output without allocation.

Rocket and oil recipe ​

Add this code to your game's src/game.ts. Call fireRocket(ctx, shooter, carAhead) from your weapon action, selecting carAhead from your circuit standings, and call dropOil(ctx, car) for the oil action. Both effects work without Health or damage. The rocket homes on the target entity each step, slows it with an opposing impulse, and imparts spin by applying a sideways impulse at the impact point. Its ray sweep has no finite radius. A terminal hit destroys the rocket once, so respawning the victim cannot replay its effect.

ts
import {
  actors,
  applyVehicleImpulse,
  defineProjectile,
  setVehicleGrip,
  spawnActor,
  VehicleGripVolume,
  Velocity,
  vehicleStateOf,
} from 'gameable';
import type { ActorHandle, GameContext } from 'gameable';

const kick = { x: 0, y: 0, z: 0 };
const behind = { x: 0, y: 0, z: 0 };
/** Homing effect-only rocket used by the documented vehicle weapons recipe. */
export const Rocket = defineProjectile('VehicleRecipeRocket', {
  props: { speed: 60, gravityScale: 0, damage: 0, hitResponse: 'effect' },
  onProjectileHit(_self, hit, ctx) {
    const car = actors.of(hit.entity);
    if (!car || !vehicleStateOf(car)) return;
    // Oppose motion, plus a sideways kick at the impact point to spin the chassis.
    kick.x = 2000 - Velocity.x[car.entity] * 500;
    kick.y = 0;
    kick.z = -Velocity.z[car.entity] * 500;
    applyVehicleImpulse(ctx, car, kick, hit.point);
    setVehicleGrip(ctx, car, 'rocket', 0.3, ctx.elapsed + 0.6);
  },
});
/**
 * Fire toward the car selected by the game's race standings.
 *
 * @param ctx Frame context.
 * @param shooter Launching vehicle.
 * @param carAhead Vehicle selected from standings.
 */
export function fireRocket(ctx: GameContext, shooter: ActorHandle, carAhead: ActorHandle): void {
  spawnActor(ctx, Rocket, {
    position: shooter.position,
    owner: shooter,
    props: { homingTarget: carAhead.entity },
  });
}
/**
 * Drop oil behind a moving car; stationary cars use their current position.
 *
 * @param ctx Frame context.
 * @param car Vehicle dropping oil.
 */
export function dropOil(ctx: GameContext, car: ActorHandle): void {
  const e = car.entity;
  const speed = Math.hypot(Velocity.x[e], Velocity.z[e]);
  const distance = speed > 0.1 ? 4 / speed : 0;
  behind.x = car.position.x - Velocity.x[e] * distance;
  behind.y = car.position.y;
  behind.z = car.position.z - Velocity.z[e] * distance;
  spawnActor(ctx, VehicleGripVolume, {
    position: behind,
    props: { radius: 2, duration: 8, gripScale: 0.15, recovery: 1 },
  });
}

applyVehicleImpulse(ctx, pawn, impulse, point) takes world-space newton-seconds and a world-space application point. setVehicleGrip(ctx, pawn, sourceId, scale, until) uses an absolute expiry in simulated seconds (ctx.elapsed + seconds), clamps scale to [0,1], refreshes the same source and combines concurrent sources by minimum. clearVehicleGrip(ctx, pawn, sourceId) removes one source; omit sourceId to clear all. These helpers run only on authority/solo and ignore dead actors and non-vehicles. Each chassis preallocates world.maxEntities reusable source slots. Reset/respawn clears all modifiers and host handling history; snapshots preserve active expiries.

Oil tests sphere versus oriented chassis overlap before vehicle input is submitted, independent of actor spawn order. duration is the area's lifetime, recovery holds the grip drop after last contact (zero means only while overlapping). Recovery is a timed hold, followed by normal host tyre recovery. Destroying the area stops refreshing; existing drops expire normally. A car respawned into an active area can be affected again. The radius follows the largest world scale axis. Supply oil art separately.

The same actor is placeable in a level file after importing VehicleGripVolume:

json
{
  "class": "VehicleGripVolume",
  "position": [0, 0.5, 10],
  "props": { "radius": 2, "duration": 8, "gripScale": 0.15, "recovery": 1 }
}

For a terrain-impact oil projectile, use hitResponse: 'effect' and call spawnActor(ctx, VehicleGripVolume, { position: hit.point }) in onProjectileHit. Verify by firing at a moving AI car: it should lose speed and yaw without a damage callback. Driving through two oil areas uses the lower grip; leaving restores grip after recovery. Resetting clears the effects immediately.

Fall recovery and client standings ​

LevelSettings.killHeight and KillVolume automatically recover every vehicle pawn. Registered racers use the circuit respawn policy without a manual call: the last accepted gate and completed laps survive, occupied slots retry, and vehicle handling, velocity and input reset. Outside a race, players use their checkpoint/PlayerStart path; AI cars reset to their saved spawn pose. Other actors retain the existing kill policy.

createCircuitRace.system publishes full public standings under circuits[id] through player HUD data, using the same transport as createMatchHudModule. This gives replicas and late joiners the complete state without client lap logic. Each circuit has a revision that advances only when its public rows change; unchanged standings emit no new HUD command. Rows include racer, seat, entity, completedLaps, nextGate, lastGate, segmentDistance, place, finishPlace, finishedAt and status. Multiple circuits share the publication. As with the match HUD module, compose additional custom HUD data in one publisher rather than overwriting it with another hud.set system.

On the client, read the parsed HUD model (custom renderers can use the host adapter's hudModel with its default HUD disabled):

ts
import { circuitStandings } from 'gameable';

const standings = circuitStandings(adapter.hudModel, 'track');
if (standings) {
  console.log(standings.revision);
  for (const row of standings.rows) console.log(row.place, row.completedLaps);
}

BlockingVolume and other premade solid bodies include the Vehicle channel. BlockAll already includes it. Overlap volumes and triggers remain sensors.