#!/usr/bin/env python3 """ Simple Tetris using tkinter How to run: python3 tetris.py Controls: Left/Right: move piece Down: soft drop Up: rotate Space: hard drop P: pause/resume R: restart This is a compact, single-file implementation intended for learning/demo purposes. """ from __future__ import annotations import random import sys import time # Try to import tkinter robustly and allow graceful degradation if unavailable try: import tkinter as tk # type: ignore except Exception as _e1: # pragma: no cover - environment dependent try: # Python 2 fallback (some environments may still alias it) import Tkinter as tk # type: ignore except Exception as _e2: # pragma: no cover - environment dependent tk = None # type: ignore _tk_import_error = _e1 from dataclasses import dataclass from typing import List, Tuple, Optional # Game configuration COLS = 10 ROWS = 20 CELL = 30 # pixel size of a cell BORDER = 10 PREVIEW_COLS = 6 PREVIEW_ROWS = 6 # Speed settings (milliseconds per tick). Level increases speed. BASE_TICK = 700 LEVEL_SPEEDUP = 50 # ms reduced per level MIN_TICK = 90 # Scoring LINE_SCORES = {1: 100, 2: 300, 3: 500, 4: 800} SOFT_DROP_SCORE = 1 HARD_DROP_SCORE_PER_CELL = 2 LINES_PER_LEVEL = 10 # Colors and fonts (modern dark theme) BG_COLOR = "#0b0f14" # canvas/background GRID_COLOR = "#131a21" # subtle grid lines TEXT_COLOR = "#e6edf3" # primary text GHOST_COLOR = "#2b3640" # ghost piece # UI fonts FONT_FAMILY = "Helvetica" # broadly available modern sans FONT_LG = (FONT_FAMILY, 16, "bold") FONT_MD = (FONT_FAMILY, 12) FONT_SM = (FONT_FAMILY, 10) PIECE_COLORS = { 'I': "#23b9f2", # cyan 'J': "#5a7cff", # indigo 'L': "#ff9f43", # orange 'O': "#ffda6a", # yellow 'S': "#2ecc71", # green 'T': "#bd7bff", # purple 'Z': "#ff6b6b", # red } # Tetromino definitions as rotation states (list of (x,y) offsets) TETROMINOES = { 'I': [ [(0, 1), (1, 1), (2, 1), (3, 1)], [(2, 0), (2, 1), (2, 2), (2, 3)], [(0, 2), (1, 2), (2, 2), (3, 2)], [(1, 0), (1, 1), (1, 2), (1, 3)], ], 'J': [ [(0, 0), (0, 1), (1, 1), (2, 1)], [(1, 0), (2, 0), (1, 1), (1, 2)], [(0, 1), (1, 1), (2, 1), (2, 2)], [(1, 0), (1, 1), (0, 2), (1, 2)], ], 'L': [ [(2, 0), (0, 1), (1, 1), (2, 1)], [(1, 0), (1, 1), (1, 2), (2, 2)], [(0, 1), (1, 1), (2, 1), (0, 2)], [(0, 0), (1, 0), (1, 1), (1, 2)], ], 'O': [ [(1, 0), (2, 0), (1, 1), (2, 1)], [(1, 0), (2, 0), (1, 1), (2, 1)], [(1, 0), (2, 0), (1, 1), (2, 1)], [(1, 0), (2, 0), (1, 1), (2, 1)], ], 'S': [ [(1, 0), (2, 0), (0, 1), (1, 1)], [(1, 0), (1, 1), (2, 1), (2, 2)], [(1, 1), (2, 1), (0, 2), (1, 2)], [(0, 0), (0, 1), (1, 1), (1, 2)], ], 'T': [ [(1, 0), (0, 1), (1, 1), (2, 1)], [(1, 0), (1, 1), (2, 1), (1, 2)], [(0, 1), (1, 1), (2, 1), (1, 2)], [(1, 0), (0, 1), (1, 1), (1, 2)], ], 'Z': [ [(0, 0), (1, 0), (1, 1), (2, 1)], [(2, 0), (1, 1), (2, 1), (1, 2)], [(0, 1), (1, 1), (1, 2), (2, 2)], [(1, 0), (0, 1), (1, 1), (0, 2)], ], } @dataclass class Piece: kind: str x: int y: int rot: int def cells(self) -> List[Tuple[int, int]]: return [(self.x + dx, self.y + dy) for (dx, dy) in TETROMINOES[self.kind][self.rot]] def rotated(self, dr: int) -> "Piece": return Piece(self.kind, self.x, self.y, (self.rot + dr) % 4) def moved(self, dx: int, dy: int) -> "Piece": return Piece(self.kind, self.x + dx, self.y + dy, self.rot) class Board: def __init__(self, cols=COLS, rows=ROWS): self.cols = cols self.rows = rows self.grid: List[List[Optional[str]]] = [[None for _ in range(cols)] for _ in range(rows)] self.rng = random.Random() self.bag: List[str] = [] self.score = 0 self.lines_cleared = 0 self.level = 1 self.current: Optional[Piece] = None self.next_kind: str = self._draw_from_bag() self.hold_kind: Optional[str] = None self.hold_used = False def reset(self): self.grid = [[None for _ in range(self.cols)] for _ in range(self.rows)] self.score = 0 self.lines_cleared = 0 self.level = 1 self.bag = [] self.current = None self.next_kind = self._draw_from_bag() self.hold_kind = None self.hold_used = False def _draw_from_bag(self) -> str: if not self.bag: self.bag = list(TETROMINOES.keys()) self.rng.shuffle(self.bag) return self.bag.pop() def spawn(self) -> bool: kind = self.next_kind self.next_kind = self._draw_from_bag() # spawn near top center piece = Piece(kind, x=self.cols // 2 - 2, y=0, rot=0) # Adjust spawn y to avoid immediate collision for tall shapes if not self.valid(piece): piece = piece.moved(0, -1) if not self.valid(piece): return False self.current = piece self.hold_used = False return True def valid(self, piece: Piece) -> bool: for x, y in piece.cells(): if x < 0 or x >= self.cols or y >= self.rows: return False if y >= 0 and self.grid[y][x] is not None: return False return True def lock_piece(self): assert self.current is not None for x, y in self.current.cells(): if 0 <= y < self.rows and 0 <= x < self.cols: self.grid[y][x] = self.current.kind cleared = self.clear_lines() self.lines_cleared += cleared if cleared: self.score += LINE_SCORES.get(cleared, 0) * self.level # Level up every LINES_PER_LEVEL new_level = 1 + self.lines_cleared // LINES_PER_LEVEL if new_level > self.level: self.level = new_level self.current = None def clear_lines(self) -> int: new_rows = [row for row in self.grid if any(cell is None for cell in row)] cleared = self.rows - len(new_rows) while len(new_rows) < self.rows: new_rows.insert(0, [None for _ in range(self.cols)]) self.grid = new_rows return cleared def hard_drop_distance(self, piece: Optional[Piece] = None) -> int: if piece is None: piece = self.current if piece is None: return 0 dist = 0 probe = piece while self.valid(probe.moved(0, 1)): probe = probe.moved(0, 1) dist += 1 return dist def ghost_cells(self) -> List[Tuple[int, int]]: if not self.current: return [] d = self.hard_drop_distance(self.current) temp = self.current.moved(0, d) return temp.cells() def tick_down(self) -> bool: if self.current is None: return self.spawn() nxt = self.current.moved(0, 1) if self.valid(nxt): self.current = nxt return True # cannot move down -> lock self.lock_piece() return self.spawn() def move(self, dx: int): if not self.current: return nxt = self.current.moved(dx, 0) if self.valid(nxt): self.current = nxt def soft_drop(self) -> bool: if not self.current: return False nxt = self.current.moved(0, 1) if self.valid(nxt): self.current = nxt self.score += SOFT_DROP_SCORE return True return False def hard_drop(self): if not self.current: return d = self.hard_drop_distance(self.current) if d > 0: self.score += d * HARD_DROP_SCORE_PER_CELL self.current = self.current.moved(0, d) self.lock_piece() self.spawn() def rotate(self, dr: int): if not self.current: return cand = self.current.rotated(dr) # simple wall kicks: try shifting left/right/up a little for dx, dy in [(0, 0), (-1, 0), (1, 0), (-2, 0), (2, 0), (0, -1)]: test = Piece(cand.kind, cand.x + dx, cand.y + dy, cand.rot) if self.valid(test): self.current = test return def hold(self): if self.current is None or self.hold_used: return kind = self.current.kind if self.hold_kind is None: self.hold_kind = kind self.current = None self.spawn() else: self.current = Piece(self.hold_kind, x=self.cols // 2 - 2, y=0, rot=0) self.hold_kind = kind if not self.valid(self.current): # try one row up self.current = self.current.moved(0, -1) if not self.valid(self.current): # cannot place -> game will end on next tick pass self.hold_used = True class TetrisApp: def __init__(self, root: tk.Tk): self.root = root root.title("Tetris — Modern") root.configure(bg=BG_COLOR) self.board = Board() # layout side_w = PREVIEW_COLS * CELL width = BORDER * 3 + COLS * CELL + side_w height = BORDER * 2 + ROWS * CELL container = tk.Frame(root, bg=BG_COLOR) container.pack(padx=10, pady=10) left = tk.Frame(container, bg=BG_COLOR) left.grid(row=0, column=0, sticky="n") right = tk.Frame(container, bg=BG_COLOR) right.grid(row=0, column=1, sticky="n", padx=(10, 0)) self.canvas = tk.Canvas(left, width=COLS * CELL, height=ROWS * CELL, bg=BG_COLOR, highlightthickness=0) self.canvas.pack() self.info_lbl = tk.Label(right, text="Score: 0\nLines: 0\nLevel: 1", font=FONT_LG, fg=TEXT_COLOR, bg=BG_COLOR, justify="left") self.info_lbl.pack(anchor="w") self.next_lbl = tk.Label(right, text="Next:", font=FONT_MD, fg=TEXT_COLOR, bg=BG_COLOR) self.next_lbl.pack(anchor="w", pady=(10, 0)) self.preview = tk.Canvas(right, width=side_w, height=PREVIEW_ROWS * CELL, bg=BG_COLOR, highlightthickness=0) self.preview.pack() self.hold_lbl = tk.Label(right, text="Hold:", font=FONT_MD, fg=TEXT_COLOR, bg=BG_COLOR) self.hold_lbl.pack(anchor="w", pady=(10, 0)) self.hold_view = tk.Canvas(right, width=side_w, height=3 * CELL, bg=BG_COLOR, highlightthickness=0) self.hold_view.pack() self.help_lbl = tk.Label(right, text="Arrows to move/rotate\nSpace: Hard drop\nShift/H: Hold\nP: Pause R: Restart", font=FONT_SM, fg=TEXT_COLOR, bg=BG_COLOR, justify="left") self.help_lbl.pack(anchor="w", pady=(10, 0)) # game state self.paused = False self.game_over = False self.last_tick_time = time.time() # key bindings root.bind("", lambda e: self.on_left()) root.bind("", lambda e: self.on_right()) root.bind("", lambda e: self.on_down()) root.bind("", lambda e: self.on_rotate()) root.bind("", lambda e: self.on_hard_drop()) root.bind("", lambda e: self.on_hold()) root.bind("", lambda e: self.on_hold()) root.bind("h", lambda e: self.on_hold()) root.bind("H", lambda e: self.on_hold()) root.bind("p", lambda e: self.toggle_pause()) root.bind("P", lambda e: self.toggle_pause()) root.bind("r", lambda e: self.restart()) root.bind("R", lambda e: self.restart()) # start if not self.board.spawn(): self.game_over = True self.schedule_tick() self.redraw() def tick_interval(self) -> int: ms = max(MIN_TICK, BASE_TICK - (self.board.level - 1) * LEVEL_SPEEDUP) return ms def schedule_tick(self): self.root.after(self.tick_interval(), self.game_loop) def game_loop(self): if not self.paused and not self.game_over: alive = self.board.tick_down() if not alive: self.game_over = True self.redraw() self.schedule_tick() def on_left(self): if self.paused or self.game_over: return self.board.move(-1) self.redraw() def on_right(self): if self.paused or self.game_over: return self.board.move(1) self.redraw() def on_down(self): if self.paused or self.game_over: return moved = self.board.soft_drop() if not moved: # lock will occur on tick pass self.redraw() def on_rotate(self): if self.paused or self.game_over: return self.board.rotate(1) self.redraw() def on_hard_drop(self): if self.paused or self.game_over: return self.board.hard_drop() self.redraw() def on_hold(self): if self.paused or self.game_over: return self.board.hold() self.redraw() def toggle_pause(self): if self.game_over: return self.paused = not self.paused self.redraw() def restart(self): self.board.reset() self.game_over = False if not self.board.spawn(): self.game_over = True self.redraw() def redraw(self): self.canvas.delete("all") self.preview.delete("all") self.hold_view.delete("all") # draw grid background for r in range(ROWS): for c in range(COLS): x0 = c * CELL y0 = r * CELL x1 = x0 + CELL y1 = y0 + CELL self.canvas.create_rectangle(x0, y0, x1, y1, outline=GRID_COLOR, fill=BG_COLOR) # draw landed blocks for y in range(ROWS): for x in range(COLS): kind = self.board.grid[y][x] if kind: self.draw_cell(self.canvas, x, y, PIECE_COLORS[kind]) # ghost piece for gx, gy in self.board.ghost_cells(): if gy >= 0: self.draw_cell(self.canvas, gx, gy, GHOST_COLOR, outline="") # current piece if self.board.current: for x, y in self.board.current.cells(): if y >= 0: self.draw_cell(self.canvas, x, y, PIECE_COLORS[self.board.current.kind]) # overlays if self.paused: self.canvas.create_text(COLS * CELL // 2, ROWS * CELL // 2, text="PAUSED", fill=TEXT_COLOR, font=(FONT_FAMILY, 24, "bold")) if self.game_over: self.canvas.create_text(COLS * CELL // 2, ROWS * CELL // 2 - 12, text="GAME OVER", fill=TEXT_COLOR, font=(FONT_FAMILY, 24, "bold")) self.canvas.create_text(COLS * CELL // 2, ROWS * CELL // 2 + 16, text="Press R to restart", fill=TEXT_COLOR, font=FONT_MD) # info self.info_lbl.config(text=f"Score: {self.board.score}\nLines: {self.board.lines_cleared}\nLevel: {self.board.level}") # next preview self.draw_preview(self.preview, self.board.next_kind) # hold preview if self.board.hold_kind: self.draw_preview(self.hold_view, self.board.hold_kind) def draw_cell(self, canvas: tk.Canvas, x: int, y: int, color: str, outline: str = ""): x0 = x * CELL + 1 y0 = y * CELL + 1 x1 = x0 + CELL - 2 y1 = y0 + CELL - 2 # soft shadow canvas.create_rectangle(x0 + 2, y0 + 2, x1 + 2, y1 + 2, outline="", fill="#000000", stipple="gray25") # main body canvas.create_rectangle(x0, y0, x1, y1, outline=outline, fill=color) # subtle highlights canvas.create_line(x0, y0, x1, y0, fill="#ffffff", stipple="gray25") canvas.create_line(x0, y0, x0, y1, fill="#ffffff", stipple="gray25") canvas.create_line(x0, y1, x1, y1, fill="#000000", stipple="gray25") canvas.create_line(x1, y0, x1, y1, fill="#000000", stipple="gray25") def draw_preview(self, canvas: tk.Canvas, kind: str): canvas.delete("all") cells = TETROMINOES[kind][0] xs = [x for x, y in cells] ys = [y for x, y in cells] minx, maxx = min(xs), max(xs) miny, maxy = min(ys), max(ys) w = (maxx - minx + 1) h = (maxy - miny + 1) # center in preview area offset_x = (canvas.winfo_width() // CELL - w) // 2 offset_y = (canvas.winfo_height() // CELL - h) // 2 for dx, dy in cells: x = dx - minx + offset_x y = dy - miny + offset_y self.draw_cell(canvas, x, y, PIECE_COLORS[kind]) def main() -> int: if tk is None: msg = ( "Error: tkinter is not available in this Python environment.\n" "This game requires a Tk GUI.\n\n" "How to install tkinter:\n" "- Debian/Ubuntu: sudo apt-get install python3-tk\n" "- Fedora: sudo dnf install python3-tkinter\n" "- Arch: sudo pacman -S tk\n" "- macOS (recommended): Install Python from python.org which includes Tk.\n" " Homebrew users: brew install python-tk@3.12 (or matching version).\n" "- Windows: tkinter comes with the official Python installer.\n" ) print(msg, file=sys.stderr) return 1 root = tk.Tk() app = TetrisApp(root) try: root.mainloop() except KeyboardInterrupt: # allow graceful exit in some environments try: root.destroy() except Exception: pass return 0 return 0 if __name__ == "__main__": sys.exit(main())