logo
ContactImpulse

vehicle · vehicle-combat

ContactImpulse

1
Unlock

Install with the CLI:

bjs download ContactImpulse

ContactImpulse

Bodies that occupy space: how far to push two overlapping cars apart, and how much speed they trade doing it.

What it does

ContactDamage answers whether two things touched and who got hurt. This answers what happens next. Without it the hit lands, the health drops, and nothing moves — cars slide through each other and a raider leaning on you has no weight at all.

It is deliberately not a rigid-body engine. CarDrive integrates and writes its own position every frame out of a handling model built from grip, slip and turn radius, and handing those cars to Havok would mean driving them with forces instead: the model goes in the bin and the feel becomes something you tune through friction coefficients. Arcade separation leaves the handling exactly as it was and adds the one thing missing.

Use it in a scene

Add it beside CarDrive on anything that should have weight. The numbers that matter are mass and radius:

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 ContactImpulse
    bjs download ContactImpulse

    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
{
  "Player": {
    "tags": ["player"],
    "components": {
      "MeshPrimitive": {
        "primitive": "box",
        "width": 1.8,
        "height": 1,
        "depth": 4,
        "position": [0, 0.5, 0]
      },
      "CarDrive": { "maxSpeed": 46, "acceleration": 23, "braking": 43 },
      "ContactImpulse": { "mass": 3 }
    }
  },
  "ConvoyTruck": {
    "tags": ["traffic"],
    "components": {
      "MeshPrimitive": {
        "primitive": "box",
        "width": 2.2,
        "height": 2.4,
        "depth": 6,
        "position": [0, 1.2, 60]
      },
      "CarDrive": { "maxSpeed": 20, "keyboardControlled": false },
      "ContactImpulse": { "mass": 6, "restitution": 0.15 }
    }
  }
}

Leave radius out and it comes from the mesh: half the larger side of the footprint. A car is longer than it is wide, and taking the width instead would let noses pass through doors.

Mass decides who moves

The correction is split by inverse mass, so momentum goes the way it should — the hatchback bounces off the convoy truck rather than the other way round, and two cars of equal mass share the correction evenly.

ContactImpulse.core.ts
  const penetration = rsum - dist;
  const invA = a.mass > 0 ? 1 / a.mass : 0;
  const invB = b.mass > 0 ? 1 / b.mass : 0;
  const invSum = invA + invB;
  if (invSum === 0) return NONE; // two immovable objects; nothing to say

  // Positional correction, split by inverse mass.
  const push = (penetration * clamp(params.separation, 0, 1)) / invSum;
  const ax = -nx * push * invA;
  const az = -nz * push * invA;
  const bx = nx * push * invB;
  const bz = nz * push * invB;

Set mass: 0 and a body becomes immovable: it shoves everything and moves for nothing. Two immovable bodies overlapping have nothing to say to each other, and resolveBump returns zero rather than dividing by an inverse mass of nothing.

Only resolve a closing contact

Speed is traded only when the two are actually coming together:

ContactImpulse.core.ts
  // Velocity exchange, but only if they are actually closing. Two cars already
  // separating are resolved by the positional term alone — applying an impulse
  // to them adds energy from nowhere and makes contacts jitter.
  const rvx = b.vx - a.vx;
  const rvz = b.vz - a.vz;
  const closing = rvx * nx + rvz * nz;
  if (closing >= 0) {
    return { ax, az, bx, bz, avx: 0, avz: 0, bvx: 0, bvz: 0, penetration };
  }

Two cars already moving apart are handled by the push alone. Give them an impulse as well and you have added energy from nowhere, which shows up as a pair of cars that will not stop chattering against each other.

separation below 1 is what makes a contact read as weight instead of as a glitch: half the overlap is taken out this frame, half of the rest next frame, so the cars ease apart over a few frames rather than teleporting.

The correction goes out as a nudge event

No mesh is moved here. The System finds the pairs, works out the push, and hands it off:

ContactImpulse.ts
  private nudge(entity: Entity, dx: number, dz: number, dvx: number, dvz: number): void {
    if (!entity.get(CarDriveComponent)) return; // static scenery absorbs it
    this.eventBus.emit(CarDriveInputEvents.NUDGE, {
      entityId: entity.id,
      dx,
      dz,
      dvx,
      dvz,
    } satisfies CarDriveNudgeEvent);
  }

carDrive.nudge is applied inside CarDriveSystem, which leaves the pose with exactly one writer. Two systems writing one handle in a frame is a last-wins race, and it shows up as flicker in whichever one lost.

A body with no CarDriveComponent still takes part in the contact — it just absorbs its share and stays put, which is what you want from a barrier.

Props

  • mass (number, default 1) — heavier moves less and shoves more. 0 is immovable.
  • radius (number, default derived) — collision radius. Omitted, it is half the larger side of the MeshPrimitive footprint.
  • separation (number, default 0.5) — fraction of the overlap corrected per frame, 0..1.
  • restitution (number, default 0.25) — bounce along the contact normal. 0 is a dead thud, 1 is billiard balls.
  • maxImpulse (number, default 14) — cap on the speed change one contact may impart, world units/sec.

Two bodies in a contact carry two opinions on how it should feel. separation and restitution are averaged, so a soft body softens the contact for both cars rather than only for itself; maxImpulse takes the lower of the two, so a cap set on one car cannot be talked out of by the other.

Events

  • emits contactImpulse.bumped{ a, b, penetration } — once per overlapping pair per frame, with the entity ids and the depth. A contact that persists keeps emitting; if you want a single event per collision, gate on the frames where the pair was not overlapping before.

Dependencies

  • MeshPrimitive — for the live world position and the fallback radius.
  • CarDrive — for a body's velocity, and as the thing that applies the correction. Optional per body: anything without it is treated as scenery.

Notes

  • Positions come off the renderer, never MeshPrimitiveComponent.position. CarDrive writes the mesh and does not write that array back, so contacts computed from it would compare two spawn points forever.
  • Contacts are found pairwise. A dozen cars is 66 checks and no bookkeeping; a bullet hell is not what this is for.
  • A body's speed is read off its car's heading and speed. Anything moved by a tween or an animation reads as parked, so it gets pushed out of the way rather than shoving.
  • Inactive entities are skipped, so a pooled car parked between lives neither collides nor gets collided with.
  • The contact is flat. Radius is a circle in X/Z and nothing here looks at height, so a car will bump a bridge deck it is driving under if their footprints overlap.

More like this

CarDrive
MeshPrimitive
AiDriver
BoatMode
BoatWater
Bumper
ContactDamage
MouseHole
Obstacle
Physics
RoadSpawner
RoadTreadmill
SmokeScreen
Spinner
SurfacePatch
TrafficConvoy
Velocity6DOF
WeaponsVan

Was this page helpful?

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

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search