Créer un Tetris complet en Python avec curses

Introduction

Dans notre précédent article, on a construit un Snake jouable dans le terminal avec curses. Cette fois, on monte d'un cran : un Tetris complet, avec les 7 pièces officielles, rotation avec wall kicks, ghost piece, hold, hard drop, niveaux progressifs et high score persistant.

Tout ça dans un seul fichier Python, sans installer la moindre bibliothèque externe.

Voilà ce que tu verras à la fin :

┌────────────────────┐ ┌────────────────────┐
│                    │ │ NEXT               │
│                    │ │   [][]             │
│       ..           │ │   [][]             │
│     [][][][]       │ │                    │
│  [][][]            │ │ HOLD               │
│  [][][][]  [][]    │ │   [][][]           │
└────────────────────┘ │   []               │
                       │ SCORE  4200        │
                       │ LEVEL  3           │
                       │ LINES  25          │
                       └────────────────────┘


Prérequis

  • Python 3.7 ou supérieur
  • Un terminal (Linux, macOS, ou Windows avec WSL)
  • Avoir lu l'article sur le Snake est un plus, mais pas obligatoire

1. Architecture du projet

Le Tetris est plus complexe que le Snake, donc on va structurer le code proprement dès le départ. On sépare clairement trois responsabilités :

tetris.py
├── Constantes & données     → formes des pièces, couleurs, scores
├── Helpers                  → rotation, highscore, safe_addch/addstr
├── class Piece              → une pièce en jeu (position, rotation, déplacement)
├── class Game               → état complet du jeu (plateau, score, logique)
├── Fonctions de rendu       → draw_board(), draw_panel(), draw_mini_piece()
└── main()                   → boucle principale, fenêtres curses, input

Cette séparation est essentielle : la logique du jeu ne sait rien de curses, et le rendu ne fait que lire l'état du jeu.


2. Les 7 tétrominos

Chaque pièce est définie comme une liste d'offsets (row, col) depuis un point pivot central :

SHAPES = {
    'I': [(-1, 0), (0, 0), (1, 0), (2, 0)],
    'O': [(0, 0), (0, 1), (1, 0), (1, 1)],
    'T': [(0, -1), (0, 0), (0, 1), (1, 0)],
    'S': [(0, 0), (0, 1), (1, -1), (1, 0)],
    'Z': [(0, -1), (0, 0), (1, 0), (1, 1)],
    'J': [(0, -1), (0, 0), (0, 1), (1, -1)],
    'L': [(0, -1), (0, 0), (0, 1), (1, 1)],
}

Chaque pièce a sa propre couleur curses :

PIECE_COLORS = {
    'I': COLOR_I,   # Cyan
    'O': COLOR_O,   # Jaune
    'T': COLOR_T,   # Magenta
    'S': COLOR_S,   # Vert
    'Z': COLOR_Z,   # Rouge
    'J': COLOR_J,   # Bleu
    'L': COLOR_L,   # Blanc
}


3. La rotation et les wall kicks

Faire pivoter une liste de (r, c) à 90° dans le sens horaire, c'est une simple transformation mathématique :

def rotate_cw(cells):
    return [(c, -r) for r, c in cells]

Le problème : si la pièce est proche d'un bord, la rotation peut sortir du terrain. C'est là qu'intervient le wall kick : on essaie la rotation, et si ça coince, on tente de décaler la pièce de ±1 ou ±2 colonnes avant d'abandonner.

def try_rotate(self, board):
    new_cells = rotate_cw(self.cells)
    for kick in [0, -1, 1, -2, 2]:
        if not collides(board, new_cells, self.row, self.col + kick):
            self.cells = new_cells
            self.col += kick
            return True
    return False

Ce mécanisme rend la rotation beaucoup plus fluide et "juste" — exactement comme dans le Tetris officiel.


4. Détection de collision

La fonction collides() est le cœur de toute la logique du jeu. Elle vérifie si une pièce à une position donnée sortirait du terrain ou chevaucherait une cellule déjà occupée :

def collides(board, cells, row, col):
    for r, c in cells:
        nr, nc = row + r, col + c
        if nc < 0 or nc >= BOARD_W or nr >= BOARD_H:
            return True           # hors des bords
        if nr >= 0 and board[nr][nc] != 0:
            return True           # cellule occupée
    return False

