Phaser AE

Pseudo-3D road

Pro packv3.0.0-12DArcade

The road of the sprite-scaling arcade racers: a strip of segments laid from straights, curves and hills, projected from a camera behind or in the car. It hands back what to draw and where everything beside the road appears, and costs almost nothing to run.

This is a Pro pack. Upgrade to Phaser Pro to use it in your games.See Phaser Pro →

Released October 19, 2026 · updated October 20, 2026

Pseudo-3D road: a screenshot of its demo
A note about these graphics

Expansion packs don't come with graphics. This screenshot is one way of drawing what the pack does, made for its demo: none of the sprites, tiles or backgrounds in it are in the pack.

That's by design. A pack is a game's logic, mechanics and effects, and it works with whatever art you give it. Some packs draw effects, backdrops or 3D scenes of their own, but none carries a game's sprites.

So the look is yours. Make the art on phaser.io, bring your own, or let your agent draw it in code: the same pack can look like anything.

The pack

pseudoRoad

function pseudoRoad(): Pack<{ pseudoRoad: PseudoRoadApi }>

The pseudo-road pack: the pseudo-3D road of the classic racers. Pure logic, so it runs headless.

Helpers

createRoad

function createRoad(o: RoadOptions): PseudoRoad

Creates a PseudoRoad from the given options.

Classes

PseudoRoad

class PseudoRoad

The segment-projected road of every arcade racer of the era: no 3D, just a strip of trapezoids whose far edge is shifted sideways by the accumulated curve. Curves are a LATERAL SHIFT PER SEGMENT, not real geometry, which is exactly why the road can bend without the camera ever turning.

