Phaser AE

Tube Playfield

Free packv3.0.0-42DVector

The playfield of a tube shooter: lanes running away to a vanishing point between a near rim the player crawls around and a far rim the enemies climb out of. This pack is the geometry and the lane arithmetic; what moves on it is up to the game.

This is a Free pack. Every phaser.io account can use it in their games.

Released October 14, 2026 · updated October 20, 2026

Tube Playfield: 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

vectorTube

function vectorTube(): Pack<{ vecTube: VecTubeApi }>

The vector-tube pack. Pure maths, so it needs no backend and runs headless.

Helpers

generateTube

function generateTube(opts: TubeOptions = {}): Tube

Build a tube playfield.

const web = game.vecTube.generateTube({ x: cx, y: cy, near: 300, far: 80, lanes: 16, profile: 'star' });
game.vector.poly(web.nearRim, { color: '#41d6ff', closed: web.closed });
game.vector.poly(web.farRim,  { color: '#2a4a7a', closed: web.closed });
for (const [a, b] of web.spokes) game.vector.seg(a.x, a.y, b.x, b.y, { color: '#2a4a7a' });

const p = web.laneAt(playerLane, 0);        // the claw, on the near rim
const e = web.laneAt(enemyLane, enemyT);    // a flipper, climbing out

The geometry is STATIC — generate once per level, not per frame. Only laneAt runs in the loop, and it is a lerp and an atan2. Lane counts below 2 are raised to 2, and a path of fewer than two points is ignored in favour of profile.

Types

LanePose

interface LanePose

Where something on a lane sits, faces, and how big to draw it.

  • x: number — The world x of the pose.
  • y: number — The world y of the pose.
  • angle: number — Facing OUT of the tube (from the far rim toward the near one) — the direction an enemy is travelling, and the way a rim ship points.
  • scale: number — 1 on the near rim, far / near at the vanishing end. Multiply sprite or line size by it and things shrink correctly as they climb.

Tube

interface Tube

