logo

Babylon.js Market

ContactDamage

vehicular · vehicle-combat

ContactDamage

Talks toHealth
1
Unlock

Install with the CLI:

bjs download ContactDamage

ContactDamage

Hurt whatever you touch, with no opinion about how you got there.

What it does

Ramming used to live inside Enemy, wired to its pathing: an enemy that hurt you on contact also had to want to walk at you. A car doesn't. ContactDamage is that half on its own. ContactDamageComponent is a ram profile — how much, how close, how often, how fast you have to be going — and ContactDamageSystem measures each damager against every body carrying its targetTag and emits health.damage when the two overlap. HealthSystem already listens for that event, so a target with a Health component loses HP with nothing else wired up.

Use it in a scene

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.

Fastest path

bjs download scene ContactDamage

Pulls this exact scene into your project and runs it — no copy-paste. Add --all to grab the components and assets it needs too.

Or 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 ContactDamage
    bjs download ContactDamage

    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
{
  "Rammer": {
    "tags": ["enemy"],
    "components": {
      "MeshPrimitive": { "primitive": "box", "width": 2, "height": 1.2, "depth": 4, "position": [0, 0.6, 30] },
      "CarDrive": { "keyboardControlled": false, "maxSpeed": 30 },
      "ContactDamage": {
        "targetTag": "player",
        "damage": 2,
        "radius": 4,
        "repeatDelay": 0.6,
        "minClosingSpeed": 8,
        "speedScaling": 0.4
      }
    }
  },
  "Car": {
    "tags": ["player"],
    "components": {
      "MeshPrimitive": { "primitive": "box", "width": 1.8, "height": 1, "depth": 4, "position": [0, 0.5, 0] },
      "CarDrive": {},
      "Health": { "hp": 20, "invulnSeconds": 0.4 }
    }
  }
}

radius is centre to centre, so it wants to be about the sum of the two bodies' half-lengths — two 4-long cars meet at roughly 4, not at 1.

Four questions, in order

resolveContact settles one damager against one target, and the first "no" wins:

TypeScript
if (!withinContact(probe.dx, probe.dz, params.radius)) return miss('apart', false);
if (params.maxHits > 0 && probe.hits >= params.maxHits) return miss('spent', true);
if (probe.cooldown > 0) return miss('cooldown', true);

const damage = impactDamage(params, closing);
if (damage <= 0) return miss('tooSlow', true);

blocked carries the answer, which is what makes a nudge and a head-on distinguishable rather than both being "nothing happened". Note that apart is the only case that isn't touching — a spent mine sitting under your wheels is still in contact, it just has nothing left to give.

Speed is relative, which is what makes it a ram

Closing speed is the relative velocity projected onto the line between the two bodies:

TypeScript
closingSpeed(0, 10, 0, 12)    // → 12   driving straight at it
closingSpeed(0, 10, 12, 0)    // → 0    passing across its bow
closingSpeed(0, 10, 0, -4)    // → -4   backing away

Two cars meeting head-on close at the sum of their speeds. A car nosing into the back of one running away closes at the difference, and gets charged accordingly. minClosingSpeed is the gate — below it a touch is a nudge worth nothing — and speedScaling pays a bonus per unit of excess:

TypeScript
impactDamage({ ...P, minClosingSpeed: 6, speedScaling: 0.5 }, 18)   // → 7
impactDamage({ ...P, minClosingSpeed: 6, speedScaling: 0.5 }, 5.9)  // → 0

Leave both at 0 and speed drops out of the expression entirely: every contact is worth damage flat, which is exactly what Enemy used to do.

Props

  • damage (number, default 1) — HP taken from the target, before any speed bonus.
  • radius (number, default 2.4) — centre-to-centre XZ distance that counts as touching.
  • repeatDelay (number, default 0.5) — seconds owed per target before this damager may hit it again.
  • minClosingSpeed (number, default 0) — closing speed below which a touch costs nothing. 0 turns the gate off.
  • speedScaling (number, default 0) — extra HP per unit of closing speed above the gate.
  • maxHits (number, default 0) — total hits over this damager's whole life. 0 is unlimited; 1 is a one-shot mine.
  • selfDamage (number, default 0) — HP the ram costs the rammer, billed through the same health.damage event.
  • targetTag (string, default "player") — what this entity is willing to hurt.

Events

  • emits health.damage{ entityId, damage, source } — the shared event HealthSystem listens for. source is the damager, so a kill feed knows who did it.
  • emits contactDamage.landed{ entityId, targetId, damage, selfDamage, closingSpeed } — the hook for sparks, camera shake and scoring. closingSpeed is how hard the hit was, which is what a shake wants to scale by.
  • emits contactDamage.spent{ entityId } — once, the frame a damager lands its last allowed hit.
  • listens for contactDamage.reset{ entityId? } — clears the hit count and every per-target delay. Omit entityId to reset every damager; a pooled entity wants this on respawn or it comes back already spent.

Dependencies

  • MeshPrimitive — the body doing the touching, on the damager and on every target. No mesh, no contact.
  • Health — on the target, if the damage is meant to land anywhere. A target without one absorbs health.damage silently, which is a legitimate way to build a scripted hazard.

Notes

  • Positions are read live through getMeshWorldPosition, never from MeshPrimitiveComponent.position. Nothing writes that array back from physics, so two bodies that both think they're still at their spawn point would never register a hit — the bug that stopped pinball's bumper, spinner and saucer scoring in a browser.
  • A body's velocity comes from the two most recent samples of its position, so it works the same whether a drive law, a physics body or a waypoint mover put it there. The first frame a body is seen has no history, so it counts as parked — that only matters when minClosingSpeed is gating the hit.
  • repeatDelay is per target; maxHits is per damager. Driving a car with maxHits: 3 through a crowd spends the allowance on the first three bodies it meets, whoever they are.
  • A parked (pooled) target is skipped, so recycling an entity doesn't leave a corpse in the road that keeps taking hits.
  • Self damage is emitted as a second health.damage at the damager's own id. That means a rammer with a Health component can die of its own ram, and contactDamage.landed reports both numbers in one payload.
  • No hotkey on the tuning panel: the driving controls own the letters and the spare digits are taken. Open it from the panel index.

More like this

Health
MeshPrimitive
AiPilot
Asteroid
BoatMode
BoatWater
Bullet
CarDrive
Enemy
MachineGun
Missile
MissileLauncher
OilBlob
Parts
RoadSpawner
RoadTreadmill
ShipLoadout
SmokeScreen
SurfacePatch
TrafficConvoy
WeaponsVan

Was this page helpful?

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

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search