{"slug":"mode7","number":"14","title":"Mode 7 floor","era":"SNES · 1990","summary":"Affine checkerboard ground, Space Harrier style. Pointer steers.","description":"For each scanline below the horizon, a distance is recovered from camera height and the line is a slice of a 2D texture. That is Mode 7 / Space Harrier: no mesh, just a perspective divide on Y. A few pylons are billboards. Move the pointer to yaw.","apis":["Canvas 2D","ImageData","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["mode7","3d"],"hasGlsl":false,"interaction":"Move the pointer left or right to steer.","url":"https://3d-retro.com/experiments/mode7","example_url":"https://3d-retro.com/examples/mode7.html","api_url":"https://3d-retro.com/api/v1/experiments/mode7","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"SNES Mode 7 checkerboard floor. Pointer steers.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/mode7\" />\n  <title>Mode 7 floor · 3d retro graphics</title>\n  <style>\n    html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #0b0c0e; color: #e8e6e1; position: absolute; inset: 0; }\n    canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; image-rendering: pixelated; }\n    .fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace; text-align: center; color: #e8e6e1; background: #0b0c0e; }\n    .fallback[hidden] { display: none; }\n  </style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"fallback\" class=\"fallback\" hidden></div>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#6ee7b7\",\n    speed: 1,\n    scale: 1\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [110, 231, 183];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  const buf = document.createElement(\"canvas\");\n  const BW = 320, BH = 180;\n  buf.width = BW; buf.height = BH;\n  const bctx = buf.getContext(\"2d\");\n  const img = bctx.createImageData(BW, BH);\n  const px = img.data;\n\n  let camX = 0, camZ = 0, yaw = 0;\n  let look = 0;\n  let last = 0;\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  const CAM_H = 1.15;\n\n  canvas.addEventListener(\"pointermove\", function (ev) {\n    const r = canvas.getBoundingClientRect();\n    look = ((ev.clientX - r.left) / r.width - 0.5) * 1.2;\n  });\n  canvas.addEventListener(\"pointerleave\", function () { look = 0; });\n\n  function draw() {\n    const rgb = hexToRgb(PARAMS.color);\n    const horizon = (BH * 0.42) | 0;\n    for (let i = 0; i < px.length; i += 4) {\n      const y = (i / 4 / BW) | 0;\n      if (y < horizon) {\n        const t = y / horizon;\n        px[i] = 28 + t * 40; px[i + 1] = 36 + t * 50; px[i + 2] = 58 + t * 40; px[i + 3] = 255;\n      }\n    }\n    const sunX = BW * 0.7, sunY = horizon * 0.45;\n    for (let y = 0; y < horizon; y++) {\n      for (let x = 0; x < BW; x++) {\n        if (Math.hypot(x - sunX, y - sunY) < 6) {\n          const i = (y * BW + x) * 4;\n          px[i] = 255; px[i + 1] = 220; px[i + 2] = 140;\n        }\n      }\n    }\n\n    const view = yaw + look * 0.35;\n    const cs = Math.cos(view), sn = Math.sin(view);\n    const cell = 1.35 / PARAMS.scale;\n    const fogFar = 42;\n    for (let y = horizon; y < BH; y++) {\n      const row = (y - horizon + 1) / BH;\n      const dist = CAM_H / row;\n      const fog = Math.max(0.08, 1 - dist / fogFar);\n      const halfW = dist * 0.92;\n      for (let x = 0; x < BW; x++) {\n        const across = ((x / BW) - 0.5) * 2 * halfW;\n        const wx = camX + cs * dist - sn * across;\n        const wz = camZ + sn * dist + cs * across;\n        const cx = Math.floor(wx / cell);\n        const cz = Math.floor(wz / cell);\n        const on = (cx + cz) & 1;\n        const stripe = ((cx & 7) === 0 || (cz & 7) === 0) ? 1.18 : 1;\n        const r = ((on ? rgb[0] : rgb[0] * 0.18) * stripe) * fog + 22 * (1 - fog);\n        const g = ((on ? rgb[1] : rgb[1] * 0.18) * stripe) * fog + 26 * (1 - fog);\n        const b = ((on ? rgb[2] : rgb[2] * 0.22) * stripe) * fog + 32 * (1 - fog);\n        const i = (y * BW + x) * 4;\n        px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = 255;\n      }\n    }\n\n    bctx.putImageData(img, 0, 0);\n    ctx.imageSmoothingEnabled = false;\n    ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);\n\n    const pylons = [\n      [4, 6], [8, -3], [-5, 10], [12, 8], [-8, -6], [2, -11], [-10, 3]\n    ];\n    ctx.save();\n    const scaleY = canvas.height / BH;\n    const scaleX = canvas.width / BW;\n    ctx.scale(scaleX, scaleY);\n    for (let p = 0; p < pylons.length; p++) {\n      const ox = pylons[p][0] * cell - camX;\n      const oz = pylons[p][1] * cell - camZ;\n      const lz = ox * cs + oz * sn;\n      const lx = -ox * sn + oz * cs;\n      if (lz < 0.6 || lz > fogFar) continue;\n      const sx = BW * 0.5 + (lx / lz) * (BW * 0.5 / 0.92);\n      const sy = horizon + (CAM_H / lz) * BH;\n      const ph = (1.4 / lz) * BH;\n      const pw = ph * 0.18;\n      const fog = Math.max(0.15, 1 - lz / fogFar);\n      ctx.fillStyle = \"rgba(\" + rgb[0] + \",\" + rgb[1] + \",\" + rgb[2] + \",\" + fog + \")\";\n      ctx.fillRect(sx - pw * 0.5, sy - ph, pw, ph);\n    }\n    ctx.restore();\n  }\n\n  function resize() {\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) {\n      canvas.width = w;\n      canvas.height = h;\n    }\n  }\n\n  function frame(now) {\n    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    if (!reduced) {\n      yaw += look * dt * 1.6;\n      camX += Math.cos(yaw) * 4.2 * PARAMS.speed * dt;\n      camZ += Math.sin(yaw) * 4.2 * PARAMS.speed * dt;\n    }\n    resize();\n    draw();\n    if (!reduced) requestAnimationFrame(frame);\n  }\n\n  if (reduced) { resize(); draw(); }\n  else requestAnimationFrame(frame);\n})();\n</script>\n</body>\n</html>\n"}