A generated web. nearRim/farRim/spokes draw it; laneAt plays it.

  • lanes: number — Playable lanes (NOT the vertex count — an open web has one more vertex).
  • closed: boolean — True when lane 0 and the last lane are neighbours, so the player wraps.
  • nearRim: VecPt[] — The near-rim vertices, in world units — the rim in the player's face.
  • farRim: VecPt[] — The far-rim vertices, in world units — the rim at the vanishing end.
  • spokes: Array<[VecPt, VecPt]> — Near vertex → far vertex, one per rim vertex.
  • rimAt(i: number, t: number): VecPt — Rim VERTEX i at depth t (0 = near, 1 = far). Wraps when closed.
  • rimPointAt(u: number, t: number): VecPt — Rim position at a CONTINUOUS vertex parameter — u 0..lanes, sliding along the rim rather than hopping vertex to vertex. rimPointAt(2.5, 0) is halfway along the near rim's third edge. Wraps or clamps like everything else. This is the glide primitive; laneAt is built on it.
  • laneAt(lane: number, t: number): LanePose — The middle of LANE lane at depth t — position, facing and scale. lane is CONTINUOUS: whole numbers sit in the middle of a lane, and everything between glides smoothly along the rim (round corners included), so a claw can slide rather than snap. t runs 0 (near rim) to 1 (far).
  • wrapLane(v: number): number — Bring a lane position into range — wraps on a closed web, clamps on an open one. Feed a fractional lane through this after moving it.
  • laneDelta(from: number, to: number): number — Signed lane distance from → to, taking the SHORT way round a closed web (lane 15 → lane 0 of 16 is +1, not -15). Steering and AI both need this; getting it wrong makes things scurry the long way round.
  • stepLane(from: number, to: number, maxStep: number): number — Move a lane position toward to by at most maxStep lanes, the short way round, never overshooting — one frame of a smooth glide. Steps in LANES, so on a profile whose lanes differ in length this changes speed as it goes. Use glide to move at a constant speed instead.
  • perimeter: number — Total near-rim length in world units — the closing edge included when the web is closed. perimeter / speed is how long one lap takes.
  • laneLength(i: number): number — Near-rim length of lane i in world units. Equal for a circle or a square; 2:1 between a star's longest and shortest.
  • advance(lane: number, dist: number): number — Move a lane position along the rim by dist WORLD UNITS, signed, wrapping or clamping like wrapLane. This is the constant-speed step: ts lane = web.advance(lane, dir * 880 * dt); // 880 world units per second, // on every profile alike where wrapLane(lane + dir * n * dt) would have moved at n lanes per second and so sped up and slowed down around the rim.
  • arcDelta(from: number, to: number): number — Signed rim DISTANCE from → to in world units, the short way round — laneDelta measured in world units instead of lanes.
  • glide(from: number, to: number, maxDist: number): number — Move toward to by at most maxDist WORLD UNITS, the short way round, never overshooting. The constant-speed twin of stepLane.
  • angleOf(lane: number): number — Where lane sits on the circle, radians about the web's centre.
  • laneAtAngle(angle: number): number — The lane at a given angle on the circle — the inverse of angleOf, and the whole of mouse steering: laneAtAngle(atan2(my - cy, mx - cx)).
  • turn(lane: number, dAngle: number): number — Turn dAngle radians round the circle — positive is clockwise on screen, on every profile alike. Clamps at the ends of an open web. This paces by ANGLE, so on a star the claw covers more ground near a spike tip than in a notch. steer paces by DISTANCE instead and holds one speed — prefer it for a player-controlled ship.
  • angleSpan: { from: number; to: number } — The angles the rim actually spans. A full turn when closed; on an open web the reachable arc, past which turn and laneAtAngle clamp.
  • bottom: number — The lane at the BOTTOM of the web — where a tube game's ship starts. Not a detail: Tempest and everything after it put the ship at the bottom middle, and the whole feel of the controls is calibrated from there. Start it at the top and every profile plays inverted.
  • steer(lane: number, dist: number): number — Move a ship dist world units the way the PLAYER reads it — positive is to their right — at one constant speed whatever the rim is doing. ts lane = web.steer(lane, dir * 880 * dt); // dir: -1 left, +1 right This is advance with the sign sorted out. Some silhouettes wind clockwise on screen and some anticlockwise, so "forward along the path" is rightward on one and leftward on the next; steer folds in that single build-time winding so left is left on all of them. Read at the BOTTOM, where the ship lives. A ring has no global left and right — carry on far enough round and the ship is climbing the far side, at which point the screen direction is reversed. Every tube game does this.
  • laneOf(x: number, y: number): number — The fractional lane nearest a world point — mouse and spinner control. laneAt(laneOf(x, y), 0) lands on the rim under the cursor.

TubeOptions

interface TubeOptions

Tuning for generateTube.

  • lanes?: number — Playable lanes around the web (default 16).
  • profile?: TubeProfile — Rim silhouette (default 'circle').
  • x?: number — Near-rim centre in world units (default 0, 0).
  • y?: number — The world y of the near-rim centre (default 0).
  • near?: number — Near-rim radius (default 300) — the rim in the player's face.
  • far?: number — Far-rim radius (default near * 0.3). Smaller = a deeper tube.
  • vanishX?: number — The vanishing point — the far rim's centre (default the near centre). Offset it and the tube leans away instead of receding straight back.
  • vanishY?: number — The world y of the vanishing point (default the near centre's y).
  • rot?: number — Spin the whole web about its centre, radians (default 0).
  • closed?: boolean — Force the loop open or closed. Default: the profile decides ('vee' is open, the rest close), which is also whether lane 0 and lane n-1 are neighbours — i.e. whether the player wraps. A custom path is closed unless this says otherwise.
  • path?: readonly VecPt[] — A custom silhouette in unit space (roughly -1..1, y down), replacing profile. Corners are kept exactly as with the built-in shapes, so a path of lanes edges gives one lane per edge: lanes points when closed, lanes + 1 when open. This is how an authored web (Tempest's sixteen, a level editor's output) becomes a tube.

TubeProfile

type TubeProfile = 'circle' | 'square' | 'plus' | 'vee' | 'star'

A web silhouette. vee is the open one; the rest close into a loop.

Vec2

interface Vec2

A 2D point or vector. Plain data, so anything can produce one.

  • x: number — Horizontal position or component, increasing to the right.
  • y: number — Vertical position or component, increasing downward (the 2D world is Y-down).

VecPt

type VecPt = Vec2

A point on a path — the same shape, named for what it is used as.

VecTubeApi

type VecTubeApi = typeof kit

game.vecTube — every function this pack carries, as one namespace.