for (const q of road.view(position, playerX)) {
  layer.seg(q.x1 - q.w1, q.y1, q.x2 - q.w2, q.y2, style(q.fog));
  layer.seg(q.x1 + q.w1, q.y1, q.x2 + q.w2, q.y2, style(q.fog));
}
  • readonly segments: RoadSegment[] — All road segments, in track order.
  • readonly segmentLength: number — The length of one segment, in world units.
  • readonly roadWidth: number — The half-width of the road surface, in world units.
  • readonly lanes: number — The number of lanes across the road.
  • readonly camDepth: number — The camera depth derived from the field of view.
  • readonly netHill: number — Net height the pieces climbed before the loop was levelled.
  • camHeight: number — The camera height above the road, in world units.
  • drawDistance: number — The number of segments drawn ahead of the camera.
  • constructor(o: RoadOptions)
  • get length(): number — The total length of the road, in world units.
  • segmentAt(z: number): RoadSegment — Segment at a world z, wrapping round the track.
  • curveAt(z: number): number — Returns the curvature of the segment at world z.
  • heightAt(z: number): number — Road height at a world z, interpolated between segments.
  • laneX(lane: number): number — Lateral offset of a lane centre, -1..1 across the road.
  • centrifugal(z: number, speed: number, maxSpeed: number, strength = 0.3): number — Sideways push from taking a corner at speed. Positive is to the right — subtract it from the player's lateral position, and a fast corner starts throwing the car off the road, which is the whole skill of these games.
  • view(rawZ: number, playerX: number, screen: { w: number; h: number }, opts: RoadViewOptions = {}): RoadQuad[] — Project the visible road. camZ is the camera's world z, playerX the lateral position in road half-widths (-1 left edge, +1 right edge).
  • ahead(z: number, from = this.view0.camZ): number — How far along the road z is from from (default: the camera's z at the last view()), signed and wrapped the short way. Positive is ahead. The gameplay counterpart to place(): place answers "where do I draw this" and returns null when it is not on screen, which is the wrong answer to "how close is it".
  • visible(z: number): boolean — Is the road at z on screen this frame? Call after view().
  • distanceAtScreenY(y: number): number — How far up the road the point drawn at screen row y is — the projection run backwards. Infinity above the horizon. This is how far in front of the camera your own car is. A chase view draws the player some way up the screen, and the road distance that corresponds to is nearly a thousand units on a typical setup. Collisions measured from the CAMERA are therefore that far out.
  • contact(z: number, offset: number, camZ: number, lateral: number, o: { atY?: number; reach?: number; halfWidth?: number; blind?: boolean } = {}): boolean — Is a thing standing at (z, offset) touching the player? Pass atY: the screen row your own car is drawn at. The camera is BEHIND the car in a chase view, and the road distance between them is large — around a thousand units at a typical camHeight. Measure contact from camZ and every hit lands that far out: things register while they are still up the road, or, worse, once they are already past you and below the bottom of the screen where you cannot see them at all. That is the classic "crashed into nothing". With atY the window is symmetric about your car, reach either side, and the geometry then guarantees contact happens while the thing is on screen. Call after view().
  • place(z: number, offset: number, height = 0): { x: number; y: number; scale: number } | null — Where a thing sitting on the road at world z, lateral offset (-1..1), height world units up, lands on the screen. Call it after view() — it reads that frame's projection so the object rides the same curve as the road under it. Returns null when it is not in view.

Types

PseudoRoadApi

type PseudoRoadApi = typeof kit

game.pseudoRoad — the pseudo-3D road and its projection, as one namespace.

RoadOptions

interface RoadOptions

The options that configure a PseudoRoad. Passed to createRoad().

  • pieces: RoadPiece[] — The pieces that make the track, in order.
  • segmentLength?: number — Length of one segment. Default 200.
  • roadWidth?: number — Half-width of the road surface in world units. Default 1000.
  • lanes?: number — The number of lanes across the road. Default 3.
  • fov?: number — Vertical field of view, degrees. Default 100 (a 1.0 camera depth).
  • camHeight?: number — Camera height above the road. Default 1000.
  • drawDistance?: number — Segments drawn ahead. Default 200.
  • closeLoop?: boolean — Level the track's height so its end meets its start. Default true. segmentAt() wraps, so a road is always a LOOP — and a loop whose hills do not sum to zero has a CLIFF at the finish line. Leave this on unless the road is a one-way route you never drive off the end of.

RoadPiece

interface RoadPiece

One stretch of track — a straight, a corner or a hill. Listed in RoadOptions.pieces.

  • length: number — How far this piece runs, in world units.
  • curve?: number — Curvature per segment: negative left, positive right. Default 0.
  • hill?: number — Height climbed (+) or dropped (-) over the piece. Default 0.
  • name?: string — A name copied to each segment of the piece — use it to style or find a stretch.
  • ease?: boolean — Ease the curve and hill in and out instead of snapping. Default true.

RoadQuad

interface RoadQuad

One projected segment, ready to draw as lines.

  • index: number — The index of the source segment.
  • name: string — The name of the piece the segment came from.
  • curve: number — The curvature of the segment.
  • x1: number — Near edge: centre x, y, half-width, all in screen units.
  • y1: number — The screen y of the near edge.
  • w1: number — The half-width of the near edge, in screen units.
  • x2: number — Far edge.
  • y2: number — The screen y of the far edge.
  • w2: number — The half-width of the far edge, in screen units.
  • fog: number — 0 near … 1 at the draw distance — fade width, glow and alpha with it.
  • looped: boolean — Segments this far along the road, for texturing rumble strips.

RoadSegment

interface RoadSegment

One segment of a built road. Made by the PseudoRoad constructor from the pieces.

  • index: number — The position of this segment in the track, from 0.
  • z: number — World z at the START of this segment.
  • curve: number — The lateral shift this segment applies: negative left, positive right.
  • y: number — World height at the start of this segment.
  • name: string — The name of the piece this segment came from, or an empty string.

RoadViewOptions

interface RoadViewOptions

Options for PseudoRoad.view().

  • occluded?: boolean — Also project the in-front segments hidden behind a crest, for place() only (they are not returned and visible() still says no). A painter that draws the road and its sprites far to near needs them: a tree standing beyond a hill keeps its top above the crest instead of popping in. Default false.
  • fov?: number — Field of view for this view only (degrees, as RoadOptions.fov). Default the road's own.
  • smoothCurves?: boolean — Lay curves continuously: the lateral offset of every point ahead is the exact double integral of the curvature from the camera (the camera looks along the road there), so a point a fixed distance ahead stays put as the camera crosses segment boundaries. The classic per-segment accumulation (the default) shifts the whole road by up to a segment's curve at each boundary: in a long bend a car held a fixed distance ahead judders sideways by a pixel or two every segment. Default false (the classic look).
  • eyeAt?: number — Road position (raw z, as passed to view) the camera height is measured above. Default the camera's own z. A chase camera measures it above the vehicle it follows, so the vehicle stays put on screen on a slope instead of riding up and down with the grade between the camera and the vehicle.