Blog

Dots tunnel: the principle, explained in JavaScript

26/10/2026demo effectsdots tunnelCPCZ80

The dots tunnel is a demo classic: rings of dots rush towards us, spin and wind, as if we were flying through a tube. On the CPC, the effect ends up as byte tables and a loop of additions, but those tables only make sense once the effect is built.

So the road goes through JavaScript first: a circle of dots, then one idea per step, up to the tables the Z80 will know how to read. Each step has its playground. The code can be edited in place, and “Show the result” starts the preview. The run button applies your changes, the other button brings back the original code.

A circle

Let us start with a single ring, seen from the front. We put the eye at the origin, and the ring in front of it, at depth z. It is a circle of radius R, and we place 16 dots on it, evenly spaced.

To draw a dot on the screen, we must project it: find where it lands on the screen, as seen from the eye. The diagram shows the scene from the side. The eye is on the left, the horizontal axis is the depth, and the screen is a vertical plane at distance P from the eye. The ray from the eye to the top of the object crosses the screen at height y.

screen eye Y Y y y / 2 P z 2z

The two triangles that start at the eye have the same shape. The small one stops at the screen, the large one at the object. Their sides are therefore proportional: y = P × Y / z. The same object placed twice as far crosses the screen half as high: it looks half as big. All of perspective comes down to this division by the depth.

P is the focal distance: the larger it is, the larger the image. For the ring, the same rule applies on both axes. Dot k is at angle angle, R * cos(angle) from the centre of the ring horizontally, and R * sin(angle) vertically. We multiply by P, divide by z, and add the centre of the screen, CX and CY: these are the two lines x = … and y = … of the code. The 16 dots are a sixteenth of a turn apart.

The screen is that of a CPC in mode 1: 320 × 200 pixels. The canvas is 640 × 400, and app.stage.scale.set(2) scales everything up twice. The code therefore works in CPC pixels, and a CPC pixel becomes a 2 × 2 square in the browser. Here is the first ring, 16 white dots at depth 1:

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);

// A CPC screen in mode 1: 320 x 200, scaled up twice.
app.stage.scale.set(2);

const CX = 160, CY = 100; // centre of the screen
const P = 90;             // focal distance, in pixels
const R = 1;              // radius of the ring
const z = 1;              // depth of the ring
const DOTS = 16;          // dots on the ring

const g = new Graphics();
app.stage.addChild(g);

for (let k = 0; k < DOTS; k++) {
  const angle = (2 * Math.PI * k) / DOTS;
  const x = CX + (P * R * Math.cos(angle)) / z;
  const y = CY + (P * R * Math.sin(angle)) / z;
  g.rect(x, y, 1, 1).fill(0xffffff);
}

On the side of PixiJS, the drawing library of the playgrounds, there is almost nothing to know. g is a Graphics object, placed on the stage: we draw into it. g.rect(x, y, 1, 1) draws a rectangle of 1 × 1 CPC pixel at position (x, y), and .fill(0xffffff) fills this rectangle in white, which makes our dot.

Try it: set z to 2, then to 3. The ring shrinks to half its size, then to a third. Doubling R or P has the opposite effect: only the ratio P * R / z counts.

Several depths

A tunnel is a row of identical rings, placed one behind the other, and we use 16. Ring i is at depth Z0 + i * GAP: the first one at Z0 = 1, then one every GAP = 0.6. They all have the same radius R, but the division by z makes the far ones smaller. Seen from the front, they form concentric circles: this is already a tube that sinks into the screen.

Colour helps the eye read the depth. Mode 1 has only four inks, and the black background takes one of them. We therefore cut the length of the tunnel into four quarters: the first in white, the second in sky blue, the last two in blue. The far end of the tunnel fades out, as in a fog. The playground stacks the 16 rings, each in its colour:

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);
app.stage.scale.set(2);

