logo

Babylon.js Market

RoadTreadmill

vehicular · vehicle-combat

RoadTreadmill

1
Unlock

Install with the CLI:

bjs download RoadTreadmill

RoadTreadmill

A road that never ends, made from twelve boxes that keep getting reused.

What it does

Drive far enough down an honestly-built road and two things break: you run out of road, and your position turns into a seven-digit float that starts eating your decimals. RoadTreadmill solves both by never building a long road in the first place. It owns a fixed ring of slab entities laid nose to tail, slides them toward the car every frame, and teleports any slab that falls far enough behind to the head of the queue. Twelve slabs, recycled forever. RoadTreadmillComponent is the shape of the ring — how many slabs, how long, how wide, how much to keep in the mirror — and RoadTreadmillSystem builds the slab entities, asks the pure core where they belong each frame, and moves them.

The slabs are ordinary entities carrying MeshPrimitive and Shadow. The System creates no meshes and disposes none; MeshPrimitiveSystem does that, and this one only ever repositions what it finds.

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 RoadTreadmill

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

    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
{
  "Road": {
    "components": {
      "RoadTreadmill": {
        "segmentCount": 12,
        "segmentLength": 20,
        "laneWidth": 10,
        "recycleBehind": 20,
        "scrollSpeed": 30,
        "curveAmplitude": 6,
        "curveWavelength": 140
      }
    }
  },
  "Car": {
    "tags": ["player"],
    "components": {
      "MeshPrimitive": {
        "primitive": "box",
        "width": 1.8,
        "height": 1,
        "depth": 4,
        "position": [0, 0.5, 0]
      }
    }
  }
}

RoadTreadmill goes on its own marker entity. It has no mesh of its own — the slabs are its output, and they appear as Road:road:0, Road:road:1, … tagged road and roadSegment.

One floor call is the whole recycler

The ring occupies a window in Z that rides with the car. Its floor is the recycle line: a slab whose centre drops below playerZ - recycleBehind - segmentLength/2 has put its whole body more than recycleBehind behind the car and owes the ring a lap. Its ceiling is one ring-length above that. Every slab must land inside [threshold, threshold + ringLength), and one expression puts it there:

TypeScript
export function stripLaps(z: number, threshold: number, stripLength: number): number {
  if (!(stripLength > 0)) return 0;
  const laps = -Math.floor((z - threshold) / stripLength);
  // Negating a zero floor yields -0, which reads badly in an event payload.
  return laps === 0 ? 0 : laps;
}

Four cases, no branches. A slab inside the window gives a ratio in [0, 1), so the floor is 0 and nothing moves. A slab off the back gives a negative ratio, so the negated floor is positive and the slab jumps forward. A slab off the front — which happens when the car reverses — jumps backward. And a slab that a frame hitch left three ring-lengths behind comes back in a single jump of 3, rather than spending three frames catching up:

TypeScript
stripLaps(-31, -30, 80)   // → 1   just past the line
stripLaps(-350, -30, 80)  // → 4   a stall, recovered in one frame
stripLaps(130, -30, 80)   // → -2  reversing

Two ways to wire it, one rule

The recycle line is computed from the car's Z, which means the same maths serves both classic arrangements.

Road moves, car parked — the scene above. Leave player unset and give the ring a scrollSpeed. Slabs slide past a car that stays at Z 0 for the whole run, so no coordinate ever grows and float precision never degrades. This is the classic combat-racer arrangement: the car steers across the road, it doesn't advance along it.

Car moves, road parked. Set scrollSpeed to 0 and name a player. Nothing pushes the slabs, but the recycle line rides forward with the car, so slabs still wrap ahead of it as it drives past them. This is the wiring to use when the car has a real drive law under it:

JSON
{ "RoadTreadmill": { "scrollSpeed": 0, "player": "Car" } }

Pick one. Give the ring a scroll speed and a car that drives itself forward and the road goes past twice as fast as it looks like it should.