Note : nr >= 0 permet aux pièces d'exister partiellement au-dessus du terrain (zone de spawn) sans déclencher de collision.


5. Le 7-bag randomizer

Le Tetris naïf pioche les pièces complètement au hasard — ce qui peut donner 6 pièces S de suite. Le vrai Tetris utilise le 7-bag : on mélange les 7 pièces, on les distribue dans l'ordre, puis on recommence.

def _new_piece(self):
    if not self._bag:
        self._bag = PIECE_NAMES[:]   # ['I', 'O', 'T', 'S', 'Z', 'J', 'L']
        random.shuffle(self._bag)
    return Piece(self._bag.pop())

Résultat : une distribution équitable qui évite les séquences frustrantes.


6. La ghost piece

La ghost piece montre où la pièce actuelle atterrira si on la lâche. On calcule ça en faisant descendre la pièce ligne par ligne jusqu'à ce qu'elle entre en collision :

def ghost_row(self, board):
    r = self.row
    while not collides(board, self.cells, r + 1, self.col):
        r += 1
    return r

À l'affichage, on dessine la ghost piece avec des .. à la place des [] habituels, pour la distinguer visuellement.


7. Le hold

La fonctionnalité hold permet de mettre une pièce de côté pour plus tard. On ne peut l'utiliser qu'une fois par pièce (sinon on pourrait garder la pièce idéale indéfiniment) :

def do_hold(self):
    if self.hold_used:
        return
    self.hold_used = True
    if self.hold is None:
        self.hold = self.current.name
        self.current = self.next
        self.next = self._new_piece()
    else:
        prev = self.hold
        self.hold = self.current.name
        self.current = Piece(prev)    # on recrée la pièce stockée

hold_used se remet à False dès qu'une pièce se pose, grâce à _lock_piece().


8. Le hard drop et le soft drop

  • Soft drop : la pièce descend d'une ligne immédiatement. Si elle ne peut pas, elle se pose.
  • Hard drop : la pièce tombe instantanément jusqu'en bas (position de la ghost piece).
def hard_drop(self):
    self.current.row = self.current.ghost_row(self.board)
    self._lock_piece()

def soft_drop(self):
    if not self.current.move(self.board, 1, 0):
        self._lock_piece()


9. Effacer les lignes et scorer

Quand une pièce se pose, on cherche les lignes complètes et on les supprime :

def _clear_lines(self):
    full = [r for r in range(BOARD_H) if all(self.board[r])]
    for r in full:
        del self.board[r]
        self.board.insert(0, [0] * BOARD_W)   # on ajoute une ligne vide en haut
    n = len(full)
    self.score += SCORE_TABLE.get(n, 800) * self.level

Le tableau de score récompense les combos :

SCORE_TABLE = {1: 100, 2: 300, 3: 500, 4: 800}

Effacer 4 lignes d'un coup (un Tetris) rapporte 800 × le niveau — bien plus que 4 × 100.


10. La vitesse par niveau

Le jeu accélère à chaque niveau. On utilise une table de délais en secondes :

def fall_delay(level):
    delays = [0.8, 0.72, 0.63, 0.55, 0.47, 0.38, 0.3, 0.22, 0.15, 0.1,
              0.08, 0.07, 0.06, 0.05, 0.04]
    idx = min(level - 1, len(delays) - 1)
    return delays[idx]

Le niveau monte tous les 10 lignes effacées. Au niveau 15, la pièce tombe toutes les 40ms — ça devient sérieux.


11. Deux fenêtres curses

Contrairement au Snake qui utilisait un seul écran, le Tetris utilise deux fenêtres côte à côte :

board_win = curses.newwin(board_win_h, board_win_w, 0, 0)
panel_win = curses.newwin(panel_h, panel_w, 0, board_win_w + 1)

Pour éviter le scintillement, on utilise le double buffer de curses :

board_win.noutrefresh()   # prépare sans afficher
panel_win.noutrefresh()   # prépare sans afficher
curses.doupdate()          # affiche tout d'un coup

doupdate() est bien plus efficace que deux refresh() séparés.


12. L'overlay Game Over

Quand le joueur perd, on dessine un cadre Unicode directement sur le plateau de jeu :

overlay_lines = [
    "╔══════════════════╗",
    "║    GAME  OVER    ║",
    "╠══════════════════╣",
    "║  [R] Restart     ║",
    "║  [Q] Quit        ║",
    "╚══════════════════╝",
]

