Skip to content

Instantly share code, notes, and snippets.

@shricodev
Created May 24, 2025 13:18
Show Gist options
  • Save shricodev/393445572cd7069a35270326ab96ace5 to your computer and use it in GitHub Desktop.
Save shricodev/393445572cd7069a35270326ab96ace5 to your computer and use it in GitHub Desktop.
Blog - Tetris (Gemini 2.5 Pro)
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<title>HTML5 Tetris</title>
<style>
body {
background-color: #0a0a0a;
color: #e0e0e0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
padding: 10px;
box-sizing: border-box;
touch-action: manipulation;
}
#game-container {
/* Main container for all game related elements */
display: flex;
flex-direction: column;
align-items: center;
}
#stats-container {
width: 100%;
max-width: 500px; /* Max width to align with game area */
display: flex;
justify-content: space-around;
margin-bottom: 10px;
padding: 10px;
background-color: #181818;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 255, 255, 0.3);
box-sizing: border-box;
}
.stat-item {
font-size: clamp(0.8em, 2.5vw, 1.1em); /* Responsive font size */
color: #fff;
text-align: center;
}
.stat-item span {
color: #00ffff;
font-weight: bold;
}
#game-area {
display: flex;
align-items: flex-start;
justify-content: center;
gap: clamp(10px, 2vw, 20px); /* Responsive gap */
width: 100%;
}
.side-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
background-color: #181818;
border: 1px solid #333;
border-radius: 8px;
padding: clamp(5px, 1.5vw, 10px);
text-align: center;
box-shadow: 0 0 8px rgba(0, 255, 255, 0.2);
flex-basis: clamp(80px, 20vw, 120px); /* Responsive width */
min-width: 70px;
}
.side-panel h3 {
margin-top: 0;
margin-bottom: 5px;
color: #00ffff;
font-size: clamp(0.9em, 2.5vw, 1.2em);
text-transform: uppercase;
}
#hold-canvas,
#next-canvas {
background-color: #101010;
border-radius: 4px;
}
#tetris-canvas {
border: 2px solid #444;
border-radius: 8px;
box-shadow:
0 0 15px rgba(0, 255, 255, 0.4),
inset 0 0 10px rgba(0, 0, 0, 0.4);
background-color: #101010; /* Fallback, though drawn over */
/* Actual width/height set by JS, this makes it responsive */
max-width: 60vw; /* Adjust as needed, relative to game layout */
max-height: 80vh;
aspect-ratio: 0.5; /* For a 10x20 grid */
}
#controls-container {
margin-top: 15px;
display: flex;
gap: clamp(5px, 1.5vw, 10px);
flex-wrap: wrap; /* Allow buttons to wrap */
justify-content: center;
}
button {
background-color: #00ffff;
color: #0a0a0a;
border: none;
padding: clamp(8px, 2vw, 12px) clamp(10px, 2.5vw, 18px);
border-radius: 5px;
font-size: clamp(0.8em, 2vw, 1em);
font-weight: bold;
cursor: pointer;
transition:
background-color 0.2s,
transform 0.1s;
box-shadow: 0 0 8px rgba(0, 255, 255, 0.7);
}
button:hover {
background-color: #00bcbc; /* Darker Cyan */
transform: scale(1.05);
}
button:active {
transform: scale(0.95);
}
#message-overlay {
position: fixed; /* Use fixed to cover viewport */
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.75);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
z-index: 100;
}
#message-overlay h2 {
font-size: clamp(2em, 8vw, 3.5em);
margin-bottom: 10px;
}
#message-overlay p {
font-size: clamp(1em, 4vw, 1.8em);
color: #e0e0e0;
}
#game-over-title {
color: #ff0000;
text-shadow: 0 0 10px #ff0000;
}
#pause-title {
color: #ffff00;
text-shadow: 0 0 10px #ffff00;
}
/* Responsive adjustments */
@media (max-width: 600px) {
/* More aggressive stacking for small screens */
#game-area {
flex-direction: column;
align-items: center;
}
#tetris-canvas {
order: 0; /* Playfield first */
max-width: 80vw; /* Allow it to be wider in column layout */
}
.side-panel {
order: 1; /* Side panels below */
flex-direction: row; /* Horizontal items in side panel */
justify-content: space-around;
width: 80vw; /* Make side panels wide */
flex-basis: auto; /* Reset basis */
}
.side-panel canvas {
/* Ensure canvases in side panels scale */
max-width: 30%;
}
#stats-container {
max-width: 80vw;
}
}
</style>
</head>
<body>
<div id="game-container">
<div id="stats-container">
<div class="stat-item">Score: <span id="score">0</span></div>
<div class="stat-item">Level: <span id="level">1</span></div>
<div class="stat-item">Lines: <span id="lines">0</span></div>
<div class="stat-item">High Score: <span id="high-score">0</span></div>
</div>
<div id="game-area">
<div id="game-info-left" class="side-panel">
<h3>HOLD</h3>
<canvas id="hold-canvas"></canvas>
</div>
<canvas id="tetris-canvas"></canvas>
<div id="game-info-right" class="side-panel">
<h3>NEXT</h3>
<canvas id="next-canvas"></canvas>
</div>
</div>
<div id="controls-container">
<button id="pause-button">Pause (P)</button>
<button id="reset-button">Reset (R)</button>
<button id="fullscreen-button">Fullscreen (F)</button>
<button id="mute-button">Mute</button>
</div>
<div id="message-overlay" style="display: none">
<h2 id="message-title"></h2>
<p id="message-text"></p>
</div>
</div>
<script>
(function () {
// DOM Elements
const tetrisCanvas = document.getElementById("tetris-canvas");
const holdCanvas = document.getElementById("hold-canvas");
const nextCanvas = document.getElementById("next-canvas");
const scoreEl = document.getElementById("score");
const levelEl = document.getElementById("level");
const linesEl = document.getElementById("lines");
const highScoreEl = document.getElementById("high-score");
const pauseButton = document.getElementById("pause-button");
const resetButton = document.getElementById("reset-button");
const fullscreenButton = document.getElementById("fullscreen-button");
const muteButton = document.getElementById("mute-button");
const messageOverlay = document.getElementById("message-overlay");
const messageTitleEl = document.getElementById("message-title");
const messageTextEl = document.getElementById("message-text");
// Canvas Contexts
const ctx = tetrisCanvas.getContext("2d");
const holdCtx = holdCanvas.getContext("2d");
const nextCtx = nextCanvas.getContext("2d");
// Game Constants
const PLAYFIELD_COLS = 10;
const PLAYFIELD_ROWS = 20;
const HIDDEN_ROWS = 2; // Rows above visible playfield for spawning
const TOTAL_ROWS = PLAYFIELD_ROWS + HIDDEN_ROWS;
let BLOCK_SIZE = calculateBlockSize();
const NEXT_QUEUE_SIZE = 3;
const PREVIEW_BOX_SIZE = 4; // 4x4 grid for piece previews
// Colors (Dark theme with neon)
const COLORS = {
I: "#00FFFF", // Cyan
O: "#FFFF00", // Yellow
T: "#AA00FF", // Purple (was #800080)
S: "#00FF00", // Green
Z: "#FF0000", // Red
J: "#0000FF", // Blue
L: "#FFA500", // Orange
GHOST: "rgba(255, 255, 255, 0.2)", // Ghost piece color
GRID: "#333333", // Grid line color
BACKGROUND: "#101010", // Playfield background
};
// Tetromino Shapes (matrices for each rotation)
// Pivots are roughly center. Standard Tetris placements.
const TETROMINOES = {
I: {
color: COLORS.I,
shapes: [
[
[0, 0, 0, 0],
[1, 1, 1, 1],
[0, 0, 0, 0],
[0, 0, 0, 0],
], // 0
[
[0, 0, 1, 0],
[0, 0, 1, 0],
[0, 0, 1, 0],
[0, 0, 1, 0],
], // 1
[
[0, 0, 0, 0],
[0, 0, 0, 0],
[1, 1, 1, 1],
[0, 0, 0, 0],
], // 2 (same as 0 in some systems, needs different offset)
[
[0, 1, 0, 0],
[0, 1, 0, 0],
[0, 1, 0, 0],
[0, 1, 0, 0],
], // 3 (same as 1 in some systems)
],
pivot: { x: 2, y: 2 }, // Approximate pivot for 4x4
},
O: {
color: COLORS.O,
shapes: [
[
[1, 1],
[1, 1],
], // O-piece only has one rotation state
],
pivot: { x: 0.5, y: 0.5 }, // For 2x2 box
},
T: {
color: COLORS.T,
shapes: [
[
[0, 1, 0],
[1, 1, 1],
[0, 0, 0],
], // 0
[
[0, 1, 0],
[0, 1, 1],
[0, 1, 0],
], // 1
[
[0, 0, 0],
[1, 1, 1],
[0, 1, 0],
], // 2
[
[0, 1, 0],
[1, 1, 0],
[0, 1, 0],
], // 3
],
pivot: { x: 1, y: 1 }, // For 3x3 box
},
S: {
color: COLORS.S,
shapes: [
[
[0, 1, 1],
[1, 1, 0],
[0, 0, 0],
],
[
[0, 1, 0],
[0, 1, 1],
[0, 0, 1],
],
],
pivot: { x: 1, y: 1 },
},
Z: {
color: COLORS.Z,
shapes: [
[
[1, 1, 0],
[0, 1, 1],
[0, 0, 0],
],
[
[0, 0, 1],
[0, 1, 1],
[0, 1, 0],
],
],
pivot: { x: 1, y: 1 },
},
J: {
color: COLORS.J,
shapes: [
[
[1, 0, 0],
[1, 1, 1],
[0, 0, 0],
],
[
[0, 1, 1],
[0, 1, 0],
[0, 1, 0],
],
[
[0, 0, 0],
[1, 1, 1],
[0, 0, 1],
],
[
[0, 1, 0],
[0, 1, 0],
[1, 1, 0],
],
],
pivot: { x: 1, y: 1 },
},
L: {
color: COLORS.L,
shapes: [
[
[0, 0, 1],
[1, 1, 1],
[0, 0, 0],
],
[
[0, 1, 0],
[0, 1, 0],
[0, 1, 1],
],
[
[0, 0, 0],
[1, 1, 1],
[1, 0, 0],
],
[
[1, 1, 0],
[0, 1, 0],
[0, 1, 0],
],
],
pivot: { x: 1, y: 1 },
},
};
// S and Z only have 2 distinct rotation states, cycle them
TETROMINOES.S.shapes[2] = TETROMINOES.S.shapes[0];
TETROMINOES.S.shapes[3] = TETROMINOES.S.shapes[1];
TETROMINOES.Z.shapes[2] = TETROMINOES.Z.shapes[0];
TETROMINOES.Z.shapes[3] = TETROMINOES.Z.shapes[1];
// SRS Kick Data (x, y offsets)
// Standard kicks for J, L, S, T, Z pieces
const KICK_DATA_JLSTZ = [
// 0 -> 1 (R)
[
{ x: 0, y: 0 },
{ x: -1, y: 0 },
{ x: -1, y: -1 },
{ x: 0, y: 2 },
{ x: -1, y: 2 },
],
// 1 -> 0 (L)
[
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: -2 },
{ x: 1, y: -2 },
],
// 1 -> 2 (R)
[
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: -2 },
{ x: 1, y: -2 },
],
// 2 -> 1 (L)
[
{ x: 0, y: 0 },
{ x: -1, y: 0 },
{ x: -1, y: -1 },
{ x: 0, y: 2 },
{ x: -1, y: 2 },
],
// 2 -> 3 (R)
[
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: -1 },
{ x: 0, y: 2 },
{ x: 1, y: 2 },
],
// 3 -> 2 (L)
[
{ x: 0, y: 0 },
{ x: -1, y: 0 },
{ x: -1, y: 1 },
{ x: 0, y: -2 },
{ x: -1, y: -2 },
],
// 3 -> 0 (R)
[
{ x: 0, y: 0 },
{ x: -1, y: 0 },
{ x: -1, y: 1 },
{ x: 0, y: -2 },
{ x: -1, y: -2 },
],
// 0 -> 3 (L)
[
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: -1 },
{ x: 0, y: 2 },
{ x: 1, y: 2 },
],
];
// Standard kicks for I piece
const KICK_DATA_I = [
// 0 -> 1 (R)
[
{ x: 0, y: 0 },
{ x: -2, y: 0 },
{ x: 1, y: 0 },
{ x: -2, y: 1 },
{ x: 1, y: -2 },
],
// 1 -> 0 (L)
[
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: -1, y: 0 },
{ x: 2, y: -1 },
{ x: -1, y: 2 },
],
// 1 -> 2 (R)
[
{ x: 0, y: 0 },
{ x: -1, y: 0 },
{ x: 2, y: 0 },
{ x: -1, y: -2 },
{ x: 2, y: 1 },
],
// 2 -> 1 (L)
[
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: -2, y: 0 },
{ x: 1, y: 2 },
{ x: -2, y: -1 },
],
// 2 -> 3 (R)
[
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: -1, y: 0 },
{ x: 2, y: -1 },
{ x: -1, y: 2 },
],
// 3 -> 2 (L)
[
{ x: 0, y: 0 },
{ x: -2, y: 0 },
{ x: 1, y: 0 },
{ x: -2, y: 1 },
{ x: 1, y: -2 },
],
// 3 -> 0 (R)
[
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: -2, y: 0 },
{ x: 1, y: 2 },
{ x: -2, y: -1 },
],
// 0 -> 3 (L)
[
{ x: 0, y: 0 },
{ x: -1, y: 0 },
{ x: 2, y: 0 },
{ x: -1, y: -2 },
{ x: 2, y: 1 },
],
];
// Note: Y-axis for kicks is often inverted in definitions. Standard SRS assumes Y increases upwards.
// My canvas Y increases downwards, so I've used -Y for "up" and +Y for "down" in kick data.
// Game State
let playfield;
let currentPiece;
let nextPieces;
let heldPiece;
let canHold;
let score;
let level;
let linesClearedTotal;
let highScore;
let isPaused;
let isGameOver;
let isMuted = false;
// Timers and Counters
let gravityTimer;
let gravityInterval; // ms per step down
const BASE_GRAVITY_INTERVAL = 800; // Level 1 speed
const LOCK_DELAY_THRESHOLD = 500; // ms
let lockDelayTimer;
let isLocking;
let pieceBag;
// Line Clearing Animation
let linesBeingCleared = [];
let lineClearAnimationTimer = 0;
const LINE_CLEAR_ANIMATION_DURATION = 300; // ms
// Audio Context and Sounds
let audioCtx;
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) {
console.warn("Web Audio API not supported.");
audioCtx = null;
}
const sounds = {};
function createSound(name, freq, type, duration, vol = 0.1) {
sounds[name] = () => {
if (!audioCtx || isMuted) return;
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(freq, audioCtx.currentTime);
gainNode.gain.setValueAtTime(vol, audioCtx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(
0.00001,
audioCtx.currentTime + duration,
);
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.start();
oscillator.stop(audioCtx.currentTime + duration);
};
}
function createComplexSound(name, sequence, vol = 0.1) {
// sequence: [{freq, duration, type, delay}]
sounds[name] = () => {
if (!audioCtx || isMuted) return;
let currentTime = audioCtx.currentTime;
sequence.forEach((note) => {
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.type = note.type || "sine";
oscillator.frequency.setValueAtTime(
note.freq,
currentTime + (note.delay || 0),
);
gainNode.gain.setValueAtTime(
vol,
currentTime + (note.delay || 0),
);
gainNode.gain.exponentialRampToValueAtTime(
0.00001,
currentTime + (note.delay || 0) + note.duration,
);
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.start(currentTime + (note.delay || 0));
oscillator.stop(currentTime + (note.delay || 0) + note.duration);
});
};
}
// Define sounds
createSound("move", 200, "square", 0.05, 0.03);
createSound("rotate", 300, "triangle", 0.05, 0.03);
createSound("softDrop", 150, "sine", 0.03, 0.02);
createSound("hardDrop", 400, "sawtooth", 0.1, 0.05);
createSound("lock", 100, "sine", 0.1, 0.05);
createComplexSound(
"lineClear",
[
{ freq: 440, duration: 0.1, type: "square" },
{ freq: 880, duration: 0.1, type: "square", delay: 0.05 },
],
0.08,
);
createComplexSound(
"tetrisClear",
[
{ freq: 523.25, duration: 0.08, type: "triangle" }, // C5
{ freq: 659.25, duration: 0.08, type: "triangle", delay: 0.08 }, // E5
{ freq: 783.99, duration: 0.08, type: "triangle", delay: 0.16 }, // G5
{ freq: 1046.5, duration: 0.15, type: "triangle", delay: 0.24 }, // C6
],
0.1,
);
createComplexSound(
"gameOver",
[
{ freq: 200, duration: 0.2, type: "sawtooth" },
{ freq: 150, duration: 0.2, type: "sawtooth", delay: 0.15 },
{ freq: 100, duration: 0.3, type: "sawtooth", delay: 0.3 },
],
0.15,
);
createSound("pause", 600, "sine", 0.1, 0.05);
createSound("unpause", 700, "sine", 0.1, 0.05);
// --- Game Initialization ---
function initGame() {
playfield = Array.from({ length: TOTAL_ROWS }, () =>
Array(PLAYFIELD_COLS).fill(0),
);
pieceBag = [];
fillPieceBag();
nextPieces = [];
for (let i = 0; i < NEXT_QUEUE_SIZE; i++) {
nextPieces.push(getRandomPieceFromBag());
}
spawnNewPiece();
heldPiece = null;
canHold = true;
score = 0;
level = 1;
linesClearedTotal = 0;
highScore = localStorage.getItem("tetrisHighScore") || 0;
isPaused = false;
isGameOver = false;
isLocking = false;
lockDelayTimer = 0;
updateGravityInterval();
updateUI();
if (messageOverlay.style.display !== "none") {
messageOverlay.style.display = "none";
}
if (gameLoopId) cancelAnimationFrame(gameLoopId); // Clear previous loop if any
gravityTimer = 0; // Reset gravity timer
lastTime = performance.now(); // Reset lastTime for gameLoop delta
gameLoop();
}
// --- Piece Generation ---
function fillPieceBag() {
const pieceTypes = Object.keys(TETROMINOES);
// Shuffle
for (let i = pieceTypes.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pieceTypes[i], pieceTypes[j]] = [pieceTypes[j], pieceTypes[i]];
}
pieceBag.push(...pieceTypes);
}
function getRandomPieceFromBag() {
if (pieceBag.length === 0) {
fillPieceBag();
}
return pieceBag.shift();
}
function spawnNewPiece() {
const type = nextPieces.shift();
nextPieces.push(getRandomPieceFromBag());
const pieceData = TETROMINOES[type];
currentPiece = {
type: type,
rotation: 0,
shape: pieceData.shapes[0],
color: pieceData.color,
x:
Math.floor(PLAYFIELD_COLS / 2) -
Math.floor(pieceData.shapes[0][0].length / 2), // Centered
y: 0, // Start at top, hidden rows adjust actual spawn position
};
// Adjust spawn Y for I and O pieces to align with standard Tetris guidelines
// (spawn flat on row 21, or row 1 if 0-indexed from top visible area)
// My current Y means top of piece matrix. Some pieces spawn "higher" in matrix.
// For example, I piece is on 2nd row of its 4x4 matrix.
if (type === "I")
currentPiece.y = 0; // I piece has its blocks on the second row of its matrix
else if (type === "O")
currentPiece.y = 0; // O piece matrix starts with blocks
else currentPiece.y = 0; // Other pieces, check if they have empty rows on top of matrix
// Check for empty top rows in shape definition
let topEmptyRows = 0;
for (let r = 0; r < currentPiece.shape.length; r++) {
if (currentPiece.shape[r].every((cell) => cell === 0)) {
topEmptyRows++;
} else {
break;
}
}
currentPiece.y -= topEmptyRows; // Adjust so piece appears just above visible area
isLocking = false;
lockDelayTimer = 0;
if (
!isValidPosition(currentPiece.x, currentPiece.y, currentPiece.shape)
) {
isGameOver = true;
sounds.gameOver && sounds.gameOver();
showGameOverMessage();
updateHighScore();
}
}
// --- Game Loop ---
let lastTime = 0;
let gameLoopId;
function gameLoop(timestamp = 0) {
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
if (!isPaused && !isGameOver) {
if (linesBeingCleared.length > 0) {
lineClearAnimationTimer -= deltaTime;
if (lineClearAnimationTimer <= 0) {
finishLineClearing();
}
} else {
gravityTimer += deltaTime;
if (gravityTimer >= gravityInterval) {
gravityTimer = 0;
if (isLocking) {
lockDelayTimer += gravityInterval; // Use actual interval step
if (lockDelayTimer >= LOCK_DELAY_THRESHOLD) {
lockPiece();
} else {
// Still in lock delay, check if it can still move down
if (
!checkCollision(
currentPiece.x,
currentPiece.y + 1,
currentPiece.shape,
)
) {
// Piece can move down again (e.g. block below cleared), cancel lock
isLocking = false;
lockDelayTimer = 0;
movePieceDown();
}
}
} else {
movePieceDown();
}
}
}
}
draw();
gameLoopId = requestAnimationFrame(gameLoop);
}
// --- Piece Movement & Collision ---
function movePiece(dx, dy) {
if (isGameOver || isPaused || linesBeingCleared.length > 0)
return false;
const newX = currentPiece.x + dx;
const newY = currentPiece.y + dy;
if (isValidPosition(newX, newY, currentPiece.shape)) {
currentPiece.x = newX;
currentPiece.y = newY;
// If piece moved while in lock delay, reset lock delay
if (isLocking) {
lockDelayTimer = 0;
// If moved off a surface, it's no longer locking immediately
if (
!checkCollision(
currentPiece.x,
currentPiece.y + 1,
currentPiece.shape,
)
) {
isLocking = false;
}
}
return true;
}
return false;
}
function movePieceDown() {
if (!movePiece(0, 1)) {
// Cannot move down
if (!isLocking) {
isLocking = true;
lockDelayTimer = 0;
// First check if we need to lock immediately (e.g., after hard drop)
// For now, always use lock delay. Hard drop can set lockDelayTimer high.
}
} else {
// Successfully moved down, reset lock status if it was about to lock from non-movement
// isLocking is set when piece LANDS. Moving down means it hasn't landed yet or just did.
}
}
function rotatePiece(direction) {
// 1 for clockwise (R), -1 for counter-clockwise (L)
if (
isGameOver ||
isPaused ||
linesBeingCleared.length > 0 ||
currentPiece.type === "O"
)
return;
const pieceData = TETROMINOES[currentPiece.type];
let newRotation =
(currentPiece.rotation + direction + pieceData.shapes.length) %
pieceData.shapes.length;
const newShape = pieceData.shapes[newRotation];
// SRS Wall Kicks
let kickTable;
if (currentPiece.type === "I") {
kickTable = KICK_DATA_I;
} else {
kickTable = KICK_DATA_JLSTZ;
}
// Determine which kick data set to use (0->1, 1->0, etc.)
// direction 1 (R): 0->1, 1->2, 2->3, 3->0
// direction -1 (L): 1->0, 2->1, 3->2, 0->3 (indices +4 for table)
let kickDataIndex;
if (direction === 1) {
// Clockwise
kickDataIndex = currentPiece.rotation * 2;
} else {
// Counter-clockwise
kickDataIndex = currentPiece.rotation * 2 + 1;
}
const kicks = kickTable[kickDataIndex];
for (let i = 0; i < kicks.length; i++) {
const kick = kicks[i];
// Apply kick: kick.x for horizontal, -kick.y for vertical because my Y is downwards
// but standard SRS tables assume Y upwards.
const testX = currentPiece.x + kick.x;
const testY = currentPiece.y - kick.y; // SRS Y is inverted compared to canvas Y
if (isValidPosition(testX, testY, newShape)) {
currentPiece.x = testX;
currentPiece.y = testY;
currentPiece.rotation = newRotation;
currentPiece.shape = newShape;
sounds.rotate && sounds.rotate();
// If piece rotated while in lock delay, reset lock delay timer
if (isLocking) {
lockDelayTimer = 0;
// If rotated off a surface, it's no longer locking immediately
if (
!checkCollision(
currentPiece.x,
currentPiece.y + 1,
currentPiece.shape,
)
) {
isLocking = false;
}
}
return;
}
}
}
function isValidPosition(testX, testY, shape) {
for (let r = 0; r < shape.length; r++) {
for (let c = 0; c < shape[r].length; c++) {
if (shape[r][c]) {
const boardX = testX + c;
const boardY = testY + r;
if (
boardX < 0 ||
boardX >= PLAYFIELD_COLS ||
boardY >= TOTAL_ROWS
) {
return false; // Out of bounds (left, right, bottom)
}
// No need to check boardY < 0, pieces spawn at top and move down
if (boardY >= 0 && playfield[boardY][boardX]) {
return false; // Collision with existing block
}
}
}
}
return true;
}
function checkCollision(testX, testY, shape) {
// Simplified for just checking, not validating full position
for (let r = 0; r < shape.length; r++) {
for (let c = 0; c < shape[r].length; c++) {
if (shape[r][c]) {
const boardX = testX + c;
const boardY = testY + r;
if (
boardX < 0 ||
boardX >= PLAYFIELD_COLS ||
boardY >= TOTAL_ROWS ||
(boardY >= 0 && playfield[boardY][boardX])
) {
return true;
}
}
}
}
return false;
}
function softDrop() {
if (isGameOver || isPaused || linesBeingCleared.length > 0) return;
if (movePiece(0, 1)) {
score += 1; // Soft drop bonus
updateUI();
// Reset gravity timer to make soft drop responsive
gravityTimer = 0;
sounds.softDrop && sounds.softDrop();
} else {
// If soft drop results in landing, start lock delay
if (!isLocking) {
isLocking = true;
lockDelayTimer = 0;
}
}
}
function hardDrop() {
if (isGameOver || isPaused || linesBeingCleared.length > 0) return;
let rowsDropped = 0;
while (
isValidPosition(
currentPiece.x,
currentPiece.y + 1,
currentPiece.shape,
)
) {
currentPiece.y++;
rowsDropped++;
}
score += rowsDropped * 2; // Hard drop bonus
sounds.hardDrop && sounds.hardDrop();
lockPiece(); // Lock immediately after hard drop
}
function holdPieceAction() {
if (
isGameOver ||
isPaused ||
!canHold ||
linesBeingCleared.length > 0
)
return;
if (!heldPiece) {
heldPiece = currentPiece.type;
spawnNewPiece();
} else {
const tempType = currentPiece.type;
const pieceData = TETROMINOES[heldPiece];
currentPiece = {
type: heldPiece,
rotation: 0,
shape: pieceData.shapes[0],
color: pieceData.color,
x:
Math.floor(PLAYFIELD_COLS / 2) -
Math.floor(pieceData.shapes[0][0].length / 2),
y: 0,
};
// Adjust spawn Y for piece from hold
let topEmptyRows = 0;
for (let r = 0; r < currentPiece.shape.length; r++) {
if (currentPiece.shape[r].every((cell) => cell === 0))
topEmptyRows++;
else break;
}
currentPiece.y -= topEmptyRows;
heldPiece = tempType;
}
canHold = false;
isLocking = false; // New piece, reset lock status
lockDelayTimer = 0;
updateUI(); // To show new held piece
}
// --- Locking and Line Clearing ---
function lockPiece() {
if (!currentPiece) return;
for (let r = 0; r < currentPiece.shape.length; r++) {
for (let c = 0; c < currentPiece.shape[r].length; c++) {
if (currentPiece.shape[r][c]) {
const boardX = currentPiece.x + c;
const boardY = currentPiece.y + r;
if (
boardY >= 0 &&
boardY < TOTAL_ROWS &&
boardX >= 0 &&
boardX < PLAYFIELD_COLS
) {
// Ensure within bounds before assignment
playfield[boardY][boardX] = currentPiece.color;
}
}
}
}
sounds.lock && sounds.lock();
isLocking = false; // Piece is locked
lockDelayTimer = 0;
checkForLineClears();
if (linesBeingCleared.length === 0) {
// Only spawn new piece if no lines are clearing
spawnNewPiece();
canHold = true; // Allow hold for the new piece
}
updateUI();
}
function checkForLineClears() {
linesBeingCleared = [];
for (let r = HIDDEN_ROWS; r < TOTAL_ROWS; r++) {
// Check only visible rows
if (playfield[r].every((cell) => cell !== 0)) {
linesBeingCleared.push(r);
}
}
if (linesBeingCleared.length > 0) {
lineClearAnimationTimer = LINE_CLEAR_ANIMATION_DURATION;
if (linesBeingCleared.length === 4)
sounds.tetrisClear && sounds.tetrisClear();
else sounds.lineClear && sounds.lineClear();
}
}
function finishLineClearing() {
let numCleared = linesBeingCleared.length;
if (numCleared > 0) {
// Remove lines from bottom up to maintain correct indexing
linesBeingCleared
.sort((a, b) => b - a)
.forEach((rowIndex) => {
playfield.splice(rowIndex, 1);
playfield.unshift(Array(PLAYFIELD_COLS).fill(0)); // Add new empty line at the top
});
linesClearedTotal += numCleared;
// Scoring based on Tetris Guideline
let pointsEarned = 0;
if (numCleared === 1) pointsEarned = 100 * level;
else if (numCleared === 2) pointsEarned = 300 * level;
else if (numCleared === 3) pointsEarned = 500 * level;
else if (numCleared >= 4) pointsEarned = 800 * level; // Tetris
// TODO: Add T-spin and combo scoring later if desired
score += pointsEarned;
// Update level
const newLevel = Math.floor(linesClearedTotal / 10) + 1;
if (newLevel > level) {
level = newLevel;
updateGravityInterval();
}
}
linesBeingCleared = [];
lineClearAnimationTimer = 0;
// After lines are cleared, spawn new piece and allow hold
spawnNewPiece();
canHold = true;
updateUI();
}
// --- Drawing ---
function calculateBlockSize() {
// Determine block size based on canvas dimensions for responsiveness
const availableHeight =
tetrisCanvas.clientHeight ||
tetrisCanvas.parentNode.clientHeight * 0.8; // Fallback to parent if canvas not sized
const availableWidth =
tetrisCanvas.clientWidth ||
tetrisCanvas.parentNode.clientWidth * 0.5;
let sizeBasedOnHeight = availableHeight / PLAYFIELD_ROWS;
let sizeBasedOnWidth = availableWidth / PLAYFIELD_COLS;
return Math.min(sizeBasedOnHeight, sizeBasedOnWidth, 30); // Cap max block size
}
function resizeCanvases() {
BLOCK_SIZE = Math.floor(
Math.min(
(window.innerHeight * 0.7) / PLAYFIELD_ROWS, // Ensure it fits height-wise
(window.innerWidth * 0.3) / PLAYFIELD_COLS, // Ensure it fits width-wise if layout demands
),
);
if (window.innerWidth < 600) {
// For column layout on small screens
BLOCK_SIZE = Math.floor(
Math.min(
(window.innerHeight * 0.5) / PLAYFIELD_ROWS,
(window.innerWidth * 0.7) / PLAYFIELD_COLS,
),
);
}
BLOCK_SIZE = Math.max(10, BLOCK_SIZE); // Minimum block size
tetrisCanvas.width = PLAYFIELD_COLS * BLOCK_SIZE;
tetrisCanvas.height = PLAYFIELD_ROWS * BLOCK_SIZE;
holdCanvas.width = PREVIEW_BOX_SIZE * BLOCK_SIZE;
holdCanvas.height = PREVIEW_BOX_SIZE * BLOCK_SIZE;
nextCanvas.width = PREVIEW_BOX_SIZE * BLOCK_SIZE;
nextCanvas.height =
PREVIEW_BOX_SIZE * BLOCK_SIZE * NEXT_QUEUE_SIZE +
(NEXT_QUEUE_SIZE - 1) * (BLOCK_SIZE / 2);
draw(); // Redraw after resize
}
window.addEventListener("resize", resizeCanvases);
function drawBlock(
ctxToUse,
x,
y,
color,
currentBlockSize = BLOCK_SIZE,
isGhost = false,
) {
if (isGhost) {
ctxToUse.fillStyle = COLORS.GHOST;
ctxToUse.strokeStyle = "rgba(255, 255, 255, 0.5)";
ctxToUse.lineWidth = 1;
} else {
// Create subtle gradient
const gradient = ctxToUse.createLinearGradient(
x * currentBlockSize,
y * currentBlockSize,
x * currentBlockSize + currentBlockSize,
y * currentBlockSize + currentBlockSize,
);
gradient.addColorStop(0, lightenColor(color, 20));
gradient.addColorStop(1, color);
ctxToUse.fillStyle = gradient;
ctxToUse.strokeStyle = darkenColor(color, 30); // Soft border
ctxToUse.lineWidth = currentBlockSize * 0.05; // Relative border size
}
// Rounded corners
const radius = currentBlockSize * 0.15;
ctxToUse.beginPath();
ctxToUse.moveTo(x * currentBlockSize + radius, y * currentBlockSize);
ctxToUse.lineTo(
x * currentBlockSize + currentBlockSize - radius,
y * currentBlockSize,
);
ctxToUse.quadraticCurveTo(
x * currentBlockSize + currentBlockSize,
y * currentBlockSize,
x * currentBlockSize + currentBlockSize,
y * currentBlockSize + radius,
);
ctxToUse.lineTo(
x * currentBlockSize + currentBlockSize,
y * currentBlockSize + currentBlockSize - radius,
);
ctxToUse.quadraticCurveTo(
x * currentBlockSize + currentBlockSize,
y * currentBlockSize + currentBlockSize,
x * currentBlockSize + currentBlockSize - radius,
y * currentBlockSize + currentBlockSize,
);
ctxToUse.lineTo(
x * currentBlockSize + radius,
y * currentBlockSize + currentBlockSize,
);
ctxToUse.quadraticCurveTo(
x * currentBlockSize,
y * currentBlockSize + currentBlockSize,
x * currentBlockSize,
y * currentBlockSize + currentBlockSize - radius,
);
ctxToUse.lineTo(x * currentBlockSize, y * currentBlockSize + radius);
ctxToUse.quadraticCurveTo(
x * currentBlockSize,
y * currentBlockSize,
x * currentBlockSize + radius,
y * currentBlockSize,
);
ctxToUse.closePath();
ctxToUse.fill();
ctxToUse.stroke();
}
function lightenColor(hex, percent) {
hex = hex.replace(/^#/, "");
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const newR = Math.min(
255,
r + Math.floor((255 - r) * (percent / 100)),
);
const newG = Math.min(
255,
g + Math.floor((255 - g) * (percent / 100)),
);
const newB = Math.min(
255,
b + Math.floor((255 - b) * (percent / 100)),
);
return `#${newR.toString(16).padStart(2, "0")}${newG.toString(16).padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`;
}
function darkenColor(hex, percent) {
hex = hex.replace(/^#/, "");
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const newR = Math.max(0, r - Math.floor(r * (percent / 100)));
const newG = Math.max(0, g - Math.floor(g * (percent / 100)));
const newB = Math.max(0, b - Math.floor(b * (percent / 100)));
return `#${newR.toString(16).padStart(2, "0")}${newG.toString(16).padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`;
}
function drawPlayfield() {
// Background
ctx.fillStyle = COLORS.BACKGROUND;
ctx.fillRect(0, 0, tetrisCanvas.width, tetrisCanvas.height);
// Grid lines (subtle)
ctx.strokeStyle = COLORS.GRID;
ctx.lineWidth = 0.5;
for (let c = 1; c < PLAYFIELD_COLS; c++) {
ctx.beginPath();
ctx.moveTo(c * BLOCK_SIZE, 0);
ctx.lineTo(c * BLOCK_SIZE, PLAYFIELD_ROWS * BLOCK_SIZE);
ctx.stroke();
}
for (let r = 1; r < PLAYFIELD_ROWS; r++) {
ctx.beginPath();
ctx.moveTo(0, r * BLOCK_SIZE);
ctx.lineTo(PLAYFIELD_COLS * BLOCK_SIZE, r * BLOCK_SIZE);
ctx.stroke();
}
// Landed blocks
for (let r = HIDDEN_ROWS; r < TOTAL_ROWS; r++) {
for (let c = 0; c < PLAYFIELD_COLS; c++) {
if (playfield[r][c]) {
// Check if this line is being cleared
if (linesBeingCleared.includes(r)) {
const 사라짐정도 =
lineClearAnimationTimer / LINE_CLEAR_ANIMATION_DURATION;
const alpha = Math.max(0, 사라짐정도 * 1.5 - 0.5); // Fade out effect
const flashColor = `rgba(255, 255, 255, ${alpha})`;
// Draw original block slightly faded, then flash overlay
drawBlock(
ctx,
c,
r - HIDDEN_ROWS,
playfield[r][c],
BLOCK_SIZE,
);
const xPos = c * BLOCK_SIZE;
const yPos = (r - HIDDEN_ROWS) * BLOCK_SIZE;
ctx.fillStyle = flashColor;
ctx.fillRect(xPos, yPos, BLOCK_SIZE, BLOCK_SIZE);
} else {
drawBlock(
ctx,
c,
r - HIDDEN_ROWS,
playfield[r][c],
BLOCK_SIZE,
);
}
}
}
}
}
function drawPiece(
piece,
targetCtx,
offsetX = 0,
offsetY = 0,
blockSize = BLOCK_SIZE,
isGhost = false,
) {
const { shape, color, x, y } = piece;
targetCtx.globalAlpha = isGhost ? 0.5 : 1; // Ghost piece transparency
for (let r = 0; r < shape.length; r++) {
for (let c = 0; c < shape[r].length; c++) {
if (shape[r][c]) {
const drawX = x + c + offsetX;
const drawY = y + r + offsetY - HIDDEN_ROWS; // Adjust for hidden rows when drawing on playfield
// Only draw visible parts of the piece
if (drawY >= 0) {
drawBlock(targetCtx, drawX, drawY, color, blockSize, isGhost);
}
}
}
}
targetCtx.globalAlpha = 1; // Reset alpha
}
function drawPieceInPreview(
targetCtx,
type,
canvasWidth,
canvasHeight,
) {
targetCtx.clearRect(0, 0, canvasWidth, canvasHeight);
if (!type) return;
const pieceData = TETROMINOES[type];
const shape = pieceData.shapes[0]; // Default rotation
const color = pieceData.color;
// Calculate scale and offsets to center the piece
const pieceHeight = shape.length; // Assuming square piece matrices
const pieceWidth = shape[0].length; // Or find actual max width/height of blocks
// Use smaller block size for previews if needed, or scale canvas drawing
const previewBlockSize = Math.min(
canvasWidth / PREVIEW_BOX_SIZE,
canvasHeight / PREVIEW_BOX_SIZE,
);
const offsetX = (PREVIEW_BOX_SIZE - pieceWidth) / 2;
const offsetY = (PREVIEW_BOX_SIZE - pieceHeight) / 2;
for (let r = 0; r < shape.length; r++) {
for (let c = 0; c < shape[r].length; c++) {
if (shape[r][c]) {
drawBlock(
targetCtx,
c + offsetX,
r + offsetY,
color,
previewBlockSize,
);
}
}
}
}
function drawGhostPiece() {
if (!currentPiece || isGameOver) return;
let ghostY = currentPiece.y;
while (
isValidPosition(currentPiece.x, ghostY + 1, currentPiece.shape)
) {
ghostY++;
}
if (ghostY > currentPiece.y) {
const ghostPiece = {
...currentPiece,
y: ghostY,
color: COLORS.GHOST,
};
drawPiece(ghostPiece, ctx, 0, 0, BLOCK_SIZE, true);
}
}
function drawNextQueue() {
nextCtx.clearRect(0, 0, nextCanvas.width, nextCanvas.height);
const previewHeightPerPiece = nextCanvas.height / NEXT_QUEUE_SIZE;
for (let i = 0; i < NEXT_QUEUE_SIZE; i++) {
if (nextPieces[i]) {
const type = nextPieces[i];
const pieceData = TETROMINOES[type];
const shape = pieceData.shapes[0];
const color = pieceData.color;
const previewBlockSize = Math.min(
nextCanvas.width / PREVIEW_BOX_SIZE,
(previewHeightPerPiece / PREVIEW_BOX_SIZE) * 0.8,
); // 0.8 for padding
const pieceMatrixHeight =
shape.filter((row) => row.some((cell) => cell === 1)).length ||
1;
const pieceMatrixWidth =
Math.max(
...shape
.map((row) => row.lastIndexOf(1) - row.indexOf(1) + 1)
.filter((w) => w > 0),
) || 1;
const offsetX =
(nextCanvas.width / previewBlockSize - pieceMatrixWidth) / 2;
// Center vertically within its slot
const slotCenterY =
(i * previewHeightPerPiece + previewHeightPerPiece / 2) /
previewBlockSize;
const offsetY = slotCenterY - pieceMatrixHeight / 2;
for (let r = 0; r < shape.length; r++) {
for (let c = 0; c < shape[r].length; c++) {
if (shape[r][c]) {
// Find actual top-left of piece blocks to align
let minR = shape.length,
minC = shape[0].length;
for (let sr = 0; sr < shape.length; sr++)
for (let sc = 0; sc < shape[sr].length; sc++)
if (shape[sr][sc]) {
minR = Math.min(minR, sr);
minC = Math.min(minC, sc);
}
drawBlock(
nextCtx,
c - minC + offsetX,
r - minR + offsetY,
color,
previewBlockSize,
);
}
}
}
}
}
}
function drawHeldPiece() {
drawPieceInPreview(
holdCtx,
heldPiece,
holdCanvas.width,
holdCanvas.height,
);
}
function draw() {
ctx.clearRect(0, 0, tetrisCanvas.width, tetrisCanvas.height);
drawPlayfield();
if (!isGameOver && currentPiece) {
drawGhostPiece();
drawPiece(currentPiece, ctx);
}
drawNextQueue();
drawHeldPiece();
}
// --- UI Updates & Messages ---
function updateUI() {
scoreEl.textContent = score;
levelEl.textContent = level;
linesEl.textContent = linesClearedTotal;
highScoreEl.textContent = highScore;
}
function updateHighScore() {
if (score > highScore) {
highScore = score;
localStorage.setItem("tetrisHighScore", highScore);
}
}
function updateGravityInterval() {
// Based on classic Tetris speeds (frames per grid cell at 60 FPS)
// Levels cap at 29 in terms of speed generally.
const levelSpeeds = [
800,
717,
633,
550,
467,
383,
300,
217,
133,
100, // Levels 1-10
83,
83,
83,
67,
67,
67,
50,
50,
50,
33, // Levels 11-20
// Speeds continue to increase for higher levels (e.g. 33ms for 19-28, then 17ms for 29+)
];
// For levels beyond this array, use the last defined speed or a faster one.
let speedIndex = Math.min(level - 1, levelSpeeds.length - 1);
if (level > 20 && level <= 29) speedIndex = levelSpeeds.length - 1; // 33ms
if (level > 29) speedIndex = levelSpeeds.length - 1; // Could go to 17ms (1 frame)
gravityInterval = levelSpeeds[speedIndex];
}
function showGameOverMessage() {
messageTitleEl.textContent = "Game Over";
messageTitleEl.id = "game-over-title"; // For specific styling
messageTextEl.textContent = `Score: ${score}. Press R to Restart.`;
messageOverlay.style.display = "flex";
}
function togglePause() {
if (isGameOver) return;
isPaused = !isPaused;
pauseButton.textContent = isPaused ? "Resume (P)" : "Pause (P)";
if (isPaused) {
sounds.pause && sounds.pause();
messageTitleEl.textContent = "Paused";
messageTitleEl.id = "pause-title";
messageTextEl.textContent = "Press P to Resume";
messageOverlay.style.display = "flex";
// Stop game loop updates but keep rendering for pause screen
// The gameLoop structure already handles this by skipping updates if isPaused
} else {
sounds.unpause && sounds.unpause();
messageOverlay.style.display = "none";
// Resume game loop (gravity timer might need adjustment for pause duration if not using delta time)
lastTime = performance.now(); // Reset lastTime to avoid large deltaTime jump
gravityTimer = 0; // Optionally reset gravity timer to prevent immediate drop after unpause
}
}
// --- Input Handling ---
function handleKeyDown(event) {
if (isGameOver && event.key.toLowerCase() !== "r") return; // Only allow reset if game over
// Allow R and P always (if not game over for P)
if (event.key.toLowerCase() === "r") {
initGame();
return;
}
if (event.key.toLowerCase() === "p") {
togglePause();
return;
}
if (event.key.toLowerCase() === "f") {
toggleFullScreen();
return;
}
if (isPaused || linesBeingCleared.length > 0) return; // No piece controls if paused or clearing lines
switch (event.key.toLowerCase()) {
case "arrowleft":
case "a":
if (movePiece(-1, 0)) sounds.move && sounds.move();
break;
case "arrowright":
case "d":
if (movePiece(1, 0)) sounds.move && sounds.move();
break;
case "arrowdown":
case "s":
softDrop();
break;
case "arrowup":
case "w":
rotatePiece(1); // Clockwise
// rotatePiece(-1); // Counter-clockwise could be another key e.g. 'z' or 'ctrl'
break;
case " ": // Space for hard drop
event.preventDefault(); // Prevent page scroll
hardDrop();
break;
case "shift": // Hold
case "c":
event.preventDefault();
holdPieceAction();
break;
}
draw(); // Redraw immediately on input for responsiveness
}
document.addEventListener("keydown", handleKeyDown);
// Button event listeners
pauseButton.addEventListener("click", togglePause);
resetButton.addEventListener("click", initGame);
fullscreenButton.addEventListener("click", toggleFullScreen);
muteButton.addEventListener("click", () => {
isMuted = !isMuted;
muteButton.textContent = isMuted ? "Unmute" : "Mute";
});
// --- Fullscreen ---
function toggleFullScreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
console.warn(
`Error attempting to enable full-screen mode: ${err.message} (${err.name})`,
);
});
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
}
// --- Initial Setup ---
resizeCanvases(); // Initial sizing
initGame(); // Start the game
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment