Created
August 12, 2025 14:14
-
-
Save basperheim/3f619f4fddadd9f170fa5cbc23857dd6 to your computer and use it in GitHub Desktop.
Tetris clone written in Python using the Pygame high-level SDL wrapper.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # Tetris clone in Python + Pygame | |
| # No assets needed. Run: python tetris.py | |
| # Controls: | |
| # Left/Right: move | |
| # Down: soft drop | |
| # Up / X: rotate CW | |
| # Z: rotate CCW | |
| # Space: hard drop | |
| # P: pause | |
| # R: restart after game over | |
| # Q or ESC: quit | |
| import sys | |
| import random | |
| import pygame | |
| # ---- Config ---- | |
| COLS, ROWS = 10, 20 | |
| BLOCK = 32 # pixel size of a cell | |
| SIDE_PANEL = 200 | |
| TOP_MARGIN = 40 | |
| WIDTH = COLS * BLOCK + SIDE_PANEL | |
| HEIGHT = ROWS * BLOCK + TOP_MARGIN | |
| FPS = 60 | |
| # Speed config | |
| START_FALL_MS = 900 | |
| MIN_FALL_MS = 80 | |
| LEVEL_UP_LINES = 10 | |
| # Scoring (roughly NES-like but simplified) | |
| SCORES = {1: 100, 2: 300, 3: 500, 4: 800} | |
| # Colors | |
| BG = (12, 13, 18) | |
| GRID = (32, 34, 45) | |
| WHITE = (235, 235, 235) | |
| DIM = (160, 160, 160) | |
| PAUSE_OVERLAY = (0, 0, 0, 140) | |
| COLORS = { | |
| 'I': (0, 240, 240), | |
| 'J': (0, 0, 240), | |
| 'L': (240, 160, 0), | |
| 'O': (240, 240, 0), | |
| 'S': (0, 240, 0), | |
| 'T': (160, 0, 240), | |
| 'Z': (240, 0, 0), | |
| 'GHOST': (120, 120, 120), | |
| } | |
| # Shapes defined inside a 4x4 box using (x,y) coords (0..3). | |
| # We'll rotate with a simple 4x4 matrix transform (Super Rotation System kicks – not implemented; simple wall kicks only). | |
| BASE_SHAPES = { | |
| 'I': [(0,1),(1,1),(2,1),(3,1)], | |
| 'J': [(0,0),(0,1),(1,1),(2,1)], | |
| 'L': [(2,0),(0,1),(1,1),(2,1)], | |
| 'O': [(1,0),(2,0),(1,1),(2,1)], | |
| 'S': [(1,0),(2,0),(0,1),(1,1)], | |
| 'T': [(1,0),(0,1),(1,1),(2,1)], | |
| 'Z': [(0,0),(1,0),(1,1),(2,1)], | |
| } | |
| # Simple 7-bag randomizer | |
| class BagRandom: | |
| def __init__(self): | |
| self.bag = [] | |
| def next(self): | |
| if not self.bag: | |
| self.bag = list(BASE_SHAPES.keys()) | |
| random.shuffle(self.bag) | |
| return self.bag.pop() | |
| def rotate_point_cw(x, y): | |
| # rotate (x,y) in 4x4 box clockwise -> (3 - y, x) | |
| return 3 - y, x | |
| def rotate_shape(shape, times): | |
| pts = shape[:] | |
| times %= 4 | |
| for _ in range(times): | |
| pts = [rotate_point_cw(x, y) for (x, y) in pts] | |
| return pts | |
| class Piece: | |
| def __init__(self, kind): | |
| self.kind = kind | |
| self.rot = 0 | |
| self.pos = [3, -1] # spawn a tad above playfield | |
| self.blocks = BASE_SHAPES[kind] | |
| def cells(self): | |
| for (x, y) in rotate_shape(self.blocks, self.rot): | |
| yield self.pos[0] + x, self.pos[1] + y | |
| def ghost_pos(self, board): | |
| gx, gy = self.pos[:] | |
| while True: | |
| gy2 = gy + 1 | |
| if not can_place(board, self.blocks, self.rot, (gx, gy2)): | |
| return gx, gy | |
| gy = gy2 | |
| def empty_board(): | |
| return [[None for _ in range(COLS)] for _ in range(ROWS)] | |
| def in_bounds(x, y): | |
| return 0 <= x < COLS and y < ROWS # y can be negative (spawn area) | |
| def can_place(board, blocks, rot, pos): | |
| px, py = pos | |
| for (x, y) in rotate_shape(blocks, rot): | |
| cx, cy = px + x, py + y | |
| if cx < 0 or cx >= COLS or cy >= ROWS: | |
| return False | |
| if cy >= 0 and board[cy][cx] is not None: | |
| return False | |
| return True | |
| def lock_piece(board, piece): | |
| for (x, y) in piece.cells(): | |
| if y < 0: | |
| # Locked above top => game over condition handled by caller | |
| continue | |
| board[y][x] = piece.kind | |
| def clear_lines(board): | |
| cleared = 0 | |
| new_rows = [] | |
| for y in range(ROWS - 1, -1, -1): | |
| if all(board[y][x] is not None for x in range(COLS)): | |
| cleared += 1 | |
| else: | |
| new_rows.append(board[y]) | |
| for _ in range(cleared): | |
| new_rows.append([None for _ in range(COLS)]) | |
| new_rows.reverse() | |
| for y in range(ROWS): | |
| board[y] = new_rows[y] | |
| return cleared | |
| def soft_drop_interval_ms(level): | |
| ms = max(MIN_FALL_MS, START_FALL_MS - level * 60) | |
| return ms | |
| def draw_grid(surface): | |
| # Playfield bg | |
| pygame.draw.rect(surface, BG, (0, 0, COLS * BLOCK, HEIGHT)) | |
| # Grid lines | |
| for x in range(COLS + 1): | |
| pygame.draw.line(surface, GRID, (x * BLOCK, TOP_MARGIN), (x * BLOCK, HEIGHT)) | |
| for y in range(ROWS + 1): | |
| pygame.draw.line(surface, GRID, (0, TOP_MARGIN + y * BLOCK - TOP_MARGIN), (COLS * BLOCK, TOP_MARGIN + y * BLOCK - TOP_MARGIN)) | |
| def draw_cell(surface, x, y, color, alpha=255): | |
| rect = pygame.Rect(x * BLOCK, TOP_MARGIN + y * BLOCK, BLOCK, BLOCK) | |
| # base | |
| base = pygame.Surface((BLOCK - 1, BLOCK - 1), pygame.SRCALPHA) | |
| base.fill((*color, alpha)) | |
| surface.blit(base, rect.topleft) | |
| # bevel | |
| edge = pygame.Rect(rect.left, rect.top, BLOCK - 1, BLOCK - 1) | |
| pygame.draw.rect(surface, (0,0,0), edge, 1) | |
| def draw_board(surface, board): | |
| for y in range(ROWS): | |
| for x in range(COLS): | |
| k = board[y][x] | |
| if k: | |
| draw_cell(surface, x, y, COLORS[k]) | |
| def draw_piece(surface, piece, ghost=False): | |
| color = COLORS['GHOST'] if ghost else COLORS[piece.kind] | |
| alpha = 90 if ghost else 255 | |
| for (x, y) in piece.cells(): | |
| if y >= 0: | |
| draw_cell(surface, x, y, color, alpha) | |
| def draw_next_queue(surface, font, queue): | |
| x0 = COLS * BLOCK + 16 | |
| y0 = 80 | |
| text = font.render("NEXT", True, WHITE) | |
| surface.blit(text, (x0, 20)) | |
| for i, k in enumerate(queue[:5]): | |
| draw_mini_shape(surface, k, x0, y0 + i * 70) | |
| def draw_stats(surface, font, score, level, lines): | |
| x0 = COLS * BLOCK + 16 | |
| surface.blit(font.render(f"SCORE", True, WHITE), (x0, 420)) | |
| surface.blit(font.render(f"{score}", True, WHITE), (x0, 450)) | |
| surface.blit(font.render(f"LEVEL", True, WHITE), (x0, 490)) | |
| surface.blit(font.render(f"{level}", True, WHITE), (x0, 520)) | |
| surface.blit(font.render(f"LINES", True, WHITE), (x0, 560)) | |
| surface.blit(font.render(f"{lines}", True, WHITE), (x0, 590)) | |
| def draw_mini_shape(surface, kind, x0, y0): | |
| pts = BASE_SHAPES[kind] | |
| # center within 4x4 box at mini scale | |
| mini = 18 | |
| offx, offy = x0, y0 | |
| for (x, y) in pts: | |
| r = pygame.Rect(offx + x * mini, offy + y * mini, mini - 2, mini - 2) | |
| s = pygame.Surface((mini - 2, mini - 2), pygame.SRCALPHA) | |
| s.fill((*COLORS[kind], 255)) | |
| surface.blit(s, r.topleft) | |
| pygame.draw.rect(surface, (0,0,0), r, 1) | |
| def draw_topbar(surface, font): | |
| title = font.render("TETRIS", True, WHITE) | |
| surface.blit(title, (8, 8)) | |
| def try_move(piece, board, dx, dy, drot=0): | |
| np = Piece(piece.kind) | |
| np.rot = (piece.rot + drot) % 4 | |
| np.pos = [piece.pos[0] + dx, piece.pos[1] + dy] | |
| if can_place(board, np.blocks, np.rot, np.pos): | |
| piece.rot = np.rot | |
| piece.pos = np.pos | |
| return True | |
| # naive wall-kick: when rotating, try small offsets | |
| if drot != 0: | |
| for (kx, ky) in [(1,0),(-1,0),(0,-1),(2,0),(-2,0)]: | |
| np.pos = [piece.pos[0] + kx, piece.pos[1] + ky] | |
| if can_place(board, np.blocks, np.rot, np.pos): | |
| piece.rot = np.rot | |
| piece.pos = np.pos | |
| return True | |
| return False | |
| def game(): | |
| pygame.init() | |
| pygame.display.set_caption("Tetris (Pygame)") | |
| screen = pygame.display.set_mode((WIDTH, HEIGHT)) | |
| clock = pygame.time.Clock() | |
| font = pygame.font.SysFont("consolas", 24) | |
| big = pygame.font.SysFont("consolas", 48, bold=True) | |
| board = empty_board() | |
| bag = BagRandom() | |
| queue = [bag.next() for _ in range(5)] | |
| current = Piece(bag.next()) | |
| hold = None # (not implemented for simplicity) | |
| can_hold = False | |
| score = 0 | |
| total_lines = 0 | |
| level = 0 | |
| fall_ms = soft_drop_interval_ms(level) | |
| fall_timer = 0 | |
| soft_dropping = False | |
| paused = False | |
| game_over = False | |
| move_repeat_ms = 120 | |
| move_left_timer = 0 | |
| move_right_timer = 0 | |
| left_held = False | |
| right_held = False | |
| def pop_next(): | |
| nonlocal queue | |
| if len(queue) < 5: | |
| queue += [bag.next() for _ in range(5 - len(queue))] | |
| return queue.pop(0) | |
| def spawn_new(): | |
| nonlocal current, can_hold | |
| current = Piece(pop_next()) | |
| can_hold = True | |
| # Force down one if starting above | |
| if not can_place(board, current.blocks, current.rot, current.pos): | |
| return False | |
| return True | |
| def hard_drop(): | |
| nonlocal score | |
| # award 2 pts per cell hard-dropped like modern guideline | |
| steps = 0 | |
| while try_move(current, board, 0, 1): | |
| steps += 1 | |
| score += steps * 2 | |
| lock_and_continue() | |
| def lock_and_continue(): | |
| nonlocal score, total_lines, level, fall_ms, game_over | |
| lock_piece(board, current) | |
| cleared = clear_lines(board) | |
| if cleared: | |
| score += SCORES.get(cleared, 0) * (1 + level // 2) | |
| total_lines += cleared | |
| level = total_lines // LEVEL_UP_LINES | |
| fall_ms = soft_drop_interval_ms(level) | |
| if any(board[0][x] is not None for x in range(COLS)): | |
| game_over = True | |
| return | |
| if not spawn_new(): | |
| game_over = True | |
| spawn_new() | |
| while True: | |
| dt = clock.tick(FPS) | |
| if not paused and not game_over: | |
| fall_timer += dt | |
| if left_held: | |
| move_left_timer += dt | |
| if right_held: | |
| move_right_timer += dt | |
| for event in pygame.event.get(): | |
| if event.type == pygame.QUIT: | |
| pygame.quit() | |
| sys.exit(0) | |
| elif event.type == pygame.KEYDOWN: | |
| if event.key in (pygame.K_ESCAPE, pygame.K_q): | |
| pygame.quit() | |
| sys.exit(0) | |
| if event.key == pygame.K_p and not game_over: | |
| paused = not paused | |
| if game_over and event.key == pygame.K_r: | |
| # restart | |
| board = empty_board() | |
| bag = BagRandom() | |
| queue = [bag.next() for _ in range(5)] | |
| current = Piece(bag.next()) | |
| score = 0 | |
| total_lines = 0 | |
| level = 0 | |
| fall_ms = soft_drop_interval_ms(level) | |
| fall_timer = 0 | |
| paused = False | |
| game_over = False | |
| left_held = right_held = False | |
| move_left_timer = move_right_timer = 0 | |
| spawn_new() | |
| if paused or game_over: | |
| continue | |
| if event.key == pygame.K_LEFT: | |
| left_held = True | |
| move_left_timer = 0 | |
| try_move(current, board, -1, 0) | |
| elif event.key == pygame.K_RIGHT: | |
| right_held = True | |
| move_right_timer = 0 | |
| try_move(current, board, 1, 0) | |
| elif event.key == pygame.K_DOWN: | |
| soft_dropping = True | |
| elif event.key in (pygame.K_UP, pygame.K_x): | |
| try_move(current, board, 0, 0, drot=1) | |
| elif event.key == pygame.K_z: | |
| try_move(current, board, 0, 0, drot=-1) | |
| elif event.key == pygame.K_SPACE: | |
| hard_drop() | |
| elif event.type == pygame.KEYUP: | |
| if event.key == pygame.K_LEFT: | |
| left_held = False | |
| elif event.key == pygame.K_RIGHT: | |
| right_held = False | |
| elif event.key == pygame.K_DOWN: | |
| soft_dropping = False | |
| # Auto move repeat for held keys | |
| if not paused and not game_over: | |
| if left_held and move_left_timer >= move_repeat_ms: | |
| if try_move(current, board, -1, 0): | |
| move_left_timer = 0 | |
| if right_held and move_right_timer >= move_repeat_ms: | |
| if try_move(current, board, 1, 0): | |
| move_right_timer = 0 | |
| # Gravity | |
| gravity_ms = 40 if soft_dropping else fall_ms | |
| if fall_timer >= gravity_ms: | |
| fall_timer = 0 | |
| if not try_move(current, board, 0, 1): | |
| lock_and_continue() | |
| # Draw | |
| screen.fill(BG) | |
| draw_grid(screen) | |
| draw_board(screen, board) | |
| # Ghost | |
| gx, gy = current.ghost_pos(board) | |
| ghost = Piece(current.kind) | |
| ghost.pos = [gx, gy] | |
| ghost.rot = current.rot | |
| draw_piece(screen, ghost, ghost=True) | |
| # Current | |
| draw_piece(screen, current, ghost=False) | |
| # Side panel | |
| pygame.draw.rect(screen, BG, (COLS * BLOCK, 0, SIDE_PANEL, HEIGHT)) | |
| draw_topbar(screen, big) | |
| draw_next_queue(screen, font, queue) | |
| draw_stats(screen, font, score, level, total_lines) | |
| if paused and not game_over: | |
| overlay = pygame.Surface((COLS * BLOCK, HEIGHT - TOP_MARGIN), pygame.SRCALPHA) | |
| overlay.fill(PAUSE_OVERLAY) | |
| screen.blit(overlay, (0, TOP_MARGIN)) | |
| label = big.render("PAUSED", True, WHITE) | |
| screen.blit(label, (WIDTH//2 - label.get_width()//2 - SIDE_PANEL//2, HEIGHT//2 - label.get_height()//2)) | |
| if game_over: | |
| overlay = pygame.Surface((COLS * BLOCK, HEIGHT - TOP_MARGIN), pygame.SRCALPHA) | |
| overlay.fill(PAUSE_OVERLAY) | |
| screen.blit(overlay, (0, TOP_MARGIN)) | |
| g1 = big.render("GAME OVER", True, WHITE) | |
| g2 = font.render("Press R to restart", True, WHITE) | |
| screen.blit(g1, (WIDTH//2 - g1.get_width()//2 - SIDE_PANEL//2, HEIGHT//2 - 60)) | |
| screen.blit(g2, (WIDTH//2 - g2.get_width()//2 - SIDE_PANEL//2, HEIGHT//2)) | |
| pygame.display.flip() | |
| if __name__ == "__main__": | |
| try: | |
| game() | |
| except Exception as e: | |
| print("Error:", e) | |
| pygame.quit() | |
| raise |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment