oneshotlm
Compare all models on this prompt →

Tetris · minimax-m3

Output generated Cost / run $0.0465 Runtime 146.9s

Prompt for this run

Build a playable falling-block Tetris game on a canvas with a 10-column, 20-row visible board. Use HTML, CSS, and JavaScript as needed. Write it to `index.html` in the current working directory using your file tools. Inline your own CSS and JavaScript. Do not use external assets, CDNs, or network requests. No third-party libraries are provided. Required behavior: - Include all seven tetrominoes with legal spawn positions. Use Left/Right to move, Up to rotate clockwise, Down to soft-drop, and Space to hard-drop. Show the controls. - Reject moves and rotations that overlap occupied cells or leave the board. Wall kicks are optional; if unsupported, reject the blocked rotation without changing the piece. - Lock landed pieces, clear complete rows, move rows above downward, and spawn the next piece. Display score, cleared-line count, level, and a next-piece preview. - Award 100, 300, 500, or 800 points for clearing one, two, three, or four rows with one piece, multiplied by the current level. Start at level 1 and increase gravity speed every ten cleared lines. - If a new piece cannot spawn, show Game Over. Provide Pause/Resume and Restart, with restart resetting the board, score, level, and piece sequence state. Hold and ghost pieces are optional. Verify: Move and rotate the first piece, then hard-drop it. Check that it locks into the board, a new piece spawns, and the next-piece preview advances. Pause and resume, then use Restart and check the cleared board, score, and level.

⛨ sandboxed Open in new tab ↗

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 0.87.1 · browser iteration
generated2026-10-14

METRICS

runtime146.9s
tokens in282k
tokens out23k
cached223k
cost / run$0.0465
Transcript · 25 messages
system

user

Build a playable falling-block Tetris game on a canvas with a 10-column, 20-row visible board. Use HTML, CSS, and JavaScript as needed. Write it to `index.html` in the current working directory using your file tools. Inline your own CSS and JavaScript. Do not use external assets, CDNs, or network requests. No third-party libraries are provided. Required behavior: - Include all seven tetrominoes with legal spawn positions. Use Left/Right to move, Up to rotate clockwise, Down to soft-drop, and Space to hard-drop. Show the controls. - Reject moves and rotations that overlap occupied cells or leave the board. Wall kicks are optional; if unsupported, reject the blocked rotation without changing the piece. - Lock landed pieces, clear complete rows, move rows above downward, and spawn the next piece. Display score, cleared-line count, level, and a next-piece preview. - Award 100, 300, 500, or 800 points for clearing one, two, three, or four rows with one piece, multiplied by the current level. Start at level 1 and increase gravity speed every ten cleared lines. - If a new piece cannot spawn, show Game Over. Provide Pause/Resume and Restart, with restart resetting the board, score, level, and piece sequence state. Hold and ghost pieces are optional. Verify: Move and rotate the first piece, then hard-drop it. Check that it locks into the board, a new piece spawns, and the next-piece preview advances. Pause and resume, then use Restart and check the cleared board, score, and level.

assistant
thinking
Let me build a complete Tetris game in a single index.html file. I'll plan the structure:

1. HTML with canvas, UI for score/level/lines/next preview, controls display, buttons
2. CSS for styling
3. JavaScript with:
   - Board (10x20 visible, plus hidden rows for spawning)
   - 7 tetrominoes (I, O, T, S, Z, J, L) with all rotations
   - Input handling (Left/Right/Up/Down/Space)
   - Gravity based on level
   - Lock delay
   - Line clearing
   - Scoring (100/300/500/800 × level)
   - Level up every 10 lines
   - Next piece preview
   - Pause/Resume/Restart
   - Game over detection

Let me write this out carefully. I'll use a canvas for the board, plus a smaller canvas for next piece.

Board: I'll use a 2D array of size (20+4) x 10 = 24x10 with the top 4 rows hidden, OR just have a 20x10 visible board and spawn at row 0. For simplicity, let me use a 20x10 visible board and spawn pieces at the top with their top row visible.

Actually, for proper Tetris, the board usually has a buffer zone at the top (rows hidden above the visible playfield). Let me use a 24x10 internal board with top 4 rows hidden.

Tetrominoes - SRS standard rotations:
- I: cyan
- O: yellow
- T: purple
- S: green
- Z: red
- J: blue
- L: orange

Let me define the shapes and rotations.

For movement:
- Move left/right: check bounds and overlap
- Rotate clockwise: try rotation, if blocked reject (no wall kicks per task spec, though wall kicks are optional)
- Soft drop: 1 point per cell? Standard Tetris awards 1 point per cell of soft drop. I'll add this.
- Hard drop: award 2 points per cell of hard drop, then lock immediately

Gravity: increase with level. Standard formula: G = (0.8 - ((level-1) * 0.007))^(level-1) seconds per row, capped. Or simpler: speed = level * 0.05 seconds, etc. Let me use a simple formula.

For lock delay: when a piece lands, give it ~500ms to still move before locking. If it moves down further, reset the timer.

Let me write the full code now.

Game loop using requestAnimationFrame. Calculate gravity drop based on time elapsed.

Let me also add a "next" preview with its own small canvas. And maybe a "hold" feature since the task says it's optional - I'll skip it to save time.

Actually let me skip hold and ghost for simplicity, focus on required features.

Let me write the code:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tetris</title>
<style>
  body {
    margin: 0;
    background: #111;
    color: #eee;
    font-family: 'Segoe UI', Arial, sans-serif;
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
  }
  .game {
    display: flex;
    gap: 24px;
    align-items: flex-start;
  }
  .board-wrap {
    position: relative;
  }
  canvas {
    background: #000;
    border: 2px solid #444;
    display: block;
  }
  #board {
    width: 300px;
    height: 600px;
  }
  #next {
    width: 120px;
    height: 120px;
  }
  .panel {
    background: #1c1c1c;
    border: 2px solid #444;
    padding: 12px 16px;
    border-radius: 6px;
    min-width: 160px;
  }
  .panel h2 {
    margin: 0 0 8px 0;
    font-size: 14px;
    color: #aaa;
    text-transform: uppercase;
    letter-spacing: 1px;
  }
  .panel .value {
    font-size: 28px;
    font-weight: bold;
    color: #fff;
    margin-bottom: 12px;
  }
  .controls {
    margin-top: 16px;
    font-size: 13px;
    line-height: 1.6;
  }
  .controls kbd {
    background: #333;
    padding: 2px 6px;
    border-radius: 3px;
    font-family: monospace;
    border: 1px solid #555;
  }
  button {
    background: #2a2a2a;
    color: #fff;
    border: 1px solid #555;
    padding: 8px 12px;
    border-radius: 4px;
    cursor: pointer;
    margin-right: 8px;
    margin-top: 4px;
    font-size: 13px;
  }
  button:hover { background: #3a3a3a; }
  .overlay {
    position: absolute;
    inset: 0;
    background: rgba(0,0,0,0.85);
    display: none;
    align-items: center;
    justify-content: center;
    flex-direction: column;
    font-size: 36px;
    font-weight: bold;
    color: #ff5252;
  }
  .overlay.show { display: flex; }
  .overlay small { font-size: 14px; color: #aaa; margin-top: 12px; font-weight: normal; }
</style>
</head>
<body>
<div class="game">
  <div class="board-wrap">
    <canvas id="board" width="300" height="600"></canvas>
    <div class="overlay" id="overlay"><span id="overlayText">PAUSED</span><small id="overlaySub">Press P to resume</small></div>
  </div>
  <div>
    <div class="panel">
      <h2>Score</h2><div class="value" id="score">0</div>
      <h2>Level</h2><div class="value" id="level">1</div>
      <h2>Lines</h2><div class="value" id="lines">0</div>
      <h2>Next</h2>
      <canvas id="next" width="120" height="120"></canvas>
      <div>
        <button id="pauseBtn">Pause</button>
        <button id="restartBtn">Restart</button>
      </div>
    </div>
    <div class="panel controls">
      <h2>Controls</h2>
      <div><kbd>←</kbd> <kbd>→</kbd> Move</div>
      <div><kbd>↑</kbd> Rotate CW</div>
      <div><kbd>↓</kbd> Soft drop</div>
      <div><kbd>Space</kbd> Hard drop</div>
      <div><kbd>P</kbd> Pause / Resume</div>
      <div><kbd>R</kbd> Restart</div>
    </div>
  </div>
</div>
<script>
(() => {
  const COLS = 10, ROWS = 20, CELL = 30;
  const board = document.getElementById('board');
  const bctx = board.getContext('2d');
  const next = document.getElementById('next');
  const nctx = next.getContext('2d');
  
  const COLORS = {
    I: '#00f0f0', O: '#f0f000', T: '#a000f0',
    S: '#00f000', Z: '#f00000', J: '#0000f0', L: '#f0a000'
  };
  
  // SRS shapes - each piece is an array of rotations
  // Each rotation is array of [x,y] cell offsets
  const SHAPES = {
    I: [
      [[0,1],[1,1],[2,1],[3,1]],
      [[2,0],[2,1],[2,2],[2,3]],
      [[0,2],[1,2],[2,2],[3,2]],
      [[1,0],[1,1],[1,2],[1,3]]
    ],
    O: [
      [[1,0],[2,0],[1,1],[2,1]],
      [[1,0],[2,0],[1,1],[2,1]],
      [[1,0],[2,0],[1,1],[2,1]],
      [[1,0],[2,0],[1,1],[2,1]]
    ],
    T: [
      [[1,0],[0,1],[1,1],[2,1]],
      [[1,0],[1,1],[2,1],[1,2]],
      [[0,1],[1,1],[2,1],[1,2]],
      [[1,0],[0,1],[1,1],[1,2]]
    ],
    S: [
      [[1,0],[2,0],[0,1],[1,1]],
      [[1,0],[1,1],[2,1],[2,2]],
      [[1,1],[2,1],[0,2],[1,2]],
      [[0,0],[0,1],[1,1],[1,2]]
    ],
    Z: [
      [[0,0],[1,0],[1,1],[2,1]],
      [[2,0],[1,1],[2,1],[1,2]],
      [[0,1],[1,1],[1,2],[2,2]],
      [[1,0],[0,1],[1,1],[0,2]]
    ],
    J: [
      [[0,0],[0,1],[1,1],[2,1]],
      [[1,0],[2,0],[1,1],[1,2]],
      [[0,1],[1,1],[2,1],[2,2]],
      [[1,0],[1,1],[0,2],[1,2]]
    ],
    L: [
      [[2,0],[0,1],[1,1],[2,1]],
      [[1,0],[1,1],[1,2],[2,2]],
      [[0,1],[1,1],[2,1],[0,2]],
      [[0,0],[1,0],[1,1],[1,2]]
    ]
  };
  
  const TYPES = ['I','O','T','S','Z','J','L'];
  
  // State
  let grid, piece, nextType, score, level, lines, dropTimer, dropInterval, lastTime, paused, gameOver, lockTimer;
  let bag = [];
  
  function refillBag() {
    bag = [...TYPES];
    // shuffle
    for (let i = bag.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [bag[i], bag[j]] = [bag[j], bag[i]];
    }
  }
  
  function nextFromBag() {
    if (bag.length === 0) refillBag();
    return bag.shift();
  }
  
  function newPiece(type) {
    const shape = SHAPES[type][0];
    let minX = 99, maxX = -99, minY = 99, maxY = -99;
    for (const [x,y] of shape) {
      if (x < minX) minX = x;
      if (x > maxX) maxX = x;
      if (y < minY) minY = y;
      if (y > maxY) maxY = y;
    }
    // Spawn: center horizontally, top row
    const x = Math.floor((COLS - (maxX - minX + 1)) / 2) - minX;
    return { type, rot: 0, x, y: -minY };
  }
  
  function init() {
    grid = Array.from({length: ROWS}, () => Array(COLS).fill(null));
    bag = [];
    refillBag();
    nextType = nextFromBag();
    piece = newPiece(nextFromBag());
    nextType = nextFromBag();
    score = 0;
    level = 1;
    lines = 0;
    dropTimer = 0;
    dropInterval = computeInterval();
    lastTime = performance.now();
    paused = false;
    gameOver = false;
    lockTimer = 0;
    updateUI();
    hideOverlay();
  }
  
  function computeInterval() {
    // Simple gravity curve
    return Math.max(50, 800 - (level - 1) * 60);
  }
  
  function pieceCells(p) {
    return SHAPES[p.type][p.rot].map(([dx, dy]) => [p.x + dx, p.y + dy]);
  }
  
  function valid(p) {
    const cells = pieceCells(p);
    for (const [x,y] of cells) {
      if (x < 0 || x >= COLS || y >= ROWS) return false;
      if (y >= 0 && grid[y][x]) return false;
    }
    return true;
  }
  
  function collide(p) {
    return !valid(p);
  }
  
  function lock() {
    const cells = pieceCells(piece);
    for (const [x,y] of cells) {
      if (y >= 0 && y < ROWS) grid[y][x] = piece.type;
    }
    let cleared = 0;
    for (let y = ROWS - 1; y >= 0; y--) {
      if (grid[y].every(c => c)) {
        grid.splice(y, 1);
        grid.unshift(Array(COLS).fill(null));
        cleared++;
        y++; // recheck same row index
      }
    }
    if (cleared > 0) {
      const points = [0, 100, 300, 500, 800][cleared];
      score += points * level;
      lines += cleared;
      const newLevel = Math.floor(lines / 10) + 1;
      if (newLevel !== level) {
        level = newLevel;
        dropInterval = computeInterval();
      }
    }
    // spawn next
    piece = newPiece(nextType);
    nextType = nextFromBag();
    if (!valid(piece)) {
      gameOver = true;
      showOverlay('GAME OVER', 'Press R to restart');
    }
    lockTimer = 0;
    updateUI();
  }
  
  function move(dx, dy) {
    const np = {...piece, x: piece.x + dx, y: piece.y + dy};
    if (valid(np)) {
      piece = np;
      if (dy > 0) lockTimer = 0;
      return true;
    }
    return false;
  }
  
  function rotate(dir) {
    const newRot = (piece.rot + dir + 4) % 4;
    const np = {...piece, rot: newRot};
    if (valid(np)) {
      piece = np;
      lockTimer = 0;
    }
  }
  
  function softDrop() {
    if (move(0, 1)) {
      score += 1;
      updateUI();
    } else {
      lock();
    }
  }
  
  function hardDrop() {
    let dist = 0;
    while (move(0, 1)) dist++;
    score += dist * 2;
    updateUI();
    lock();
  }
  
  function step(dt) {
    if (paused || gameOver) return;
    dropTimer += dt;
    // gravity
    while (dropTimer >= dropInterval) {
      dropTimer -= dropInterval;
      if (!move(0, 1)) {
        lockTimer += dropInterval;
        if (lockTimer >= 500) {
          lock();
          break;
        }
      } else {
        lockTimer = 0;
      }
    }
  }
  
  function drawCell(ctx, x, y, color, size) {
    const s = size || CELL;
    ctx.fillStyle = color;
    ctx.fillRect(x*s, y*s, s, s);
    ctx.fillStyle = 'rgba(255,255,255,0.3)';
    ctx.fillRect(x*s, y*s, s, 2);
    ctx.fillRect(x*s, y*s, 2, s);
    ctx.fillStyle = 'rgba(0,0,0,0.3)';
    ctx.fillRect(x*s, y*s+s-2, s, 2);
    ctx.fillRect(x*s+s-2, y*s, 2, s);
  }
  
  function render() {
    bctx.fillStyle = '#000';
    bctx.fillRect(0, 0, board.width, board.height);
    // grid
    bctx.strokeStyle = '#1a1a1a';
    for (let i = 0; i <= COLS; i++) {
      bctx.beginPath();
      bctx.moveTo(i*CELL, 0);
      bctx.lineTo(i*CELL, ROWS*CELL);
      bctx.stroke();
    }
    for (let i = 0; i <= ROWS; i++) {
      bctx.beginPath();
      bctx.moveTo(0, i*CELL);
      bctx.lineTo(COLS*CELL, i*CELL);
      bctx.stroke();
    }
    // placed
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLS; x++) {
        if (grid[y][x]) {
          drawCell(bctx, x, y, COLORS[grid[y][x]]);
        }
      }
    }
    // current piece (only if visible)
    if (piece && !gameOver) {
      const cells = pieceCells(piece);
      for (const [x,y] of cells) {
        if (y >= 0) drawCell(bctx, x, y, COLORS[piece.type]);
      }
    }
    // next preview
    nctx.fillStyle = '#000';
    nctx.fillRect(0, 0, next.width, next.height);
    const ns = SHAPES[nextType][0];
    let minX = 99, maxX = -99, minY = 99, maxY = -99;
    for (const [x,y] of ns) {
      if (x < minX) minX = x;
      if (x > maxX) maxX = x;
      if (y < minY) minY = y;
      if (y > maxY) maxY = y;
    }
    const w = maxX - minX + 1, h = maxY - minY + 1;
    const cellSize = Math.floor(Math.min(next.width / (w + 1), next.height / (h + 1)));
    const ox = (next.width - w * cellSize) / 2;
    const oy = (next.height - h * cellSize) / 2;
    for (const [x,y] of ns) {
      drawCell(nctx, Math.floor((ox + (x - minX) * cellSize) / cellSize), Math.floor((oy + (y - minY) * cellSize) / cellSize), COLORS[nextType], cellSize);
    }
    // Hmm let me fix the cell position calculation
    
    function updateUI() {
      document.getElementById('score').textContent = score;
      document.getElementById('level').textContent = level;
      document.getElementById('lines').textContent = lines;
    }
  }
```

Wait, the cell position calculation in the next preview is getting tangled. Let me fix that.

Actually, let me use a separate helper function for drawing cells at non-uniform positions. Let me rewrite:

```js
function drawCellAt(ctx, px, py, color, s) {
  ctx.fillStyle = color;
  ctx.fillRect(px, py, s, s);
  // borders
  ctx.fillStyle = 'rgba(255,255,255,0.3)';
  ctx.fillRect(px, py, s, 2);
  ctx.fillRect(px, py, 2, s);
  ctx.fillStyle = 'rgba(0,0,0,0.3)';
  ctx.fillRect(px, py+s-2, s, 2);
  ctx.fillRect(px+s-2, py, 2, s);
}
```

Then for next preview, use that.

Also let me put updateUI before render.

Let me restructure cleanly:

```js
function updateUI() {
  document.getElementById('score').textContent = score;
  document.getElementById('level').textContent = level;
  document.getElementById('lines').textContent = lines;
}

function renderNext() {
  nctx.fillStyle = '#000';
  nctx.fillRect(0, 0, next.width, next.height);
  const cells = SHAPES[nextType][0];
  let minX = 99, maxX = -99, minY = 99, maxY = -99;
  for (const [x,y] of cells) {
    minX = Math.min(minX, x); maxX = Math.max(maxX, x);
    minY = Math.min(minY, y); maxY = Math.max(maxY, y);
  }
  const w = maxX - minX + 1, h = maxY - minY + 1;
  const cs = Math.floor(Math.min(next.width / (w + 0.5), next.height / (h + 0.5)));
  const ox = Math.floor((next.width - w * cs) / 2 - minX * cs);
  const oy = Math.floor((next.height - h * cs) / 2 - minY * cs);
  for (const [x,y] of cells) {
    drawCellAt(nctx, ox + x*cs, oy + y*cs, COLORS[nextType], cs);
  }
}
```

Let me clean up everything and put it together.

Also, I need to think about the spawn position more carefully. In my newPiece function, I'm computing minX/maxX of the shape and centering. But the shape coords vary by piece. Let me use a simpler approach:

For each piece type, define the bounding box dimensions and use them for centering.

Actually, the current approach works. Let me trace through:
- T piece rotation 0: [[1,0],[0,1],[1,1],[2,1]]
  - minX=0, maxX=2, minY=0, maxY=1
  - width=3, height=2
- x = floor((10 - 3)/2) - 0 = 3 (leftmost column)
- y = 0

Hmm, the piece is positioned so its bounding box top-left is at column 3. The cells are at (3+1, 0)=(4,0), (3+0, 1)=(3,1), etc. That puts it in the middle. 

But for the I piece horizontal: [[0,1],[1,1],[2,1],[3,1]]
  - minX=0, maxX=3, minY=1, maxY=1
  - width=4, height=1
- x = floor((10-4)/2) - 0 = 3
- y = -1

So the piece would be at row -1 (above the visible area). That allows it to be partially visible when descending.

Actually, for I piece, that's a common issue - the spawn can be entirely above the visible area. Let me think... 

When y = -1, the cell at y=1 in shape maps to row 0 (visible). So the I piece's visible row is row 0, which is the top row of the visible board. That's correct.

For other pieces, they spawn at y=0 with their minY=0 part visible at the top.

Actually let me check S piece: [[1,0],[2,0],[0,1],[1,1]]
- minX=0, maxX=2, minY=0, maxY=1
- x = floor((10-3)/2) - 0 = 3
- y = 0

The piece cells: (4,0),(5,0),(3,1),(4,1). Good.

I think this is fine. Let me finalize.

One more thing: the initial spawn - when the player presses hard drop on the very first piece, we should make sure it locks properly and advances to the next piece.

Let me also think about soft drop. In many Tetris versions, holding down does continuous soft drop. For simplicity, I'll have down do one cell per press. I could also implement holding - let me just use press (with key repeat handled by OS) for simplicity.

Actually for soft drop, I think the typical behavior is holding down drops continuously at high speed. Let me implement that:

```js
const keys = {};
document.addEventListener('keydown', (e) => {
  if (e.repeat) return; // ignore repeats
  keys[e.code] = true;
  // ... handle initial press
});
document.addEventListener('keyup', (e) => {
  keys[e.code] = false;
});
```

Then in the game loop:
```js
if (keys['ArrowDown']) {
  if (move(0, 1)) {
    score += 1;
    updateUI();
  }
}
```

Hmm, this would do many soft drops per frame, which is fine. But to keep it simple and predictable, let me make soft drop drop once per key press OR with high-speed continuous. Let me do continuous for proper gameplay.

Actually let me handle it cleanly:

```js
const keyState = {};
window.addEventListener('keydown', e => {
  if (e.code === 'F5' || e.code === 'Tab') return;
  if (['ArrowLeft','ArrowRight','ArrowDown'].includes(e.code)) e.preventDefault();
  if (keyState[e.code]) return; // already pressed
  keyState[e.code] = true;
  handleKey(e.code);
});
window.addEventListener('keyup', e => {
  keyState[e.code] = false;
});

function handleKey(code) {
  if (gameOver && code !== 'KeyR') return;
  if (code === 'KeyP') { togglePause(); return; }
  if (code === 'KeyR') { restart(); return; }
  if (paused) return;
  if (code === 'ArrowLeft') move(-1, 0);
  else if (code === 'ArrowRight') move(1, 0);
  else if (code === 'ArrowUp') rotate(1);
  else if (code === 'ArrowDown') softDrop();
  else if (code === 'Space') { e.preventDefault?.(); hardDrop(); }
}
```

For Space, I'll need to handle preventDefault. Let me adjust.

Actually let me simplify and handle everything in the game loop with continuous checks:

```js
function handleInput() {
  if (paused || gameOver) return;
  if (keyState['ArrowLeft']) move(-1, 0);
  if (keyState['ArrowRight']) move(1, 0);
  if (keyState['ArrowDown']) {
    if (move(0, 1)) { score += 1; }
  }
  if (keyState['ArrowUp']) rotate(1);
}
```

But this would move multiple cells per frame at 60fps which is too fast. Let me add a DAS (delayed auto shift):

Actually, simpler approach: do move on keydown event. For arrow left/right, hold to repeat after delay. Let me implement basic DAS:

Actually, let me keep it simple: arrow keys trigger on keydown only (no auto-repeat). Press repeatedly for movement. This works fine and matches what many implementations do. Let me go with that.

Final plan:

```js
function handleKey(code, e) {
  if (code === 'KeyP') { togglePause(); return; }
  if (code === 'KeyR') { restart(); return; }
  if (paused || gameOver) return;
  if (code === 'ArrowLeft') move(-1, 0);
  else if (code === 'ArrowRight') move(1, 0);
  else if (code === 'ArrowUp') rotate(1);
  else if (code === 'ArrowDown') softDrop();
  else if (code === 'Space') { e.preventDefault(); hardDrop(); }
}
```

For UI buttons, I'll wire them up too.

Let me also think about: when piece lands (move down fails), I should start lock timer. When move(0,1) succeeds, reset lock timer.

In my current step():
```js
function step(dt) {
  if (paused || gameOver) return;
  dropTimer += dt;
  while (dropTimer >= dropInterval) {
    dropTimer -= dropInterval;
    if (!move(0, 1)) {
      // can't move down
      lockTimer += dropInterval;
      if (lockTimer >= 500) {
        lock();
        break;
      }
    } else {
      lockTimer = 0;
    }
  }
}
```

This handles lock delay when gravity tries to drop but is blocked. Good.

But when a piece spawns or moves, lockTimer should be reset. Let me ensure that.

Actually the lock should be tied to a state where the piece has been sitting on the floor. So I'll only increment lockTimer when piece can't move down. When piece moves (left/right/down/rotate), reset lockTimer to 0.

Let me adjust:
- In move(): if dy > 0 and move succeeded, reset lockTimer to 0. If move failed, don't change lockTimer (it accumulates).
- Actually move(0,1) success means it's not on the floor; failure means it's on the floor.

Hmm let me think differently. After each move:
- If piece was on floor before but can move down now, reset lockTimer (it moved)
- If piece is on floor, increment lockTimer; if exceeds 500, lock

Wait, actually lock delay works like:
- When the piece becomes grounded (move(0,1) fails), start lock timer
- If piece moves successfully (down or sideways), reset lock timer (the player has adjusted it)
- After 500ms of being grounded without movement, lock

So in step(), when gravity applies:
- If move(0,1) succeeds: lockTimer = 0 (it just moved, not grounded anymore)
- If move(0,1) fails: lockTimer += dt; if lockTimer >= 500, lock

When player moves:
- If move() succeeds (and piece is on floor): the player moved it, reset lockTimer = 0

Wait but after moving sideways, the piece is still on the floor. The lock timer shouldn't necessarily reset. Actually in modern Tetris, lock delay resets on any movement, including rotation and horizontal shifts. The idea is the player has done an action so they get the full delay.

OK, my plan:
- move(dx, dy) where dy>0 success: lockTimer = 0 (it dropped down)
- move(dx, dy) where dy=0 success: lockTimer = 0 (player adjusted horizontally)
- move(0, 1) fail in step(): increment lockTimer

Actually the simpler model: any successful move resets lockTimer. Let me do that.

```js
function move(dx, dy) {
  const np = {...piece, x: piece.x + dx, y: piece.y + dy};
  if (valid(np)) {
    piece = np;
    lockTimer = 0;
    return true;
  }
  return false;
}
```

Then in step():
```js
function step(dt) {
  if (paused || gameOver) return;
  dropTimer += dt;
  while (dropTimer >= dropInterval) {
    dropTimer -= dropInterval;
    if (!move(0, 1)) {
      lockTimer += dropInterval;
      if (lockTimer >= 500) {
        lock();
        break;
      }
    }
  }
}
```

This works. Move resets lockTimer. Gravity fails to drop = increment lockTimer.

Now let me think about edge cases:
- When piece spawns, lockTimer = 0 (set in init). Good.
- When line clear happens, new piece spawns, lockTimer = 0 (set in lock()). Good.

One issue: after lock(), lockTimer was set to 0, but I should ensure that. Let me add that. Actually I already have `lockTimer = 0;` in lock(). Good.

Alright, let me put it all together and write the file.

Wait, I also need to think about renderNext. The bounding box calculation and offsets. Let me make sure it works.

For the next preview canvas (120x120):
- I piece (4 wide, 1 tall in default rot): cs = min(120/4.5, 120/1.5) = min(26, 80) = 26
- W=4, H=1, so total size = 104x26. Centered: ox = (120-104)/2 = 8, oy = (120-26)/2 = 47

Then cells at (8 + x*26, 47 + y*26). With x in [0,3] and y in [1,1]:
- (8, 73), (34, 73), (60, 73), (86, 73). Wait, that's only if we don't subtract minX and minY offsets.

Actually let me restructure:
```js
const cs = Math.floor(Math.min(next.width / (w + 0.5), next.height / (h + 0.5)));
const totalW = w * cs, totalH = h * cs;
const offsetX = (next.width - totalW) / 2;
const offsetY = (next.height - totalH) / 2;
for (const [x,y] of cells) {
  drawCellAt(nctx, offsetX + (x - minX) * cs, offsetY + (y - minY) * cs, COLORS[nextType], cs);
}
```

Yes this is the cleanest. Let me use this.

OK, let me write the full file now. I'll be careful to get everything right.

Actually, one more thing: let me also add the piece visibility - if the piece is at y < 0 (above the board), only show the parts at y >= 0. I already have that check in render: `if (y >= 0) drawCell(...)`. Good.

Final code coming up. Let me make it clean and complete.

Actually one more concern: the spawn collision check. When piece spawns and overlaps existing blocks, game over. Let me ensure `valid(piece)` is called after each spawn in `lock()`. Yes, I have that.

What if the very first spawn fails (board is already full)? That would also be game over. Good.

What if a line clear causes the new piece to spawn into a now-cleared area? Should work fine because the grid was modified before the spawn check.

OK let me write the full code.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tetris</title>
<style>
  * { box-sizing: border-box; }
  body {
    margin: 0;
    background: linear-gradient(180deg, #0a0a14, #1a1a28);
    color: #eee;
    font-family: 'Segoe UI', system-ui, Arial, sans-serif;
    min-height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 20px;
  }
  .game {
    display: flex;
    gap: 24px;
    align-items: flex-start;
  }
  .board-wrap {
    position: relative;
    border: 2px solid #444;
    border-radius: 4px;
    background: #000;
    box-shadow: 0 0 20px rgba(80,80,255,0.2);
  }
  canvas { display: block; }
  #board { width: 300px; height: 600px; }
  #next  { width: 120px; height: 120px; background: #000; border: 1px solid #333; border-radius: 4px; display: block; }
  .panel {
    background: #1c1c28;
    border: 2px solid #444;
    padding: 14px 16px;
    border-radius: 6px;
    min-width: 180px;
  }
  .panel h2 {
    margin: 0 0 6px 0;
    font-size: 11px;
    color: #88f;
    text-transform: uppercase;
    letter-spacing: 2px;
  }
  .panel .value {
    font-size: 26px;
    font-weight: bold;
    color: #fff;
    margin-bottom: 14px;
    font-family: 'Courier New', monospace;
  }
  .panel .value:last-child { margin-bottom: 6px; }
  .controls {
    margin-top: 16px;
    font-size: 13px;
    line-height: 1.8;
  }
  .controls kbd {
    background: #2a2a3a;
    padding: 2px 7px;
    border-radius: 3px;
    font-family: 'Courier New', monospace;
    border: 1px solid #555;
    color: #fff;
    min-width: 22px;
    display: inline-block;
    text-align: center;
  }
  button {
    background: #2a2a3a;
    color: #fff;
    border: 1px solid #555;
    padding: 8px 14px;
    border-radius: 4px;
    cursor: pointer;
    margin-right: 6px;
    margin-top: 6px;
    font-size: 13px;
    font-family: inherit;
  }
  button:hover { background: #3a3a5a; border-color: #88f; }
  button:active { background: #4a4a6a; }
  .overlay {
    position: absolute;
    inset: 0;
    background: rgba(0,0,0,0.88);
    display: none;
    align-items: center;
    justify-content: center;
    flex-direction: column;
    text-align: center;
  }
  .overlay.show { display: flex; }
  .overlay .big {
    font-size: 42px;
    font-weight: bold;
    letter-spacing: 4px;
  }
  .overlay .sub {
    font-size: 13px;
    color: #aaa;
    margin-top: 12px;
    letter-spacing: 1px;
  }
  .overlay.paused .big { color: #fc3; }
  .overlay.over .big { color: #f55; }
</style>
</head>
<body>
<div class="game">
  <div class="board-wrap">
    <canvas id="board" width="300" height="600"></canvas>
    <div class="overlay paused" id="overlay">
      <div class="big" id="overlayBig">PAUSED</div>
      <div class="sub" id="overlaySub">Press P to resume</div>
    </div>
  </div>
  <div>
    <div class="panel">
      <h2>Score</h2>
      <div class="value" id="score">0</div>
      <h2>Level</h2>
      <div class="value" id="level">1</div>
      <h2>Lines</h2>
      <div class="value" id="lines">0</div>
      <h2>Next</h2>
      <canvas id="next"></canvas>
      <div style="margin-top:12px">
        <button id="pauseBtn">Pause</button>
        <button id="restartBtn">Restart</button>
      </div>
    </div>
    <div class="panel controls">
      <h2>Controls</h2>
      <div><kbd>&larr;</kbd> <kbd>&rarr;</kbd> &nbsp;Move</div>
      <div><kbd>&uarr;</kbd> &nbsp;Rotate CW</div>
      <div><kbd>&darr;</kbd> &nbsp;Soft drop</div>
      <div><kbd>Space</kbd> &nbsp;Hard drop</div>
      <div><kbd>P</kbd> &nbsp;Pause / Resume</div>
      <div><kbd>R</kbd> &nbsp;Restart</div>
    </div>
  </div>
</div>
<script>
(() => {
  'use strict';
  const COLS = 10, ROWS = 20, CELL = 30;
  const board = document.getElementById('board');
  const bctx = board.getContext('2d');
  const nextCanvas = document.getElementById('next');
  const nctx = nextCanvas.getContext('2d');
  // Size the next canvas via attributes for crisp rendering
  nextCanvas.width = 120;
  nextCanvas.height = 120;

  const COLORS = {
    I: '#22d3ee', O: '#facc15', T: '#c084fc',
    S: '#22c55e', Z: '#ef4444', J: '#3b82f6', L: '#f97316'
  };
  const GHOST = 'rgba(255,255,255,0.18)';

  // SRS rotations for each piece. Each rotation is array of [x,y] cell offsets.
  const SHAPES = {
    I: [
      [[0,1],[1,1],[2,1],[3,1]],
      [[2,0],[2,1],[2,2],[2,3]],
      [[0,2],[1,2],[2,2],[3,2]],
      [[1,0],[1,1],[1,2],[1,3]]
    ],
    O: [
      [[1,0],[2,0],[1,1],[2,1]],
      [[1,0],[2,0],[1,1],[2,1]],
      [[1,0],[2,0],[1,1],[2,1]],
      [[1,0],[2,0],[1,1],[2,1]]
    ],
    T: [
      [[1,0],[0,1],[1,1],[2,1]],
      [[1,0],[1,1],[2,1],[1,2]],
      [[0,1],[1,1],[2,1],[1,2]],
      [[1,0],[0,1],[1,1],[1,2]]
    ],
    S: [
      [[1,0],[2,0],[0,1],[1,1]],
      [[1,0],[1,1],[2,1],[2,2]],
      [[1,1],[2,1],[0,2],[1,2]],
      [[0,0],[0,1],[1,1],[1,2]]
    ],
    Z: [
      [[0,0],[1,0],[1,1],[2,1]],
      [[2,0],[1,1],[2,1],[1,2]],
      [[0,1],[1,1],[1,2],[2,2]],
      [[1,0],[0,1],[1,1],[0,2]]
    ],
    J: [
      [[0,0],[0,1],[1,1],[2,1]],
      [[1,0],[2,0],[1,1],[1,2]],
      [[0,1],[1,1],[2,1],[2,2]],
      [[1,0],[1,1],[0,2],[1,2]]
    ],
    L: [
      [[2,0],[0,1],[1,1],[2,1]],
      [[1,0],[1,1],[1,2],[2,2]],
      [[0,1],[1,1],[2,1],[0,2]],
      [[0,0],[1,0],[1,1],[1,2]]
    ]
  };
  const TYPES = ['I','O','T','S','Z','J','L'];

  // Game state
  let grid, piece, nextType, score, level, lines, dropTimer, dropInterval;
  let lastTime, paused, gameOver, lockTimer, bag;

  function shuffle(arr) {
    for (let i = arr.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [arr[i], arr[j]] = [arr[j], arr[i]];
    }
    return arr;
  }
  function refillBag() {
    bag = shuffle([...TYPES]);
  }
  function popBag() {
    if (bag.length === 0) refillBag();
    return bag.shift();
  }

  function newPiece(type) {
    // Spawn with bounding box horizontally centered, top-aligned.
    const cells = SHAPES[type][0];
    let minX = 99, maxX = -99;
    for (const [x,y] of cells) {
      if (x < minX) minX = x;
      if (x > maxX) maxX = x;
    }
    const width = maxX - minX + 1;
    const x = Math.floor((COLS - width) / 2) - minX;
    return { type, rot: 0, x, y: 0 };
  }

  function computeInterval() {
    // Gravity in ms per cell. Faster as level rises.
    return Math.max(50, Math.round(800 * Math.pow(0.85, level - 1)));
  }

  function pieceCells(p) {
    return SHAPES[p.type][p.rot].map(([dx, dy]) => [p.x + dx, p.y + dy]);
  }
  function valid(p) {
    for (const [x, y] of pieceCells(p)) {
      if (x < 0 || x >= COLS || y >= ROWS) return false;
      if (y >= 0 && grid[y][x]) return false;
    }
    return true;
  }

  function init() {
    grid = Array.from({ length: ROWS }, () => Array(COLS).fill(null));
    refillBag();
    nextType = popBag();
    piece = newPiece(popBag());
    nextType = popBag();
    score = 0; level = 1; lines = 0;
    dropInterval = computeInterval();
    dropTimer = 0; lockTimer = 0;
    lastTime = performance.now();
    paused = false; gameOver = false;
    updateUI();
    render();
    hideOverlay();
  }

  function move(dx, dy) {
    const np = { ...piece, x: piece.x + dx, y: piece.y + dy };
    if (valid(np)) {
      piece = np;
      lockTimer = 0;
      return true;
    }
    return false;
  }
  function rotate(dir) {
    const np = { ...piece, rot: (piece.rot + dir + 4) % 4 };
    if (valid(np)) {
      piece = np;
      lockTimer = 0;
    }
  }

  function lock() {
    const cells = pieceCells(piece);
    for (const [x, y] of cells) {
      if (y >= 0 && y < ROWS) grid[y][x] = piece.type;
    }
    let cleared = 0;
    for (let y = ROWS - 1; y >= 0; y--) {
      if (grid[y].every(c => c !== null)) {
        grid.splice(y, 1);
        grid.unshift(Array(COLS).fill(null));
        cleared++;
        y++; // re-check this row index
      }
    }
    if (cleared > 0) {
      const pts = [0, 100, 300, 500, 800][cleared];
      score += pts * level;
      lines += cleared;
      const newLevel = Math.floor(lines / 10) + 1;
      if (newLevel !== level) {
        level = newLevel;
        dropInterval = computeInterval();
      }
    }
    piece = newPiece(nextType);
    nextType = popBag();
    lockTimer = 0;
    dropTimer = 0;
    if (!valid(piece)) {
      gameOver = true;
      showOverlay('GAME OVER', 'Press R to restart', 'over');
    }
    updateUI();
  }

  function softDrop() {
    if (move(0, 1)) {
      score += 1;
      dropTimer = 0;
      updateUI();
    } else {
      // can't move, treat as instant lock
      lock();
    }
  }
  function hardDrop() {
    let dist = 0;
    while (move(0, 1)) dist++;
    score += dist * 2;
    updateUI();
    lock();
  }

  function step(dt) {
    if (paused || gameOver) return;
    dropTimer += dt;
    while (dropTimer >= dropInterval) {
      dropTimer -= dropInterval;
      if (!move(0, 1)) {
        lockTimer += dropInterval;
        if (lockTimer >= 500) {
          lock();
          break;
        }
      } else {
        lockTimer = 0;
      }
    }
  }

  // ---------- Rendering ----------
  function drawCell(ctx, x, y, color, size) {
    const s = size;
    ctx.fillStyle = color;
    ctx.fillRect(x, y, s, s);
    // bevel
    ctx.fillStyle = 'rgba(255,255,255,0.35)';
    ctx.fillRect(x, y, s, Math.max(1, Math.floor(s*0.12)));
    ctx.fillRect(x, y, Math.max(1, Math.floor(s*0.12)), s);
    ctx.fillStyle = 'rgba(0,0,0,0.4)';
    ctx.fillRect(x, y+s-Math.max(1, Math.floor(s*0.12)), s, Math.max(1, Math.floor(s*0.12)));
    ctx.fillRect(x+s-Math.max(1, Math.floor(s*0.12)), y, Math.max(1, Math.floor(s*0.12)), s);
  }

  function render() {
    // board
    bctx.fillStyle = '#000';
    bctx.fillRect(0, 0, board.width, board.height);
    // grid lines
    bctx.strokeStyle = '#181820';
    bctx.lineWidth = 1;
    bctx.beginPath();
    for (let i = 1; i < COLS; i++) {
      bctx.moveTo(i*CELL + 0.5, 0);
      bctx.lineTo(i*CELL + 0.5, ROWS*CELL);
    }
    for (let i = 1; i < ROWS; i++) {
      bctx.moveTo(0, i*CELL + 0.5);
      bctx.lineTo(COLS*CELL, i*CELL + 0.5);
    }
    bctx.stroke();
    // placed cells
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLS; x++) {
        if (grid[y][x]) drawCell(bctx, x*CELL, y*CELL, COLORS[grid[y][x]], CELL);
      }
    }
    // ghost
    if (piece && !gameOver) {
      let ghost = { ...piece };
      while (true) {
        const test = { ...ghost, y: ghost.y + 1 };
        if (!valid(test)) break;
        ghost = test;
      }
      const gcells = pieceCells(ghost);
      for (const [x, y] of gcells) {
        if (y >= 0) {
          bctx.fillStyle = COLORS[piece.type] + '33';
          bctx.fillRect(x*CELL, y*CELL, CELL, CELL);
          bctx.strokeStyle = COLORS[piece.type];
          bctx.lineWidth = 2;
          bctx.strokeRect(x*CELL+1, y*CELL+1, CELL-2, CELL-2);
        }
      }
    }
    // current piece
    if (piece && !gameOver) {
      for (const [x, y] of pieceCells(piece)) {
        if (y >= 0) drawCell(bctx, x*CELL, y*CELL, COLORS[piece.type], CELL);
      }
    }

    // next preview
    nctx.fillStyle = '#000';
    nctx.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
    if (nextType) {
      const cells = SHAPES[nextType][0];
      let minX = 99, maxX = -99, minY = 99, maxY = -99;
      for (const [x,y] of cells) {
        if (x < minX) minX = x;
        if (x > maxX) maxX = x;
        if (y < minY) minY = y;
        if (y > maxY) maxY = y;
      }
      const w = maxX - minX + 1, h = maxY - minY + 1;
      const cs = Math.floor(Math.min(nextCanvas.width / (w + 0.5), nextCanvas.height / (h + 0.5)));
      const totalW = w*cs, totalH = h*cs;
      const ox = Math.floor((nextCanvas.width - totalW) / 2) - minX * cs;
      const oy = Math.floor((nextCanvas.height - totalH) / 2) - minY * cs;
      for (const [x,y] of cells) {
        drawCell(nctx, ox + x*cs, oy + y*cs, COLORS[nextType], cs);
      }
    }
  }

  function updateUI() {
    document.getElementById('score').textContent = score;
    document.getElementById('level').textContent = level;
    document.getElementById('lines').textContent = lines;
  }

  // ---------- Overlay ----------
  const overlay = document.getElementById('overlay');
  const overlayBig = document.getElementById('overlayBig');
  const overlaySub = document.getElementById('overlaySub');
  function showOverlay(text, sub, cls) {
    overlayBig.textContent = text;
    overlaySub.textContent = sub;
    overlay.classList.remove('paused','over');
    if (cls) overlay.classList.add(cls);
    overlay.classList.add('show');
  }
  function hideOverlay() { overlay.classList.remove('show'); }

  // ---------- Buttons ----------
  document.getElementById('pauseBtn').addEventListener('click', () => {
    if (gameOver) return;
    togglePause();
  });
  document.getElementById('restartBtn').addEventListener('click', restart);
  function togglePause() {
    if (gameOver) return;
    paused = !paused;
    if (paused) showOverlay('PAUSED', 'Press P to resume', 'paused');
    else hideOverlay();
    lastTime = performance.now();
  }
  function restart() {
    init();
  }

  // ---------- Input ----------
  // Single press for move/rotate, hold for continuous soft drop.
  const heldKeys = {};
  window.addEventListener('keydown', e => {
    if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'ArrowDown' || e.code === 'ArrowLeft' || e.code === 'ArrowRight') e.preventDefault();
    if (heldKeys[e.code]) return;
    heldKeys[e.code] = true;
    onPress(e.code);
  });
  window.addEventListener('keyup', e => {
    heldKeys[e.code] = false;
  });

  function onPress(code) {
    if (code === 'KeyP' || code === 'Escape') { togglePause(); return; }
    if (code === 'KeyR') { restart(); return; }
    if (paused || gameOver) return;
    if (code === 'ArrowLeft') { move(-1, 0); }
    else if (code === 'ArrowRight') { move(1, 0); }
    else if (code === 'ArrowUp') { rotate(1); }
    else if (code === 'ArrowDown') { softDrop(); }
    else if (code === 'Space') { hardDrop(); }
  }

  // ---------- Main loop ----------
  function loop(now) {
    let dt = now - lastTime;
    if (dt > 200) dt = 200; // clamp big stalls
    lastTime = now;
    step(dt);
    // Continuous soft drop while ArrowDown is held
    if (!paused && !gameOver && heldKeys['ArrowDown']) {
      if (move(0, 1)) { score += 1; updateUI(); dropTimer = 0; }
    }
    render();
    requestAnimationFrame(loop);
  }

  init();
  requestAnimationFrame(loop);
})();
</script>
</body>
</html>
```

Wait, there's an issue with the ghost piece drawing - I'm using a hex color with alpha like '#22d3ee33'. CSS supports 8-digit hex but maybe I should use rgba for clarity. Let me check - the alpha would be 0x33 = 51/255 ≈ 0.2. That's fine in modern browsers. But to be safe, let me use rgba.

Actually CSS 8-digit hex is widely supported. Should be fine.

Let me double check the spawn logic. The very first piece is spawned in init:
- `nextType = popBag();` -> take first from bag
- `piece = newPiece(popBag());` -> take next from bag for current piece
- `nextType = popBag();` -> take next from bag for next preview

Wait, this calls popBag() 3 times. Let me trace:
1. refillBag() creates shuffled bag with all 7 types
2. nextType = popBag() -> takes first, sets nextType
3. piece = newPiece(popBag()) -> takes second for current piece
4. nextType = popBag() -> takes third for nextType

So after init, bag has 4 remaining, piece is the second, nextType is the third. That works.

Actually this is a bit redundant. Let me clean up:
```js
function init() {
  grid = Array.from({length: ROWS}, () => Array(COLS).fill(null));
  refillBag();
  piece = newPiece(popBag());
  nextType = popBag();
  ...
}
```

Yes that's cleaner. Let me update.

Now let me verify the rotation logic. For the T piece:
- Rotation 0: [[1,0],[0,1],[1,1],[2,1]] - T pointing up
- Rotation 1: [[1,0],[1,1],[2,1],[1,2]] - T pointing right
- Rotation 2: [[0,1],[1,1],[2,1],[1,2]] - T pointing down
- Rotation 3: [[1,0],[0,1],[1,1],[1,2]] - T pointing left

These are SRS rotations. Looks correct.

Let me also think about scoring when hard dropping: 2 points per cell. The standard Tetris score for hard drop is 2 per cell. Yes.

For soft drop: 1 point per cell. Standard.

For tetris (4 lines): 800 points. Yes.

OK I think this is ready. Let me write the file.

Hmm wait, one bug: in `lock()`, when `cleared > 0` and a line clears, the loop `for (let y = ROWS - 1; y >= 0; y--)` has `y++;` to re-check the row. But after splicing, the row that was at y is now the row above (which came from the top). So we need to re-check at the same index to see if the new row is also full.

Wait actually let me think again. After splice:
- grid.splice(y, 1) removes row y
- grid.unshift(...) adds empty row at top
- Now what was at y+1, y+2, ... moved down to y, y+1, ...

So the new row at y (which used to be at y+1) needs to be re-checked. `y++` then `y--` from loop increment = stays at y. Yes, that's correct.

Actually after the splice and unshift, the index y now contains what was previously at y+1. We want to check if that row is full. So we increment y to "skip" the loop's auto-decrement, then it decrements back to the same y. Net effect: re-check y. Good.

Alright let me finalize and write the file.

Actually I realize there might be an issue with how I'm centering the spawn. Let me trace for L piece:
- L rot 0: [[2,0],[0,1],[1,1],[2,1]]
- minX=0, maxX=2
- width = 3
- x = floor((10-3)/2) - 0 = 3

Cells: (3+2, 0+0)=(5,0), (3+0, 0+1)=(3,1), (3+1, 0+1)=(4,1), (3+2, 0+1)=(5,1). Good, L shape with vertical part on left at column 3.

For I piece rot 0: [[0,1],[1,1],[2,1],[3,1]]
- minX=0, maxX=3
- width = 4
- x = floor((10-4)/2) - 0 = 3

Cells: (3,1),(4,1),(5,1),(6,1). Good, horizontal I bar centered at columns 3-6.

For O piece rot 0: [[1,0],[2,0],[1,1],[2,1]]
- minX=1, maxX=2
- width = 2
- x = floor((10-2)/2) - 1 = 4 - 1 = 3

Cells: (3+1, 0)=(4,0), (3+2, 0)=(5,0), (3+1, 1)=(4,1), (3+2, 1)=(5,1). Good, O at columns 4-5.

For T piece rot 0: [[1,0],[0,1],[1,1],[2,1]]
- minX=0, maxX=2
- width = 3
- x = floor((10-3)/2) - 0 = 3

Cells: (3+1, 0)=(4,0), (3+0, 1)=(3,1), (3+1, 1)=(4,1), (3+2, 1)=(5,1). Good, T shape.

All pieces center nicely. 

Let me write the file now. Actually one more thing - the soft drop while held. If ArrowDown is held, we drop one cell per frame. At 60fps, that's 60 cells per second, which is quite fast but reasonable. Actually for better feel, let me drop multiple cells per frame? No, one cell per frame is fine.

Actually wait, if I drop one cell per frame continuously, that's already fast. Let me check - at 16ms per frame, dropping once per frame = 62.5 cells/sec. That's like 16ms per cell, which is pretty fast. Good for soft drop.

But it might be too fast. Let me think... Actually many Tetris games have soft drop speed of like 10-20 cells per second. Let me use a slower soft drop. Actually for modern Tetris, soft drop speed is usually limited to ~20 cells/sec.

Let me add a rate limit:
```js
let softDropTimer = 0;
const SOFT_DROP_INTERVAL = 50; // ms per cell when held

// in main loop:
if (!paused && !gameOver && heldKeys['ArrowDown']) {
  softDropTimer += dt;
  while (softDropTimer >= SOFT_DROP_INTERVAL) {
    softDropTimer -= SOFT_DROP_INTERVAL;
    if (move(0, 1)) { score += 1; updateUI(); dropTimer = 0; }
    else { lock(); break; }
  }
}
```

Actually that's more complex. Let me just use a fixed interval and call softDrop() in the loop... but softDrop() is event-based with scoring. Let me just inline the logic.

Actually, you know what, let me just drop one cell per frame when held. At 60fps that's fast enough. Or let me make it 2 cells per frame by doing it twice. Whatever. Let me just do the simple thing: 1 cell per frame. That's ~62 cells/sec. Fast but OK.

Actually let me cap it at maybe 1 cell per 30ms which is ~33 cells/sec. Let me add a timer.

Hmm I'm overthinking this. Let me just do simple held-key detection and move one cell per frame.

Actually, a cleaner way is to use the soft drop approach with timer:

```js
let softDropAccum = 0;
function loop(now) {
  let dt = now - lastTime;
  ...
  if (!paused && !gameOver && heldKeys['ArrowDown']) {
    softDropAccum += dt;
    const SOFT_DROP_MS = 40;
    while (softDropAccum >= SOFT_DROP_MS) {
      softDropAccum -= SOFT_DROP_MS;
      if (move(0, 1)) { score += 1; dropTimer = 0; }
      else { lock(); break; }
    }
    updateUI();
  } else {
    softDropAccum = 0;
  }
  ...
}
```

This drops one cell per 40ms = 25 cells/sec. Reasonable.

OK let me finalize the file. Writing it now.

Actually let me also add some quality-of-life: when game over, the restart button should be highlighted. And maybe show "Press R to restart" hint. I have that in the overlay.

Let me write the file:

Actually I realize the `init()` function calls `render()` and `updateUI()` and `hideOverlay()`. But on the very first frame, the loop also renders. So render is called twice. That's fine, it's idempotent.

Let me also ensure that the overlay can show "GAME OVER" on first init if somehow the board is already full (shouldn't happen but defensive).

OK writing the file now. Let me put it all together carefully.

I'll build a complete Tetris game in a single `index.html` file. Let me plan the implementation, write it, then verify the behavior in a browser.

→ bash {"command":"ls -la /tmp/job-VOWcpw/"}
toolResult

total 12 drwx------ 3 node node 4096 Oct 14 02:47 . drwxrwxrwt 6 root root 4096 Oct 14 02:47 .. drwxr-xr-x 3 node node 4096 Oct 14 02:47 .home

assistant
thinking
Empty directory. Let me write the index.html file with the complete Tetris game.
→ write /tmp/job-VOWcpw/index.html (15870 bytes)
toolResult

Successfully wrote to /tmp/job-VOWcpw/index.html

assistant
thinking
Now let me run it in the browser and verify the behavior.

Now let me load it in the browser and verify the behavior.

→ browser {"action":"reload"}
toolResult

Frame 1, +0ms

[Browser image supplied to model]

{"console":["warning: An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing."],"dom":"TETRIS\nSCORE\n0\nLEVEL\n1\nLINES\n0\nNEXT\nPause Restart\nCONTROLS\n← →  Move\n↑  Rotate CW\n↓  Soft drop\nSpace  Hard drop\nP  Pause / Resume\nR  Restart","images":"attached"}

assistant
thinking
Initial render looks good. I can see:
- A green S-piece (S tetromino) at the top of the board with its ghost piece at the bottom
- Yellow O piece in the next preview
- Score 0, Level 1, Lines 0
- All controls shown
- Pause and Restart buttons visible

Now let me verify behavior step by step:
1. Move the piece (left/right)
2. Rotate
3. Hard drop and check it locks
4. Check new piece spawns
5. Check next preview advances
6. Pause/resume
7. Restart and verify reset

Let me first inspect the DOM to confirm everything is set up properly.

The initial render shows a green S-piece at the top with its ghost piece at the bottom, and an O piece in the next preview. Let me verify the behavior step by step.

→ browser {"action":"inspect"}
toolResult

Frame 1, +0ms

[Browser image supplied to model]

{"console":["warning: An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing."],"dom":"TETRIS\nSCORE\n0\nLEVEL\n1\nLINES\n0\nNEXT\nPause Restart\nCONTROLS\n← →  Move\n↑  Rotate CW\n↓  Soft drop\nSpace  Hard drop\nP  Pause / Resume\nR  Restart","images":"attached"}

assistant
thinking
Let me test the controls by pressing keys. First, let me move the piece to the right, rotate it, and hard drop.

Now let me test the controls. I'll move the piece left, rotate, and hard-drop.

→ browser {"action":"key","text":"ArrowLeft"}
toolResult

Frame 1, +0ms

[Browser image supplied to model]

{"console":["warning: An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing."],"dom":"TETRIS\nSCORE\n0\nLEVEL\n1\nLINES\n0\nNEXT\nPause Restart\nCONTROLS\n← →  Move\n↑  Rotate CW\n↓  Soft drop\nSpace  Hard drop\nP  Pause / Resume\nR  Restart","images":"attached"}

assistant
thinking
The piece moved to the left. The S-piece shifted left by one cell, and the ghost followed. Now let me rotate and hard drop.

The S-piece moved left successfully. Let me rotate and hard-drop it.

→ browser {"action":"key","text":"ArrowUp"}
toolResult

Frame 1, +0ms

[Browser image supplied to model]

{"console":["warning: An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing."],"dom":"TETRIS\nSCORE\n0\nLEVEL\n1\nLINES\n0\nNEXT\nPause Restart\nCONTROLS\n← →  Move\n↑  Rotate CW\n↓  Soft drop\nSpace  Hard drop\nP  Pause / Resume\nR  Restart","images":"attached"}

assistant
thinking
The S-piece rotated clockwise. Now it's in vertical orientation. The ghost piece also rotated and is positioned differently at the bottom. Now let me hard-drop with Space.

The piece rotated to vertical orientation. The ghost piece rotated too. Now let me hard-drop.

→ browser {"action":"key","text":"Space"}
toolResult

Frame 1, +0ms

[Browser image supplied to model]

{"console":["warning: An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing."],"dom":"TETRIS\nSCORE\n16\nLEVEL\n1\nLINES\n0\nNEXT\nPause Restart\nCONTROLS\n← →  Move\n↑  Rotate CW\n↓  Soft drop\nSpace  Hard drop\nP  Pause / Resume\nR  Restart","images":"attached"}

assistant
thinking
The hard drop worked:
- The S-piece landed at the bottom (visible green cells with the orange/yellow ghost outline showing where it landed)
- A new yellow O-piece spawned at the top
- The next preview advanced to show an orange L-piece
- Score went up to 16 (8 cells × 2 points per cell for hard drop)

This is working perfectly. The score of 16 means the hard drop traveled 8 cells (16/2 = 8), which makes sense given the S piece was around row 4 and landed around row 18.

Now let me verify:
1. ✓ Move and rotate the first piece
2. ✓ Hard drop locks into board
3. ✓ New piece spawns
4. ✓ Next piece preview advances
5. Now let me test pause/resume
6. Then test restart

Let me test pause.

Excellent! The hard drop worked perfectly: - The green S-piece locked at the bottom (visible green cells with yellow ghost outline showing where it landed) - A new yellow O-piece spawned at the top - The next preview advanced to show an orange L-piece - Score is 16 (8 cells × 2 points per hard drop cell) Now let me test pause/resume.

→ browser {"action":"key","text":"p"}
toolResult

Frame 1, +0ms

[Browser image supplied to model]

{"console":["warning: An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing."],"dom":"TETRIS\nPAUSED\nPress P to resume\nSCORE\n16\nLEVEL\n1\nLINES\n0\nNEXT\nPause Restart\nCONTROLS\n← →  Move\n↑  Rotate CW\n↓  Soft drop\nSpace  Hard drop\nP  Pause / Resume\nR  Restart","images":"attached"}

assistant
thinking
Pause works - the "PAUSED" overlay is showing in yellow. Now let me resume and verify the game continues. Then I'll test restart.

Pause overlay shows in yellow. Now let me resume.

→ browser {"action":"key","text":"p"}
toolResult

Frame 1, +0ms

[Browser image supplied to model]

{"console":["warning: An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing."],"dom":"TETRIS\nSCORE\n16\nLEVEL\n1\nLINES\n0\nNEXT\nPause Restart\nCONTROLS\n← →  Move\n↑  Rotate CW\n↓  Soft drop\nSpace  Hard drop\nP  Pause / Resume\nR  Restart","images":"attached"}

assistant
thinking
Resume worked - the overlay is gone, the score is still 16 (preserved during pause), and the game state looks the same as before pause. The yellow O-piece is still at the top, and the S-piece is still at the bottom.

Now let me test restart with the R key.

Resume worked - the overlay is gone and the game state is preserved (score still 16, board state intact). Now let me test restart.

→ browser {"action":"key","text":"r"}
toolResult

Frame 1, +0ms

[Browser image supplied to model]

{"console":["warning: An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing."],"dom":"TETRIS\nSCORE\n0\nLEVEL\n1\nLINES\n0\nNEXT\nPause Restart\nCONTROLS\n← →  Move\n↑  Rotate CW\n↓  Soft drop\nSpace  Hard drop\nP  Pause / Resume\nR  Restart","images":"attached"}

assistant
thinking
Restart worked perfectly:
- Board is completely empty
- Score reset to 0
- Level reset to 1
- Lines reset to 0
- New piece (green S) spawned at the top
- Next preview shows a new piece (red Z)

All required behaviors are working. Let me also do a quick check on a couple more things:
1. Verify soft drop with the Down key
2. Verify that rotation is rejected when blocked

Actually, let me also verify that the line clearing and scoring works correctly. Let me fill a row by manipulating pieces.

Actually, let me just verify soft drop quickly. I can hold down the arrow key. Let me press it a few times.

Actually I think everything is working well. Let me do a final summary and check.

Let me also verify the level increases when 10 lines are cleared. I can verify by reading the code:
```js
const newLevel = Math.floor(lines / 10) + 1;
if (newLevel !== level) {
  level = newLevel;
  dropInterval = computeInterval();
}
```

Yes, this increases level every 10 lines. And scoring:
```js
const pts = [0, 100, 300, 500, 800][cleared];
score += pts * level;
```

So 1/2/3/4 lines = 100/300/500/800 × level. Matches the spec.

Let me also verify all 7 tetrominoes are in the TYPES array: ['I','O','T','S','Z','J','L'] - yes all 7.

The spec is fully implemented. Let me do one more verification - test that a blocked rotation is rejected (rotation near a wall or in a tight space).

Actually let me also verify that soft drop works. Press Down and see if the piece moves down.

Restart worked perfectly: - Board is empty - Score reset to 0 - Level reset to 1 - Lines reset to 0 - New piece (green S) spawned, next preview shows red Z Let me do one more check - test soft drop to make sure the Down key works and the score increments.

→ browser {"action":"key","text":"ArrowDown"}
toolResult

Frame 1, +0ms

[Browser image supplied to model]

{"console":["warning: An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing."],"dom":"TETRIS\nSCORE\n1\nLEVEL\n1\nLINES\n0\nNEXT\nPause Restart\nCONTROLS\n← →  Move\n↑  Rotate CW\n↓  Soft drop\nSpace  Hard drop\nP  Pause / Resume\nR  Restart","images":"attached"}

assistant
thinking
Soft drop works - the piece moved down 1 cell and score went up to 1 (1 point per cell of soft drop). The ghost piece moved down correspondingly.

All required behaviors are working correctly. Let me write a final report.

Actually let me verify one more thing - that blocked rotations are rejected. I'll move the S piece all the way to the left wall, then rotate - the rotation should be rejected (no wall kicks per the spec).

Actually let me test that. Currently the S piece is at the left edge. Let me check.

Actually the S piece has cells at:
- Rotation 0: [[1,0],[2,0],[0,1],[1,1]] -> with x position, columns would be at piece_x+0, piece_x+1, piece_x+2
- Rotation 1: [[1,0],[1,1],[2,1],[2,2]] -> with x position, columns would be at piece_x+1, piece_x+2

The current piece position after pressing ArrowLeft is at... let me check by looking at the screenshot. The S piece is positioned around columns 2-4. If I rotate, the new shape would need columns at 1+0=1 to 1+2=3 relative to the piece origin. So it would fit if the origin is at column 2 or more.

Actually let me just check that the rotation rejection works in principle. From the code:
```js
function rotate(dir) {
  const np = { ...piece, rot: (piece.rot + dir + 4) % 4 };
  if (valid(np)) {
    piece = np;
    lockTimer = 0;
  }
  // Reject blocked rotations silently (no wall kicks).
}
```

If valid(np) is false, the piece remains unchanged. This is correct per the spec.

I think all the required functionality is in place. Let me write the final summary.

Soft drop works - score went up to 1 (1 point per cell), and the piece moved down with its ghost following. All required behaviors are verified. ## Summary I built a complete playable Tetris game in `/tmp/job-VOWcpw/index.html` — single file, fully self-contained, no external resources. ### What I verified in the browser: 1. **Initial render** — empty board, Score 0, Level 1, Lines 0, controls panel visible, a green S-piece spawned at top with ghost preview at bottom, next-preview shows an O piece. 2. **Move** (ArrowLeft) — the S-piece shifted left one cell, ghost followed. 3. **Rotate CW** (ArrowUp) — piece rotated to vertical orientation, ghost repositioned. 4. **Hard drop** (Space) — piece teleported to the floor, locked into the grid, score jumped to **16** (= 8 cells × 2 points/cell). A new yellow O-piece spawned at top, and the next-preview advanced to show an orange L-piece. 5. **Pause** (P) — yellow "PAUSED" overlay appears; gameplay halted; board state preserved. 6. **Resume** (P) — overlay disappears, score and pieces preserved. 7. **Restart** (R) — board fully cleared, Score/Level/Lines all reset to 0/1/0, new piece spawns, fresh next-preview (red Z). 8. **Soft drop** (ArrowDown) — piece moved down one cell, score increased by 1. ### Spec compliance — all required behavior implemented: - 10×20 board, all 7 tetrominoes (I, O, T, S, Z, J, L) with all 4 SRS rotations defined, 7-bag randomizer, centered horizontal spawn. - Controls: ←/→ move, ↑ rotate CW, ↓ soft-drop (+1/cell), Space hard-drop (+2/cell), P pause/resume, R restart, plus on-screen buttons. - Overlap/OOB move/rotation rejected silently (no wall kicks — per spec "wall kicks are optional"). - 500 ms lock delay after landing; resets on any successful move. - Line clear: full rows removed, rows above drop down via `splice + unshift` loop. - Scoring: 100/300/500/800 × current level for 1/2/3/4 lines. - Level starts at 1, increments every 10 cleared lines (`Math.floor(lines/10)+1`), and gravity interval is recomputed (formula `800 × 0.85^(level-1)`, clamped to ≥ 50 ms). - Game Over triggered when a freshly-spawned piece fails the `valid()` check — shows red "GAME OVER" overlay. - Restart resets grid, score, level, lines, bag, and timers. - Bonus extras (not required): ghost piece outline, soft-drop held-key repeat at 40 ms/cell, bevel-shaded cell rendering. ### Unverified: - I did not exhaustively play long enough to trigger a level-up, multi-line clear, or natural top-out game over in the browser session — those code paths are exercised by the same `lock()`/`step()` routines that I confirmed work for the hard-drop case.