Home

18. Dot tunnel · demoscene, early 1990s · Retro techniques

Dot/square tunnel

Demos are animating. Following your system setting.
Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

Rings recede along Z. Each ring is a square outlined with dots, corners drawn as squares. A 1/z divide is the tunnel. Twist is a spin that grows with depth. Dot tunnels were a stock intro effect on the Amiga and PC around 1990 to 1993; this is the generic form, not a port of one. Move the pointer to drag the vanishing point.

Move the pointer to shift the vanishing point.

Reference

Interpretation: this evokes a period look and does not reproduce one specific original.

Browser APIs

  • Canvas 2D
  • Pointer Events
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

Source

(function () {
  const PARAMS = {
    color: "#6ee7b7",
    speed: 1,
    twist: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [110, 231, 183];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  const RINGS = 36;
  const SIDES = 4;
  const PER = 7;
  const rings = [];
  for (let i = 0; i < RINGS; i++) rings.push((i / RINGS) * 18 + 0.55);

  let t = 0, lookX = 0, lookY = 0, targetX = 0, targetY = 0, last = 0;
  let raf = 0;
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.matches;

  canvas.addEventListener("pointermove", function (ev) {
    const r = canvas.getBoundingClientRect();
    targetX = (ev.clientX - r.left) / r.width - 0.5;
    targetY = (ev.clientY - r.top) / r.height - 0.5;
    wake();
  });
  canvas.addEventListener("pointerleave", function () { targetX = 0; targetY = 0; wake(); });

  function onSquare(k, n, spin) {
    const u = (k / n) * 4;
    const side = u | 0;
    const f = u - side;
    let x = 0, y = 0;
    if (side === 0) { x = -1 + 2 * f; y = -1; }
    else if (side === 1) { x = 1; y = -1 + 2 * f; }
    else if (side === 2) { x = 1 - 2 * f; y = 1; }
    else { x = -1; y = 1 - 2 * f; }
    const c = Math.cos(spin), s = Math.sin(spin);
    return [x * c - y * s, x * s + y * c];
  }

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
  }

  function draw(w, h) {
    ctx.fillStyle = "#0b0c0e";
    ctx.fillRect(0, 0, w, h);
    const rgb = hexToRgb(PARAMS.color);
    const cx = w * 0.5 + lookX * w * 0.42;
    const cy = h * 0.5 + lookY * h * 0.42;
    const fov = Math.min(w, h) * 0.55;
    const n = SIDES * PER;

    for (let i = 0; i < RINGS; i++) {
      const z = rings[i];
      const spin = t * 0.35 + z * 0.28 * PARAMS.twist;
      const fog = Math.max(0.08, 1 - z / 19);
      const s = (1.15 / z) * fov;
      const size = Math.max(1.2, 5.5 / z);
      ctx.fillStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + fog + ")";
      ctx.strokeStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + (fog * 0.55) + ")";
      ctx.lineWidth = Math.max(1, 2.2 / z);
      ctx.beginPath();
      for (let k = 0; k <= n; k++) {
        const p = onSquare(k % n, n, spin);
        const x = cx + p[0] * s;
        const y = cy + p[1] * s;
        if (k === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
      }
      ctx.stroke();
      for (let k = 0; k < n; k++) {
        const p = onSquare(k, n, spin);
        const x = cx + p[0] * s;
        const y = cy + p[1] * s;
        const corner = k % PER === 0;
        if (corner) ctx.fillRect(x - size, y - size, size * 2, size * 2);
        else {
          ctx.beginPath();
          ctx.arc(x, y, size * 0.45, 0, Math.PI * 2);
          ctx.fill();
        }
      }
    }
  }

  function frame(now) {
    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
    last = now;
    lookX += (targetX - lookX) * Math.min(1, dt * 6);
    lookY += (targetY - lookY) * Math.min(1, dt * 6);
    if (!reduced) {
      t += dt * PARAMS.speed;
      const dz = dt * 6.5 * PARAMS.speed;
      for (let i = 0; i < RINGS; i++) {
        rings[i] -= dz;
        if (rings[i] < 0.5) rings[i] += 18;
      }
    }
    resize();
    draw(canvas.width, canvas.height);
    if (!reduced) raf = requestAnimationFrame(frame);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    last = 0;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();