const CX = 160, CY = 100;   // centre of the screen
const P = 90;               // focal distance, in pixels
const R = 1;                // radius of the rings
const Z0 = 1;               // depth of the nearest ring
const GAP = 0.6;            // gap between two rings
const RINGS = 16;
const DOTS = 16;            // dots per ring
const LENGTH = RINGS * GAP; // length of the tunnel

// Three inks, from the nearest to the farthest.
const COLORS = [0xffffff, 0x0080ff, 0x000080, 0x000080];

const g = new Graphics();
app.stage.addChild(g);

for (let i = 0; i < RINGS; i++) {
  const z = Z0 + i * GAP;
  const color = COLORS[Math.floor((z - Z0) / (LENGTH / 4))];
  for (let k = 0; k < DOTS; k++) {
    const angle = (2 * Math.PI * k) / DOTS;
    const x = CX + (P * R * Math.cos(angle)) / z;
    const y = CY + (P * R * Math.sin(angle)) / z;
    g.rect(x, y, 1, 1).fill(color);
  }
}

Try it: set GAP to 0.3. The rings close up, and the tunnel looks shorter. At 1.2, the far rings stick to the centre.

Spinning

So far, the image is still. To animate it, PixiJS calls a function at each frame of the display: we give it to app.ticker.add. It receives ticker.deltaMS, the time since the previous frame, in milliseconds. We add it up in time, in seconds. At each call, we clear everything with g.clear(), then draw the scene again at instant time.

To spin the tunnel, we add to the angle of each dot a value that grows with time. All the dots turn together, at SPIN turns per second. The drawing moves into the ticker function, and the angle of each dot gains SPIN * time turns:

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);
app.stage.scale.set(2);

const CX = 160, CY = 100;
const P = 90, R = 1;
const Z0 = 1, GAP = 0.6;
const RINGS = 16, DOTS = 16;
const LENGTH = RINGS * GAP;
const SPIN = 0.2;         // rotation speed, in turns per second
const COLORS = [0xffffff, 0x0080ff, 0x000080, 0x000080];

const g = new Graphics();
app.stage.addChild(g);

let time = 0; // in seconds
app.ticker.add((ticker) => {
  time += ticker.deltaMS / 1000;
  g.clear();
  for (let i = 0; i < RINGS; i++) {
    const z = Z0 + i * GAP;
    const color = COLORS[Math.floor((z - Z0) / (LENGTH / 4))];
    for (let k = 0; k < DOTS; k++) {
      const angle = 2 * Math.PI * (k / DOTS + SPIN * time);
      const x = CX + (P * R * Math.cos(angle)) / z;
      const y = CY + (P * R * Math.sin(angle)) / z;
      g.rect(x, y, 1, 1).fill(color);
    }
  }
});

Try it: set SPIN to 1. One turn per second is already violent. A negative value spins the tunnel the other way.

Moving forward

To fly through the tunnel, each ring must come towards us: its depth decreases with time, at SPEED units per second. But a ring that passes the eye must go back to the far end, or the tunnel empties. A modulo does this: the depth of the ring becomes Z0 + mod(i * GAP - SPEED * time, LENGTH). The modulo keeps the distance to Z0 between 0 and LENGTH, the length of the tunnel. The ring that reaches Z0 therefore reappears at Z0 + LENGTH, at the very far end. All the rings are identical: the eye does not see the jump, and the tunnel looks endless.

In JavaScript, % keeps the sign of the left operand: -1 % 10 is -1, not 9. The mod function fixes this.

The colour still depends on z, so a ring turns white as it comes closer. In the loop, only the z line changes:

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);
app.stage.scale.set(2);

const CX = 160, CY = 100;
const P = 90, R = 1;
const Z0 = 1, GAP = 0.6;
const RINGS = 16, DOTS = 16;
const LENGTH = RINGS * GAP;
const SPIN = 0.2;
const SPEED = 7.5;        // forward speed, in depth units per second
const COLORS = [0xffffff, 0x0080ff, 0x000080, 0x000080];

// Modulo that is never negative: mod(-1, 10) is 9.
const mod = (a, n) => ((a % n) + n) % n;

const g = new Graphics();
app.stage.addChild(g);

