oneshotlm
Compare all models on this prompt →

Snake · laguna-s-2.1

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 · 31 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
assistant
assistant
assistant
thinking
The user wants me to build a playable Snake game in a single self-contained HTML file. Let me plan this out:

1. Canvas-based Snake game
2. Arrow key controls
3. Growing tail
4. Food
5. Score display
6. Game-over state
7. Restart state (button or key)
8. CSS in `<style>` tag, JS in `<script>` tag, all inline
9. No external resources

Let me write this carefully. I'll include:
- A canvas element
- Score display
- Game over overlay with restart button
- CSS for styling
- JavaScript for game logic

Let me think about the game logic:
- Grid-based movement (snake moves in cells)
- Snake represented as array of segments
- Food spawns randomly on grid
- Arrow keys change direction
- Collision detection: walls and self
- Growing: when food eaten, don't remove tail that turn
- Game loop using requestAnimationFrame or setInterval

I'll use a grid system. Let's say 20x20 cells, each 20px = 400x400 canvas.

Let me write clean, well-structured code.

I should make sure the game is actually playable and polished. Let me add:
- Smooth movement at a reasonable speed
- Prevent reversing into itself
- Game over screen with score and restart button
- Maybe high score stored in localStorage? The user said sandboxed with no network, but localStorage works offline. Actually, let me keep it simple but localStorage for high score is fine since it's sandboxed. Let me add it for polish.

Let me write the code.

