logo

Babylon.js Market

SurfacePatch

vehicular · vehicle-combat

SurfacePatch

1
Unlock

Install with the CLI:

bjs download SurfacePatch

SurfacePatch

A stretch of road that changes how the car handles while it is standing on it — oil, ice or gravel from one component.

What it does

An obstacle answers "can I be here?". A surface answers "how well does this go?" — and the answer is the same six numbers the drive law already reads. SurfacePatchComponent is a footprint plus six multipliers, and SurfacePatchSystem collects the live patches each frame, works out which cars are standing on them, and writes the scaled profile onto CarDriveComponent. Nothing about the driving changes: CarDriveSystem reads grip next frame the way it always does and the car slides because the road said so.

Multipliers rather than absolute values, so a patch is a property of the ground and not of the car. Oil at gripScale: 0.12 takes nine tenths of the bite from whatever drove onto it — the rally car keeps its edge over the limousine, and both are in trouble.

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 SurfacePatch

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

    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
{
  "Input": {
    "components": {
      "KeyboardInput": {}
    }
  },
  "Car": {
    "tags": ["player"],
    "components": {
      "MeshPrimitive": {
        "primitive": "box",
        "width": 1.8,
        "height": 1,
        "depth": 4,
        "position": [0, 0.5, 0]
      },
      "CarDrive": {
        "maxSpeed": 26,
        "grip": 0.85
      }
    }
  },
  "Slick": {
    "components": {
      "MeshPrimitive": {
        "primitive": "cylinder",
        "diameter": 12,
        "height": 0.05,
        "position": [0, 0.03, 40],
        "color": [0.05, 0.05, 0.07]
      },
      "SurfacePatch": {
        "surface": "oil",
        "shape": "disc",
        "radius": 6
      }
    }
  },
  "Shoulder": {
    "components": {
      "MeshPrimitive": {
        "primitive": "box",
        "width": 6,
        "height": 0.05,
        "depth": 200,
        "position": [12, 0.02, 0],
        "color": [0.35, 0.3, 0.24]
      },
      "SurfacePatch": {
        "surface": "gravel",
        "halfWidth": 3,
        "halfDepth": 100
      }
    }
  }
}

The mesh is the decoration and the component is the trigger: halfWidth / halfDepth / radius are the footprint, the mesh's own dimensions are what you see. Keep the two in step or the slick will catch cars a metre off its painted edge. For an invisible patch, set "visible": false on the MeshPrimitive — the System still needs it, because that is where the patch's position comes from.

The three surfaces

surface picks a preset, which seeds the six scales in the constructor. From then on the scales are the live values — the System never looks at surface again except to name it in an event.

oilicegravel
grip×0.12×0.25×0.6
top speed×1×1×0.75
acceleration×0.55×0.35×0.7
braking×0.3×0.25×0.85
steering×0.75×0.9×1
coasting drag×0.5×0.2×1.8

Oil takes away your say, not your speed. Top speed is untouched: the car arrives at the far side of the slick doing exactly what it was doing, just pointing somewhere it didn't choose. Ice is oil that also refuses to give anything back — acceleration and braking go, and drag at a fifth means a coast across it never ends. Gravel costs time. Half the grip is still driveable; a quarter off the top speed and nearly double the drag is the price of running wide.

Override any scale individually — { "surface": "oil", "brakingScale": 0.6 } is a forgiving slick — or push a scale above 1 and the same component becomes a boost strip.

Leaving is a rederivation, not an undo

The obvious way to build this is "multiply on entry, divide on exit", and the obvious way is what leaves a car on ice forever. Two overlapping patches, one deleted mid-slide, a hazard disarmed from a menu, a car parked in a pool — every one of those is a division that never happens.

So the System keeps a snapshot of the profile the car had before any patch touched it, and each frame writes:

TypeScript
applyModifier(state.base, this._mod, car);

base is the clean road, _mod is every patch the car is standing on this frame multiplied together, and car is the target. The car's current values are never an input, so the frame it is standing on nothing the snapshot goes back verbatim and the tracking entry is dropped. Five different ways of getting off a patch all resolve to the same line:

  • driving off it
  • surfacePatch.enabled.set disarming it
  • deleting the patch entity
  • parking the car (entity.active = false, the pooling flag)
  • removing the System from the world — onShutdown restores every tracked car

One consequence worth knowing: while a car is on a patch, its six handling fields belong to the patch. Retune the car's own grip with the car on clean road, or the edit is overwritten on the next frame and lost when the snapshot goes back.

Props

  • surface ("oil" | "ice" | "gravel", default "oil") — preset that seeds the six scales. Read once, in the constructor.
  • shape ("box" | "disc", default "box") — a disc for a slick, a box for a shoulder or a sheet.
  • halfWidth / halfDepth (number, default 3) — box half-extents. The patch is twice these across.
  • radius (number, default 3) — disc radius.
  • gripScale, maxSpeedScale, accelerationScale, brakingScale, steerRateScale, dragScale (number) — multipliers on the car's own profile, each defaulting to the preset's value. 1 leaves a field alone.
  • enabled (boolean, default true) — false parks the hazard without deleting it; cars standing on it get their handling back next frame. This is the framework's own Component.enabled flag, so flipping it also emits component.SurfacePatchComponent.disabled for anything else that cares.
  • targetTag (string, default "") — only cars carrying this tag are affected. Empty affects every car.

Events

  • emits surfacePatch.entered{ entityId, patchEntityId, surface, modifier } — the frame a car drives on. modifier is a copy of that patch's six scales, which is enough to start tyre smoke or flash a HUD warning without reading the component.
  • emits surfacePatch.exited{ entityId, patchEntityId, surface } — the frame it stops being on that patch, whether it drove off or the patch went away.
  • listens for surfacePatch.enabled.set{ entityId?, enabled } — arm or disarm a hazard. Omit entityId to switch every patch in the world.

Both edges fire once each way, not sixty times a second, so they suit audio and particles directly.

Dependencies

  • MeshPrimitive — on the patch, because the footprint is centred on the patch's live world position; and on the car, because that is where the car's position is read from.
  • CarDrive — the handling profile being scaled. Cars without one are ignored: this component knows how to change a car's handling, not how to invent it.

Notes

  • Positions come off the renderer (getMeshWorldPosition), never MeshPrimitiveComponent.position. Nothing writes that array back from physics, so a car pushed by a collision would appear to be parked on the slick it just spun off. Same read for the patch, which is what lets a slick dropped behind a car move with it.
  • Overlapping patches multiply: oil spilt on ice is worse than either, and the order they are collected in doesn't change the answer.
  • grip is clamped to 0..1 on the way in. It is documented over that range and the panel's slider draws it there; a stored 1.7 would show up in the panel and in serialize() looking like a real value.
  • Everything the System reads it reads every frame, so every knob on the panel is live — including with a car sitting on the slick.
  • The footprint is the component's, the mesh is decoration. Nothing scales one to the other, exactly as Obstacle leaves its halfWidth and its box independent.
  • No hotkey on the tuning panel: WASD is the driving control and the spare digits are taken. Open it from the panel index.

More like this

CarDrive
MeshPrimitive
Asteroid
BoatMode
BoatWater
ContactDamage
OilBlob
RoadSpawner
RoadTreadmill
Skybox
SmokeScreen
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