I made a 30fps CLI Tetris game in PHP after watching the Tetris movie

5 min read Original article ↗
#!/usr/bin/env php <?php /** * Tetris - A Terminal-Based Implementation * * @author Alireza Bashiri * @version 1.0 * @license MIT * * Created after watching the Tetris movie (2023), which motivated me to both learn * something fun and explore how optimized we can make a 30 FPS terminal game. */ class Tetris { private $board; private $width = 10; private $height = 20; private $currentPiece; private $currentX; private $currentY; private $currentRotation = 0; private $score = 0; private $gameOver = false; private $renderBuffer = ''; private $pieceCache = []; private $pieces = [ // I piece - all 4 rotations pre-calculated [ [[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]] ], // O piece [ [[1,1],[1,1]], [[1,1],[1,1]], [[1,1],[1,1]], [[1,1],[1,1]] ], // T piece [ [[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]] ], // S piece [ [[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]] ], // Z piece [ [[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]] ], // J piece [ [[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 piece [ [[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]] ] ]; private $pieceIndex; public function __construct() { $this->initBoard(); $this->spawnPiece(); } private function initBoard() { $this->board = array_fill(0, $this->height, 0); for ($y = 0; $y < $this->height; $y++) { $this->board[$y] = 0; } } private function spawnPiece() { $this->pieceIndex = mt_rand(0, count($this->pieces) - 1); $this->currentRotation = 0; $this->currentPiece = $this->pieces[$this->pieceIndex][$this->currentRotation]; $this->currentX = floor(($this->width - count($this->currentPiece[0])) / 2); $this->currentY = 0; if (!$this->isValidPosition($this->currentPiece, $this->currentX, $this->currentY)) { $this->gameOver = true; } } private function isValidPosition($piece, $x, $y) { $pieceHeight = count($piece); $pieceWidth = count($piece[0]); for ($py = 0; $py < $pieceHeight; $py++) { for ($px = 0; $px < $pieceWidth; $px++) { if (!$piece[$py][$px]) continue; $boardX = $x + $px; $boardY = $y + $py; if ($boardX < 0 || $boardX >= $this->width || $boardY >= $this->height) { return false; } if ($boardY >= 0 && ($this->board[$boardY] & (1 << $boardX))) { return false; } } } return true; } private function lockPiece() { $pieceHeight = count($this->currentPiece); $pieceWidth = count($this->currentPiece[0]); for ($py = 0; $py < $pieceHeight; $py++) { for ($px = 0; $px < $pieceWidth; $px++) { if (!$this->currentPiece[$py][$px]) continue; $boardY = $this->currentY + $py; $boardX = $this->currentX + $px; if ($boardY >= 0) { $this->board[$boardY] |= (1 << $boardX); } } } $this->clearLines(); $this->spawnPiece(); } private function clearLines() { $linesCleared = 0; $fullLine = (1 << $this->width) - 1; for ($y = $this->height - 1; $y >= 0; $y--) { if ($this->board[$y] === $fullLine) { array_splice($this->board, $y, 1); array_unshift($this->board, 0); $linesCleared++; $y++; } } if ($linesCleared > 0) { $points = [0, 100, 300, 500, 800]; $this->score += $points[min($linesCleared, 4)]; } } public function moveLeft() { if ($this->isValidPosition($this->currentPiece, $this->currentX - 1, $this->currentY)) { $this->currentX--; } } public function moveRight() { if ($this->isValidPosition($this->currentPiece, $this->currentX + 1, $this->currentY)) { $this->currentX++; } } public function moveDown() { if ($this->isValidPosition($this->currentPiece, $this->currentX, $this->currentY + 1)) { $this->currentY++; return true; } $this->lockPiece(); return false; } public function hardDrop() { $dropDistance = 0; while ($this->isValidPosition($this->currentPiece, $this->currentX, $this->currentY + 1)) { $this->currentY++; $dropDistance++; } $this->score += $dropDistance * 2; $this->lockPiece(); } public function rotate() { $newRotation = ($this->currentRotation + 1) % 4; $rotatedPiece = $this->pieces[$this->pieceIndex][$newRotation]; if ($this->isValidPosition($rotatedPiece, $this->currentX, $this->currentY)) { $this->currentRotation = $newRotation; $this->currentPiece = $rotatedPiece; return; } // Wall kick attempts $kicks = [[-1, 0], [1, 0], [0, -1], [-2, 0], [2, 0]]; foreach ($kicks as $kick) { if ($this->isValidPosition($rotatedPiece, $this->currentX + $kick[0], $this->currentY + $kick[1])) { $this->currentX += $kick[0]; $this->currentY += $kick[1]; $this->currentRotation = $newRotation; $this->currentPiece = $rotatedPiece; return; } } } public function render() { $this->renderBuffer = "\033[H\033[J"; // Create display bitmap $display = $this->board; // Add current piece to display $pieceHeight = count($this->currentPiece); $pieceWidth = count($this->currentPiece[0]); for ($py = 0; $py < $pieceHeight; $py++) { for ($px = 0; $px < $pieceWidth; $px++) { if (!$this->currentPiece[$py][$px]) continue; $boardY = $this->currentY + $py; $boardX = $this->currentX + $px; if ($boardY >= 0 && $boardY < $this->height && $boardX >= 0 && $boardX < $this->width) { $display[$boardY] |= (1 << $boardX); } } } // Build render buffer $this->renderBuffer .= "<!"; for ($x = 0; $x < $this->width; $x++) { $this->renderBuffer .= "=="; } $this->renderBuffer .= "!>\n"; for ($y = 0; $y < $this->height; $y++) { $this->renderBuffer .= "<!"; for ($x = 0; $x < $this->width; $x++) { $this->renderBuffer .= ($display[$y] & (1 << $x)) ? "[]" : ". "; } $this->renderBuffer .= "!>\n"; } $this->renderBuffer .= "<!"; for ($x = 0; $x < $this->width; $x++) { $this->renderBuffer .= "=="; } $this->renderBuffer .= "!>\n"; $this->renderBuffer .= "\nScore: " . $this->score . "\n"; $this->renderBuffer .= "\nControls: a/d:move s:drop w:rotate space:hard q:quit\n"; if ($this->gameOver) { $this->renderBuffer .= "\n*** GAME OVER ***\nFinal Score: " . $this->score . "\n"; } echo $this->renderBuffer; } public function isGameOver() { return $this->gameOver; } } // Terminal handling if (!function_exists('readline_callback_handler_install')) { function enableRawMode() { system('stty -icanon -echo min 0 time 0'); } function disableRawMode() { system('stty sane'); } } else { function enableRawMode() { readline_callback_handler_install('', function() {}); stream_set_blocking(STDIN, false); } function disableRawMode() { readline_callback_handler_remove(); stream_set_blocking(STDIN, true); } } function getChar() { return fgetc(STDIN); } // Main game loop $game = new Tetris(); enableRawMode(); register_shutdown_function('disableRawMode'); $lastDrop = microtime(true); $dropInterval = 0.5; $lastRender = 0; $renderInterval = 0.03333; // 30 FPS pcntl_signal(SIGINT, function() { disableRawMode(); echo "\nThanks for playing!\n"; exit(0); }); while (!$game->isGameOver()) { $currentTime = microtime(true); // Auto drop if ($currentTime - $lastDrop > $dropInterval) { $game->moveDown(); $lastDrop = $currentTime; } // Render at 30 FPS if ($currentTime - $lastRender > $renderInterval) { $game->render(); $lastRender = $currentTime; } // Handle input $input = getChar(); if ($input !== false && $input !== '') { switch (strtolower($input)) { case 'a': $game->moveLeft(); break; case 'd': $game->moveRight(); break; case 's': if ($game->moveDown()) { $lastDrop = $currentTime; } break; case 'w': $game->rotate(); break; case ' ': $game->hardDrop(); $lastDrop = $currentTime; break; case 'q': disableRawMode(); echo "\nThanks for playing!\n"; exit(0); } } pcntl_signal_dispatch(); usleep(10000); // 10ms } $game->render(); disableRawMode(); echo "\nPress any key to exit...\n"; fgetc(STDIN);