let time = 0;
app.ticker.add((ticker) => {
  time += ticker.deltaMS / 1000;
  g.clear();
  for (let i = 0; i < RINGS; i++) {
    const z = Z0 + mod(i * GAP - SPEED * time, LENGTH);
    const color = COLORS[Math.floor((z - Z0) / (LENGTH / 4))];
    for (let k = 0; k < DOTS; k++) {
      const angle = 2 * Math.PI * (k / DOTS + SPIN * time);
      const x = CX + (P * R * Math.cos(angle)) / z;
      const y = CY + (P * R * Math.sin(angle)) / z;
      g.rect(x, y, 1, 1).fill(color);
    }
  }
});

Try it: set Z0 to 0.2. The nearest ring becomes huge, and the division by a z close to zero sends its dots off the screen. That is why the tunnel starts at Z0 = 1, and not at 0.

Shifting the centres

A straight tunnel soon becomes dull. To make it wind, we shift the centre of each ring: by ox horizontally, by oy vertically. Each shift follows a sine. The vertical sine goes twice as fast as the horizontal one: while ox makes one round trip, oy makes two. The centre draws a Lissajous curve. The + 0.7 shifts the two sines, so that the curve does not cross the centre at the same moment on both axes. The phase u tells where the ring is on the curve, in turns.

f grows with the distance: 0 for the nearest ring, 1 at the far end. The near rings stay centred, the far ones move away. Without this factor, the whole tunnel would slide across the screen, without bending.

We still have to choose the phase u. The simplest idea makes it depend on time alone: u = SPEED * time / CURVE, where CURVE is the length of one period of the curve. All the rings then have the same phase, and only f changes from one ring to the next. The centres therefore lie on a straight line that starts at the centre of the screen. The axis of the tunnel stays straight and swings around the centre: the tunnel does not bend.

The right phase adds the distance of the ring: u = (z - Z0 + SPEED * time) / CURVE. When a ring moves forward, z decreases exactly as much as SPEED * time grows: the sum does not change. Each ring therefore keeps its phase during its whole trip, as if it ran on a rail. Neighbouring rings have neighbouring phases, and together they draw a tube that twists in front of us. We feel that we follow its axis.

CURVE is 64 * GAP: one period of the curve spans 64 rings. Two neighbouring rings are therefore only a 64th of a turn apart, and the curve stays smooth. The four lines of the shift come right before the loop over the dots:

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);
app.stage.scale.set(2);

const CX = 160, CY = 100;
const P = 90, R = 1;
const Z0 = 1, GAP = 0.6;
const RINGS = 16, DOTS = 16;
const LENGTH = RINGS * GAP;
const SPIN = 0.2, SPEED = 7.5;
const AX = 72, AY = 50;   // amplitude of the curve, in pixels
const CURVE = 64 * GAP;   // length of one period of the curve
const COLORS = [0xffffff, 0x0080ff, 0x000080, 0x000080];

const mod = (a, n) => ((a % n) + n) % n;

const g = new Graphics();
app.stage.addChild(g);

let time = 0;
app.ticker.add((ticker) => {
  time += ticker.deltaMS / 1000;
  g.clear();
  for (let i = 0; i < RINGS; i++) {
    const z = Z0 + mod(i * GAP - SPEED * time, LENGTH);
    const color = COLORS[Math.floor((z - Z0) / (LENGTH / 4))];

    // Offset of the centre: zero close by, largest at the far end.
    const f = (z - Z0) / LENGTH;
    const u = (z - Z0 + SPEED * time) / CURVE;
    const ox = AX * f * Math.sin(2 * Math.PI * u);
    const oy = AY * f * Math.sin(4 * Math.PI * u + 0.7);

    for (let k = 0; k < DOTS; k++) {
      const angle = 2 * Math.PI * (k / DOTS + SPIN * time);
      const x = CX + ox + (P * R * Math.cos(angle)) / z;
      const y = CY + oy + (P * R * Math.sin(angle)) / z;
      g.rect(x, y, 1, 1).fill(color);
    }
  }
});

