Skip to content

Instantly share code, notes, and snippets.

@shricodev
Last active May 24, 2025 13:16
Show Gist options
  • Save shricodev/8164e2c716bc0f0c362fa379e942f1df to your computer and use it in GitHub Desktop.
Save shricodev/8164e2c716bc0f0c362fa379e942f1df to your computer and use it in GitHub Desktop.
Blog - Tetris (OpenAI o3)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Neon Tetris</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
/* --- Layout -------------------------------------------------------------- */
html,
body {
margin: 0;
height: 100%;
background: #0b0d13;
color: #eee;
font-family:
system-ui,
Segoe UI,
sans-serif;
user-select: none;
overflow: hidden;
}
#wrap {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
canvas {
background: #0f1118;
outline: none;
}
/* --- Optional small overlay buttons ------------------------------------- */
#btns {
position: fixed;
top: 12px;
right: 12px;
display: flex;
gap: 8px;
}
button {
padding: 6px 12px;
background: #1e2233;
color: #9ce8ff;
border: 1px solid #355;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
}
button:hover {
background: #29304d;
}
</style>
</head>
<body>
<div id="wrap">
<canvas id="game" tabindex="0"></canvas>
</div>
<div id="btns">
<button id="pauseBtn">Pause (P)</button>
<button id="restartBtn">Restart (R)</button>
</div>
<script>
/* ===========================================================================
Neon Tetris – Vanilla-JS • HTML5 Canvas • Single File
=========================================================================== */
const COLS = 10,
ROWS = 20,
HIDDEN = 2; // playfield + hidden rows
const PREVIEW = 3; // pieces shown in next queue
const THEMES = {
// neon colors
I: "#32e0e0",
O: "#ffe76b",
T: "#d96bff",
S: "#6cff72",
Z: "#ff6b6b",
J: "#6b90ff",
L: "#ff9b36",
};
const DARK = "#0f1118",
GRID = "#141722";
/* Scoring (classic guideline) */
const LINE_SCORE = [0, 100, 300, 500, 800]; // per line count * level
const DROP_SOFT = 1,
DROP_HARD = 2;
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
let size; // pixel size of one block, set on resize
let frameId; // requestAnimationFrame handle
/* ---------------------------- Piece definitions --------------------------- */
const SHAPES = {
I: [
[
[0, 0, 0, 0],
[1, 1, 1, 1],
[0, 0, 0, 0],
[0, 0, 0, 0],
],
[
[0, 0, 1, 0],
[0, 0, 1, 0],
[0, 0, 1, 0],
[0, 0, 1, 0],
],
[
[0, 0, 0, 0],
[0, 0, 0, 0],
[1, 1, 1, 1],
[0, 0, 0, 0],
],
[
[0, 1, 0, 0],
[0, 1, 0, 0],
[0, 1, 0, 0],
[0, 1, 0, 0],
],
],
J: [
[
[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],
],
],
L: [
[
[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],
],
],
O: [
[
[1, 1],
[1, 1],
],
[
[1, 1],
[1, 1],
],
[
[1, 1],
[1, 1],
],
[
[1, 1],
[1, 1],
],
],
S: [
[
[0, 1, 1],
[1, 1, 0],
[0, 0, 0],
],
[
[0, 1, 0],
[0, 1, 1],
[0, 0, 1],
],
[
[0, 0, 0],
[0, 1, 1],
[1, 1, 0],
],
[
[1, 0, 0],
[1, 1, 0],
[0, 1, 0],
],
],
T: [
[
[0, 1, 0],
[1, 1, 1],
[0, 0, 0],
],
[
[0, 1, 0],
[0, 1, 1],
[0, 1, 0],
],
[
[0, 0, 0],
[1, 1, 1],
[0, 1, 0],
],
[
[0, 1, 0],
[1, 1, 0],
[0, 1, 0],
],
],
Z: [
[
[1, 1, 0],
[0, 1, 1],
[0, 0, 0],
],
[
[0, 0, 1],
[0, 1, 1],
[0, 1, 0],
],
[
[0, 0, 0],
[1, 1, 0],
[0, 1, 1],
],
[
[0, 1, 0],
[1, 1, 0],
[1, 0, 0],
],
],
};
/* ------------------------------ SRS kicks -------------------------------- */
const KICK_NORMAL = {
"0>1": [
[0, 0],
[-1, 0],
[-1, 1],
[0, -2],
[-1, -2],
],
"1>0": [
[0, 0],
[1, 0],
[1, -1],
[0, 2],
[1, 2],
],
"1>2": [
[0, 0],
[1, 0],
[1, -1],
[0, 2],
[1, 2],
],
"2>1": [
[0, 0],
[-1, 0],
[-1, 1],
[0, -2],
[-1, -2],
],
"2>3": [
[0, 0],
[1, 0],
[1, 1],
[0, -2],
[1, -2],
],
"3>2": [
[0, 0],
[-1, 0],
[-1, -1],
[0, 2],
[-1, 2],
],
"3>0": [
[0, 0],
[1, 0],
[1, 1],
[0, -2],
[1, -2],
],
"0>3": [
[0, 0],
[-1, 0],
[-1, -1],
[0, 2],
[-1, 2],
],
};
const KICK_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],
],
"1>2": [
[0, 0],
[-1, 0],
[2, 0],
[-1, 2],
[2, -1],
],
"2>1": [
[0, 0],
[1, 0],
[-2, 0],
[1, -2],
[-2, 1],
],
"2>3": [
[0, 0],
[2, 0],
[-1, 0],
[2, 1],
[-1, -2],
],
"3>2": [
[0, 0],
[-2, 0],
[1, 0],
[-2, -1],
[1, 2],
],
"3>0": [
[0, 0],
[1, 0],
[-2, 0],
[1, -2],
[-2, 1],
],
"0>3": [
[0, 0],
[-1, 0],
[2, 0],
[-1, 2],
[2, -1],
],
};
/* --------------------------- Utility helpers ----------------------------- */
const rand = (n) => Math.floor(Math.random() * n);
function createMatrix(w, h, fill = 0) {
return Array.from({ length: h }, () => Array(w).fill(fill));
}
function deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
/* ---------------------------- Game State --------------------------------- */
const game = {
board: createMatrix(COLS, ROWS + HIDDEN, 0),
current: null, // piece object
queue: [],
hold: null,
canHold: true,
score: 0,
lines: 0,
level: 1,
over: false,
paused: false,
dropTimer: 0,
dropDelay: 1000,
lockTimer: 0,
lockDelay: 500,
clearLines: [], // lines currently fading
clearTimer: 0,
};
/* --------------------------- Initialization ------------------------------ */
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
spawnPiece();
fillQueue();
gameLoop(0);
/* ------------------------- Event Listeners ------------------------------- */
canvas.focus();
document.addEventListener("keydown", handleKey);
document.getElementById("pauseBtn").onclick = () => togglePause();
document.getElementById("restartBtn").onclick = resetGame;
/* ----------------------------- Functions --------------------------------- */
function resizeCanvas() {
// choose size so board fits 60% height, 40% width
const h = window.innerHeight * 0.9;
const w = window.innerWidth * 0.5;
size = Math.floor(Math.min(h / (ROWS + 2), w / COLS));
canvas.width = size * (COLS + 12); // extra space for UI
canvas.height = size * (ROWS + HIDDEN);
}
function bagGenerator() {
const bag = ["I", "J", "L", "O", "S", "T", "Z"];
const out = [];
while (bag.length) out.push(bag.splice(rand(bag.length), 1)[0]);
return out;
}
function fillQueue() {
while (game.queue.length < PREVIEW + 1) {
game.queue.push(...bagGenerator());
}
}
function spawnPiece() {
fillQueue();
const type = game.queue.shift();
const piece = {
type,
rot: 0,
matrix: deepCopy(SHAPES[type][0]),
x: Math.floor(COLS / 2) - 2,
y: -HIDDEN,
};
if (collide(piece, piece.x, piece.y)) {
game.over = true;
}
game.current = piece;
game.canHold = true;
}
function rotate(dir) {
if (game.over || game.paused) return;
const p = game.current;
const oldRot = p.rot;
const newRot = (p.rot + dir + 4) % 4;
const kicks = (p.type === "I" ? KICK_I : KICK_NORMAL)[
`${oldRot}>${newRot}`
];
const newMatrix = SHAPES[p.type][newRot];
for (const [dx, dy] of kicks) {
if (!collide({ matrix: newMatrix }, p.x + dx, p.y + dy)) {
p.matrix = newMatrix;
p.rot = newRot;
p.x += dx;
p.y += dy;
playSound("rotate");
return;
}
}
}
function collide(obj, x, y) {
const m = obj.matrix;
for (let r = 0; r < m.length; r++) {
for (let c = 0; c < m[r].length; c++) {
if (!m[r][c]) continue;
const nx = x + c,
ny = y + r;
if (nx < 0 || nx >= COLS || ny >= ROWS + HIDDEN) return true;
if (ny >= 0 && game.board[ny][nx]) return true;
}
}
return false;
}
function mergePiece() {
const { matrix, x, y, type } = game.current;
for (let r = 0; r < matrix.length; r++) {
for (let c = 0; c < matrix[r].length; c++) {
if (matrix[r][c]) {
const ny = y + r;
if (ny >= 0) game.board[ny][x + c] = type;
}
}
}
}
function clearLines() {
const lines = [];
for (let r = ROWS + HIDDEN - 1; r >= 0; r--) {
if (game.board[r].every((v) => v)) {
lines.push(r);
}
}
if (lines.length) {
game.clearLines = lines;
game.clearTimer = 0;
animateClear(lines);
const pts = LINE_SCORE[lines.length] * game.level;
game.score += pts;
game.lines += lines.length;
if (game.lines >= game.level * 10) {
game.level++;
game.dropDelay = Math.max(
100,
1000 *
(0.8 - Math.min(0.7, (game.level - 1) * 0.007)) **
(game.level - 1),
);
}
} else {
spawnPiece();
}
}
function animateClear(lines) {
// remove lines after fade animation
setTimeout(() => {
for (const r of lines) {
game.board.splice(r, 1);
game.board.unshift(Array(COLS).fill(0));
}
spawnPiece();
}, 200); // 200ms fade
}
function hold() {
if (!game.canHold) return;
playSound("hold");
const cur = game.current.type;
if (game.hold) {
const swap = game.hold;
game.hold = cur;
spawnPieceFromHold(swap);
} else {
game.hold = cur;
spawnPiece();
}
game.canHold = false;
}
function spawnPieceFromHold(type) {
const piece = {
type,
rot: 0,
matrix: deepCopy(SHAPES[type][0]),
x: Math.floor(COLS / 2) - 2,
y: -HIDDEN,
};
game.current = piece;
}
function move(dx) {
const p = game.current;
if (!collide(p, p.x + dx, p.y)) {
p.x += dx;
playSound("move");
}
}
function softDrop() {
const p = game.current;
if (!collide(p, p.x, p.y + 1)) {
p.y++;
game.score += DROP_SOFT;
} else lockPiece();
}
function hardDrop() {
let n = 0;
while (!collide(game.current, game.current.x, game.current.y + 1)) {
game.current.y++;
n++;
}
game.score += n * DROP_HARD;
lockPiece(true);
}
function lockPiece(immediate = false) {
if (immediate) {
mergePiece();
clearLines();
return;
}
if (game.lockTimer === 0) {
game.lockTimer = performance.now();
}
}
function togglePause() {
if (game.over) return;
game.paused = !game.paused;
document.getElementById("pauseBtn").innerText = game.paused
? "Resume (P)"
: "Pause (P)";
if (!game.paused) gameLoop(performance.now());
}
function resetGame() {
Object.assign(game, {
board: createMatrix(COLS, ROWS + HIDDEN, 0),
queue: [],
hold: null,
score: 0,
lines: 0,
level: 1,
over: false,
paused: false,
dropTimer: 0,
dropDelay: 1000,
lockTimer: 0,
clearLines: [],
clearTimer: 0,
});
fillQueue();
spawnPiece();
canvas.focus();
}
function handleKey(e) {
if (game.over) return;
const kc = e.code;
if (
["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Space"].includes(
kc,
)
)
e.preventDefault();
if (game.paused && kc !== "KeyP") return;
switch (kc) {
case "ArrowLeft":
move(-1);
break;
case "ArrowRight":
move(1);
break;
case "ArrowUp":
rotate(1);
break;
case "KeyZ":
rotate(-1);
break;
case "ArrowDown":
softDrop();
break;
case "Space":
hardDrop();
break;
case "ShiftLeft":
case "ShiftRight":
hold();
break;
case "KeyP":
togglePause();
break;
case "KeyR":
resetGame();
break;
case "KeyF":
toggleFull();
break;
}
}
function toggleFull() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}
/* ------------------------- Game loop & timing --------------------------- */
function gameLoop(now) {
if (game.paused) {
cancelAnimationFrame(frameId);
return;
}
const delta = now - (game.prev || now);
game.prev = now;
/* gravity */
game.dropTimer += delta;
if (game.dropTimer > game.dropDelay) {
game.dropTimer = 0;
if (!collide(game.current, game.current.x, game.current.y + 1)) {
game.current.y++;
} else {
lockPiece();
}
}
/* lock delay */
if (game.lockTimer) {
if (performance.now() - game.lockTimer > game.lockDelay) {
mergePiece();
game.lockTimer = 0;
clearLines();
} else if (
!collide(game.current, game.current.x, game.current.y + 1)
) {
game.lockTimer = 0; // lifted
}
}
draw();
if (!game.over) frameId = requestAnimationFrame(gameLoop);
else drawGameOver();
}
/* ------------------------------ Rendering ------------------------------- */
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawPlayfield();
drawPiece(game.current);
drawGhost();
drawUI();
if (game.paused) drawPause();
}
function drawPlayfield() {
/* background grid */
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
ctx.fillStyle = GRID;
ctx.fillRect(c * size, (r + HIDDEN) * size, size - 1, size - 1);
const cell = game.board[r + HIDDEN][c];
if (cell) drawBlock(c, r + HIDDEN, THEMES[cell]);
}
}
/* fading lines */
if (game.clearLines.length) {
const t = (performance.now() % 200) / 200;
ctx.fillStyle = "rgba(255,255,255," + 0.7 * (1 - t) + ")";
for (const r of game.clearLines) {
ctx.fillRect(0, r * size, COLS * size, size);
}
}
}
function drawBlock(x, y, color, alpha = 1) {
const px = x * size,
py = y * size;
const grd = ctx.createLinearGradient(px, py, px + size, py + size);
grd.addColorStop(0, color);
grd.addColorStop(1, "#000");
ctx.fillStyle = grd;
ctx.globalAlpha = alpha;
ctx.beginPath();
const rad = 4;
ctx.moveTo(px + rad, py);
ctx.lineTo(px + size - rad, py);
ctx.quadraticCurveTo(px + size, py, px + size, py + rad);
ctx.lineTo(px + size, py + size - rad);
ctx.quadraticCurveTo(px + size, py + size, px + size - rad, py + size);
ctx.lineTo(px + rad, py + size);
ctx.quadraticCurveTo(px, py + size, px, py + size - rad);
ctx.lineTo(px, py + rad);
ctx.quadraticCurveTo(px, py, px + rad, py);
ctx.fill();
ctx.globalAlpha = 1;
}
function drawPiece(p) {
const { matrix, x, y, type } = p;
for (let r = 0; r < matrix.length; r++) {
for (let c = 0; c < matrix[r].length; c++) {
if (matrix[r][c]) {
const ny = y + r;
if (ny >= 0) drawBlock(x + c, ny, THEMES[type]);
}
}
}
}
function drawGhost() {
const p = game.current;
let gy = p.y;
while (!collide(p, p.x, gy + 1)) gy++;
for (let r = 0; r < p.matrix.length; r++) {
for (let c = 0; c < p.matrix[r].length; c++) {
if (p.matrix[r][c]) {
const ny = gy + r;
if (ny >= 0) drawBlock(p.x + c, ny, THEMES[p.type], 0.2);
}
}
}
}
function drawUI() {
const left = COLS * size + size;
ctx.fillStyle = "#9ce8ff";
ctx.font = size * 0.8 + "px Segoe UI";
ctx.fillText("NEXT", left, size * 1);
for (let i = 0; i < PREVIEW; i++) {
const type = game.queue[i];
if (!type) continue;
const m = SHAPES[type][0];
const offsetX = left;
const offsetY = size * (2 + i * 4);
for (let r = 0; r < m.length; r++) {
for (let c = 0; c < m[r].length; c++) {
if (m[r][c]) {
drawMiniBlock(
offsetX + c * size * 0.7,
offsetY + r * size * 0.7,
THEMES[type],
);
}
}
}
}
ctx.fillText("HOLD", left, size * (2 + PREVIEW * 4));
if (game.hold) {
const m = SHAPES[game.hold][0];
const offsetX = left;
const offsetY = size * (3 + PREVIEW * 4);
for (let r = 0; r < m.length; r++) {
for (let c = 0; c < m[r].length; c++) {
if (m[r][c])
drawMiniBlock(
offsetX + c * size * 0.7,
offsetY + r * size * 0.7,
THEMES[game.hold],
);
}
}
}
ctx.fillText("SCORE " + game.score, left, size * (ROWS - 6));
ctx.fillText("LINES " + game.lines, left, size * (ROWS - 5));
ctx.fillText("LEVEL " + game.level, left, size * (ROWS - 4));
}
function drawMiniBlock(px, py, color) {
const s = size * 0.7;
const grd = ctx.createLinearGradient(px, py, px + s, py + s);
grd.addColorStop(0, color);
grd.addColorStop(1, "#000");
ctx.fillStyle = grd;
ctx.fillRect(px, py, s, s);
}
function drawPause() {
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#fff";
ctx.font = size * 2 + "px Segoe UI";
ctx.textAlign = "center";
ctx.fillText("PAUSED", canvas.width / 2, canvas.height / 2);
ctx.textAlign = "start";
}
function drawGameOver() {
draw();
ctx.fillStyle = "rgba(0,0,0,0.7)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#ff5555";
ctx.font = size * 2 + "px Segoe UI";
ctx.textAlign = "center";
ctx.fillText("GAME OVER", canvas.width / 2, canvas.height / 2);
ctx.font = size + "px Segoe UI";
ctx.fillStyle = "#9ce8ff";
ctx.fillText(
"Press R to restart",
canvas.width / 2,
canvas.height / 2 + size * 2,
);
ctx.textAlign = "start";
}
/* ----------------------------- Sounds ----------------------------------- */
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
function beep(freq, dur) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = "square";
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(audioCtx.destination);
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(
0.001,
audioCtx.currentTime + dur / 1000,
);
osc.start();
osc.stop(audioCtx.currentTime + dur / 1000);
}
function playSound(event) {
switch (event) {
case "move":
beep(200, 30);
break;
case "rotate":
beep(300, 40);
break;
case "hold":
beep(150, 30);
break;
}
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment