raw Software
Stage 4 · Axial rotation around the moving frame · Move mouse to look around

Building a 3D Tunnel Walker from Scratch

Robert Eisele

A complete derivation of a procedural 3D tunnel renderer and runner game in vanilla JavaScript. Starting from pinhole perspective projection and similar triangles, it builds an undulating, curving spatial tube with moving reference frames, decoupled camera look, robust near-plane clipping, and an unrolled discrete grid for collision and jumping.

Read the story

A tunnel rushes toward the viewpoint. Glowing grid lines converge in the distance, sweep past the periphery, and curve over crests and through sweeping turns. Polygonal tiles can rotate around the moving axis while the tunnel itself maintains its path through space.

Generating this motion requires neither WebGL nor an external graphics library. The entire scene emerges from elementary trigonometry, vectors, and a 2D canvas context. We place geometric vertices in three-dimensional space, project them onto a planar screen using similar triangles, and guide an orthonormal reference frame along an undulating parametric curve. Expanding this wireframe foundation with depth-sorted quad polygons, radial gravity, and hazard detection yields an arcade tunnel runner.

Perspective Projection from Pinhole Geometry

To project 3D points onto a 2D surface, we define a right-handed camera coordinate system. The camera sits at the origin \((0, 0, 0)\) looking down the positive \(z\)-axis. The \(x\)-axis points to the right, and the \(y\)-axis points downward to match screen pixel coordinates. In this layout, a smaller \(y\)-coordinate corresponds to a higher physical elevation.

An image plane is positioned at focal distance \(f\) in front of the camera. A spatial point \(\mathbf{P} = (x, y, z)\) with \(z > 0\) projects onto the image plane at the intersection of the ray connecting the origin to \(\mathbf{P}\).

z y Camera (0,0) Image Plane P = (y, z) y′ f z y y′ / f = y / z
Side view of pinhole projection. The smaller triangle formed by the focal plane and the larger triangle formed by point P share an angle at the camera origin, making their side ratios identical.

Examining the side profile reveals two similar right triangles, yielding the ratio \(\frac{y'}{f} = \frac{y}{z}\). The identical geometric proportionality governs the horizontal axis: \(\frac{x'}{f} = \frac{x}{z}\). Translating the optical center to the center of a screen of dimensions \(W \times H\) gives the final screen projection formulas:

\[\begin{aligned} x_{\text{screen}} &= \frac{W}{2} + f \frac{x}{z} \\[6pt] y_{\text{screen}} &= \frac{H}{2} + f \frac{y}{z} \end{aligned}\]

A point twice as far from the camera exhibits half the screen displacement from the center. The focal length \(f\) is expressed in pixels. Choosing \(f = 0.8 \max(W, H)\) delivers a natural field of view of approximately \(64^\circ\). Larger values narrow the view like a telephoto lens, while smaller values widen the viewing angle.

function project(x, y, z, width, height, focal) {
  return {
    x: width / 2 + focal * x / z,
    y: height / 2 + focal * y / z
  };
}

Points lying at or behind the camera (\(z \le 0\)) cannot pass through this formula, as dividing by zero or negative depths inverts geometry across the screen. These points are handled prior to division by clipping against a near plane.

Constructing Polygonal Tunnel Rings

A circle of radius \(r\) centered on the \(z\)-axis is parameterized by an angle \(\alpha\):

\[P(s, \alpha) = \big(r \cos \alpha, \, r \sin \alpha, \, s\big)\]

Rather than attempting to render continuous smooth cylinders, we discretize each cross-section into a regular polygon with \(N\) vertices. Sample angles are spaced evenly across the circle for \(j \in \{0, 1, \dots, N - 1\}\):

\[\alpha_j = \frac{2\pi j}{N}\]
α r Cross-Section (N = 12) Δs Longitudinal View z
Cross-sectional polygon with radius r and vertex angles α (left), and successive rings positioned at spacing Δs along the depth axis (right).

Sequential rings are positioned along the tunnel at fixed longitudinal intervals \(s = k \Delta s\). Within each ring, transverse edges connect vertex \(j\) to vertex \((j + 1) \bmod N\). Longitudinal rails connect vertex \(j\) of ring \(k\) to the corresponding vertex \(j\) of ring \(k + 1\). Each quadrilateral panel of the tunnel is thus bounded by four line segments.

function straightRing(s, radius, sides) {
  const points = [];
  for (let j = 0; j < sides; j++) {
    const angle = (2 * Math.PI * j) / sides;
    points.push({
      x: radius * Math.cos(angle),
      y: radius * Math.sin(angle),
      z: s
    });
  }
  return points;
}

To simulate forward motion, the camera advances to position \(C(q) = (0, 0, q)\). For a world point at depth \(s\), its relative depth from the camera is \(z = s - q\). As \(q\) increases over time at forward speed \(v\), the rings expand outward on screen until they pass behind the camera plane:

distance += speed * dt;
const relativeZ = worldZ - distance;
const screen = project(worldX, worldY, relativeZ, width, height, focal);

Multiplying speed by delta time \(\Delta t\) guarantees frame-rate-independent travel. An infinite tunnel does not require unbounded memory: we only generate a sliding window of rings—typically 50 rings ahead and a few rings behind the camera.

Centerline Trajectory and Moving Reference Frames

A straight tunnel is monotonous. To introduce hills and valleys, we replace the straight \(z\)-axis with a spatial curve \(C(s)\). The parameter \(s\) continues to track depth:

\[C(s) = \big(0, \, A_y \sin(k_y s), \, s\big)\]

Here, \(A_y\) denotes vertical amplitude, while \(k_y\) is the angular wavenumber. The wave repeats every wavelength \(\lambda = \frac{2\pi}{k_y}\).

z y Camera C(q) Centerline C(s)
Centerline elevation profile. The camera moves along C(s). Both the viewpoint orientation and the ring orientations tilt to match the local curve tangent.

A curved path introduces a fundamental requirement: the camera must not only shift its position, but also rotate to face along the track. Similarly, each tunnel ring must tilt so that its cross-sectional plane remains orthogonal to the local forward tangent.

The local tangent vector is obtained cleanly via symmetric central differences without analytical derivation:

\[\mathbf{\Delta} = C(s + \varepsilon) - C(s - \varepsilon)\]

Setting \(\varepsilon = 1\) delivers smooth, accurate approximations. In the vertical plane, the tangent components are \(\Delta y\) and \(\Delta z\). The pitch angle \(\theta\) follows from the two-argument arctangent:

\[\theta = \operatorname{atan2}(\Delta y, \, \Delta z)\]
Forward F Ring cross-section C(s) Dashed line: unpitched ring Down D
The cross-section tilts with the forward tangent. The ring center remains anchored at C(s), while its planar cross-section stays orthogonal to forward vector F.

For vertical-only curvature, the orthonormal moving triad consists of the forward unit vector \(\mathbf{F}\), the downward unit vector \(\mathbf{D}\), and the invariant right vector \(\mathbf{R}\):

\[\begin{aligned} \mathbf{F} &= (0, \, \sin \theta, \, \cos \theta) \\ \mathbf{D} &= (0, \, \cos \theta, \, -\sin \theta) \\ \mathbf{R} &= (1, \, 0, \, 0) \end{aligned}\]

Given local radial offsets \(u = r \cos \alpha\) and \(v = r \sin \alpha\), the 3D position of each vertex on the ring is:

\[\mathbf{P} = C(s) + u \mathbf{R} + v \mathbf{D}\]

Full 3D Curves: Yaw and Pitch Moving Frames

To add lateral curves, the centerline incorporates a second wave in the \(x\)-dimension:

\[C(s) = \big(A_x \sin(k_x s), \, A_y \sin(k_y s), \, s\big)\]

Choosing incommensurate frequencies (\(k_x \neq k_y\)) prevents horizontal and vertical extrema from synchronizing, producing organic turns and dives.

Top View: Yaw Angle ψ z x ψ Δz Δx Side View: Pitch Angle θ h = √(Δx² + Δz²) Δy y θ
Two successive rotations define the local frame: yaw ψ sets horizontal direction in the x-z plane, and pitch θ tilts against the horizontal projected distance h.

Orientation in 3D curve space requires two angles. The yaw angle \(\psi\) sets the horizontal direction, while the pitch angle \(\theta\) dictates incline relative to the horizontal distance \(h\):

\[\begin{aligned} \psi &= \operatorname{atan2}(\Delta x, \, \Delta z) \\ h &= \sqrt{\Delta x^2 + \Delta z^2} \\ \theta &= \operatorname{atan2}(\Delta y, \, h) \end{aligned}\]

From these two angles, we construct a normalized moving frame \(\{\mathbf{R}, \mathbf{D}, \mathbf{F}\}\):

\[\begin{aligned} \mathbf{R} &= (\cos \psi, \, 0, \, -\sin \psi) \\ \mathbf{D} &= (-\sin \psi \sin \theta, \, \cos \theta, \, -\cos \psi \sin \theta) \\ \mathbf{F} &= (\sin \psi \cos \theta, \, \sin \theta, \, \cos \psi \cos \theta) \end{aligned}\]

This triad is orthonormal: each vector has unit length (\(\|\mathbf{R}\| = \|\mathbf{D}\| = \|\mathbf{F}\| = 1\)), and they are mutually perpendicular (\(\mathbf{R} \cdot \mathbf{D} = 0\), \(\mathbf{R} \cdot \mathbf{F} = 0\), \(\mathbf{D} \cdot \mathbf{F} = 0\)), satisfying the identity \(\sin^2 \omega + \cos^2 \omega = 1\). Because they form a right-handed basis, the forward direction also equals the cross product \(\mathbf{F} = \mathbf{R} \times \mathbf{D}\).

function frame(s) {
  const a = path(s - 1);
  const b = path(s + 1);
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const dz = b.z - a.z;

  const yaw = Math.atan2(dx, dz);
  const pitch = Math.atan2(dy, Math.hypot(dx, dz));

  const sy = Math.sin(yaw), cy = Math.cos(yaw);
  const sp = Math.sin(pitch), cp = Math.cos(pitch);

  return {
    right:   { x: cy,        y: 0,  z: -sy },
    down:    { x: -sy * sp,  y: cp, z: -cy * sp },
    forward: { x: sy * cp,   y: sp, z: cy * cp }
  };
}

World-to-Camera Coordinate Transformation

A ring at distance \(s\) uses the reference frame computed at \(s\). The camera at distance \(q\) has its own independent reference frame computed at \(q\). The visual perspective of entering a curve arises from the difference between these two systems.

To observe a world-space point \(\mathbf{P}\) through the camera, we first compute its relative displacement vector:

\[\mathbf{Q} = \mathbf{P} - C(q)\]

We then project \(\mathbf{Q}\) onto the camera's orthonormal axes using the scalar dot product:

\[\begin{aligned} x_{\text{cam}} &= \mathbf{Q} \cdot \mathbf{R}_{\text{cam}} \\ y_{\text{cam}} &= \mathbf{Q} \cdot \mathbf{D}_{\text{cam}} \\ z_{\text{cam}} &= \mathbf{Q} \cdot \mathbf{F}_{\text{cam}} \end{aligned}\]

Component-wise, this expands to \(x_{\text{cam}} = Q_x R_x + Q_y R_y + Q_z R_z\), and similarly for \(y_{\text{cam}}\) and \(z_{\text{cam}}\).

function dot(a, b) {
  return a.x * b.x + a.y * b.y + a.z * b.z;
}

const q = { x: p.x - camera.x, y: p.y - camera.y, z: p.z - camera.z };
const cameraPoint = {
  x: dot(q, axes.right),
  y: dot(q, axes.down),
  z: dot(q, axes.forward)
};

Geometrically, the dot product computes the scalar projection of \(\mathbf{Q}\) onto each camera axis. Feeding \((x_{\text{cam}}, y_{\text{cam}}, z_{\text{cam}})\) into the perspective projection formula completes the 3D pipeline.

Axial Rotation and Longitudinal Twisting

Rotating the tunnel does not require altering the centerline curve or camera trajectory. Instead, we offset the angle at which vertices are distributed across each ring:

\[\begin{aligned} \alpha_j(t) &= \frac{2\pi j}{N} + \varphi(t) \\[4pt] u &= r \cos \alpha_j(t) \\ v &= r \sin \alpha_j(t) \end{aligned}\]
φ C u = r cos(α + φ) v = r sin(α + φ) u² + v² = r² Center and circular envelope remain invariant.
Angular phase shift φ rotates vertices along the circle. The center and radius remain invariant, while the polygonal edges rotate smoothly around the moving tangent.

Setting \(\varphi(t) = \omega t\) produces continuous rotation at angular rate \(\omega\). With \(\omega = 0.22\text{ rad/s}\), one full revolution takes approximately 28.6 seconds.

A distinction exists between temporal spin and longitudinal twist. When \(\varphi\) depends on time \(t\), the tunnel spins uniformly. Adding a spatial term \(\tau s\) introduces a progressive twist along the tunnel length: \(\alpha_j(s, t) = \frac{2\pi j}{N} + \omega t + \tau s\).