I'll define:
- canvas size: 400x400 (20px per cell, but let's make cells 20px and 20 columns)
- Actually let me use 400 width/height, cellSize 20, cols 20, rows 20

Let me structure the JS:

```js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const cellSize = 20;
const cols = 20;
const rows = 20;
const width = cols * cellSize;
const height = rows * cellSize;
canvas.width = width;
canvas.height = height;

let snake, food, dir, nextDir, score, gameOver, speed;

function init() {
  snake = [{x: 10, y: 10}];
  dir = {x: 1, y: 0};
  nextDir = {x: 1, y: 0};
  score = 0;
  gameOver = false;
  placeFood();
  ...
}
```

Let me write the full game with good features. I'll make it polished with a nice visual style.

Let me think about preventing the reverse direction: if you're moving right (x:1, y:0), pressing left (x:-1) should be ignored.

I'll handle input with keydown listener updating nextDir.

For the game loop, I'll use a fixed timestep with requestAnimationFrame but control speed with a timer. Actually, let me use a tick-based approach.

Let me write the complete file now.

I want to make sure the game feels good. Let me add some polish:
- Food is red
- Snake head is a different color
- Grid lines maybe
- Score animates

Let me keep it focused but complete. Let me write it.

For game loop, I'll use setInterval or a manual timer with requestAnimationFrame. Using requestAnimationFrame with accumulated time is cleaner:

```js
let lastTime = 0;
let frameDuration = 200; // ms per move

function gameLoop(timestamp) {
  if (!lastTime) lastTime = timestamp;
  const delta = timestamp - lastTime;
  if (delta > frameDuration) {
    update();
    lastTime = timestamp;
  }
  draw();
  requestAnimationFrame(gameLoop);
}
```

But game over state should stop the loop visually. Let me handle that.

Actually let me just write it cleanly. Let me also make the speed increase as you eat food for more challenge. frameDuration decreases.

Let me write the file.
bash {"command":"ls -la /workspace"}
toolResult

total 4 drwxrwxrwx 2 node node 64 Aug 2 10:10 . drwxr-xr-x 1 root root 4096 Aug 2 10:10 ..

assistant
thinking
Empty directory. Let me create the index.html file with the Snake game.

I'll build a polished, fully self-contained Snake game. Let me write it all into one file.

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

Successfully wrote 10597 bytes to /workspace/index.html

assistant
thinking
Let me review the code I wrote and check for bugs. Let me re-read the update logic, especially the tail growth logic since I changed how moveCap works.

The growth logic:
- snake array
- when food eaten: moveCap = 2
- on normal move: moveCap--, if moveCap <= 0, pop tail. Else keep.

Wait, there's a bug. Let me trace through:
1. Initial snake has 5 segments, moveCap = 2.

Actually wait, moveCap starts at 2 in reset(). That means on the FIRST move (no food), it decrements to 1, and since moveCap <= 0 is false (1 is not <=0), it doesn't pop the tail. So the snake grows on the first move without food? That's a bug.

Let me rethink the growth logic. The standard approach:
- Each tick, add a new head.
- Normally, also remove the tail (so length stays constant).
- When eating food, don't remove the tail this tick (so length grows by 1).

A cleaner way is to use a counter `grow` that, when > 0, means "don't pop tail". 

Let me rewrite:
```
let grow = 0; // when food eaten, set grow += N

function update(){
  ...
  snake.unshift(head);
  if (food eaten){
    grow += 2; // grow by 2 cells
    placeFood();
    frameMs ...
  }
  if (grow > 0){
    grow--;
    // don't pop -> grows
  } else {
    snake.pop();
  }
}
```

This is cleaner. Initially grow=0, so snake pops tail normally.

But there's also the initial moveCap logic. Let me trace with the clean version:
- Initial: snake 5 segments, grow=0.
- Move 1: unshift head (6), grow=0 so pop (5). Length stays 5. Good.
- Food eaten: grow=2.
- Move: unshift (6), grow>0 so grow-- (1), no pop. Length 6. 
- Move: unshift (7), grow>0 so grow-- (0), no pop. Length 7.
- Move: unshift (8), grow=0 so pop. Length 7.

So it grows by 2. Good, that's a clean growth by 2 per food.

Now let me also reconsider: the initial snake of 5 is fine, and grows naturally.

Let me rewrite the update function and reset.

Also there's another bug in the grid drawing - I used `ctx.strokeStyle = 'var(--grid)'` but for strokeStyle, CSS variables don't work directly as strings unless... actually canvas does NOT resolve CSS variables in the string. `var(--grid)` as a strokeStyle string won't work. Let me fix that - I'll just use a hex color. Actually I already fill the grid cells with a fill rect approach using fillStyle. Let me fix the strokeStyle.

Also `ctx.fillStyle = 'var(--grid)'` in drawGrid? No, I used 'rgba(224,230,241,0.04)' which is fine.

Let me fix the strokeStyle var issue.

Also let me double check `drawIcon` - it's fine.

Let me also verify the win condition: when snake fills the grid (empties.length === 0), food = null. Then in update, `if (food && head...)` - food is null so no food collision. The snake keeps moving. But there's no win detection! Let me add a win check: if snake.length === COLS*ROWS, show win message.

Actually when food is null because grid is full, the snake can't eat and just moves forever (popping tail each move since grow=0). That's fine but there's no win message. Let me add win detection.

Let me rewrite the relevant parts cleanly. Let me rewrite the whole update function and reset and the win check.

Let me also reconsider: when food is null (full grid), the snake just cycles. I should add a win state.

Let me rewrite the file with fixes:
1. Fix growth logic (clean grow counter)
2. Fix `var(--grid)` strokeStyle
3. Add win detection
4. Make initial moveCap not cause growth

Let me rewrite the script portions. Actually, let me just rewrite the whole file cleanly to avoid subtle bugs. I'll keep the visual style.

Let me rewrite update(), reset(), and the win logic.

Actually, let me reconsider the win condition more carefully. The grid is 20x20 = 400 cells. The snake starts at 5. To fill is 400. With growth of 2 per food, you need 197.5 foods = ~198 foods to fill. That's a LOT but possible. The win is rare but let me add it for completeness.

Let me also think: when the grid is nearly full, placeFood picks random empty. Fine.

Let me rewrite the JS section. I'll rewrite the whole file to be safe and clean.

Let me also double-check the `setDir` logic for preventing reversal. I check against vel and nextVel. Actually using nextVel as the "current" direction is a bit redundant. Let me simplify: track the direction that's queued (nextVel), and the head moves by nextVel each tick. Prevent opposite of nextVel.

Actually simpler: just use a single `vel` that gets applied, and a `pending` direction. On input, set pending if not opposite to pending. On tick, apply pending to vel... hmm.

Let me just do: direction is stored, input rejected if opposite to current direction. Since the tick rate (~150ms) is slow relative to keypress, single `vel` is fine. But if player presses two opposite keys between ticks, only the last matters. Let me keep nextVel to queue one turn per tick (allows one turn per frame).

Standard approach:
```
let vel = {1,0};
function setDir(dx,dy){
  if (dx===-vel.x && dy===-vel.y) return; // no reversal
  vel.x=dx; vel.y=dy;
}
```
This allows instant reversal-prevention. But allows queueing. The issue: pressing rapid opposite could reverse via two presses. E.g. moving right, press left (ignored, opposite), press up (accepted, vel=up). Good, single vel is fine and simpler. The "queue one turn per frame" gives more responsive feel where you can press a turn before the next tick. Let me use a single current direction with nextVel queue that's committed each tick:

```
let vel={1,0}, queued={1,0};
function setDir(dx,dy){
  if (dx===-queued.x && dy===-queued.y) return; // can't reverse the queued dir
  queued.x=dx; queued.y=dy;
}
// each tick:
vel.x=queued.x; vel.y=queued.y;
```

Wait but if queued is the direction to be applied, reversing the QUEUED direction: if queued=up and you press down, that's reversal of queued → rejected. But the actual moving dir is vel (up). Pressing down when moving up is reversal → correctly rejected. Good. This is clean and allows smooth turns queued one step ahead. Let me use this.

Actually there's subtlety: queued starts = vel = right. Tick 1: moving right. Before tick 2, you press up → queued=up (allowed, not opposite of right). Tick 2 applies up. Good. You press left (opposite of up? no, left opposite is right; up's opposite is down). Left vs queued=up: not opposite, allowed, queued=left. Then tick applies left. Good.

Hmm but you can't press right (opposite of queued up) — wait if queued is up, opposite is down. Right is fine. So you can queue right then... Actually this is getting complicated but the single-queued approach is standard and works.

Let me simplify and just use single vel with immediate reject of opposite. That's the most robust and simple. The responsiveness difference is negligible at 150ms. Actually, allowing a queued turn (one step lookahead) IS more responsive and feels better. Let me keep queued approach but simplify the rejection to only the actual current vel... 

