arRow(rowIndex: number): void {
this.grid[rowIndex].fill(0);
}
shiftRowsDown(fromIndex: number): void {
for (let r = fromIndex; r > 0; r--) {
this.grid[r] = [...this.grid[r - 1]];
}
this.grid[0] = Array(this.cols).fill(0);
}
}
### Step 2: Implement the Input Buffer
Browser `keydown` events fire repeatedly when a key is held. Processing them directly causes jitter and unintended rapid movements. We buffer inputs and process them once per simulation tick.
```typescript
export class InputController {
private queue: string[] = [];
private isProcessing = false;
constructor() {
window.addEventListener('keydown', this.handleKeyDown);
}
private handleKeyDown = (e: KeyboardEvent): void => {
if (e.isComposing || e.key === 'Dead') return;
this.queue.push(e.code);
};
consumeNext(): string | null {
return this.queue.shift() ?? null;
}
clear(): void {
this.queue = [];
}
}
Step 3: Build the Simulation Engine
The engine manages the game loop, accumulator, collision detection, and state transitions. Notice the strict boundary checks and the separation of validation from mutation.
export class TetrisEngine {
private board: BoardState;
private input: InputController;
private currentPiece: TetrominoShape | null = null;
private piecePos: GridPosition = { row: 0, col: 0 };
private accumulator: number = 0;
private lastTimestamp: number = 0;
private readonly TICK_RATE: number = 1000 / 60; // 60Hz simulation
private readonly DROP_INTERVAL: number = 800; // ms per drop
private dropAccumulator: number = 0;
constructor(board: BoardState, input: InputController) {
this.board = board;
this.input = input;
}
public startLoop(timestamp: number): void {
if (!this.lastTimestamp) this.lastTimestamp = timestamp;
const delta = timestamp - this.lastTimestamp;
this.lastTimestamp = timestamp;
this.accumulator += delta;
this.dropAccumulator += delta;
// Process simulation at fixed tick rate
while (this.accumulator >= this.TICK_RATE) {
this.processInput();
this.accumulator -= this.TICK_RATE;
}
// Handle piece drop independently of tick rate
if (this.dropAccumulator >= this.DROP_INTERVAL) {
this.attemptDrop();
this.dropAccumulator = 0;
}
// Render happens every frame, regardless of simulation ticks
this.render();
requestAnimationFrame((ts) => this.startLoop(ts));
}
private processInput(): void {
const action = this.input.consumeNext();
if (!action || !this.currentPiece) return;
const targetPos = { ...this.piecePos };
let rotated = false;
switch (action) {
case 'ArrowLeft': targetPos.col--; break;
case 'ArrowRight': targetPos.col++; break;
case 'ArrowDown': targetPos.row++; break;
case 'ArrowUp':
this.rotatePiece();
rotated = true;
break;
default: return;
}
if (!rotated && !this.validatePlacement(targetPos, this.currentPiece)) {
return; // Revert if invalid
}
if (rotated && !this.validatePlacement(this.piecePos, this.currentPiece)) {
this.rotatePiece(); // Revert rotation if invalid
} else {
this.piecePos = targetPos;
}
}
private validatePlacement(pos: GridPosition, shape: TetrominoShape): boolean {
for (let r = 0; r < shape.matrix.length; r++) {
for (let c = 0; c < shape.matrix[r].length; c++) {
if (shape.matrix[r][c] === 0) continue;
const boardR = pos.row + r;
const boardC = pos.col + c;
if (boardR < 0 || boardR >= this.board.rows || boardC < 0 || boardC >= this.board.cols) {
return false;
}
if (this.board.getCell(boardR, boardC) !== 0) {
return false;
}
}
}
return true;
}
private attemptDrop(): void {
if (!this.currentPiece) return;
const nextPos = { ...this.piecePos, row: this.piecePos.row + 1 };
if (this.validatePlacement(nextPos, this.currentPiece)) {
this.piecePos = nextPos;
} else {
this.lockPiece();
this.clearCompletedRows();
this.spawnNewPiece();
}
}
private lockPiece(): void {
if (!this.currentPiece) return;
for (let r = 0; r < this.currentPiece.matrix.length; r++) {
for (let c = 0; c < this.currentPiece.matrix[r].length; c++) {
if (this.currentPiece.matrix[r][c]) {
this.board.setCell(this.piecePos.row + r, this.piecePos.col + c, 1);
}
}
}
}
private clearCompletedRows(): void {
const completed: number[] = [];
for (let r = 0; r < this.board.rows; r++) {
const isFull = this.board.grid[r].every(cell => cell === 1);
if (isFull) completed.push(r);
}
// Remove from bottom to top to preserve indices
completed.sort((a, b) => b - a).forEach(rowIdx => {
this.board.clearRow(rowIdx);
this.board.shiftRowsDown(rowIdx);
});
}
private rotatePiece(): void {
if (!this.currentPiece) return;
const matrix = this.currentPiece.matrix;
const N = matrix.length;
const rotated: number[][] = Array.from({ length: N }, () => Array(N).fill(0));
for (let r = 0; r < N; r++) {
for (let c = 0; c < N; c++) {
rotated[c][N - 1 - r] = matrix[r][c];
}
}
this.currentPiece.matrix = rotated;
}
private spawnNewPiece(): void {
// Placeholder for piece selection logic
this.currentPiece = { id: 'T', matrix: [[0,1,0],[1,1,1],[0,0,0]] };
this.piecePos = { row: 0, col: Math.floor(this.board.cols / 2) - 1 };
if (!this.validatePlacement(this.piecePos, this.currentPiece)) {
this.gameOver();
}
}
private render(): void {
// Canvas drawing logic delegated to a separate renderer
// This keeps the engine pure and testable
}
private gameOver(): void {
console.log('Simulation terminated. Grid locked.');
this.input.clear();
}
}
Architecture Decisions & Rationale
- Time Accumulator Pattern: The loop decouples simulation ticks from frame rendering.
requestAnimationFrame fires as fast as the display allows, but the simulation only advances when accumulator >= TICK_RATE. This prevents physics drift on high-refresh monitors and ensures consistent drop speeds on low-end devices.
- Input Queue over Direct Binding: Processing
keydown directly couples game logic to browser event timing. The queue buffers inputs and consumes exactly one per tick, eliminating key-repeat jitter and enabling predictable control response.
- Validation Before Mutation:
validatePlacement checks boundaries and grid occupancy without modifying state. Movement and rotation only commit if validation passes. This prevents off-by-one clipping and simplifies rollback logic.
- Separation of Engine and Renderer: The engine manages state and simulation. Rendering is intentionally omitted from the core loop to maintain single responsibility. In production, a
CanvasRenderer class would subscribe to state changes and handle devicePixelRatio scaling, clearing, and drawing independently.
Pitfall Guide
1. Timer Drift & Frame Stutter
Explanation: Using setInterval or setTimeout for game loops causes drift because they don't sync with the display compositor. Frames may render mid-refresh, causing tearing, or stack up during tab switches, causing sudden jumps.
Fix: Always use requestAnimationFrame with a time accumulator. Decouple simulation frequency from render frequency.
Explanation: Holding an arrow key triggers keydown events at the OS repeat rate (typically 30-50Hz), which often exceeds or misaligns with the game tick. This causes pieces to slide unpredictably or rotate multiple times per intended press.
Fix: Implement an input queue. Consume exactly one action per simulation tick. Add a short cooldown buffer if rapid-fire movement is undesired.
3. Off-by-One Collision Errors
Explanation: Checking grid boundaries after accessing the array causes undefined reads or silent failures. Rotations that push pieces against walls often fail to revert correctly.
Fix: Validate all coordinates against 0 <= r < rows and 0 <= c < cols before grid lookup. Perform collision checks on a hypothetical position, only committing if validation returns true.
4. Memory Leaks from Animation Frames
Explanation: Forgetting to cancel requestAnimationFrame when pausing or destroying the game causes the loop to run indefinitely in the background, consuming CPU and potentially throwing errors on detached DOM nodes.
Fix: Store the frame ID returned by requestAnimationFrame. Call cancelAnimationFrame(id) on pause, resize, or component unmount.
5. Mixing DOM State with Game Logic
Explanation: Reading innerText, style.display, or canvas dimensions during the simulation loop forces layout thrashing and blocks the main thread. Game state should never depend on the DOM.
Fix: Keep all game state in memory. Sync to the DOM or canvas only during the render phase. Use configuration objects for UI thresholds instead of querying elements.
6. Ignoring HiDPI Display Scaling
Explanation: Drawing on a canvas without accounting for devicePixelRatio results in blurry graphics on Retina/4K displays. The logical coordinate system doesn't match physical pixels.
Fix: Multiply canvas width and height attributes by window.devicePixelRatio, then scale the context with ctx.scale(ratio, ratio).
7. Synchronous Row Clearing Blocking the Loop
Explanation: Clearing multiple rows simultaneously with heavy array operations can cause micro-stutters, especially if the logic isn't optimized.
Fix: Batch row operations. Identify completed rows first, sort them descending by index, then shift in a single pass. Keep the operation O(n) relative to grid height.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple prototype / learning | setInterval + direct DOM manipulation | Fastest to implement, minimal boilerplate | Low initial, high maintenance |
| Production canvas game | requestAnimationFrame + accumulator + input queue | Deterministic simulation, smooth rendering, battery efficient | Moderate initial, low maintenance |
| Complex physics / multiplayer | Web Workers + shared state + rAF render | Offloads simulation from main thread, prevents UI blocking | High initial, requires architecture overhead |
| Mobile / low-end devices | Fixed tick rate with frame skipping | Guarantees consistent gameplay regardless of FPS | Low performance cost, predictable UX |
Configuration Template
export interface GameConfig {
grid: {
rows: number;
cols: number;
};
timing: {
simulationHz: number;
dropIntervalMs: number;
};
input: {
left: string;
right: string;
down: string;
rotate: string;
cooldownMs: number;
};
canvas: {
cellSize: number;
scaleForHiDPI: boolean;
};
}
export const DEFAULT_CONFIG: GameConfig = {
grid: { rows: 20, cols: 10 },
timing: { simulationHz: 60, dropIntervalMs: 800 },
input: { left: 'ArrowLeft', right: 'ArrowRight', down: 'ArrowDown', rotate: 'ArrowUp', cooldownMs: 0 },
canvas: { cellSize: 30, scaleForHiDPI: true }
};
Quick Start Guide
- Initialize the Canvas: Create a
<canvas> element, apply devicePixelRatio scaling, and set up a 2D context. Clear the canvas on every render call to prevent ghosting.
- Instantiate Core Modules: Create
BoardState with your grid dimensions, attach InputController to the window, and pass both to TetrisEngine.
- Bind the Loop: Call
engine.startLoop(performance.now()) once. The engine will recursively schedule itself via requestAnimationFrame.
- Wire Rendering: Implement a separate
CanvasRenderer that reads BoardState and currentPiece position, then draws filled rectangles. Call this renderer inside the engine's render() method.
- Test & Tune: Adjust
dropIntervalMs and simulationHz to match your target difficulty. Verify input responsiveness and collision accuracy across different display refresh rates.