Le restart réinitialise complètement l'état du jeu (game.reset()) sans recréer les fenêtres curses, ce qui est beaucoup plus propre.


Le code complet

Le code complet est disponible ci-dessous. Il tient en un seul fichier tetris.py, sans dépendances externes.

import curses
import json
import os
import time
import random

BOARD_W, BOARD_H = 10, 20
HIGHSCORE_FILE = ".tetris_highscore.json"

SHAPES = {
    'I': [(-1, 0), (0, 0), (1, 0), (2, 0)],
    'O': [(0, 0), (0, 1), (1, 0), (1, 1)],
    'T': [(0, -1), (0, 0), (0, 1), (1, 0)],
    'S': [(0, 0), (0, 1), (1, -1), (1, 0)],
    'Z': [(0, -1), (0, 0), (1, 0), (1, 1)],
    'J': [(0, -1), (0, 0), (0, 1), (1, -1)],
    'L': [(0, -1), (0, 0), (0, 1), (1, 1)],
}

PIECE_NAMES = list(SHAPES.keys())

COLOR_I, COLOR_O, COLOR_T = 1, 2, 3
COLOR_S, COLOR_Z, COLOR_J = 4, 5, 6
COLOR_L, COLOR_GHOST      = 7, 8
COLOR_BORDER, COLOR_UI    = 9, 10

PIECE_COLORS = {
    'I': COLOR_I, 'O': COLOR_O, 'T': COLOR_T,
    'S': COLOR_S, 'Z': COLOR_Z, 'J': COLOR_J, 'L': COLOR_L,
}

SCORE_TABLE = {1: 100, 2: 300, 3: 500, 4: 800}

def fall_delay(level):
    delays = [0.8, 0.72, 0.63, 0.55, 0.47, 0.38, 0.3, 0.22, 0.15, 0.1,
              0.08, 0.07, 0.06, 0.05, 0.04]
    return delays[min(level - 1, len(delays) - 1)]

def rotate_cw(cells):
    return [(c, -r) for r, c in cells]

def load_highscore():
    try:
        with open(HIGHSCORE_FILE, 'r') as f:
            return json.load(f).get('highscore', 0)
    except Exception:
        return 0

def save_highscore(score):
    try:
        with open(HIGHSCORE_FILE, 'w') as f:
            json.dump({'highscore': score}, f)
    except Exception:
        pass

def safe_addch(win, y, x, ch, attr=0):
    try:
        h, w = win.getmaxyx()
        if 0 <= y < h and 0 <= x < w - 1:
            win.addch(y, x, ch, attr)
    except curses.error:
        pass

def safe_addstr(win, y, x, text, attr=0):
    try:
        h, w = win.getmaxyx()
        if y < 0 or y >= h or x >= w:
            return
        if x < 0:
            text = text[-x:]
            x = 0
        max_len = w - x - 1
        if max_len <= 0:
            return
        win.addstr(y, x, text[:max_len], attr)
    except curses.error:
        pass

def collides(board, cells, row, col):
    for r, c in cells:
        nr, nc = row + r, col + c
        if nc < 0 or nc >= BOARD_W or nr >= BOARD_H:
            return True
        if nr >= 0 and board[nr][nc] != 0:
            return True
    return False

class Piece:
    def __init__(self, name):
        self.name  = name
        self.cells = list(SHAPES[name])
        self.color = PIECE_COLORS[name]
        self.row   = 0
        self.col   = BOARD_W // 2

    def absolute(self):
        return [(self.row + r, self.col + c) for r, c in self.cells]

    def try_rotate(self, board):
        new_cells = rotate_cw(self.cells)
        for kick in [0, -1, 1, -2, 2]:
            if not collides(board, new_cells, self.row, self.col + kick):
                self.cells  = new_cells
                self.col   += kick
                return True
        return False

    def move(self, board, dr, dc):
        if not collides(board, self.cells, self.row + dr, self.col + dc):
            self.row += dr
            self.col += dc
            return True
        return False

    def ghost_row(self, board):
        r = self.row
        while not collides(board, self.cells, r + 1, self.col):
            r += 1
        return r

