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.mixerpour des effets sonores - Interface graphique : porter le jeu vers
pygameen gardant la même logiqueGame
Merci pour votre lecture !
Sylvain Altmayer