# Import libraries for random piece selection, terminal graphics, and timing import random import curses import time # Board dimensions: 10 columns wide, 20 rows tall (standard Tetris size) W, H = 10, 20 # Terminal layout: Scale up cells for bigger blocks # CELL_W: number of characters wide per block (4 = wider blocks) # CELL_H: number of rows tall per block (2 = taller blocks) CELL_W = 4 CELL_H = 2 # Function to calculate centered positioning in the terminal # This reads the terminal size and returns padding values to center the game board def get_centered_offsets(stdscr): """Calculate PAD_X and PAD_Y to center the game board in the terminal.""" # Get terminal dimensions (max_y = height, max_x = width) max_y, max_x = stdscr.getmaxyx() # Calculate board dimensions in characters: width and height with borders board_width = W * CELL_W + 2 board_height = H * CELL_H + 2 # Total width includes board + side panel (score, next piece preview) total_width = board_width + 6 + 20 # Center horizontally and vertically: divide remaining space by 2 pad_x = max(1, (max_x - total_width) // 2) pad_y = max(1, (max_y - board_height) // 2) return pad_x, pad_y # Default padding values (will be overridden by get_centered_offsets in main_curses) # PAD_X: left padding (columns from terminal edge to board edge) # PAD_Y: top padding (rows from terminal top to board edge) # SIDE_X: column position of the side panel (score, next block) PAD_X = 2 PAD_Y = 1 SIDE_X = PAD_X + W * CELL_W + 6 # 2D game board: 20 rows (height) × 10 columns (width) # Each cell is either None (empty) or a color string (filled with a block) board = [[None] * W for _ in range(H)] # Function to draw the game board on screen def draw_board_curses(win): # Draw the left and right borders (| characters) for y in range(H + 2): win.addstr(PAD_Y + y, PAD_X - 1, '|') win.addstr(PAD_Y + y, PAD_X + W * 2, '|') # Draw border that spans exactly from the top corner to the bottom corner top_row = PAD_Y - 1 bottom_row = PAD_Y + H * CELL_H left_col = PAD_X - 1 right_col = PAD_X + W * CELL_W # Draw vertical borders from top_row to bottom_row (inclusive) for row in range(top_row, bottom_row + 1): win.addstr(row, left_col, '|') win.addstr(row, right_col, '|') # Draw top and bottom border lines top = '+' + '-' * (W * CELL_W) + '+' win.addstr(top_row, left_col, top) win.addstr(bottom_row, left_col, top) # Draw each cell on the board (filled cells are shown as ██, empty as spaces) for i, row in enumerate(board[::-1]): # board[::-1] reverses rows so row 0 (bottom) is at bottom for h in range(CELL_H): # CELL_H is the height in rows each block takes screen_row = PAD_Y + 1 + i * CELL_H + h for j, color in enumerate(row): # Empty cell: spaces; filled cell: solid blocks (██) ch = ' ' * CELL_W attr = curses.color_pair(0) if color: ch = '█' * CELL_W attr = curses.color_pair(2) win.addstr(screen_row, PAD_X + j * CELL_W, ch, attr) # Function to find rows that are completely filled (ready to be cleared) # Yields (returns) each full row one at a time def find_completed_rows(): for row in board: # Check if all cells in the row have a color (no None values) if all(row): yield row # Define all 7 Tetris piece shapes (I, O, T, S, Z, J, L) # Each shape is stored as a list of (x, y) offsets from the piece's center BLOCKS = [[] for _ in range(7)] # Parse ASCII template: each 'o' represents a block, positions calculated from the string for i, line in enumerate(''' oo o o oo oo o oooo oo ooo ooo oo oo ooo'''.split('\n')): for j, char in enumerate(line): if char == 'o': # Store block position relative to piece center BLOCKS[j // 5].append((j%5 - 1, -i + 2)) # Function to draw a tetromino (falling block) on screen # Can draw on the main board or in the side panel (preview area) def draw_block_curses(win, block, x0, y0, use_board=True, draw_x=None, draw_y=None): """Draw a block either on the main board (use_board=True) or in a side area. For board drawing, x0,y0 are board coordinates. For side drawing, set use_board=False and provide draw_x, draw_y as screen coordinates (columns, rows). """ # Iterate through each block cell in the tetromino for dx, dy in block: # Calculate actual position by adding offsets to base position x, y = x0 + dx, y0 + dy if use_board: # Draw on main game board if 0 <= x < W and 0 <= y < H: # Convert board coordinates to screen coordinates # (accounting for CELL_W and CELL_H scaling) screen_row_base = PAD_Y + 1 + (H - 1 - y) * CELL_H screen_col = PAD_X + x * CELL_W # Draw the block with height CELL_H (fill multiple rows) for hh in range(CELL_H): win.addstr(screen_row_base + hh, screen_col, '█' * CELL_W, curses.color_pair(2)) else: # Draw in side panel (for next-piece preview) if draw_x is None or draw_y is None: continue # Calculate side panel position using scaled cell dimensions sx = draw_x + x * CELL_W sy = draw_y + (y * CELL_H) try: # Draw block with CELL_H rows for hh in range(CELL_H): win.addstr(sy + hh, sx, '█' * CELL_W, curses.color_pair(2)) except Exception: pass # Function to check if a piece can be placed at a given position # Returns True if the position is valid (no collisions), False otherwise def can_place_block_clipped(block, x0, y0): # Check each cell of the piece for dx, dy in block: # Calculate actual position x, y = x0 + dx, y0 + dy # Check if position is out of bounds or collides with existing blocks if not (0 <= x < W and 0 <= y) or (y < H and board[y][x]): return False return True # Function to lock a piece permanently onto the board # Marks each cell of the piece as filled def place_block(block, x0, y0): # Assume placement is complete initially complete = True # Place each block cell on the board for dx, dy in block: x, y = x0 + dx, y0 + dy # Only place if within board bounds (x and y must be valid) if 0 <= x < W and 0 <= y < H: board[y][x] = 'blue' # Mark cell as filled with color 'blue' else: # If any part extends outside, placement is incomplete (game over) complete = False return complete # Game state variables # score: player's current score score = 0 # t: game tick counter (increments each game loop iteration) t = 0 # falling_period: how many ticks before a piece falls one row (decreases with level) falling_period = 0 # state: current game state ('normal' = playing, 'game_over' = lost) state = 'normal' # block: currently falling piece (list of offsets), next_block: preview of next piece block = next_block = None # x, y: current position of the falling piece (x=column, y=row) x, y = 0, 0 # falling_generator: iterator that controls piece falling animation falling_generator = None # deleting_rows_generator: iterator that controls line-clear animation deleting_rows_generator = None # Function to move the current falling piece left, right, or down # dx: horizontal direction (-1=left, +1=right), dy: vertical direction (-1=down) def move_block(dx, dy): global x, y # Calculate new position new_x, new_y = x + dx, y + dy # Check if new position is valid (no collisions) possible = can_place_block_clipped(block, new_x, new_y) # If valid, update piece position if possible: x, y = new_x, new_y return possible # Function to rotate the current falling piece 90 degrees clockwise # Rotation formula: (x, y) becomes (-y, x) def rotate_block(): global block # Calculate rotated positions new_block = [(-y, x) for x, y in block] # Check if rotated position is valid (no collisions) possible = can_place_block_clipped(new_block, x, y) # If valid, update piece with rotated version if possible: block = new_block return possible # Function to spawn the next piece and start it falling # Also calculates falling speed based on level (score) def reset_block(): global falling_period, block, next_block, x, y, state # Calculate falling period (how many ticks before piece falls): decreases with level # Higher t (more time) = higher level = faster falling falling_period = [10, 9, 8, 7, 6, 5, 4][min(t // 100, 6)] # Swap pieces: current next_block becomes new block, pick random next new_block = None while not new_block: new_block, next_block = next_block, BLOCKS[random.randrange(7)] # Spawn at center-top of board x, y = W // 2, H - 1 # Check for game over: if new piece collides immediately, game is over if not can_place_block_clipped(new_block, x, y): state = 'game_over' return # Set active piece and start falling animation block = new_block begin_falling(falling_period) # Function to create a falling animation generator for a piece # The piece falls one row every 'period' ticks until it hits bottom def begin_falling(period): global falling_generator def fall(): global falling_generator # Main falling loop while True: # Wait 'period' ticks before falling next row for t in range(period): yield # Pause here and resume on next tick # Try to move piece down one row if not move_block(0, -1): # Piece can't move down, so it has landed break # When piece lands, clear the falling generator and lock piece falling_generator = None place_block_and_begin_deleting() yield # Create and start the generator falling_generator = fall() # Function to lock the current falling piece onto the board permanently # Then start checking for completed rows to clear def place_block_and_begin_deleting(): global block, state # Save the current piece before clearing it old_block = block block = None # Clear active piece # Place the piece on the board if not place_block(old_block, x, y): # If placement failed (piece extended above board), game over state = 'game_over' return # Start animation for clearing completed rows begin_deleting_rows() # Function to animate and process line clears # Completed rows flash and then disappear, with remaining blocks dropping down def begin_deleting_rows(): global deleting_rows_generator # Check if any rows are completed if any(find_completed_rows()): # Animation duration based on falling period (faster at higher levels) duration = falling_period def delete(): global deleting_rows_generator, score # Animate the row clearing (flash between red and blue) for t in range(duration): if t in (0, duration // 2): # Change color of completed rows (red or blue) color = ('red', 'blue')[t // (duration//2)] for row in find_completed_rows(): row[:] = [color] * W yield # Pause to show animation # After animation, remove completed rows incomplete_rows = [row for row in board if not all(row)] n_deleted = H - len(incomplete_rows) # Shift remaining rows down and fill with empty rows board[:] = incomplete_rows + [[None] * W for _ in range(n_deleted)] # Award points: (number of rows cleared)^2 × 100 score += (n_deleted**2) * 100 # Spawn next piece reset_block() deleting_rows_generator = None yield # Create and start the generator deleting_rows_generator = delete() else: # No completed rows, just spawn next piece immediately reset_block() # Function called once per game tick (100ms) # Advances animations (falling piece, line clears) and increments the time counter def update_stage(): global t # Advance the falling animation generator (piece drops) if block and falling_generator: next(falling_generator) # Advance the line-clear animation generator if deleting_rows_generator: next(deleting_rows_generator) # Increment tick counter (used for level calculation) t += 1 # Function to render (draw) the current game state on the terminal screen def render_stage_curses(win): # Clear the screen win.erase() # Draw the main game board with borders and filled cells draw_board_curses(win) # Display score in the side panel win.addstr(PAD_Y, SIDE_X, f'SCORE: {score}') # Display "NEXT:" label win.addstr(PAD_Y + 2, SIDE_X, 'NEXT:') # Draw the next piece preview in the side panel if next_block: # Normalize block coordinates so preview fits in a compact box minx = min(dx for dx, dy in next_block) miny = min(dy for dx, dy in next_block) # Position preview below the NEXT label preview_x = SIDE_X preview_y = PAD_Y + 6 draw_block_curses(win, next_block, -minx, -miny, use_board=False, draw_x=preview_x, draw_y=preview_y) # Draw the currently falling piece on the main board if block: draw_block_curses(win, block, x, y) # Display GAME OVER message if the game has ended if state == 'game_over': win.addstr(PAD_Y + H//2, PAD_X + W - 4, 'GAME OVER', curses.color_pair(1)) # Update the screen display win.refresh() # Input handler: move piece left # Called when 'a' or left arrow is pressed def on_key_left(): if block: move_block(-1, 0) # Input handler: move piece right # Called when 'd' or right arrow is pressed def on_key_right(): if block: move_block(1, 0) # Input handler: soft drop (or lock piece if already at bottom) # Called when 's' or down arrow is pressed def on_key_down(): if block: if not move_block(0, -1): place_block_and_begin_deleting() # Input handler: hard drop (instant fall to bottom) # Called when spacebar is pressed def on_key_space(): if block: begin_falling(period=0) # period=0 means fall instantly # Input handler: rotate piece 90 degrees # Called when 'w' or up arrow is pressed def on_key_up(): if block: rotate_block() # Main game function - runs the curses terminal game loop def main_curses(stdscr): # Declare globals so we can modify them in this function global PAD_X, PAD_Y, SIDE_X # Curses initialization curses.curs_set(0) # Hide the cursor stdscr.nodelay(True) # Non-blocking input (don't wait for keypresses) stdscr.keypad(True) # Enable special keys (arrow keys, etc.) curses.start_color() # Enable color support curses.use_default_colors() # Use terminal's default colors # Define color pairs: (ID, foreground color, background color) curses.init_pair(1, curses.COLOR_RED, -1) # Red for GAME OVER text curses.init_pair(2, curses.COLOR_BLUE, -1) # Blue for game blocks # Calculate centered board position based on terminal size PAD_X, PAD_Y = get_centered_offsets(stdscr) SIDE_X = PAD_X + W * CELL_W + 6 # Initialize the game: spawn first piece reset_block() # Game tick rate: 100ms (0.1 seconds) between game updates tick = 0.1 # Track time for tick timing last = time.time() # Main game loop while True: # Get current time now = time.time() # If enough time has passed, do a game update if now - last >= tick: # Advance game state (gravity, animations, etc.) update_stage() # Redraw the screen with updated state render_stage_curses(stdscr) # Reset timer for next tick last = now # Check for keyboard input (non-blocking) try: ch = stdscr.getch() except Exception: ch = -1 # Process keyboard input if ch != -1: # Quit game if ch in (ord('q'), ord('Q')): break # Move left elif ch in (curses.KEY_LEFT, ord('a')): on_key_left() # Move right elif ch in (curses.KEY_RIGHT, ord('d')): on_key_right() # Soft drop elif ch in (curses.KEY_DOWN, ord('s')): on_key_down() # Rotate elif ch in (curses.KEY_UP, ord('w')): on_key_up() # Hard drop elif ch == ord(' '): on_key_space() # Small sleep to avoid consuming 100% CPU time.sleep(0.001) # Entry point: run the game using curses wrapper (handles cleanup automatically) if __name__ == '__main__':