Either way roadTreadmill.recycled carries a distance odometer that counts road gone by, because it sums the ring's travel and the car's.

The curve is a property of the road, not of the trip

curveAmplitude swings the centreline sideways as A · sin(2πz / λ) — a function of world Z, never of elapsed time. That is what makes a recycled slab land on geometry that fits: jump a slab forward by a whole ring-length and it takes the X offset and yaw belonging to its new Z, which is the same road the slab ahead of it already drove. There is no seam to hide. Set alignToCurve and each slab also yaws by atan of the curve's slope, turning a staircase of rectangles into one ribbon.

Props

  • segmentCount (number, default 12) — slabs in the ring. Ring length is segmentCount × segmentLength; that product is how far you can see.
  • segmentLength (number, default 20) — slab depth along Z, and the spacing between slab centres.
  • laneWidth (number, default 10) — road width across X.
  • recycleBehind (number, default 20) — how far behind the car a slab's trailing edge must fall before it jumps. At 0 slabs vanish inside the frame.
  • scrollSpeed (number, default 30) — world units/sec the ring slides past the car. Negative reverses; 0 hands the job to a moving player.
  • roadY (number, default 0) — height of the road surface. The slab centre sits half a thickness below it, so the deck lands where you asked.
  • roadThickness (number, default 0.2) — slab height, so the road reads as a raised deck rather than a decal.
  • curveAmplitude (number, default 0) — sideways swing of the centreline. 0 is a drag strip.
  • curveWavelength (number, default 120) — Z distance for one full S-bend.
  • alignToCurve (boolean, default true) — yaw each slab to the centreline's heading.
  • player (string, default null) — entity whose Z anchors the ring. Unset anchors it on world Z 0.
  • color / altColor ([r,g,b], defaults dark asphalt) — even and odd slabs. Two shades are what make the scroll readable at speed.
  • receiveShadows (boolean, default true) — build-time; puts a Shadow receiver on each slab.

Events

  • emits roadTreadmill.ready{ entityId, segmentIds } — the frame the slab entities exist. Spawners that want to decorate the road wait for this.
  • emits roadTreadmill.recycled{ entityId, ownerEntityId, index, x, z, laps, distance } — every time a slab reaches the head of the ring. Fresh road just appeared at a known X and Z, which is the moment to place an obstacle or an enemy on it.
  • listens for roadTreadmill.speed.set{ entityId?, speed } — a director ramping the difficulty. Omit entityId to retune every treadmill.
  • listens for roadTreadmill.reset{ entityId? } — snap the ring back to its opening layout and zero the odometer.

Dependencies

  • MeshPrimitive — every slab is one, and MeshPrimitiveSystem must be in the world or the slabs exist without meshes.
  • Shadow — optional, on by default: slabs receive but never cast, so the car's shadow lands on the road.

Notes

  • The layout fills the wrap window exactly: slab 0 straddles the recycle line with half a body left to live, the rest run forward from there. Lay the ring out any wider and the head slab starts on the wrong side of the window and wraps on frame one.
  • The frame a slab is created it stays where MeshPrimitiveSystem put it. Two systems writing one mesh handle in one frame is the flicker bug; the treadmill takes the slab over from the next frame.
  • roadTreadmill.reset sets a flag rather than moving anything. The relayout happens in the next onUpdate, so mesh writes stay inside the update loop instead of running from a bus callback.
  • A resize is a scale, not a rebuild. Slabs keep the box dimensions they were built with and laneWidth / segmentLength / roadThickness are pushed to the renderer as setMeshScale, so dragging a slider costs no mesh churn. Turning segmentCount up adds slab entities at the head of the ring; turning it down removes them from the tail.
  • Nothing here scrolls a texture and nothing shifts the world origin. The renderer adapter has no texture-offset call, and moving twelve boxes is cheaper than either.
  • 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

MeshPrimitive
Shadow
BoatMode
BoatWater
CarDrive
ContactDamage
RoadSpawner
Skybox
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