Try it:

Quantising

The effect is complete, but it computes sines, cosines and floating-point divisions, for each dot and at each frame. Our 8-bit dinosaurs cannot handle floating-point numbers efficiently: their processors only know integers, and above all bytes. A floating-point calculation goes through software routines, far too slow for a real-time effect. They can, on the other hand, add, subtract and compare bytes very fast. So the effect must move to integers.

Quantising prepares this move. Each value a dot depends on becomes an integer, which takes only a small number of values: time is counted in frames, depth in positions, angles in steps. When all the inputs are bounded integers, we can compute all the possible positions in advance, and store them in tables indexed by these integers. The Z80 will only have to read them.

The image loses a little in the process. The rotation moves in notches, the curve in small jumps, and each dot snaps to the pixel grid. The goal is to keep these losses small enough for the effect to stay smooth. In this step, the code keeps its sines and its floating-point numbers, without arrays: only their arguments become integers.

The number 64 comes back three times in the list below: 64 depths, 64 steps per turn, 64 phases. This choice has three reasons:

  1. Time is counted in frames: t is an integer, which goes up by 1 every 20 ms. That is the pace of a CPC, 50 frames per second.
  2. Depth has only 64 positions: d goes from 0 (very close) to 63 (far end), and z = Z0 + d * DZ, with DZ = GAP / 4 = 0.15. The rings sit at one position in four. At frame t, ring i is at d = (4 * i - t) & 63: it moves forward one position per frame, and goes back to the far end after 0. & 63 keeps the 6 low bits of the number: it is a modulo 64, even for a negative number.
  3. Rotation is counted in 64 steps per turn. Dot k is at angle 4 * k + rot, in steps. rot moves one step every 4 frames: at 50 frames per second, this gives 0.195 turns per second, almost the SPIN of earlier. A rotation of 4 steps brings each dot to the place of its neighbour, and the ring is identical again. There are therefore only 4 different rotations: rot = (t >> 2) & 3.
  4. The phase of the curve becomes phase = ((t + d) >> 2) & 63. It is the phase u of the previous step, counted in 64ths of a turn. Two neighbouring rings are 4 positions apart, so one phase step.
  5. The factor f goes down to 16 levels: f = (d >> 2) / 15.
  6. The colour is read in the two high bits of d: COLORS[d >> 4]. These are the four quarters of the tunnel.
  7. The positions are rounded to the pixel, as on the CPC.

Four integers remain: d (64 values), rot (4), k (16) and phase (64). The next step stores the whole calculation in tables indexed by these values. Until then, the playground still computes everything at each frame, but from these integers:

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);
app.stage.scale.set(2);

const CX = 160, CY = 100;
const P = 90, R = 1;
const Z0 = 1, DZ = 0.15;  // z = Z0 + d * DZ
const RINGS = 16, DOTS = 16;
const AX = 72, AY = 50;
const COLORS = [0xffffff, 0x0080ff, 0x000080, 0x000080];

const g = new Graphics();
app.stage.addChild(g);

let time = 0;
app.ticker.add((ticker) => {
  time += ticker.deltaMS / 1000;
  const t = Math.floor(time * 50);       // frame number, 50 per second
  const rot = (t >> 2) & 3;              // rotation: 4 values
  g.clear();
  for (let i = 0; i < RINGS; i++) {
    const d = (4 * i - t) & 63;          // depth: 64 values
    const z = Z0 + d * DZ;
    const phase = ((t + d) >> 2) & 63;   // phase of the curve: 64 values
    const f = (d >> 2) / 15;             // 16 levels
    const ox = Math.round(AX * f * Math.sin((2 * Math.PI * phase) / 64));
    const oy = Math.round(AY * f * Math.sin((4 * Math.PI * phase) / 64 + 0.7));
    const color = COLORS[d >> 4];
    for (let k = 0; k < DOTS; k++) {
      const angle = (2 * Math.PI * (4 * k + rot)) / 64;
      const dx = Math.round((P * R * Math.cos(angle)) / z);
      const dy = Math.round((P * R * Math.sin(angle)) / z);
      g.rect(CX + ox + dx, CY + oy + dy, 1, 1).fill(color);
    }
  }
});

