oneshotlm
Compare all models on this prompt →

Snake · minimax-m3

Build a playable Snake game on a canvas with arrow-key controls, a growing tail, food, score, and a game-over and restart state.

⛨ sandboxed Open in new tab ↗
transcript · 4 messages
user

Build a playable Snake game on a canvas with arrow-key controls, a growing tail, food, score, and a game-over and restart state.

assistant
thinking
The user wants me to build a playable Snake game. Let me plan this out:

1. Single-page web app in `index.html`
2. All CSS in `<style>` tag, all JS in `<script>` tag
3. No external resources - fully self-contained
4. Features needed:
   - Canvas-based rendering
   - Arrow key controls
   - Growing tail
   - Food
   - Score display
   - Game over state
   - Restart functionality

Let me design this nicely:
- Classic snake game with smooth controls
- Grid-based movement
- Score display
- High score (using localStorage? Wait - no external resources but localStorage is local so it's fine)
- Nice visuals with rounded corners, gradient background
- Game over overlay with restart button
- Start screen too maybe? Or just immediate play
- Speed increases as score grows? Optional but nice

Let me build this. I'll use a 20x20 grid with 25px tiles = 500x500 canvas, or maybe make it responsive.

Actually let me make it a fixed-size canvas with a nice aesthetic. Let me use a dark theme with neon-ish accents.

Game design:
- Grid: 25 cols x 25 rows
- Tile size: 20px → 500x500 canvas
- Snake starts in middle, length 3, moving right
- Food appears randomly on empty cells
- Arrow keys change direction (queued to prevent reversal)
- Score = number of food eaten
- Game over when hitting wall or self
- Press Space or click button to restart
- High score persisted in localStorage

Let me code it up nicely with:
- A header showing score and high score
- Canvas in the middle
- Game over overlay
- Maybe a subtle pause when not focused

Let me write this:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Snake</title>
<style>
  /* Reset and base */
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    min-height: 100vh;
    background: radial-gradient(ellipse at top, #1a1f3a 0%, #0a0a1a 60%, #000 100%);
    color: #e0e0ff;
    font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 20px;
    overflow: hidden;
  }
  
  h1 {
    font-size: 2.5rem;
    font-weight: 300;
    letter-spacing: 0.5em;
    text-transform: uppercase;
    margin-bottom: 20px;
    background: linear-gradient(90deg, #4ade80, #22d3ee, #a78bfa);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-clip: text;
  }
  
  .hud {
    display: flex;
    gap: 40px;
    margin-bottom: 16px;
    font-size: 1.1rem;
    letter-spacing: 0.1em;
  }
  
  .hud-item {
    display: flex;
    flex-direction: column;
    align-items: center;
  }
  
  .hud-label {
    font-size: 0.75rem;
    color: #8888aa;
    text-transform: uppercase;
    letter-spacing: 0.2em;
  }
  
  .hud-value {
    font-size: 1.8rem;
    font-weight: 600;
    color: #4ade80;
    font-variant-numeric: tabular-nums;
  }
  
  .game-wrapper {
    position: relative;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 0 60px rgba(74, 222, 128, 0.15), 0 20px 60px rgba(0,0,0,0.5);
    border: 1px solid rgba(74, 222, 128, 0.2);
  }
  
  canvas {
    display: block;
    background: #0a0f1a;
  }
  
  .overlay {
    position: absolute;
    inset: 0;
    background: rgba(5, 10, 20, 0.85);
    backdrop-filter: blur(8px);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 16px;
    transition: opacity 0.3s ease;
  }
  
  .overlay.hidden {
    opacity: 0;
    pointer-events: none;
  }
  
  .overlay h2 {
    font-size: 2.5rem;
    font-weight: 700;
    color: #f87171;
    letter-spacing: 0.1em;
  }
  
  .overlay.start h2 {
    color: #4ade80;
  }
  
  .overlay p {
    color: #aaaacc;
    font-size: 1rem;
    margin-bottom: 8px;
  }
  
  .final-score {
    font-size: 1.2rem;
    color: #e0e0ff;
  }
  
  .final-score span {
    color: #4ade80;
    font-weight: 700;
    font-size: 1.6rem;
  }
  
  button {
    background: linear-gradient(135deg, #4ade80, #22d3ee);
    color: #0a0a1a;
    border: none;
    padding: 12px 32px;
    font-size: 1rem;
    font-weight: 700;
    letter-spacing: 0.15em;
    text-transform: uppercase;
    border-radius: 6px;
    cursor: pointer;
    transition: transform 0.15s ease, box-shadow 0.15s ease;
    font-family: inherit;
  }
  
  button:hover {
    transform: translateY(-2px);
    box-shadow: 0 8px 20px rgba(74, 222, 128, 0.4);
  }
  
  button:active {
    transform: translateY(0);
  }
  
  .hint {
    margin-top: 20px;
    font-size: 0.85rem;
    color: #6666aa;
    letter-spacing: 0.1em;
  }
  
  .hint kbd {
    display: inline-block;
    padding: 2px 8px;
    background: rgba(255,255,255,0.05);
    border: 1px solid rgba(255,255,255,0.1);
    border-radius: 4px;
    font-family: monospace;
    margin: 0 2px;
    color: #a0a0cc;
  }
</style>
</head>
<body>
  <h1>Snake</h1>
  <div class="hud">
    <div class="hud-item">
      <div class="hud-label">Score</div>
      <div class="hud-value" id="score">0</div>
    </div>
    <div class="hud-item">
      <div class="hud-label">Best</div>
      <div class="hud-value" id="best">0</div>
    </div>
  </div>
  <div class="game-wrapper">
    <canvas id="game" width="500" height="500"></canvas>
    <div class="overlay start" id="overlay">
      <h2 id="overlayTitle">Ready?</h2>
      <p id="overlayText">Use arrow keys to move</p>
      <button id="startBtn">Start Game</button>
    </div>
  </div>
  <div class="hint">Move <kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> &nbsp;·&nbsp; Pause <kbd>Space</kbd> &nbsp;·&nbsp; Restart <kbd>R</kbd></div>

<script>
(() => {
  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');
  const scoreEl = document.getElementById('score');
  const bestEl = document.getElementById('best');
  const overlay = document.getElementById('overlay');
  const overlayTitle = document.getElementById('overlayTitle');
  const overlayText = document.getElementById('overlayText');
  const startBtn = document.getElementById('startBtn');

  const COLS = 25;
  const ROWS = 25;
  const TILE = canvas.width / COLS; // 20px

  // State
  let snake, dir, nextDir, food, score, best, alive, paused, started, tickInterval, tickTimer;
  const BASE_SPEED = 130; // ms per tick
  const MIN_SPEED = 60;
  
  best = parseInt(localStorage.getItem('snake-best') || '0', 10);
  bestEl.textContent = best;

  function reset() {
    const mid = Math.floor(COLS / 2);
    snake = [
      { x: mid - 1, y: Math.floor(ROWS / 2) },
      { x: mid,     y: Math.floor(ROWS / 2) },
      { x: mid + 1, y: Math.floor(ROWS / 2) },
    ];
    dir = { x: 1, y: 0 };
    nextDir = { x: 1, y: 0 };
    score = 0;
    alive = true;
    paused = false;
    scoreEl.textContent = score;
    placeFood();
    draw();
  }

  function placeFood() {
    const free = [];
    const occupied = new Set(snake.map(s => `${s.x},${s.y}`));
    for (let x = 0; x < COLS; x++) {
      for (let y = 0; y < ROWS; y++) {
        if (!occupied.has(`${x},${y}`)) free.push({ x, y });
      }
    }
    if (free.length === 0) { // won
      win();
      return;
    }
    food = free[Math.floor(Math.random() * free.length)];
  }

  function start() {
    reset();
    started = true;
    overlay.classList.add('hidden');
    overlay.classList.remove('start');
    scheduleTick();
  }

  function scheduleTick() {
    clearTimeout(tickTimer);
    const speed = Math.max(MIN_SPEED, BASE_SPEED - score * 2);
    tickTimer = setTimeout(() => {
      if (alive && !paused) {
        step();
        if (alive) scheduleTick();
      }
    }, speed);
  }

  function step() {
    dir = nextDir;
    const head = snake[snake.length - 1];
    const newHead = { x: head.x + dir.x, y: head.y + dir.y };
    
    // wall collision
    if (newHead.x < 0 || newHead.x >= COLS || newHead.y < 0 || newHead.y >= ROWS) {
      die();
      return;
    }
    
    // self collision (excluding tail tip which will move away unless we grow)
    const willGrow = newHead.x === food.x && newHead.y === food.y;
    const bodyToCheck = willGrow ? snake : snake.slice(1);
    for (const s of bodyToCheck) {
      if (s.x === newHead.x && s.y === newHead.y) {
        die();
        return;
      }
    }
    
    snake.push(newHead);
    if (willGrow) {
      score++;
      scoreEl.textContent = score;
      placeFood();
    } else {
      snake.shift();
    }
    draw();
  }

  function die() {
    alive = false;
    if (score > best) {
      best = score;
      bestEl.textContent = best;
      localStorage.setItem('snake-best', best);
    }
    showOverlay('Game Over', `You scored <span>${score}</span>`, 'Play Again');
  }

  function win() {
    alive = false;
    if (score > best) {
      best = score;
      bestEl.textContent = best;
      localStorage.setItem('snake-best', best);
    }
    showOverlay('You Win!', `Perfect run: <span>${score}</span>`, 'Play Again');
  }

  function showOverlay(title, html, btn) {
    overlayTitle.textContent = title;
    overlayText.innerHTML = html;
    startBtn.textContent = btn;
    overlay.classList.remove('hidden', 'start');
    if (title === 'Game Over') overlay.classList.add('start'); // for color logic? actually we want red
    // We'll handle colors via inline styles
    overlayTitle.style.color = title === 'Game Over' ? '#f87171' : (title === 'You Win!' ? '#fbbf24' : '#4ade80');
    overlay.classList.add('show');
  }

  function draw() {
    // background with subtle grid
    ctx.fillStyle = '#0a0f1a';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    
    // grid
    ctx.strokeStyle = 'rgba(74, 222, 128, 0.04)';
    ctx.lineWidth = 1;
    for (let i = 0; i <= COLS; i++) {
      ctx.beginPath();
      ctx.moveTo(i * TILE, 0);
      ctx.lineTo(i * TILE, canvas.height);
      ctx.stroke();
    }
    for (let i = 0; i <= ROWS; i++) {
      ctx.beginPath();
      ctx.moveTo(0, i * TILE);
      ctx.lineTo(canvas.width, i * TILE);
      ctx.stroke();
    }
    
    // food with pulsing glow
    const t = Date.now() / 200;
    const pulse = 0.5 + 0.5 * Math.sin(t);
    const foodR = TILE * 0.35;
    const fx = food.x * TILE + TILE / 2;
    const fy = food.y * TILE + TILE / 2;
    
    const grad = ctx.createRadialGradient(fx, fy, 0, fx, fy, TILE * 0.8);
    grad.addColorStop(0, `rgba(248, 113, 113, ${0.4 + 0.3 * pulse})`);
    grad.addColorStop(1, 'rgba(248, 113, 113, 0)');
    ctx.fillStyle = grad;
    ctx.fillRect(food.x * TILE - TILE/2, food.y * TILE - TILE/2, TILE * 2, TILE * 2);
    
    ctx.fillStyle = '#f87171';
    ctx.beginPath();
    ctx.arc(fx, fy, foodR, 0, Math.PI * 2);
    ctx.fill();
    
    // little shine
    ctx.fillStyle = 'rgba(255,255,255,0.6)';
    ctx.beginPath();
    ctx.arc(fx - foodR * 0.3, fy - foodR * 0.3, foodR * 0.25, 0, Math.PI * 2);
    ctx.fill();
    
    // snake
    snake.forEach((seg, i) => {
      const t = i / snake.length;
      const headness = i === snake.length - 1 ? 1 : 0;
      const r = Math.round(74 + (34 - 74) * t);
      const g = Math.round(222 + (211 - 222) * t);
      const b = Math.round(128 + (238 - 128) * t);
      ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
      
      const pad = 1.5;
      const x = seg.x * TILE + pad;
      const y = seg.y * TILE + pad;
      const s = TILE - pad * 2;
      const radius = headness ? 6 : 4;
      
      ctx.beginPath();
      ctx.roundRect(x, y, s, s, radius);
      ctx.fill();
    });
    
    // head eyes
    const head = snake[snake.length - 1];
    if (head && alive) {
      const hx = head.x * TILE + TILE / 2;
      const hy = head.y * TILE + TILE / 2;
      const eyeOff = TILE * 0.18;
      const eyeR = TILE * 0.08;
      
      // eye positions based on direction
      let e1, e2;
      if (dir.x === 1)      { e1 = [hx + eyeOff, hy - eyeOff]; e2 = [hx + eyeOff, hy + eyeOff]; }
      else if (dir.x === -1) { e1 = [hx - eyeOff, hy - eyeOff]; e2 = [hx - eyeOff, hy + eyeOff]; }
      else if (dir.y === 1)  { e1 = [hx - eyeOff, hy + eyeOff]; e2 = [hx + eyeOff, hy + eyeOff]; }
      else                   { e1 = [hx - eyeOff, hy - eyeOff]; e2 = [hx + eyeOff, hy - eyeOff]; }
      
      ctx.fillStyle = '#0a0f1a';
      for (const [ex, ey] of [e1, e2]) {
        ctx.beginPath();
        ctx.arc(ex, ey, eyeR, 0, Math.PI * 2);
        ctx.fill();
      }
    }
    
    // paused indicator
    if (paused && alive) {
      ctx.fillStyle = 'rgba(0,0,0,0.5)';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.fillStyle = '#e0e0ff';
      ctx.font = 'bold 48px system-ui';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText('PAUSED', canvas.width / 2, canvas.height / 2);
      ctx.font = '20px system-ui';
      ctx.fillStyle = '#8888aa';
      ctx.fillText('Press SPACE to resume', canvas.width / 2, canvas.height / 2 + 40);
    }
  }

  // Animation loop for pulsing food and any other animations
  function animate() {
    if (started) draw();
    requestAnimationFrame(animate);
  }

  // Input
  function setDir(x, y) {
    // prevent reversing onto self
    if (dir.x === -x && dir.y === -y) return;
    // also prevent same direction (no-op) but allow in general
    nextDir = { x, y };
  }

  document.addEventListener('keydown', (e) => {
    const k = e.key;
    if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'w','a','s','d','W','A','S','D'].includes(k)) {
      e.preventDefault();
    }
    if (!started) {
      if (k === 'Enter' || k === ' ') start();
      return;
    }
    if (k === 'ArrowUp' || k === 'w' || k === 'W') setDir(0, -1);
    else if (k === 'ArrowDown' || k === 's' || k === 'S') setDir(0, 1);
    else if (k === 'ArrowLeft' || k === 'a' || k === 'A') setDir(-1, 0);
    else if (k === 'ArrowRight' || k === 'd' || k === 'D') setDir(1, 0);
    else if (k === ' ') {
      if (alive) {
        paused = !paused;
        if (!paused) scheduleTick();
      }
    } else if (k === 'r' || k === 'R') {
      start();
    }
  });

  startBtn.addEventListener('click', start);
  
  // initial draw (empty/ready screen)
  reset();
  draw();
  animate();
})();
</script>
</body>
</html>
```

Wait, I have a few things to fix:
1. roundRect may not be in all browsers - I should add a polyfill or check. Modern Chrome/Firefox/Safari support it now, but let me be safe and add a fallback.
2. The overlay should not start visible if I show initial draw - actually let me have the start screen as overlay initially.
3. When paused I call scheduleTick but clearTimeout on each step... let me check the logic. Actually scheduleTick clears previous then sets new. When unpausing I call scheduleTick which restarts the loop. Good.
4. localStorage might not be available in all environments - let me wrap in try/catch.
5. The "You Win" condition: if snake fills the grid. Score would be COLS*ROWS - 3 (initial length). That's 622. Quite a lot. Will still be reachable.

Let me also think about the canvas size — 500x500 is good for desktop. The page should be responsive enough.

Let me refine and write the final file.

Actually, regarding `roundRect`: it's available in modern browsers since 2022/2023, but to be safe I'll add a fallback.

Let me also make the start screen show a snake preview or just be cleaner. The overlay initially says "Ready?" but actually "Snake" or "Press any key to start" might be nicer. Let me use "Ready to play?" with instructions.

Let me also add a touch control? For mobile would be nice but the user just asked for arrow keys. I'll add swipe support optionally — actually let me add it as a nice touch since it's not external resources, just JS.

Hmm, the user explicitly said "arrow-key controls" so I'll stick with keyboard primarily but add WASD support as a bonus. Let me not add mobile touch to keep it focused.

Let me finalize and write the file.

One more consideration: the overlay shouldn't render behind the canvas with a blur backdrop. Actually with the canvas being inside `.game-wrapper` and `.overlay` being positioned absolute inside it, the backdrop blur will work nicely.

Wait, `backdrop-filter` might not work in all browsers but it gracefullydegrades. Good.

Let me write it now.

I'll build a polished Snake game with smooth animations, scoring, high score persistence, and a clean game-over/restart flow.

write /workspace/index.html (19430 bytes)
toolResult

Successfully wrote 19430 bytes to /workspace/index.html

assistant

TAKE IT TO CODE

A single self-contained HTML file — copy it and open in any browser, or reuse the prompt with your own pi-agent run.

RUN

modelminimax-m3
vendorMiniMax
harnesspi-agent
generated2026-07-22

EVALUATION

3/5 partial

Snake menu loads and Space triggers a paused state, but arrow/WASD/click never actually starts gameplay in screenshots.

Vision grade · anthropic/claude-sonnet-5

MEASUREMENTS

Raw readings from the capture, not a verdict — the grade above is the only judgement. Percentages are how much of the screen changed; the measure under-reports sparse motion on a dark background.

idle motion0.0%
on click0.0%
on drag0.0%
on wheel0.0%
on enter + space2.8%
on arrow keys0.0%
on w/a/s/d0.0%
frame spread15.2 / 255
console errors0
js errors none

METRICS

runtime86.5s
tokens in1.6k
tokens out11k
cached128
cost / run$0.0140