Editor

Falling Blocks in Python

Pygame Online · 225 lines · Left and Right, Up to turn, Down and Space to drop

Turn and drop the falling pieces to fill whole rows.

How it works

Turning a piece

Each piece is a few cells inside a small square box. A quarter turn maps every cell (x, y) to (size - 1 - y, x).

def rotate(cells, size):
    """A quarter turn clockwise inside the piece's box."""
    return [(size - 1 - y, x) for x, y in cells]

Does it fit?

One function answers the question every move asks: is every cell inside the board and on an empty square? Moving, turning, the ghost piece and game over all use it.

def fits(cells, px, py):
    """True if the piece fits with its box at column px, row py."""
    for x, y in cells:
        col, row = px + x, py + y
        if col < 0 or col >= COLS or row >= ROWS or board[row][col]:
            return False
    return True

Landing and clearing rows

A landed piece is copied into the board. Rows without an empty square are dropped and new empty rows go on top. More rows at once score more, times the level.

def land():
    """Fix the piece into the board, clear full rows, then bring the next piece."""
    global board, score, lines, level
    for x, y in cells:
        board[py + y][px + x] = PIECES[current][2]
    kept = [row for row in board if None in row]
    cleared = ROWS - len(kept)
    board = [[None] * COLS for _ in range(cleared)] + kept
    score += LINE_POINTS[cleared] * level
    lines += cleared
    level = lines // 10 + 1
    spawn()

Make it yours

Change the code above and press Run again. Some ideas:

  • Add a hold slot: press C to swap the current piece with a saved one.
  • Show the next three pieces instead of one.
  • Make levels come every 5 lines instead of 10.
  • Add a new piece shape to PIECES and see how it plays.

The whole program

Plain pygame: it runs the same on a computer with Python and pygame installed.

import random
import pygame

pygame.init()
WIDTH, HEIGHT = 480, 360
CELL = 16
COLS, ROWS = 10, 20
BOARD_X, BOARD_Y = 160, 20  # where the board's top-left corner is drawn
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Falling Blocks")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 26)
number_font = pygame.font.Font(None, 38)
big_font = pygame.font.Font(None, 52)

BG = (16, 18, 22)
WELL = (24, 27, 33)
YELLOW = (255, 212, 59)
BLUE = (48, 105, 152)
TEXT = (230, 232, 235)
DIM = (140, 146, 156)

# Each piece: its cells inside a square box, the size of that box, its colour.
PIECES = {
    "I": ([(0, 1), (1, 1), (2, 1), (3, 1)], 4, (90, 200, 230)),
    "O": ([(0, 0), (1, 0), (0, 1), (1, 1)], 2, (255, 212, 59)),
    "T": ([(1, 0), (0, 1), (1, 1), (2, 1)], 3, (170, 120, 220)),
    "S": ([(1, 0), (2, 0), (0, 1), (1, 1)], 3, (110, 200, 120)),
    "Z": ([(0, 0), (1, 0), (1, 1), (2, 1)], 3, (235, 95, 85)),
    "J": ([(0, 0), (0, 1), (1, 1), (2, 1)], 3, (70, 130, 210)),
    "L": ([(2, 0), (0, 1), (1, 1), (2, 1)], 3, (240, 150, 60)),
}
LINE_POINTS = [0, 100, 300, 500, 800]  # for clearing 0, 1, 2, 3 or 4 rows at once
START_KEYS = (pygame.K_UP, pygame.K_SPACE, pygame.K_RETURN)


def rotate(cells, size):
    """A quarter turn clockwise inside the piece's box."""
    return [(size - 1 - y, x) for x, y in cells]


def fits(cells, px, py):
    """True if the piece fits with its box at column px, row py."""
    for x, y in cells:
        col, row = px + x, py + y
        if col < 0 or col >= COLS or row >= ROWS or board[row][col]:
            return False
    return True


def take_from_bag():
    """Pieces come from a shuffled bag of all seven, so none is missing for long."""
    if not bag:
        bag.extend(PIECES)
        random.shuffle(bag)
    return bag.pop()


def spawn():
    """The next piece appears at the top. If there is no room, the game is over."""
    global current, cells, size, px, py, next_name, fall_timer, state, best, wait
    current = next_name
    next_name = take_from_bag()
    cells = PIECES[current][0]
    size = PIECES[current][1]
    px, py = (COLS - size) // 2, 0
    fall_timer = 0
    if not fits(cells, px, py):
        state = "over"
        best = max(best, score)
        wait = 30


def new_game():
    global board, bag, next_name, score, lines, level
    board = [[None] * COLS for _ in range(ROWS)]  # None or the colour of a block
    bag = []
    next_name = take_from_bag()
    score, lines, level = 0, 0, 1
    spawn()


def move(dx, dy):
    """Move the piece if there is room; True if it moved."""
    global px, py
    if fits(cells, px + dx, py + dy):
        px += dx
        py += dy
        return True
    return False


def turn():
    """Rotate, nudging sideways if the turned piece would hit a wall or a block."""
    global cells, px
    turned = rotate(cells, size)
    for nudge in (0, -1, 1, -2, 2):
        if fits(turned, px + nudge, py):
            cells = turned
            px += nudge
            return