class Game:
    def __init__(self):
        self.highscore = load_highscore()
        self.reset()

    def reset(self):
        self.board     = [[0] * BOARD_W for _ in range(BOARD_H)]
        self.score     = 0
        self.level     = 1
        self.lines     = 0
        self.game_over = False
        self.hold      = None
        self.hold_used = False
        self._bag      = []
        self.current   = self._new_piece()
        self.next      = self._new_piece()
        self.last_fall = time.time()

    def _new_piece(self):
        if not self._bag:
            self._bag = PIECE_NAMES[:]
            random.shuffle(self._bag)
        return Piece(self._bag.pop())

    def _lock_piece(self):
        for r, c in self.current.absolute():
            if r < 0:
                self.game_over = True
                return
            self.board[r][c] = self.current.color
        self._clear_lines()
        self.current   = self.next
        self.next      = self._new_piece()
        self.hold_used = False
        if collides(self.board, self.current.cells, self.current.row, self.current.col):
            self.game_over = True

    def _clear_lines(self):
        full = [r for r in range(BOARD_H) if all(self.board[r])]
        if not full:
            return
        for r in full:
            del self.board[r]
            self.board.insert(0, [0] * BOARD_W)
        n           = len(full)
        self.lines += n
        self.score += SCORE_TABLE.get(n, 800) * self.level
        if self.score > self.highscore:
            self.highscore = self.score
            save_highscore(self.highscore)
        self.level = self.lines // 10 + 1

    def hard_drop(self):
        self.current.row = self.current.ghost_row(self.board)
        self._lock_piece()

    def soft_drop(self):
        if not self.current.move(self.board, 1, 0):
            self._lock_piece()

    def do_hold(self):
        if self.hold_used:
            return
        self.hold_used = True
        if self.hold is None:
            self.hold    = self.current.name
            self.current = self.next
            self.next    = self._new_piece()
        else:
            prev         = self.hold
            self.hold    = self.current.name
            self.current = Piece(prev)

    def tick(self):
        now = time.time()
        if now - self.last_fall >= fall_delay(self.level):
            self.last_fall = now
            if not self.current.move(self.board, 1, 0):
                self._lock_piece()

BLOCK       = '[]'
GHOST_BLOCK = '..'
EMPTY       = '  '

def draw_block(win, row, col, color_pair, ghost=False):
    safe_addstr(win, row + 1, col * 2 + 1,
                GHOST_BLOCK if ghost else BLOCK,
                curses.color_pair(color_pair))

def draw_board(win, game):
    win.border()
    for r in range(BOARD_H):
        for c in range(BOARD_W):
            if game.board[r][c] != 0:
                draw_block(win, r, c, game.board[r][c])
            else:
                safe_addstr(win, r + 1, c * 2 + 1, EMPTY)

    if not game.game_over:
        gr = game.current.ghost_row(game.board)
        for r, c in game.current.cells:
            ar, ac = gr + r, game.current.col + c
            if 0 <= ar < BOARD_H and 0 <= ac < BOARD_W and game.board[ar][ac] == 0:
                draw_block(win, ar, ac, COLOR_GHOST, ghost=True)
        for r, c in game.current.cells:
            ar, ac = game.current.row + r, game.current.col + c
            if 0 <= ar < BOARD_H and 0 <= ac < BOARD_W:
                draw_block(win, ar, ac, game.current.color)

    if game.game_over:
        overlay = [
            "╔══════════════════╗",
            "║    GAME  OVER    ║",
            "╠══════════════════╣",
            "║  [R] Restart     ║",
            "║  [Q] Quit        ║",
            "╚══════════════════╝",
        ]
        start = BOARD_H // 2 - 2
        for i, line in enumerate(overlay):
            x = (BOARD_W * 2 - len(line)) // 2 + 1
            safe_addstr(win, start + i + 1, x, line,
                        curses.color_pair(COLOR_UI) | curses.A_BOLD)

def draw_mini_piece(win, row, col, name, label):
    cells = SHAPES[name] if name else []
    color = PIECE_COLORS.get(name, COLOR_UI)
    safe_addstr(win, row, col, label,
                curses.color_pair(COLOR_UI) | curses.A_BOLD)
    for br in range(4):
        for bc in range(4):
            safe_addstr(win, row + 1 + br, col + bc * 2, '  ')
    if name:
        min_r = min(r for r, c in cells)
        max_r = max(r for r, c in cells)
        min_c = min(c for r, c in cells)
        max_c = max(c for r, c in cells)
        off_r = (3 - (max_r - min_r)) // 2 - min_r
        off_c = (3 - (max_c - min_c)) // 2 - min_c
        for r, c in cells:
            safe_addstr(win, row + 1 + r + off_r, col + (c + off_c) * 2,
                        BLOCK, curses.color_pair(color))

