16. Atari · 1984 · Retro techniques
I, Robot polygons
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();
})();