No. Let me just do the queued approach properly. It's fine.

Let me rewrite cleanly now.

One more consideration: the `die()` when hitting wall or self. Good.

Let me also handle the "win" when grid full: check `if (snake.length >= COLS*ROWS)` → win.

Let me rewrite the file.

Also, I realize in drawGrid I have `ctx.strokeStyle = 'var(--grid)'` which won't work. And the fillRect lines for grid. Let me just draw grid with a subtle fill color directly. Actually drawing grid lines with strokeStyle as a hex like '#1e293b66' or 'rgba(224,230,241,0.04)'. Let me use the fill approach I already have (which works) and fix/remove the strokeStyle line.

Let me rewrite the whole script for clarity and correctness.

Let me review the code for bugs, especially the tail-growth logic and CSS variable usage in canvas.

read {"path":"/workspace/index.html","offset":130,"limit":120}
toolResult

gameOver = false; msg.classList.remove('show'); placeFood(); updateStats(); } function placeFood() { // gather empty cells const occupied = new Set(snake.map(p => p.x + ',' + p.y)); const empties = []; for (let y=0;y<ROWS;y++){ for (let x=0;x<COLS;x++){ if (!occupied.has(x+','+y)) empties.push({x,y}); } } if (empties.length === 0){ /* win! full grid */ food=null; return; } food = empties[Math.floor(Math.random()*empties.length)]; streak = 0; } function setDir(dx, dy){ // no 180 reversal if (dx === -vel.x && dy === -vel.y) return; // also prevent same-axis reversal via nextVel if (dx === -nextVel.x && dy === -nextVel.y) return; nextVel.x = dx; nextVel.y = dy; } function update() { moves++; vel.x = nextVel.x; vel.y = nextVel.y; const head = {x: snake[0].x + vel.x, y: snake[0].y + vel.y}; // wall collision if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS) return die(); // self collision for (let i=0;i<snake.length;i++){ if (snake[i].x===head.x && snake[i].y===head.y) return die(); } snake.unshift(head); // food? if (food && head.x===food.x && head.y===food.y){ score++; streak++; // shrink cap so snake grows (skip removing tail `moveCap` times) moveCap = 2; frameMs = Math.max(65, frameMs - 4); // speed up gently placeFood(); draw(); // re-render food location } else { moveCap--; if (moveCap <= 0) snake.pop(); else /* grow: keep extra */ void 0; // when moveCap > 0 we keep head but don't remove tail -> growth if (moveCap <= 0) moveCap = 0; // prevent negative } } function die(){ gameOver = true; showMessage('Game Over', ('Final score: ' + score)); saveBest(); } function drawGrid(){ ctx.fillStyle = '#0f172a'; ctx.fillRect(0,0,W,H); ctx.strokeStyle = 'var(--grid)'; ctx.lineWidth = 1; ctx.fillStyle = 'rgba(224,230,241,0.04)'; for (let i=1;i<COLS;i++){ ctx.fillRect(i*CELL-1,0,1,H); ctx.fillRect(0,i*CELL-1,W,1); } } function draw(){ drawGrid(); const grad = ctx.createLinearGradient(food?food.x*CELL:0,0, (food?food.x*CELL:0)+CELL,0); GRAD_FOOD.forEach((c,i)=>grad.addColorStop(i/(GRAD_FOOD.length-1), c)); if (food){ ctx.fillStyle = grad; ctx.fillRect(food.x*CELL, food.y*CELL, CELL, CELL); ctx.strokeStyle='#000';ctx.lineWidth=1;ctx.strokeRect(food.x*CELL, food.y*CELL, CELL, CELL); // sparkle ctx.fillStyle='#fff'; ctx.fillRect(food.x*CELL+7, food.y*CELL+7,2,2); } // snake body for (let i=snake.length-1;i>=0;i--){ const p = snake[i]; const t = i===0 ? 'head' : 'body'; const base = t==='head'?'#4ade80':'#22c58e'; ctx.fillStyle = base; ctx.fillRect(p.x*CELL, p.y*CELL, CELL, CELL); ctx.strokeStyle = t==='head'?'#86efac':'#16a34a'; ctx.lineWidth=1; ctx.strokeRect(p.x*CELL, p.y*CELL, CELL, CELL); if (t==='head'){ // eyes based on direction const eyeSize=3; let ex1,ey1,ex2,ey2; if (vel.x===1){ex1=p.x*CELL+12;ey1=p.y*CELL+7;ex2=p.x*CELL+12;ey2=p.y*CELL+14} else if (vel.x===-1){ex1=p.x*CELL+6;ey1=p.y*CELL+7;ex2=p.x*CELL+6;ey2=p.y*CELL+14} else if (vel.y===-1){ex1=p.x*CELL+7;ey1=p.y*CELL+6;ex2=p.x*CELL+14;ey2=p.y*CELL+6} else {ex1=p.x*CELL+7;ey1=p.y*CELL+13;ex2=p.x*CELL+14;ey2=p.y*CELL+13} ctx.fillStyle='#000'; ctx.fillRect(ex1,ey1,eyeSize,eyeSize); ctx.fillRect(ex2,ey2,eyeSize,eyeSize); } } } function loop(now){ if (!loop._t0) loop._t0 = now; if (!gameOver){ if (now - loop._t0 >= frameMs){ update(); draw(); loop._t0 = now; } } else { [66 more lines in file. Use offset=250 to continue.]

assistant
thinking
I see a few bugs:

1. **Growth logic bug**: `moveCap` starts at 2 in `reset()`. On the very first `update()` (no food), `moveCap--` → 1, and since `1 <= 0` is false, it doesn't pop the tail. So the snake grows by 1 on the first move without eating. This is a bug.

2. **`moveCap` interaction**: When food is eaten, `moveCap = 2`. Then on the NEXT tick (which may not be food), `moveCap--` → 1, still no pop (grow). Next tick `moveCap--` → 0... wait, `if (moveCap <= 0) snake.pop()`. When moveCap=0... actually the order: moveCap-- makes it -1? Let me trace:
   - Food eaten: moveCap=2.
   - Next tick, no food: moveCap-- → 1. 1<=0 false, don't pop. Then `if(moveCap<=0) moveCap=0` → no. moveCap=1.
   - Next tick, no food: moveCap-- → 0. 0<=0 true, pop. Then `if(0<=0)` moveCap=0.
   - Next tick, no food: moveCap-- → -1. -1<=0 true, pop. moveCap=0.
   
   So it grows for 2 ticks then shrinks cap to 0. The growth: unshift head each tick, pop on 2nd and 3rd... Actually the first food tick unshifts head AND doesn't pop (because moveCap becomes 2 then stays since no else). Wait, the food branch doesn't pop at all — only the else branch pops. So:
   - Food tick: head added (len+1), no pop. moveCap=2.
   - Tick1 (else): moveCap--→1, no pop. head added (len+1). Total len+2, pops 0.
   - Tick2 (else): moveCap--→0, pop! head added (len+1), pop (len-1). net 0. 
   - Tick3 (else): moveCap=0, moveCap--→-1, pop. net 0.
   
   So growth = 2 cells from food. That's actually consistent (grow by 2 per food). But the INITIAL moveCap=2 bug means the snake grows 2 cells right at the start before eating anything. That's wrong.

The cleanest fix is to rewrite with a simple `grow` counter. Let me rewrite the whole script cleanly to fix all issues:
- Use `grow` counter instead of `moveCap`.
- Fix `var(--grid)` strokeStyle.
- Add win detection when grid is full.
- Clean direction queueing.

Let me rewrite the entire file cleanly. It's easier than patching.

Let me write a clean version.

Key design:
- `snake`: array of {x,y}, index 0 = head.
- `dir`: current velocity, `queued`: pending turn.
- `grow`: when >0, don't pop tail.
- Tick: apply queued to dir (reject reversal of queued), compute head, check collisions, unshift, if food eat (grow+=2, new food, speed up) else if grow>0 grow-- else pop.
- Win: grid full.
- Game loop with rAF and frameMs.

Let me write it.

For setDir reversal prevention: reject if queued opposite. Let me keep both dir and queued, and on tick set dir=queued. Reject reversal of `queued`.

Actually simpler and standard: single dir, setDir rejects if (dx,dy) is opposite of dir. This means one turn per keypress and a turn queued between ticks is lost if it's opposite... no. Let me think about responsiveness.

If I use single `dir` and apply immediately on keypress:
- Moving right, press up between ticks → dir=up. Next tick moves up. 
- Moving right, press left (opposite) → rejected. Good.
- Moving right, press up then left quickly between ticks → dir=up (left rejected as opposite of up). Good.

This single-dir-immediate approach is simplest and correct! No queueing needed. The only downside: you can't queue a turn slightly before the tick, but since keypress latency is the bottleneck, that's negligible. Actually the issue with immediate: if you press a key AFTER the tick committed but before you see the frame, it's fine. The "one turn per frame" lookahead (queued) is a micro-optimization for responsiveness that I can include. 

Let me use the queued approach for that extra polish:
```
let dir = {x:1,y:0};   // applied this tick
let queued = {x:1,y:0}; // next tick's dir (one-step lookahead)

function setDir(dx,dy){
  // prevent reversing the pending (queued) direction
  if (dx === -queued.x && dy === -queued.y) return;
  queued = {x:dx, y:dy};
}
function update(){
  dir.x = queued.x; dir.y = queued.y;
  const head = {x: snake[0].x+dir.x, y: snake[0].y+dir.y};
  ...
}
```

Wait, reversal check: queued=up, press down → opposite → reject. Good. But what about queued=right, press up (allowed), queued=up, then press down (opposite of up) → reject. That's correct since you can't reverse.

But subtle bug: with one-step queue, you could do queued=right, press down (allowed, queued=down), then before tick press up... up opposite of down? down's opposite is up → reject. So you can't reverse. Good. This works.

But there's a known issue with one-step lookahead allowing reversal: queued=right. Press up (queued=up). Press left — is left opposite of up? No (left opposite is right). So queued=left allowed. Now queue=left. Tick: dir=left. Hmm but you just moved right then left? No — between the tick that moved right and now, you've queued up then left. The head is at position after moving right. Next tick moves left — that would reverse into... no wait. Let me see: the snake moved right, head now at new position. You queued up then left. The direction sequence applied: right (tick 1), then left (tick 2)?? That reverses right→left, which would go back into the neck. That's the classic "you die from reversing via two queued turns" bug!

The fix: only allow ONE queued turn at a time. Once you've queued a turn (queued != current moving dir in the changed sense), don't allow another change until that tick happens. OR, the standard robust approach: reject reversal against the CURRENT applied `dir` AND require that queued is only updated when... 

Actually the cleanest robust approach is the single-dir immediate one. Let me NOT over-engineer. Single dir, reject opposite of dir. This is bulletproof. The responsiveness is perfectly fine for Snake. Let me go with that.

```
let dir = {x:1,y:0};
function setDir(dx,dy){
  if (dx === -dir.x && dy === -dir.y) return;
  dir = {x:dx, y:dy};
}
```
Wait but this also rejects the case where you press the SAME direction — no, same direction isn't opposite. Pressing right when dir is right: dx=1, -dir.x=-1, 1 !== -1, so allowed, dir stays right. Fine. Pressing down when dir right: allowed. Good. This is correct.

But this doesn't allow queueing: if tick is ~150ms away and you press up right after a tick, the move happens 150ms later. That's the natural Snake feel. Good.

Actually there IS a subtle issue with immediate single-dir: it does allow reversal if you press two perpendicular keys between ticks in a way... no. dir only changes to non-opposite of CURRENT dir. If dir=right, any single press is either right (ok), up/down (ok, becomes up/down — perpendicular, fine), or left (rejected). dir never becomes opposite of itself. And since we update dir immediately on each key, the only state is dir. There's no way to set dir to the reversal of itself because each set rejects reversal of the current dir. So it's bulletproof. 

Let me go with single-dir immediate. Simple and correct.

Now the grow logic:
```
let grow = 0;
function update(){
  const head = {x: snake[0].x+dir.x, y: snake[0].y+dir.y};
  // collisions
  if (outside wall || self hit) die();
  snake.unshift(head);
  if (food && same cell){
    score++; streak++; grow += GROW; placeFood(); speed up; play sound (n/a)
  }
  if (grow > 0){ grow--; }
  else { snake.pop(); }
  // win check
  if (snake.length >= COLS*ROWS){ win }
}
```

Wait, order: I unshift head first, THEN handle food/pop. But collision check must be before unshift (check against current body). And the self-collision: head hits body. But if grow>0, the tail isn't removed, so... actually standard Snake: when you eat, the new head is added and you check collision against the body as it is. If the head moves into a cell that's currently occupied (including where food will... food cell is empty of snake). Fine. The head-into-tail-when-moving: in Snake, you CAN move into the cell the tail currently occupies if the tail is about to move away (you ate). But my logic checks self-collision against current snake BEFORE unshift. If head would be at tail's cell but tail will be removed (grow=0, about to pop), that's a false death. This is a known minor issue. 

To be precise, the head can occupy the tail cell only on the move where the tail is removed. Let me handle: check self-collision excluding the last segment IF the tail will move (grow=0). Actually simpler: do the collision check, but allow moving into the tail position when grow==0 (since tail will vacate). 

Hmm, this is the classic edge. Let me handle it: 
- If grow > 0: full body blocks (tail stays, so head can't enter tail cell).
- If grow == 0: tail will be removed this tick, so head MAY enter the tail cell without dying.

So self-collision check should exclude the last body cell when grow==0.

Let me implement:
```
function hitsSelf(head){
  const len = grow > 0 ? snake.length : snake.length - 1; // exclude tail if it'll move
  for (let i=0;i<len;i++){ if (snake[i].x===head.x && snake[i].y===head.y) return true; }
  return false;
}
```

Wait, when grow>0, tail stays (all cells block), so check full length. When grow==0, tail (last cell) will pop, exclude it → check snake.length-1. But also note: currently snake.length is the length BEFORE adding head. After unshift head, length+1, tail pops → back to length. The head's new cell: if it equals the current tail cell and grow==0, that's allowed (tail vacating). Good.

But careful: the head's new cell could equal the tail cell only if the snake is moving into its own tail — which in grow==0 case is the "safe" move. In grow>0 case, tail doesn't vacate so it's a death. Correct.

Let me also make sure: when grow>0, is the tail really staying? grow>0 → we don't pop → length increases. Yes tail stays. So full body blocks. Correct.

Good, this is the correct Snake behavior.

Now wall collision: die on hitting wall. (Classic Snake dies on wall.) Fine.

Win: when snake fills grid (can't be, because to fill you'd need 400 cells and growth of 2 per food). Actually you CAN fill the grid if you're very good. When empties.length===0, food=null. Then snake.length keeps growing? No—when food is null, grow stays as is (no food branch), so grow decrements and tail pops normally; length stable. The win check `snake.length >= COLS*ROWS` triggers → win. Let me add it.

But realistically the snake won't fill the grid (needs 395 more food). It's a theoretical win. I'll add it with a "You Win!" message. Actually, achieving a full grid is essentially impossible due to speed, but let me add the check anyway for correctness. When food is null, I should also stop spawning. Fine.

Let me also reconsider: with speed increasing frameMs down to 65ms, the snake gets very fast. That's the challenge. Good.

Let me rewrite the file completely and cleanly now. I'll also remove the unused `moves` var or keep minimal.

Let me write the clean full file.

For the message icon, let me make a simple snake icon for game-over (skull/X) and a trophy for win. I'll keep drawIcon simple.

Let me also add sound? The user said sandboxed no network, but Web Audio API / oscillator works offline without network. I could add a simple beep via oscillator for eating and a low tone for death. That adds polish. But it might be flaky in browsers. Let me add simple oscillator sounds — they're generated, no network. Actually let me keep it focused; sounds are nice-to-have. I'll add minimal oscillator tones since they're self-contained (no network, no files). 

Let me add a small sound helper using AudioContext with oscillator. On eat: short high beep. On die: low descending tone. On win: ascending. Keep it optional (wrapped in try/catch so if AudioContext blocked, no crash).

Actually, let me keep it but guard heavily. I'll include it — it's pure JS, no network.

Let me write the final clean file.

Let me also reconsider the grid drawing color var issue — I'll use a literal rgba.

Let me write it all out.

Final structure:
- CSS variables and styles (keep mostly same, maybe enhance message).
- HTML: scoreboard, board-wrap with canvas + overlay + message, restart btn, footer.
- JS: full clean logic.

Let me write the complete file now.

For the eyes drawing — fine. For food sparkle — fine.

Let me write it.

I'll set GROW = 2 (grow by 2 per food). Initial snake length 5.

Let me make frameMs start at 160 and decrease to min 60.

Let me finalize and write.

I found bugs in the growth logic (`moveCap` starts nonzero, causing spurious growth at start) and `var(--grid)` won't resolve in canvas `strokeStyle`. Let me rewrite the whole file cleanly with a correct `grow` counter, proper tail-vacancy collision, win detection, and self-contained sound effects via oscillators (no network).

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

Successfully wrote 10620 bytes to /workspace/index.html

assistant
assistant
thinking
Wait, there's a bug: I used `ctx.fillStyle='var(--head)'`, `ctx.fillStyle='var(--body)'`, etc. Canvas does NOT resolve CSS custom properties in fillStyle/strokeStyle when passed as a string like `var(...)`. Canvas requires actual color values. `var(--head)` as a string won't work — canvas treats it as "unknown" and ignores it or throws.

Wait, actually, modern browsers: `ctx.fillStyle = 'var(--head)'` does NOT work. CSS custom properties are resolved by the CSS engine, and canvas fillStyle is not a CSS property applied to an element in a way that resolves var(). Actually there's nuance: `fillStyle` does accept some things but `var()` is not resolved. Let me just use literal hex colors instead of var() references in the JS.

Let me fix all `var(...)` usages in canvas context:
- `drawGrid`: `ctx.fillStyle='var(--grid)'` → use `rgba(224,230,241,.05)` which equals my --grid.
- `draw`: `ctx.fillStyle='var(--head)'` → `'#4ade80'`, `ctx.fillStyle='var(--body)'` → `'#22c58e'`.
- strokeStyle uses literal hexes already (#86efac, #16a34a) — fine.

Also the `beep` function: `beep` returns nothing (it returns the result of the try block). `beep(360,0.04).then?...:0` — beep returns undefined, so `.then` throws! That's a bug. Let me fix eatSound: `beep(360,0.04); setTimeout(()=>beep(520,0.05),30);` — don't chain .then.

Let me fix these bugs:
1. Replace CSS var() in canvas with literals.
2. Fix eatSound .then chain.

Let me also double check `initAudio` is called on keydown/touchstart. Good.

Let me also verify the win detection logic: when food===null (grid full), in update after the grow/pop logic, I check `if(food===null){ if(snake.length>=COLS*ROWS) win(); }`. But food==null only when empties.length===0 in placeFood, which is when grid is full (snake.length === COLS*ROWS already). Actually if grid is full, snake.length===COLS*ROWS=400 and empties=0 → food=null. Then next update: head added (401)... wait, if grid is full the snake can't move without hitting itself. Actually when grid becomes full, we set food=null but snake.length is 400. The next move: head moves, hitsSelf? The snake is packed 400 cells, moving head anywhere hits self (since all 400 cells filled except... no, all filled). Actually the tail would vacate a cell (grow==0 → pop), so head could move into the vacated tail cell. hitsSelf head vs snake.length-1 (400-1=399 cells). The vacated tail cell isn't in that 399, so no collision → moves. Then snake.length stays 400 (unshift+pop). food still null. The win check: snake.length>=400 → 400>=400 true → win! Good. But wait, it might win a frame before. Actually as soon as food becomes null (placeFood returns null when empties=0), at that moment snake.length===400 already. The win check in the SAME update where food was set null: food was just set null (from placeFood during a food-eat), grow+=GROW. Then grow>0 so we don't pop, snake length = 401. food===null → check 401>=400 true → win. So win triggers. 

But actually the food-eat that fills the grid: placeFood is called, empties=0 (because after adding the head that ate, the grid... wait. Let me think about the exact full-grid scenario. It's an edge case unlikely in practice. The win detection is reasonable. Good enough.

Actually, there's a subtle issue: the win check `if(food===null)` is gated on food being null which means grid was full when placeFood ran. That's correct.

Hmm, but consider: after a normal food eat, placeFood sets food to a non-null cell (empties>0). So food===null only when grid full. Good.

Let me make the fixes now.

Fix 1: In drawGrid, `ctx.fillStyle='var(--grid)'` → `ctx.fillStyle='rgba(224,230,241,.05)'`
Fix 2: In draw, `ctx.fillStyle=isHead?'var(--head)':'var(--body)'` → `isHead?'#4ade80':'#22c58e'`
Fix 3: eatSound `beep(360,0.04).then?...` → `beep(360,0.04); setTimeout(()=>beep(520,0.05),30);`

Let me apply these edits.

I spotted two bugs: (1) canvas doesn't resolve CSS `var()` in `fillStyle`, and (2) `beep(...).then` will throw since `beep` returns nothing. Let me fix both.

edit {"path":"/workspace/index.html","edits":"<arg_key>oldText</arg_key><arg_value> function eatSound(){ /* rising two-tone */ beep(360,0.04).th
toolResult

Validation failed for tool "edit": - root: must not have additional properties - edits.0: must be object Received arguments: { "path": "/workspace/index.html", "edits": "<arg_key>oldText</arg_key><arg_value> function eatSound(){ /* rising two-tone */ beep(360,0.04).then?void 0:0; setTimeout(()=>beep(520,0.05),30); }", "newText": "function eatSound(){ /* rising two-tone */ beep(360,0.04); setTimeout(()=>beep(520,0.05),30); }" }

assistant
thinking
The edit tool needs the edits parameter to be an array of objects. Let me retry with proper format.Actually the issue is the parameter format. Let me look — the tool expects `edits` as array. The error says "must be object" for edits.0 — meaning each element must be object. But I passed edits.0 as an object already... wait the JSON shows edits as a single object string. Let me retry properly with array format.Hmm, actually it seems my tool call serialization got mangled. Let me retry with proper JSON array structure.
edit {"path":"/workspace/index.html","edits":[{"oldText":" function eatSound(){ /* rising two-tone */ beep(360,0.04).then?void 0:0; setTimeout((
toolResult

Successfully replaced 1 block(s) in /workspace/index.html.

assistant
assistant
thinking
Now fix the canvas var() issues. Let me do the two edits in drawGrid and draw.
edit {"edits":[{"oldText":" ctx.fillStyle='var(--grid)';","newText":" ctx.fillStyle='rgba(224,230,241,.05)';"},{"oldText":"ctx.fillStyle=isHead
toolResult

Successfully replaced 2 block(s) in /workspace/index.html.

assistant

Now let me verify the full file reads correctly:

read {"path":"/workspace/index.html","offset":120,"limit":130}
toolResult

} function placeFood(){ const occ=new Set(snake.map(p=>p.x+','+p.y)); const empt=[]; for(let y=0;y<ROWS;y++) for(let x=0;x<COLS;x++){ if(!occ.has(x+','+y)) empt.push({x,y}); } if(empt.length===0){ food=null; return; } // full grid -> win triggers in update food=empt[Math.floor(Math.random()*empt.length)]; streak=0; } // Reject 180° reversal against the current heading (bulletproof single-dir). function setDir(dx,dy){ if(dx===-dir.x && dy===-dir.y) return; dir.x=dx; dir.y=dy; } function hitsSelf(head){ // If tail will move away this tick (grow==0), the tail cell is passable. const len = grow>0 ? snake.length : snake.length-1; for(let i=0;i<len;i++){ if(snake[i].x===head.x && snake[i].y===head.y) return true; } return false; } function update(){ const head={x:snake[0].x+dir.x, y:snake[0].y+dir.y}; // wall if(head.x<0||head.x>=COLS||head.y<0||head.y>=ROWS) return die(); // self if(hitsSelf(head)) return die(); snake.unshift(head); if(food && head.x===food.x && head.y===food.y){ score++; streak++; grow+=GROW; frameMs=Math.max(MIN_MS,frameMs-SPEED_STEP); eatSound(); placeFood(); } if(grow>0){ grow--; } else { snake.pop(); } if(food===null){ if(snake.length>=COLS*ROWS){ return win(); } } updateStats(); } function die(){ gameOver=true; showMessage('Game Over','Final score: '+score); dieSound(); saveBest(); } function win(){ won=true; showMessage('You Win!','Ate '+score+' fruits · '+(COLS*ROWS)+'-cell grid filled'); winSound(); saveBest(); } function drawGrid(){ ctx.fillStyle='#0f172a'; ctx.fillRect(0,0,W,H); ctx.fillStyle='rgba(224,230,241,.05)'; for(let i=1;i<COLS;i++){ ctx.fillRect(i*CELL-1,0,1,H); } for(let j=1;j<ROWS;j++){ ctx.fillRect(0,j*CELL-1,W,1); } } function draw(){ drawGrid(); // food if(food){ const g=ctx.createLinearGradient(food.x*CELL,0,food.x*CELL+CELL,0); FOOD_GRAD.forEach((c,i)=>g.addColorStop(i/(FOOD_GRAD.length-1),c)); ctx.fillStyle=g; ctx.fillRect(food.x*CELL,food.y*CELL,CELL,CELL); ctx.strokeStyle='#000';ctx.lineWidth=1;ctx.strokeRect(food.x*CELL,food.y*CELL,CELL,CELL); ctx.fillStyle='#fff';ctx.fillRect(food.x*CELL+7,food.y*CELL+7,2,2); } // snake (head last drawn on top) for(let i=0;i<snake.length;i++){ const p=snake[i]; const isHead=i===0; ctx.fillStyle=isHead?'#4ade80':'#22c58e'; ctx.fillRect(p.x*CELL,p.y*CELL,CELL,CELL); ctx.strokeStyle=isHead?'#86efac':'#16a34a';ctx.lineWidth=1; ctx.strokeRect(p.x*CELL,p.y*CELL,CELL,CELL); if(isHead){ // eyes / direction indicator const sz=3; const cx=p.x*CELL+10, cy=p.y*CELL+10; let a1,a2; if(dir.x===1){a1={x:cx+2,y:cy-3};a2={x:cx+2,y:cy+3}} else if(dir.x===-1){a1={x:cx-4,y:cy-3};a2={x:cx-4,y:cy+3}} else if(dir.y===-1){a1={x:cx-3,y:cy-4};a2={x:cx+3,y:cy-4}} else {a1={x:cx-3,y:cy+4};a2={x:cx+3,y:cy+4}} ctx.fillStyle='#000';ctx.fillRect(a1.x,a1.y,sz,sz);ctx.fillRect(a2.x,a2.y,sz,sz); } } } // Game loop (rAF with fixed timestep = frameMs) let t0=0; function loop(ts){ if(!t0) t0=ts; if(!gameOver && !won){ if(ts-t0>=frameMs){ update(); draw(); t0=ts; } } requestAnimationFrame(loop); } function updateStats(){ scoreEl.textContent=score; streakEl.textContent=streak; bestEl.textContent=best; } function saveBest(){ if(score>best){best=score;localStorage.setItem('snakeBest',best);} } function showMessage(title, sub){ msgTitle.textContent=title; msgScore.textContent=sub; msg.classList.add('show'); } // --- Input --- window.addEventListener('keydown',e=>{ if(gameOver||won){ if(e.key==='r'||e.key==='R'||e.key===' '){reset();e.preventDefault();} return; } switch(e.key){ case 'ArrowUp':setDir(0,-1);break; case 'ArrowDown':setDir(0,1);break; case 'ArrowLeft':setDir(-1,0);break; case 'ArrowRight':setDir(1,0);break; case 'r':case 'R':case ' ':break; } }); let touchStart=null; canvas.addEventListener('touchstart',e=>{ if(e.cancelable){e.preventDefault();} const t=e.touches[0]; touchStart={x:t.clientX,y:t.clientY}; }, {passive:false}); canvas.addEventListener('touchend',e=>{ if(!touchStart) return; const t=e.changedTouches[0]; const dx=t.clientX-touchStart.x, dy=t.clientY-touchStart.y; if(Math.abs(dx)>Math.abs(dy)){ setDir(dx>0?1:-1,0); } else { setDir(0,dy>0?1:-1); } touchStart=null; }); restartBtn.addEventListener('click',reset); canvas.addEventListener('click',()=>{ if(gameOver||won) reset(); }); reset(); requestAnimationFrame(loop); })(); </script> </body> </html>

assistant
assistant
assistant
assistant
thinking
The code looks good now. Let me do a quick validation of the HTML and JS for syntax errors using node.

Let me check:
1. The `beep` function returns undefined. eatSound now calls `beep(360,0.04); setTimeout(...)` — no .then. Good.
2. `beep(freq,dur,type='sine')` — used. Good.
3. In die/wiin, dieSound calls `beep(150,0.22,'square')`. Good.

Let me verify there are no syntax errors by running node --check on a stripped JS or using a quick parse. Actually I can use node to check syntax of the script portion. Let me extract and check, or just validate the whole HTML with a parser.

Let me run a quick check: I'll use node to evaluate the JS by extracting the script. Actually simpler: use `node --check` after stripping. Let me just grep for obvious issues.

Actually, let me verify by running the file in a headless way isn't possible. Let me at least syntax-check the JS. I'll extract the script content and run node --check.

Let me syntax-check the JavaScript portion to be safe:

bash {"command":"cd /workspace && node -e \"\nconst fs=require('fs');\nconst html=fs.readFileSync('index.html','utf8');\nconst m=html.match(/<scr
toolResult

JS syntax OK

assistant
assistant
assistant
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

modellaguna-s-2.1
vendorpoolside
harnesspi-agent
generated2026-08-02

EVALUATION

1/5 broken

Snake game stuck permanently on Game Over screen; no input causes gameplay to start, score remains 0 throughout.

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 drag1.4%
on wheel0.0%
on enter + space0.1%
on arrow keys0.0%
on w/a/s/d0.0%
frame spread12.7 / 255
console errors0
js errors none

METRICS

runtime220.1s
tokens in114k
tokens out16k
cached95k
cost / run$0.0814