def draw_panel(win, game):
    win.erase()
    win.border()
    col, row = 2, 1
    draw_mini_piece(win, row, col, game.next.name, "NEXT")
    row += 6
    draw_mini_piece(win, row, col, game.hold,
                    "HOLD" + (" (used)" if game.hold_used else ""))
    row += 6
    for label, val in [("SCORE", game.score), ("HIGH ", game.highscore),
                       ("LEVEL", game.level),  ("LINES", game.lines)]:
        safe_addstr(win, row,     col, label, curses.color_pair(COLOR_UI) | curses.A_BOLD)
        safe_addstr(win, row + 1, col, str(val), curses.color_pair(COLOR_UI))
        row += 3
    row += 1
    for line in ["CONTROLS", "← →/A D : move", "↑ /W    : rotate",
                 "↓ /S    : soft drop", "SPACE   : hard drop",
                 "H       : hold", "Q       : quit"]:
        safe_addstr(win, row, col, line, curses.color_pair(COLOR_UI))
        row += 1

def init_colors():
    curses.start_color()
    curses.use_default_colors()
    curses.init_pair(COLOR_I,      curses.COLOR_CYAN,    -1)
    curses.init_pair(COLOR_O,      curses.COLOR_YELLOW,  -1)
    curses.init_pair(COLOR_T,      curses.COLOR_MAGENTA, -1)
    curses.init_pair(COLOR_S,      curses.COLOR_GREEN,   -1)
    curses.init_pair(COLOR_Z,      curses.COLOR_RED,     -1)
    curses.init_pair(COLOR_J,      curses.COLOR_BLUE,    -1)
    curses.init_pair(COLOR_L,      curses.COLOR_WHITE,   -1)
    curses.init_pair(COLOR_GHOST,  curses.COLOR_WHITE,   -1)
    curses.init_pair(COLOR_BORDER, curses.COLOR_WHITE,   -1)
    curses.init_pair(COLOR_UI,     curses.COLOR_WHITE,   -1)

def main(stdscr):
    curses.curs_set(0)
    stdscr.nodelay(True)
    stdscr.timeout(50)
    init_colors()

    board_win_w = BOARD_W * 2 + 2
    board_win_h = BOARD_H + 2
    panel_w, panel_h = 22, board_win_h

    board_win = curses.newwin(board_win_h, board_win_w, 0, 0)
    panel_win = curses.newwin(panel_h, panel_w, 0, board_win_w + 1)

    game = Game()

    while True:
        try:
            key = stdscr.getch()
        except curses.error:
            key = -1

        if key in (ord('q'), ord('Q')):
            break

        if game.game_over:
            if key in (ord('r'), ord('R')):
                game.reset()
        else:
            if key in (curses.KEY_LEFT,  ord('a'), ord('A')):
                game.current.move(game.board, 0, -1)
            elif key in (curses.KEY_RIGHT, ord('d'), ord('D')):
                game.current.move(game.board, 0,  1)
            elif key in (curses.KEY_UP,   ord('w'), ord('W')):
                game.current.try_rotate(game.board)
            elif key in (curses.KEY_DOWN, ord('s'), ord('S')):
                game.soft_drop()
                game.last_fall = time.time()
            elif key == ord(' '):
                game.hard_drop()
            elif key in (ord('h'), ord('H')):
                game.do_hold()
            game.tick()

        board_win.erase()
        draw_board(board_win, game)
        board_win.noutrefresh()

        panel_win.erase()
        draw_panel(panel_win, game)
        panel_win.noutrefresh()

        curses.doupdate()

if __name__ == '__main__':
    curses.wrapper(main)

Pour aller plus loin

  • Système de scoring T-spin : récompenser les rotations en position serrée
  • Mode multijoueur local : deux grilles côte à côte, lignes envoyées à l'adversaire
  • Sauvegarde des 10 meilleurs scores avec pseudo
  • Sons : intégrer pygame.mixer pour des effets sonores
  • Interface graphique : porter le jeu vers pygame en gardant la même logique Game

Merci pour votre lecture !

Sylvain Altmayer

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *