Skip to content

Instantly share code, notes, and snippets.

@basperheim
Created August 12, 2025 14:14
Show Gist options
  • Select an option

  • Save basperheim/3f619f4fddadd9f170fa5cbc23857dd6 to your computer and use it in GitHub Desktop.

Select an option

Save basperheim/3f619f4fddadd9f170fa5cbc23857dd6 to your computer and use it in GitHub Desktop.
Tetris clone written in Python using the Pygame high-level SDL wrapper.
#!/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