logo
AiDriver

vehicle · vehicle-combat

AiDriver

1
Unlock

Install with the CLI:

bjs download AiDriver

AiDriver

A computer driver for a CarDrive car. It works the pedals and the wheel, and nothing else.

What it does

Most AI movement in this library writes a velocity or a position: AISeek decides where an agent should be and puts it there. That is fine for a walker and wrong for a car, because it drives straight past the handling model. A rival that sets its own position can corner at any radius, stop dead, and never slide — and the moment you try to out-brake it, it reads as a cheat.

AiDriver only ever fills in throttle, brake and steer on the sibling CarDriveComponent — the same three fields the keyboard writes. CarDriveSystem integrates every car from there, so a raider corners on the same grip, understeers on the same falloff and slides on the same slip you are fighting.

Two modes. cruise holds a lane at a set speed, which is traffic that actually drives rather than parking in your path. hunt steers at a tagged car and leans on it.

Use it in a scene

A commuter that holds its lane, and a raider that comes for the player:

A scene is just data — a list of entities, each with its components. At startup the SceneLoader turns this JSON into a live world; edit the file and reload to rebuild it.

Build it from scratch with the bjs CLI:

  1. 1Scaffold a project
    npm create @babylonjsmarket/arcade@latest my-game

    World, renderer, and dev server — ready to run.

  2. 2Install dependencies
    cd my-game && npm install
  3. 3Add AiDriver
    bjs download AiDriver

    Copies its source into src/ so the scene resolves.

    First time? Run bjs login once.

  4. 4Paste the scene into src/scenes/arcade-room.ts and run
    npm run dev

    SceneLoader builds the world from the JSON; reload to rebuild.

JSON
{
  "Commuter": {
    "tags": ["traffic"],
    "components": {
      "MeshPrimitive": {
        "primitive": "box",
        "width": 1.8,
        "height": 1.2,
        "depth": 4.2,
        "position": [3, 0.6, 60]
      },
      "CarDrive": {
        "maxSpeed": 28,
        "acceleration": 12,
        "braking": 28,
        "steerRate": 1.8,
        "grip": 0.9,
        "keyboardControlled": false
      },
      "AiDriver": {
        "mode": "cruise",
        "desiredSpeed": 28,
        "roadHalfWidth": 7,
        "separation": 6,
        "lookAhead": 16
      }
    }
  },
  "Raider": {
    "tags": ["enemy"],
    "components": {
      "MeshPrimitive": {
        "primitive": "box",
        "width": 1.9,
        "height": 1.1,
        "depth": 4.4,
        "position": [-3, 0.6, 40]
      },
      "CarDrive": {
        "maxSpeed": 53,
        "acceleration": 25,
        "braking": 40,
        "steerRate": 2.4,
        "grip": 0.8,
        "keyboardControlled": false
      },
      "AiDriver": {
        "mode": "hunt",
        "targetTag": "player",
        "desiredSpeed": 53,
        "ramBias": 1.5,
        "huntGiveUpBehind": 16,
        "steerGain": 1.9
      }
    }
  }
}

keyboardControlled: false is not optional. Leave it on and the keyboard overwrites all three fields every frame, and the car sits there doing nothing while its AI runs perfectly.

Where the car wants to be

Steering starts from one number: the world X this car is trying to reach. desiredLateral builds it as a stack of opinions, weakest first, each free to overrule the last.

AiDriver.core.ts
export function desiredLateral(params: AiDriverParams, input: AiDriverInput): number {
  const centre = input.laneCenterX;
  let aim = input.x;

  const targetBehind =
    input.targetZ !== null && input.z - input.targetZ > params.huntGiveUpBehind;

  if (params.mode === 'hunt' && input.targetX !== null && !targetBehind) {
    // Aim slightly PAST the target, on the side we are already on, so contact
    // happens while still turning in — a car that aims exactly at the target
    // arrives parallel and merely rides alongside it.
    const side = Math.sign(input.targetX - input.x) || 1;
    aim = input.targetX + side * params.ramBias;
  }

  // Separation: every close neighbour nudges us away from it. Weighted by
  // closeness so a car half a length away matters more than one at the edge
  // of the radius.
  for (const other of input.neighbours) {
    const dz = other.z - input.z;
    if (Math.abs(dz) > params.separation * 2) continue;
    const dx = other.x - input.x;
    const dist = Math.hypot(dx, dz);
    if (dist >= params.separation || dist < 1e-4) continue;
    const push = (1 - dist / params.separation) * params.separationStrength;
    aim -= Math.sign(dx || 1) * push;
  }

  // The edge has the final say.
  const limit = Math.max(0, params.roadHalfWidth - params.edgeMargin);
  return clamp(aim, centre - limit, centre + limit);
}