def land():
    """Fix the piece into the board, clear full rows, then bring the next piece."""
    global board, score, lines, level
    for x, y in cells:
        board[py + y][px + x] = PIECES[current][2]
    kept = [row for row in board if None in row]
    cleared = ROWS - len(kept)
    board = [[None] * COLS for _ in range(cleared)] + kept
    score += LINE_POINTS[cleared] * level
    lines += cleared
    level = lines // 10 + 1
    spawn()


def draw_cell(x, y, color):
    pygame.draw.rect(screen, color, (x + 1, y + 1, CELL - 2, CELL - 2), border_radius=3)


def draw_text(text, text_font, color, center):
    image = text_font.render(text, True, color)
    screen.blit(image, image.get_rect(center=center))


new_game()
state = "ready"  # "ready", "playing" or "over"
best = 0
hold_frames = 0  # how long Left or Right has been held
wait = 0  # frames until a new game may start
running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if state == "playing":
                if event.key == pygame.K_LEFT:
                    move(-1, 0)
                elif event.key == pygame.K_RIGHT:
                    move(1, 0)
                elif event.key == pygame.K_UP:
                    turn()
                elif event.key == pygame.K_DOWN:
                    if move(0, 1):
                        score += 1
                    fall_timer = 0
                elif event.key == pygame.K_SPACE:  # hard drop
                    while move(0, 1):
                        score += 2
                    land()
            elif event.key in START_KEYS and wait == 0:
                if state == "over":
                    new_game()
                state = "playing"

    if wait > 0:
        wait -= 1

    if state == "playing":
        keys = pygame.key.get_pressed()
        # Holding Left or Right keeps moving, after a short pause.
        if keys[pygame.K_LEFT] or keys[pygame.K_RIGHT]:
            hold_frames += 1
            if hold_frames > 12 and hold_frames % 4 == 0:
                move(-1 if keys[pygame.K_LEFT] else 1, 0)
        else:
            hold_frames = 0

        # The piece falls one row every few frames: fewer at higher levels.
        delay = max(4, 50 - level * 5)
        if keys[pygame.K_DOWN]:
            delay = 3  # soft drop
        fall_timer += 1
        if fall_timer >= delay:
            fall_timer = 0
            if move(0, 1):
                if keys[pygame.K_DOWN]:
                    score += 1
            else:
                land()

    screen.fill(BG)
    pygame.draw.rect(screen, WELL, (BOARD_X, BOARD_Y, COLS * CELL, ROWS * CELL))
    for row in range(ROWS):
        for col in range(COLS):
            if board[row][col]:
                draw_cell(BOARD_X + col * CELL, BOARD_Y + row * CELL, board[row][col])
    if state == "playing":
        # The ghost shows where the piece would land.
        ghost_y = py
        while fits(cells, px, ghost_y + 1):
            ghost_y += 1
        for x, y in cells:
            rect = (BOARD_X + (px + x) * CELL + 1, BOARD_Y + (ghost_y + y) * CELL + 1, CELL - 2, CELL - 2)
            pygame.draw.rect(screen, DIM, rect, 1, border_radius=3)
        for x, y in cells:
            draw_cell(BOARD_X + (px + x) * CELL, BOARD_Y + (py + y) * CELL, PIECES[current][2])
    pygame.draw.rect(screen, BLUE, (BOARD_X - 3, BOARD_Y - 3, COLS * CELL + 6, ROWS * CELL + 6), 2, border_radius=4)

    # Left: score, lines, level. Right: the next piece and the keys.
    for i, (label, value) in enumerate([("Score", score), ("Lines", lines), ("Level", level), ("Best", best)]):
        draw_text(label, font, DIM, (80, 40 + i * 70))
        draw_text(str(value), number_font, YELLOW if label == "Score" else TEXT, (80, 68 + i * 70))
    draw_text("Next", font, DIM, (400, 40))
    next_cells, next_size, next_color = PIECES[next_name]
    for x, y in next_cells:
        draw_cell(400 - next_size * CELL // 2 + x * CELL, 60 + y * CELL, next_color)
    for i, line in enumerate(["Up: turn", "Left, Right: move", "Down: drop faster", "Space: drop"]):
        draw_text(line, font, DIM, (400, 200 + i * 26))

    if state != "playing":
        panel = pygame.Rect(0, 0, 300, 120)
        panel.center = (BOARD_X + COLS * CELL // 2, HEIGHT // 2)
        pygame.draw.rect(screen, BG, panel, border_radius=12)
        pygame.draw.rect(screen, BLUE, panel, 2, border_radius=12)
        draw_text("Blocks" if state == "ready" else "Game over", big_font, YELLOW, (panel.centerx, panel.y + 40))
        hint = "Press Up or Space to start" if state == "ready" else "Press Up or Space to play again"
        draw_text(hint, font, TEXT, (panel.centerx, panel.y + 88))

    pygame.display.flip()
    clock.tick(60)

pygame.quit()