Try it:

Byte tables

Each dot is now the sum of two independent parts. The centre of the ring depends on the level d >> 2 and on phase. The offset of the dot from this centre depends on the depth d, the rotation rot and the number k. We compute the two tables only once, at start-up:

An x coordinate from 0 to 319 does not fit in a byte. But the tunnel does not use the full width of the screen: the largest offset from the centre is 90 pixels, for the nearest ring, and its dots go from x = 70 to x = 250. Their x coordinates therefore fit in a byte, and the centre in a Uint8Array.

In the loop, no sine and no division are left, only two reads from CENTER per ring, then two reads from SHAPE and two additions per dot:

import { Application, Graphics } from "pixi.js";

const app = new Application();
await app.init({ width: 640, height: 400, background: "#000000" });
document.body.append(app.canvas);
app.stage.scale.set(2);

const CX = 160, CY = 100;
const P = 90, R = 1;
const Z0 = 1, DZ = 0.15;
const RINGS = 16, DOTS = 16;
const AX = 72, AY = 50;
const COLORS = [0xffffff, 0x0080ff, 0x000080, 0x000080];

// Offset (dx, dy) from the centre, for each depth d, rotation rot and dot k.
const SHAPE = new Int8Array(64 * 4 * DOTS * 2);
for (let d = 0; d < 64; d++) {
  const s = (P * R) / (Z0 + d * DZ);
  for (let rot = 0; rot < 4; rot++) {
    for (let k = 0; k < DOTS; k++) {
      const angle = (2 * Math.PI * (4 * k + rot)) / 64;
      const n = ((d * 4 + rot) * DOTS + k) * 2;
      SHAPE[n] = Math.round(s * Math.cos(angle));
      SHAPE[n + 1] = Math.round(s * Math.sin(angle));
    }
  }
}

// Centre (x, y) of the ring, for each level q = d >> 2 and each phase.
const CENTER = new Uint8Array(16 * 64 * 2);
for (let q = 0; q < 16; q++) {
  for (let phase = 0; phase < 64; phase++) {
    const n = (q * 64 + phase) * 2;
    CENTER[n] = CX + Math.round(((AX * q) / 15) * Math.sin((2 * Math.PI * phase) / 64));
    CENTER[n + 1] = CY + Math.round(((AY * q) / 15) * Math.sin((4 * Math.PI * phase) / 64 + 0.7));
  }
}

const g = new Graphics();
app.stage.addChild(g);

let time = 0;
app.ticker.add((ticker) => {
  time += ticker.deltaMS / 1000;
  const t = Math.floor(time * 50);
  const rot = (t >> 2) & 3;
  g.clear();
  for (let i = 0; i < RINGS; i++) {
    const d = (4 * i - t) & 63;
    const c = ((d >> 2) * 64 + (((t + d) >> 2) & 63)) * 2;
    const ringX = CENTER[c], ringY = CENTER[c + 1];
    const base = (d * 4 + rot) * DOTS * 2;
    const color = COLORS[d >> 4];
    for (let k = 0; k < DOTS; k++) {
      const x = ringX + SHAPE[base + 2 * k];
      const y = ringY + SHAPE[base + 2 * k + 1];
      g.rect(x, y, 1, 1).fill(color);
    }
  }
});

The image is identical, pixel for pixel, to that of the previous step. But the loop now does only byte additions and memory reads: it is a program a Z80 can run.

Conclusion

The effect now fits in two tables, 8,192 bytes for SHAPE and 2,048 for CENTER, and in a loop that only reads and adds. Quantising has a price, which the playgrounds let you see:

The next post will move to the CPC. A Locomotive BASIC program will compute the two tables there, with the same names as here, then RASM will compute them in turn, and a first Z80 version will at last draw the tunnel.