Hold your lane, chase the target, get off the neighbour, get off the edge. The order matters at the ends. Hunting overrides the lane because a raider that stays in its lane is traffic; the edge clamp overrides everything because a raider that rams you into the scenery and follows you off is a spectator rather than a threat.

ramBias is the shove. A car that aims exactly at its target arrives parallel and rides politely alongside; aiming a metre and a half past it, on the side you are already on, means contact happens while the car is still turning in.

Pedals

Speed control is two comparisons and no controller:

AiDriver.core.ts
  // Speed control. Brake rather than coast when well over, so a car boxed in
  // behind a slower one actually falls back instead of nosing through it.
  const over = input.speed - params.desiredSpeed;
  const throttle = over < 0 ? 1 : 0;
  const brake = over > params.desiredSpeed * 0.15 ? 1 : 0;

Braking rather than coasting is what makes a boxed-in car fall back instead of nosing through the one in front. The 15% deadband keeps it from pumping the brake at cruising speed.

Props

  • mode ("cruise" | "hunt", default "cruise") — hold a lane, or go after a target.
  • targetTag (string, default "player") — tag naming the car to hunt. Read in hunt only; the first entity carrying the tag wins.
  • desiredSpeed (number, default 18) — speed to hold, world units/sec. Set it at or under the car's own maxSpeed, or the car spends the race flat out and the figure means nothing.
  • roadHalfWidth (number, default 7) — drivable half-width from the centre line.
  • edgeMargin (number, default 1.6) — how far off the edge to stay. The clamp is roadHalfWidth - edgeMargin.
  • separation (number, default 5) — cars closer than this push each other apart.
  • separationStrength (number, default 6) — how hard, in world units of lateral aim.
  • lookAhead (number, default 14) — how far down the road the car aims. Larger reads as smoother and lazier.
  • steerGain (number, default 1.6) — gain handed to CarDrive's steerToward.
  • ramBias (number, default 1.4) — hunt only. How far past the target to aim.
  • huntGiveUpBehind (number, default 14) — hunt only. Once the target is this far behind, the car goes back to holding a lane.

Events

None. The AI writes to its own car's component and says nothing on the bus.

Dependencies

  • CarDrive — on the same entity. This writes its controls and reads its heading and speed.
  • MeshPrimitive — on the AI car and on whatever it hunts, for the live world position.
  • RoadTreadmill — anywhere in the scene. The System finds it by component and asks it where the road's centre and heading are at each car's Z.

Notes

  • Without a RoadTreadmill in the scene every car aims at world X 0 and quietly walks off the outside of every bend — which is the edge the AI is supposed to be avoiding. It runs, it just drives badly.
  • Positions come off the renderer, never MeshPrimitiveComponent.position. CarDrive writes the mesh and does not write that array back, so an AI reading it would chase cars parked at wherever they were authored.
  • Steering sign comes from CarDrive's steerToward, injected rather than imported, so the handedness convention lives in exactly one file.
  • Every car sees every other car as a neighbour, including the ones it is hunting. A raider closing on the player is being pushed away by separation at the same time — with the defaults, ramBias wins at contact distance, but a large separationStrength will make raiders that never quite touch you.
  • The target is resolved by tag every frame, so a pooled player that dies and respawns is picked up again with no rewiring.

More like this

CarDrive
MeshPrimitive
RoadTreadmill
AIGoalSeek
AIKick
AIZone
AiPilot
BallPossession
BallPursuit
BoatMode
BoatWater
ContactDamage
ContactImpulse
Enemy
EnemySpawner
FreighterChain
Jump
KeyboardMover
LineOfSight
Movement
PokerAI
PokerAIBill
PokerAICali
PokerAIRandy
PokerAIShark
PokerAISurge
PokerAITilt
RoadSpawner
ShipFlight
SmokeScreen
SurfacePatch
TrafficConvoy
Transform6DOF
TwinStickEnemy
Velocity6DOF
WaveDirector
WeaponsVan
WorldOriginAnchor

Was this page helpful?

We read every note — tell us what's working and what isn't.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search