{"slug":"boing","number":"21","title":"Boing ball","era":"Amiga · 1984","summary":"Checkered sphere, bounce, shadow. Click to kick it.","description":"Commodore's 1984 show ball: a sphere with longitude/latitude checkers, Lambert-shaded, a flattened shadow on a perspective grid. It bounces with a little energy loss and rolls with its spin. Click to kick it.","apis":["Canvas 2D","ImageData","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["amiga","3d"],"hasGlsl":false,"interaction":"Click the preview to kick the ball.","url":"https://3d-retro.com/experiments/boing","example_url":"https://3d-retro.com/examples/boing.html","api_url":"https://3d-retro.com/api/v1/experiments/boing","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=\"Amiga Boing ball: a checkered sphere, a bounce, a shadow.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/boing\" />\n  <title>Boing ball · 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; }\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: \"#d8241c\",\n    speed: 1,\n    checks: 8\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [216, 36, 28];\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  let x = 0, y = 1.8, vx = 1.15, vy = 0, spin = 0.4, last = 0;\n  let poke = 0;\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  const G = 9.4, FLOOR = 0, R = 0.72;\n\n  canvas.addEventListener(\"pointerdown\", function (ev) {\n    const rect = canvas.getBoundingClientRect();\n    const nx = ((ev.clientX - rect.left) / rect.width - 0.5) * 4.4;\n    vx = (nx - x) * 1.4;\n    vy = 4.2;\n    poke = 1;\n  });\n\n  function project(px, py, pz, w, h) {\n    const z = pz + 5.2;\n    const f = Math.min(w, h) * 0.95 / z;\n    return { x: w * 0.5 + px * f, y: h * 0.62 - py * f, s: f };\n  }\n\n  function drawGrid(w, h, rgb) {\n    ctx.strokeStyle = \"rgba(\" + rgb[0] + \",\" + rgb[1] + \",\" + rgb[2] + \",0.22)\";\n    ctx.lineWidth = 1;\n    for (let i = -6; i <= 6; i++) {\n      const a = project(i * 0.55, FLOOR, -3.2, w, h);\n      const b = project(i * 0.55, FLOOR, 3.4, w, h);\n      ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();\n      const c = project(-3.4, FLOOR, i * 0.55, w, h);\n      const d = project(3.4, FLOOR, i * 0.55, w, h);\n      ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(d.x, d.y); ctx.stroke();\n    }\n  }\n\n  function drawBall(w, h, rgb) {\n    const p = project(x, y + R, 0, w, h);\n    const rad = R * p.s;\n    const sh = project(x + 0.35, FLOOR + 0.01, 0.15, w, h);\n    const lift = Math.max(0.15, 1 - (y / 2.6));\n    ctx.fillStyle = \"rgba(0,0,0,\" + (0.38 * lift) + \")\";\n    ctx.beginPath();\n    ctx.ellipse(sh.x, sh.y, rad * 0.85, rad * 0.22, 0, 0, Math.PI * 2);\n    ctx.fill();\n\n    const nCheck = Math.max(4, PARAMS.checks | 0);\n    const xmin = Math.max(0, (p.x - rad) | 0);\n    const xmax = Math.min(w - 1, (p.x + rad) | 0);\n    const ymin = Math.max(0, (p.y - rad) | 0);\n    const ymax = Math.min(h - 1, (p.y + rad) | 0);\n    const id = ctx.getImageData(xmin, ymin, xmax - xmin + 1, ymax - ymin + 1);\n    const data = id.data;\n    const cw = xmax - xmin + 1;\n    const cs = Math.cos(spin), sn = Math.sin(spin);\n    const white = [236, 232, 224];\n    for (let py = ymin; py <= ymax; py++) {\n      for (let px = xmin; px <= xmax; px++) {\n        const nx = (px - p.x) / rad;\n        const ny = (py - p.y) / rad;\n        const r2 = nx * nx + ny * ny;\n        if (r2 > 1) continue;\n        const nz = Math.sqrt(1 - r2);\n        const rx = nx * cs + nz * sn;\n        const rz = -nx * sn + nz * cs;\n        const lon = Math.atan2(rx, rz);\n        const lat = Math.asin(Math.max(-1, Math.min(1, ny)));\n        const u = Math.floor(((lon + Math.PI) / (Math.PI * 2)) * nCheck);\n        const v = Math.floor(((lat + Math.PI / 2) / Math.PI) * (nCheck * 0.5));\n        const on = (u + v) & 1;\n        const wrap = 0.18 + 0.85 * Math.max(0, 0.35 + 0.75 * (nx * 0.4 + ny * -0.5 + nz * 0.7));\n        const col = on ? rgb : white;\n        const i = ((py - ymin) * cw + (px - xmin)) * 4;\n        data[i] = col[0] * wrap;\n        data[i + 1] = col[1] * wrap;\n        data[i + 2] = col[2] * wrap;\n        data[i + 3] = 255;\n      }\n    }\n    ctx.putImageData(id, xmin, ymin);\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.032, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    if (!reduced) {\n      vy -= G * dt * PARAMS.speed;\n      y += vy * dt * PARAMS.speed;\n      x += vx * dt * PARAMS.speed;\n      spin += vx * dt * 0.85 * PARAMS.speed;\n      if (y < FLOOR) {\n        y = FLOOR;\n        vy = Math.abs(vy) * 0.84;\n        if (vy < 0.4) vy = 3.6 + poke * 1.2;\n      }\n      if (x > 2.1) { x = 2.1; vx = -Math.abs(vx); }\n      if (x < -2.1) { x = -2.1; vx = Math.abs(vx); }\n      poke *= 0.9;\n    }\n    resize();\n    const w = canvas.width, h = canvas.height;\n    ctx.fillStyle = \"#0b0c0e\";\n    ctx.fillRect(0, 0, w, h);\n    const rgb = hexToRgb(PARAMS.color);\n    drawGrid(w, h, rgb);\n    drawBall(w, h, rgb);\n    if (!reduced) requestAnimationFrame(frame);\n  }\n\n  if (reduced) {\n    y = 0.8; x = 0;\n    resize();\n    ctx.fillStyle = \"#0b0c0e\";\n    ctx.fillRect(0, 0, canvas.width, canvas.height);\n    const rgb = hexToRgb(PARAMS.color);\n    drawGrid(canvas.width, canvas.height, rgb);\n    drawBall(canvas.width, canvas.height, rgb);\n  } else requestAnimationFrame(frame);\n})();\n</script>\n</body>\n</html>\n"}