Decoupled Mouse Look with Exponential Smoothing

Fixed forward tracking can feel rigid. Decoupling the camera's gaze allows looking left, right, up, and down without deviating from the trajectory.

Mouse coordinates within the canvas are mapped to normalized coordinates in \([-1, 1]\):

\[\begin{aligned} m_x &= \frac{2(x_{\text{mouse}} - \text{left})}{W} - 1 \\[8pt] m_y &= \frac{2(y_{\text{mouse}} - \text{top})}{H} - 1 \end{aligned}\]

Multiplying by maximum deflection angles (\(a_{\max} \approx 0.55\text{ rad} \approx 31.5^\circ\) and \(b_{\max} \approx 0.38\text{ rad} \approx 21.8^\circ\)) gives target angles \(a = a_{\max} m_x\) and \(b = b_{\max} m_y\).

Canvas Coordinates Center: mx = my = 0 Local Frame Rotation a F R F₁ Horizontal swivel around local D axis
Canvas mouse coordinates (left) mapped into yaw and pitch rotations relative to the camera's moving triad (right).

Rotations must apply relative to the moving camera triad \(\{\mathbf{R}, \mathbf{D}, \mathbf{F}\}\), not static world axes. First, yaw swivels the forward vector horizontally around \(\mathbf{D}\):

\[\begin{aligned} \mathbf{F}_1 &= \sin a \, \mathbf{R} + \cos a \, \mathbf{F} \\ \mathbf{R}_1 &= \cos a \, \mathbf{R} - \sin a \, \mathbf{F} \\ \mathbf{D}_1 &= \mathbf{D} \end{aligned}\]

Second, pitch tilts the resulting frame around the newly rotated right axis \(\mathbf{R}_1\):

\[\begin{aligned} \mathbf{F}_2 &= \cos b \, \mathbf{F}_1 + \sin b \, \mathbf{D}_1 \\ \mathbf{D}_2 &= \cos b \, \mathbf{D}_1 - \sin b \, \mathbf{F}_1 \\ \mathbf{R}_2 &= \mathbf{R}_1 \end{aligned}\]

To eliminate abrupt motion, we interpolate the current angle toward the target using exponential decay:

\[\text{blend} = 1 - e^{-\lambda \Delta t}\] \[\theta \leftarrow \theta + (\theta_{\text{target}} - \theta) \cdot \text{blend}\]

With \(\lambda = 8\), approximately 98% of any pointing offset is smoothed away within 0.5 seconds, delivering a fluid, damped camera feel.

Near-Plane 3D Line and Polygon Clipping

When a tilted ring passes near or through the camera plane, some vertices have \(z < z_{\text{near}}\). Dividing by shallow or negative \(z\) values creates explosive visual artifacts and inverted lines across the screen.

We define a near clipping plane at \(z = z_{\text{near}} > 0\) (e.g. \(z_{\text{near}} = 30\)). Line segments \(\mathbf{A}\mathbf{B}\) are clipped in 3D camera space before projection:

\[t = \frac{z_{\text{near}} - A_z}{B_z - A_z}\] \[\mathbf{P}_{\text{clip}} = \mathbf{A} + t(\mathbf{B} - \mathbf{A})\]
function clipLine(a, b, near) {
  if (a.z < near && b.z < near) return null;
  if (a.z < near || b.z < near) {
    const t = (near - a.z) / (b.z - a.z);
    const hit = {
      x: a.x + t * (b.x - a.x),
      y: a.y + t * (b.y - a.y),
      z: near
    };
    return a.z < near ? [hit, b] : [a, hit];
  }
  return [a, b];
}

For solid quadrilateral panels in the game stage, we apply Sutherland-Hodgman polygon clipping against the single plane \(z = z_{\text{near}}\). Each edge of the quad is tested sequentially: interior vertices are preserved, and edge intersections with \(z = z_{\text{near}}\) generate new boundary vertices, creating valid clipped polygons ready for depth-sorted projection.

Parameter Kinematics and Arc-Length Considerations

The procedural tunnel appearance is controlled by nine geometric parameters:

ParameterRole & Effect
Radius \(r\)Cross-sectional width of the tunnel corridor.
Ring Spacing \(\Delta s\)Longitudinal length of each panel segment.
Vertex Count \(N\)Polygon resolution around the tunnel perimeter.
Amplitudes \(A_x, A_y\)Maximum lateral and vertical track deflection.
Wavenumbers \(k_x, k_y\)Frequency of curves and undulations per unit depth.
Focal Length \(f\)Field of view and perspective compression.
Angular Speed \(\omega\)Axial roll rate of tunnel walls.

Because \(s\) represents longitudinal \(z\)-depth rather than measured arc length along the curve, advancing \(s\) at a constant rate results in higher physical velocity on steep slopes:

\[\frac{ds_{\text{arc}}}{ds} = \sqrt{\left(\frac{dx}{ds}\right)^2 + \left(\frac{dy}{ds}\right)^2 + 1}\]

To maintain a constant physical speed \(v\), we compute the local metric stretch \(g\) using finite differences:

\[g \approx \frac{\|C(s + \varepsilon) - C(s - \varepsilon)\|}{2\varepsilon}\] \[\Delta s \approx \frac{v \Delta t}{g}\]

The Tunnel Runner: Transforming the Visualizer into a Game

The final stage converts the visualizer into an interactive tunnel runner. The tunnel surface is mapped to an unrolled cylindrical grid where the player maneuvers around the perimeter, clears gaps, and dodges obstacles.

Columns → Angular Lane ℓ around Tunnel Rows → Longitudinal Distance s Hazard Gap Player ↔ ↔
Unrolled cylindrical tunnel grid. The horizontal perimeter wraps toroidally. Hazards are indexed on this 2D plane, decoupling collision logic from 3D curves.

The unrolled surface indexes longitudinal rows and angular columns:

\[\begin{aligned} \text{row} &= \left\lfloor \frac{s}{\Delta s} \right\rfloor \\ \text{col} &= \lfloor \ell \rfloor \bmod N \\ \alpha &= \frac{2\pi \ell}{N} + \varphi \end{aligned}\]

Here, \(\ell\) denotes continuous angular position in units of columns. Lateral steering shifts \(\ell\) continuously, wrapping toroidally across the interval \([0, N)\).

Ballistic Jumps with Radial Gravity

Pressing the jump control accelerates the player radially toward the tunnel centerline with initial velocity \(v_0\). A constant inward restorative acceleration \(g\) acts as radial gravity:

\[\begin{aligned} H(t) &= v_0 t - \frac{1}{2} g t^2 \\ v(t) &= v_0 - g t \end{aligned}\]

With \(v_0 = 480\) and \(g = 1100\), flight duration lasts \(T = \frac{2v_0}{g} \approx 0.87\text{ s}\), reaching peak height \(H_{\max} = \frac{v_0^2}{2g} \approx 105\) units. At an initial forward velocity of 350 units/second, the jump traverses approximately 305 units of track—safely clearing standard two-row gaps of 240 units.

The radial position of the player contracts during a jump:

\[r_{\text{player}} = r \cos\left(\frac{\pi}{N}\right) - H\]

Substepping and Collision Detection

To prevent high-speed tunneling through hazard tiles between animation frames, physics updates run in fixed sub-steps of at most \(\frac{1}{120}\) second:

function gameStep(dt) {
  const steps = Math.ceil(dt / (1 / 120));
  for (let i = 0; i < steps && gameState === 'running'; i++) {
    const subDt = dt / steps;
    const dir = (held.has('left') ? 1 : 0) - (held.has('right') ? 1 : 0);
    lane = wrap(lane + dir * 4 * subDt, sides);

    const speed = 350 + 150 * Math.min(distance / finishDistance, 1);
    distance += speed * subDt;

    jumpHeight += jumpVelocity * subDt - 0.5 * gravity * subDt * subDt;
    jumpVelocity -= gravity * subDt;
    if (jumpHeight <= 0) {
      jumpHeight = 0;
      jumpVelocity = 0;
    }

    const row = Math.floor((distance + lead) / spacing);
    const col = Math.floor(lane);

    if (jumpHeight === 0 && isHole(row, col)) {
      gameState = 'lost';
    } else if (distance >= finishDistance) {
      gameState = 'won';
    }
  }
}

Tunnel panels are sorted by average camera depth (\(z_{\text{cam}}\)) and rendered back-to-front (painter's algorithm). This lightweight sorting maintains high frame rates on standard 2D canvases without requiring a depth buffer.

Structural Constraints and Boundary Conditions

While effective for real-time visualization, this geometry model has specific boundaries:

References