linesCleared: 0,
};
}
export function validatePlacement(state: GridState, candidate: Piece): boolean {
const shape = getPieceShape(candidate.type, candidate.rotation);
for (let row = 0; row < shape.length; row++) {
for (let col = 0; col < shape[row].length; col++) {
if (shape[row][col]) {
const boardX = candidate.x + col;
const boardY = candidate.y + row;
if (boardX < 0 || boardX >= COLS || boardY >= ROWS) return false;
if (boardY >= 0 && state.board[boardY][boardX] !== 0) return false;
}
}
}
return true;
}
**Rationale**: Separating validation from mutation prevents partial state updates on failed moves. The `validatePlacement` function acts as a gatekeeper, ensuring the engine never enters an invalid configuration.
### 2. The 7-Bag Distribution System
Naive randomization fails because true randomness allows clustering. The official guideline mandates a shuffled pool of all seven piece types, refilled only when exhausted. This guarantees statistical fairness and eliminates droughts longer than 14 pieces.
```typescript
// piecePool.ts
import { PieceType } from './types';
const ALL_PIECES: PieceType[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'];
export class PiecePool {
private buffer: PieceType[] = [];
public draw(): PieceType {
if (this.buffer.length === 0) {
this.buffer = [...ALL_PIECES];
this.shuffleBuffer();
}
return this.buffer.shift()!;
}
private shuffleBuffer(): void {
for (let i = this.buffer.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.buffer[i], this.buffer[j]] = [this.buffer[j], this.buffer[i]];
}
}
}
Rationale: Fisher-Yates shuffling ensures uniform distribution. The buffer approach guarantees that every piece appears exactly once per cycle, capping consecutive duplicates at two (only possible at buffer boundaries).
3. SRS Rotation & Wall Kicks
The Super Rotation System (SRS) solves boundary collisions by attempting predefined offset corrections before rejecting a rotation. Each piece type has distinct kick tables for clockwise and counter-clockwise transitions.
// rotation.ts
import { PieceType } from './types';
type KickTable = Record<string, [number, number][]>;
const KICK_TABLES: Record<PieceType, KickTable> = {
I: {
'0->1': [[0,0], [-2,0], [1,0], [-2,-1], [1,2]],
'1->0': [[0,0], [2,0], [-1,0], [2,1], [-1,-2]],
// ... other transitions
},
T: {
'0->1': [[0,0], [-1,0], [-1,1], [0,-2], [-1,-2]],
'1->0': [[0,0], [1,0], [1,-1], [0,2], [1,2]],
// ... other transitions
},
// O, S, Z, J, L follow similar patterns
};
export function attemptRotation(
state: GridState,
piece: Piece,
direction: 1 | -1
): Piece | null {
const targetRot = (piece.rotation + direction + 4) % 4;
const key = `${piece.rotation}->${targetRot}`;
const kicks = KICK_TABLES[piece.type]?.[key] ?? [[0,0]];
for (const [dx, dy] of kicks) {
const candidate: Piece = {
...piece,
rotation: targetRot,
x: piece.x + dx,
y: piece.y + dy,
};
if (validatePlacement(state, candidate)) return candidate;
}
return null;
}
Rationale: The [0,0] offset is always evaluated first to preserve valid rotations without unnecessary shifting. I-piece tables differ from 3x3 pieces due to its unique rotation axis. CW and CCW tables are not sign-flipped; they are independently defined to match official guideline behavior.
4. Frame-Rate Independent Physics
Tying drops to requestAnimationFrame makes difficulty hardware-dependent. A millisecond accumulator decouples simulation time from render time, ensuring consistent gravity across all displays.
// physics.ts
export class PhysicsClock {
private accumulator = 0;
private lastTimestamp = 0;
public tick(timestamp: number, level: number, onDrop: () => void): void {
if (this.lastTimestamp === 0) this.lastTimestamp = timestamp;
const delta = Math.min(timestamp - this.lastTimestamp, 100); // Clamp runaway tabs
this.lastTimestamp = timestamp;
this.accumulator += delta;
const dropInterval = this.getGravityInterval(level);
while (this.accumulator >= dropInterval) {
this.accumulator -= dropInterval;
onDrop();
}
}
private getGravityInterval(level: number): number {
const curve = [800,720,630,550,470,380,300,220,130,100,80,80,80,70,70,70,50,50,50,30];
return level >= curve.length ? 30 : curve[level];
}
}
Rationale: The while loop handles frame drops gracefully by processing multiple gravity steps in a single tick. The 100ms clamp prevents the "tab-switch disaster" where backgrounded tabs cause the accumulator to overflow and instantly lock pieces.
OS key repeat is too slow and inconsistent for competitive play. Delayed Auto-Shift (DAS) implements a custom repeat loop: an initial delay followed by a rapid, steady interval.
// input.ts
export class InputRouter {
private activeKeys = new Set<string>();
private timers = new Map<string, number>();
private readonly DAS_DELAY = 170;
private readonly DAS_INTERVAL = 50;
public onKeyDown(key: string, action: () => void): void {
if (this.activeKeys.has(key)) return;
this.activeKeys.add(key);
action();
if (this.isRepeatable(key)) {
this.timers.set(key, window.setTimeout(() => this.startRepeat(key, action), this.DAS_DELAY));
}
}
public onKeyUp(key: string): void {
this.activeKeys.delete(key);
const timer = this.timers.get(key);
if (timer) {
clearTimeout(timer);
this.timers.delete(key);
}
}
private startRepeat(key: string, action: () => void): void {
const interval = window.setInterval(() => {
if (!this.activeKeys.has(key)) {
clearInterval(interval);
return;
}
action();
}, this.DAS_INTERVAL);
this.timers.set(key, interval);
}
private isRepeatable(key: string): boolean {
return ['ArrowLeft', 'ArrowRight', 'ArrowDown'].includes(key);
}
}
Rationale: Hard drop and rotation bypass DAS to prevent accidental inputs during precise maneuvers. The 170ms/50ms values align with competitive standards, providing immediate response while allowing rapid wall slides.
6. Non-Linear Scoring & Progression
Scoring must reward high-risk, high-reward play. The official guideline uses exponential multipliers for multi-line clears, encouraging players to maintain open columns for Tetrises rather than clearing lines incrementally.
// scoring.ts
export function calculateLineScore(lines: number, level: number): number {
const base = [0, 40, 100, 300, 1200][lines] ?? 0;
return base * (level + 1);
}
export function calculateDropBonus(cells: number, isHard: boolean): number {
return cells * (isHard ? 2 : 1);
}
Rationale: A 4-line clear yields 30Γ the points of a single line at level 0. This mathematical asymmetry is the primary driver of advanced stacking strategies. Drop bonuses incentivize active placement over passive gravity reliance.
Pitfall Guide
1. Coupling State to the DOM/Canvas
Explanation: Storing piece coordinates in CSS transforms or Canvas context variables makes state retrieval impossible without parsing the DOM. This breaks headless testing and causes synchronization bugs.
Fix: Maintain a single source of truth in a plain object/array. The renderer should only read from this state and never mutate it.
2. Naive Random Number Generation
Explanation: Math.random() follows true statistical randomness, which naturally produces streaks. Players perceive 3+ consecutive identical pieces as "broken" or "unfair," even though it's mathematically valid.
Fix: Implement a 7-bag pool with Fisher-Yates shuffling. This caps consecutive duplicates at two and guarantees distribution fairness.
3. Ignoring Kick Table Priority Order
Explanation: Attempting wall kicks in arbitrary order causes pieces to snap to incorrect positions or fail valid rotations. The official system requires [0,0] first, followed by specific offsets.
Fix: Hardcode the exact SRS kick tables per piece type and rotation direction. Always evaluate [0,0] before attempting offsets.
4. Tying Physics to requestAnimationFrame
Explanation: Frame rates vary by hardware and browser tab visibility. A 120Hz display will double drop speed, while background tabs will freeze physics entirely.
Fix: Use a millisecond accumulator with a delta clamp. Process multiple simulation steps per frame if necessary to maintain consistent time progression.
5. Relying on OS Key Repeat Events
Explanation: Browser keydown auto-repeat is OS-dependent, typically delayed by 500ms and repeating at ~30Hz. This latency destroys precise movement and wall-kick execution.
Fix: Implement custom DAS logic. Ignore OS repeat, track key hold state, and trigger custom setTimeout/setInterval loops for repeatable actions.
6. Linear Scoring Assumptions
Explanation: Awarding equal points per line (e.g., 100 per line) removes strategic depth. Players will clear lines immediately rather than building for multi-line clears.
Fix: Use exponential multipliers (40, 100, 300, 1200) scaled by level. Add hard/soft drop bonuses to reward active play.
7. Unclamped Delta Time
Explanation: When a user switches tabs, requestAnimationFrame pauses. Upon return, the timestamp difference can be several seconds. An unclamped accumulator will process hundreds of gravity steps instantly, locking pieces and breaking gameplay.
Fix: Clamp delta to a maximum of 100ms before adding to the accumulator. This preserves simulation integrity across tab switches.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Rapid Prototype | Naive RNG + Frame-tied drops | Fastest implementation, acceptable for internal demos | Low dev cost, high player churn risk |
| Commercial Release | 7-Bag + SRS + DAS + Accumulator | Meets player expectations, ensures cross-hardware fairness | Moderate dev cost, high retention ROI |
| Competitive/Esports | Guideline-compliant + Headless testing + Input logging | Required for tournament integrity, enables replay validation | High dev cost, essential for credibility |
Configuration Template
// config.ts
export const GameConfig = {
grid: { cols: 10, rows: 20 },
physics: {
gravityCurve: [800,720,630,550,470,380,300,220,130,100,80,80,80,70,70,70,50,50,50,30],
deltaClampMs: 100,
},
input: {
dasDelayMs: 170,
dasIntervalMs: 50,
repeatableKeys: ['ArrowLeft', 'ArrowRight', 'ArrowDown'],
},
scoring: {
lineMultipliers: [0, 40, 100, 300, 1200],
levelScale: true,
hardDropBonusPerCell: 2,
softDropBonusPerCell: 1,
},
testing: {
headlessEnabled: true,
maxTestRuntimeMs: 100,
},
};
Quick Start Guide
- Initialize the Engine: Import
createInitialState and PiecePool. Instantiate the physics clock and input router.
- Wire the Game Loop: Bind
requestAnimationFrame to the physics accumulator. Pass the accumulator callback to your drop handler.
- Connect Input: Attach
keydown/keyup listeners to the InputRouter. Map directional keys to move/rotate actions.
- Validate & Ship: Run headless tests against the engine. Verify SRS kicks, 7-bag distribution, and scoring multipliers. Deploy the renderer as a pure projection layer.