Home

16. Atari · 1984 · Retro techniques

I, Robot polygons

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

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

About

I, Robot was the first arcade game to fill polygons in real time. Faces are sorted back-to-front and painted. No z-buffer, no textures: a platform of blocks and pyramids in primary colours on black, seen from above the way the cabinet framed it. The scene is an homage to the game's look, not a level from it. Drag to orbit. It keeps a slow auto-spin until you take over.

Drag on the preview to orbit. Auto-rotation stops after the first drag.

Reference

Atari, I, Robot (arcade, 1984), the first arcade game with filled 3D polygons

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: "#e23b2e",
    accent: "#f0c43c",
    speed: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [226, 59, 46];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }
  function css(rgb, k) {
    return "rgb(" + ((rgb[0] * k) | 0) + "," + ((rgb[1] * k) | 0) + "," + ((rgb[2] * k) | 0) + ")";
  }

  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;
  }

  let yaw = 0.6, pitch = 0.42, auto = true, dragging = false, lastX = 0, lastY = 0, last = 0;
  let raf = 0;
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.matches;

  canvas.addEventListener("pointerdown", function (e) {
    dragging = true; auto = false; lastX = e.clientX; lastY = e.clientY;
    canvas.setPointerCapture(e.pointerId);
  });
  canvas.addEventListener("pointerup", function () { dragging = false; });
  canvas.addEventListener("pointercancel", function () { dragging = false; });
  canvas.addEventListener("pointermove", function (e) {
    if (!dragging) return;
    yaw += (e.clientX - lastX) * 0.008;
    pitch += (e.clientY - lastY) * 0.008;
    pitch = Math.max(0.15, Math.min(1.1, pitch));
    lastX = e.clientX; lastY = e.clientY;
    wake();
  });

  // Yaw about the vertical axis, then tilt so a positive pitch looks down on
  // the platform from above, the way the arcade cabinet framed it.
  function xform(x, y, z, w, h) {
    const cy = Math.cos(yaw), sy = Math.sin(yaw);
    const cx = Math.cos(pitch), sx = Math.sin(pitch);
    const x1 = x * cy + z * sy;
    const z1 = -x * sy + z * cy;
    const y1 = y * cx + z1 * sx;
    const z2 = -y * sx + z1 * cx;
    const zc = z2 + 16;
    if (zc < 0.4) return null;
    const fov = Math.min(w, h) * 0.95;
    return { x: w * 0.5 + (x1 / zc) * fov, y: h * 0.52 - (y1 / zc) * fov, z: zc };
  }

  function face(pts, col, k, faces) {
    const p = [];
    let z = 0;
    for (let i = 0; i < pts.length; i++) {
      if (!pts[i]) return;
      p.push(pts[i]);
      z += pts[i].z;
    }
    const a = p[1].x - p[0].x, b = p[1].y - p[0].y;
    const c = p[2].x - p[0].x, d = p[2].y - p[0].y;
    if (a * d - b * c <= 0) return;
    faces.push({ p: p, z: z / p.length, col: col, k: k });
  }

  function cube(cx, cy, cz, s, h, col, faces, w, hh) {
    const p = [
      xform(cx - s, cy, cz - s, w, hh),
      xform(cx + s, cy, cz - s, w, hh),
      xform(cx + s, cy, cz + s, w, hh),
      xform(cx - s, cy, cz + s, w, hh),
      xform(cx - s, cy + h, cz - s, w, hh),
      xform(cx + s, cy + h, cz - s, w, hh),
      xform(cx + s, cy + h, cz + s, w, hh),
      xform(cx - s, cy + h, cz + s, w, hh)
    ];
    face([p[0], p[1], p[2], p[3]], col, 0.28, faces);
    face([p[4], p[7], p[6], p[5]], col, 1, faces);
    face([p[0], p[4], p[5], p[1]], col, 0.72, faces);
    face([p[1], p[5], p[6], p[2]], col, 0.52, faces);
    face([p[2], p[6], p[7], p[3]], col, 0.4, faces);
    face([p[3], p[7], p[4], p[0]], col, 0.6, faces);
  }

  function pyramid(cx, cy, cz, s, h, col, faces, w, hh) {
    const a = xform(cx - s, cy, cz - s, w, hh);
    const b = xform(cx + s, cy, cz - s, w, hh);
    const c = xform(cx + s, cy, cz + s, w, hh);
    const d = xform(cx - s, cy, cz + s, w, hh);
    const t = xform(cx, cy + h, cz, w, hh);
    face([a, b, c, d], col, 0.3, faces);
    face([a, t, b], col, 0.9, faces);
    face([b, t, c], col, 0.65, faces);
    face([c, t, d], col, 0.45, faces);
    face([d, t, a], col, 0.75, faces);
  }

  function draw(w, h) {
    ctx.fillStyle = "#000";
    ctx.fillRect(0, 0, w, h);
    const red = hexToRgb(PARAMS.color);
    const gold = hexToRgb(PARAMS.accent);
    const cyan = [70, 170, 190];
    const faces = [];

    // The grid lies under everything, so it goes down first rather than into
    // the painter's sort, where a line's midpoint depth would misplace it.
    ctx.strokeStyle = css(red, 0.35);
    ctx.lineWidth = 1;
    ctx.beginPath();
    for (let i = -6; i <= 6; i++) {
      const a1 = xform(i, 0, -7, w, h), b1 = xform(i, 0, 7, w, h);
      if (a1 && b1) { ctx.moveTo(a1.x, a1.y); ctx.lineTo(b1.x, b1.y); }
      const a2 = xform(-7, 0, i, w, h), b2 = xform(7, 0, i, w, h);
      if (a2 && b2) { ctx.moveTo(a2.x, a2.y); ctx.lineTo(b2.x, b2.y); }
    }
    ctx.stroke();

    for (let z = -5; z <= 5; z++) {
      for (let x = -5; x <= 5; x++) {
        if ((x + z) % 2 !== 0) continue;
        const n = Math.sin(x * 1.7 + z * 0.9) * 0.5 + 0.5;
        const ht = 0.35 + n * 1.8;
        const col = n > 0.66 ? gold : n > 0.33 ? red : cyan;
        if ((x * z) % 3 === 0 && Math.abs(x) + Math.abs(z) > 2) {
          pyramid(x * 1.35, 0, z * 1.35, 0.42, ht + 0.4, col, faces, w, h);
        } else {
          cube(x * 1.35, 0, z * 1.35, 0.42, ht, col, faces, w, h);
        }
      }
    }
    cube(0, 0, 0, 0.28, 1.55, red, faces, w, h);
    cube(0, 1.55, 0, 0.22, 0.22, gold, faces, w, h);

    faces.sort(function (a, b) { return b.z - a.z; });
    for (let i = 0; i < faces.length; i++) {
      const f = faces[i];
      ctx.beginPath();
      ctx.moveTo(f.p[0].x, f.p[0].y);
      for (let k = 1; k < f.p.length; k++) ctx.lineTo(f.p[k].x, f.p[k].y);
      ctx.closePath();
      ctx.fillStyle = css(f.col, f.k);
      ctx.fill();
      ctx.strokeStyle = css(f.col, Math.min(1, f.k + 0.15));
      ctx.lineWidth = 1;
      ctx.stroke();
    }
  }

  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 frame(now) {
    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
    last = now;
    if (auto && !reduced) yaw += dt * 0.28 * PARAMS.speed;
    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);

  window.__irobotTest = {
    xform: function (x, y, z, w, h) { return xform(x, y, z, w, h); },
    setView: function (y, p) { yaw = y; pitch = p; auto = false; wake(); }
  };

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