En moins de 20 minutes, tu vas avoir un Snake jouable directement dans ton terminal.
Introduction
Tu veux créer ton premier jeu en Python sans installer de moteur graphique, sans bibliothèque externe, sans rien ? C'est possible — et bien plus simple qu'on ne le croit — grâce au module curses, inclus nativement dans Python.
Dans ce tutoriel, on va construire pas à pas un Snake jouable dans le terminal : serpent qui grandit, pommes à attraper, score, high score persistant, accélération progressive et écran de Game Over avec option de relance.
Voilà ce que tu verras à la fin :
┌─ Score: 40 Best: 80 [Q] Quit ──────────────────┐
│
│ oooO
│ @
│
└──────────────────────────────────────────────────┘
Prérequis
- Python 3.7 ou supérieur
- Un terminal (Linux, macOS, ou Windows avec WSL)
- Aucune installation supplémentaire
Note Windows :
cursesn'est pas disponible nativement sous Windows. Utilise WSL (Windows Subsystem for Linux) ou installewindows-cursesviapip install windows-curses.
1. C'est quoi curses ?
curses est une bibliothèque qui permet de contrôler le terminal comme un écran : écrire à n'importe quelle position, utiliser des couleurs, capturer les touches du clavier en temps réel.
C'est la base des interfaces texte (TUI) comme vim, htop ou nano.
On l'initialise toujours de la même façon :
import curses
def main(stdscr):
# stdscr = l'écran principal, fourni automatiquement
stdscr.addstr(0, 0, "Bonjour !")
stdscr.refresh()
stdscr.getch() # attendre une touche
curses.wrapper(main) # wrapper gère l'init et le nettoyage automatiquement
curses.wrapper() s'occupe de tout initialiser proprement et de restaurer le terminal en cas d'erreur. Toujours utiliser wrapper.
2. Structure du projet
Notre jeu est contenu dans un seul fichier snake.py. Voici les grandes parties :
snake.py
├── load_highscore() → lit le meilleur score depuis un fichier JSON
├── save_highscore() → sauvegarde le meilleur score
├── spawn_apple() → place une pomme aléatoirement
├── safe_addch() → dessine un caractère sans planter curses
└── main() → boucle principale du jeu
Et un fichier .snake_highscore.json créé automatiquement pour sauvegarder le meilleur score entre les parties.
3. Initialisation des couleurs
curses gère les couleurs par paires (texte + fond). On les déclare une fois au démarrage :
curses.start_color()
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK) # serpent
curses.init_pair(2, curses.COLOR_RED, curses.COLOR_BLACK) # pomme / game over
curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK) # score / nouveau record
curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_BLACK) # bordure
curses.init_pair(5, curses.COLOR_CYAN, curses.COLOR_BLACK) # message vitesse
Pour utiliser une couleur lors du dessin :
stdscr.attron(curses.color_pair(1) | curses.A_BOLD) # activer
stdscr.addch(y, x, 'O') # dessiner
stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) # désactiver
4. Le serpent : une liste de coordonnées
Le serpent est simplement une liste de tuples (y, x), du plus récent (tête) au plus ancien (queue) :
snake = [(10, 20), (10, 19), (10, 18)] # tête à gauche
À chaque frame :
- On calcule la nouvelle tête selon la direction
- On l'insère en début de liste
- Si on n'a pas mangé de pomme, on supprime le dernier élément (la queue recule)
head = (snake[0][0] + direction[0], snake[0][1] + direction[1])
snake.insert(0, head)
if head == apple:
score += 10 # la queue reste → le serpent grandit
else:
snake.pop() # la queue disparaît → longueur constante
5. Détecter les collisions
Deux types de collisions mettent fin à la partie :
# Collision avec les murs
if head[0] <= 0 or head[0] >= height - 1 or head[1] <= 0 or head[1] >= width - 1:
break
# Collision avec soi-même
if head in snake:
break
head in snake vérifie si la nouvelle tête est déjà dans la liste du serpent — Python rend ça très lisible.
6. Capturer les touches en temps réel
On configure le terminal pour ne pas bloquer le jeu à chaque frame :
stdscr.nodelay(True) # getch() ne bloque pas
stdscr.timeout(100) # rafraîchissement toutes les 100ms
On mappe les touches aux directions :
KEY_MAP = {
ord('w'): (-1, 0), curses.KEY_UP: (-1, 0),
ord('s'): ( 1, 0), curses.KEY_DOWN: ( 1, 0),
ord('a'): ( 0, -1), curses.KEY_LEFT: ( 0, -1),
ord('d'): ( 0, 1), curses.KEY_RIGHT: ( 0, 1),
}
Et on empêche le demi-tour (revenir sur soi-même) :
if key in KEY_MAP:
new_dir = KEY_MAP[key]
# Si la somme des deux directions est (0,0), c'est un demi-tour → interdit
if (new_dir[0] + direction[0], new_dir[1] + direction[1]) != (0, 0):
direction = new_dir
7. Éviter le bug du coin inférieur droit
curses plante si on essaie d'écrire dans la toute dernière cellule du terminal (coin bas-droit). On crée une fonction sécurisée :
def safe_addch(stdscr, y, x, ch, attr, height, width):
if y == height - 1 and x == width - 1:
return # on saute ce caractère
try:
stdscr.addch(y, x, ch, attr)
except curses.error:
pass
8. L'accélération progressive
Plus le score est élevé, plus le serpent va vite. On réduit le délai entre chaque frame :
new_speed = max(50, 100 - (score // 50) * 5)
if new_speed < speed:
speedup_frames = 8 # afficher le message pendant 8 frames
speed = new_speed
stdscr.timeout(speed)
Le jeu commence à 100ms par frame et descend jusqu'à 50ms minimum (vitesse doublée).
9. Sauvegarder le meilleur score
On utilise un simple fichier JSON pour la persistance :
HIGHSCORE_FILE = ".snake_highscore.json"
def load_highscore():
if os.path.exists(HIGHSCORE_FILE):
try:
with open(HIGHSCORE_FILE) as f:
return json.load(f).get("highscore", 0)
except (json.JSONDecodeError, KeyError, OSError):
pass
return 0
def save_highscore(score):
try:
with open(HIGHSCORE_FILE, "w") as f:
json.dump({"highscore": score}, f)
except OSError:
pass
Les try/except protègent contre un fichier corrompu ou des droits d'écriture manquants.
10. L'écran Game Over et le restart
La structure clé du jeu est une double boucle :
while True: # ← boucle extérieure : gère le restart
# reset de l'état du jeu
snake = [...]
score = 0
...
while True: # ← boucle intérieure : une partie
# logique du jeu
...
if collision:
break # ← on sort de la partie
# Écran Game Over
# Si le joueur appuie sur R → break de l'écran → continue outer → nouvelle partie
# Si le joueur appuie sur Q → return → fin du programme
L'écran Game Over affiche le score, le meilleur score, et "NEW RECORD !" si applicable :
if new_record:
stdscr.attron(curses.color_pair(3) | curses.A_BOLD)
stdscr.addstr(cy, cx - len(record) // 2, "NEW RECORD !")
stdscr.attroff(curses.color_pair(3) | curses.A_BOLD)
stdscr.addstr(cy + 2, cx - len(options) // 2, "[R] Restart [Q] Quit")
Le code complet
import curses
import json
import os
import random
HIGHSCORE_FILE = ".snake_highscore.json"
def load_highscore():
if os.path.exists(HIGHSCORE_FILE):
try:
with open(HIGHSCORE_FILE) as f:
return json.load(f).get("highscore", 0)
except (json.JSONDecodeError, KeyError, OSError):
pass
return 0
def save_highscore(score):
try:
with open(HIGHSCORE_FILE, "w") as f:
json.dump({"highscore": score}, f)
except OSError:
pass
def spawn_apple(snake, height, width):
while True:
ay = random.randint(1, height - 2)
ax = random.randint(1, width - 2)
if (ay, ax) not in snake:
return (ay, ax)
def safe_addch(stdscr, y, x, ch, attr, height, width):
if y == height - 1 and x == width - 1:
return
try:
stdscr.addch(y, x, ch, attr)
except curses.error:
pass
def main(stdscr):
curses.curs_set(0)
curses.start_color()
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
curses.init_pair(2, curses.COLOR_RED, curses.COLOR_BLACK)
curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK)
curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_BLACK)
curses.init_pair(5, curses.COLOR_CYAN, curses.COLOR_BLACK)
height, width = stdscr.getmaxyx()
if height < 12 or width < 22:
stdscr.nodelay(False)
stdscr.addstr(0, 0, "Terminal too small! Resize and press any key.")
stdscr.refresh()
stdscr.getch()
return
KEY_MAP = {
ord('w'): (-1, 0), ord('W'): (-1, 0), curses.KEY_UP: (-1, 0),
ord('s'): ( 1, 0), ord('S'): ( 1, 0), curses.KEY_DOWN: ( 1, 0),
ord('a'): ( 0, -1), ord('A'): ( 0, -1), curses.KEY_LEFT: ( 0, -1),
ord('d'): ( 0, 1), ord('D'): ( 0, 1), curses.KEY_RIGHT: ( 0, 1),
}
highscore = load_highscore()
while True:
sy, sx = height // 2, width // 2
snake = [(sy, sx), (sy, sx - 1), (sy, sx - 2)]
direction = (0, 1)
apple = spawn_apple(snake, height, width)
score = 0
speed = 100
speedup_frames = 0
stdscr.nodelay(True)
stdscr.timeout(speed)
quit_game = False
while True:
key = stdscr.getch()
if key in (ord('q'), ord('Q')):
quit_game = True
break
if key in KEY_MAP:
new_dir = KEY_MAP[key]
if (new_dir[0] + direction[0], new_dir[1] + direction[1]) != (0, 0):
direction = new_dir
head = (snake[0][0] + direction[0], snake[0][1] + direction[1])
if head[0] <= 0 or head[0] >= height - 1 or head[1] <= 0 or head[1] >= width - 1:
break
if head in snake:
break
snake.insert(0, head)
if head == apple:
score += 10
apple = spawn_apple(snake, height, width)
new_speed = max(50, 100 - (score // 50) * 5)
if new_speed < speed:
speedup_frames = 8
speed = new_speed
stdscr.timeout(speed)
else:
snake.pop()
stdscr.erase()
stdscr.attron(curses.color_pair(4))
stdscr.border()
stdscr.attroff(curses.color_pair(4))
score_text = f" Score: {score} Best: {max(score, highscore)} [Q] Quit "
stdscr.attron(curses.color_pair(3) | curses.A_BOLD)
stdscr.addstr(0, 2, score_text[:width - 4])
stdscr.attroff(curses.color_pair(3) | curses.A_BOLD)
if speedup_frames > 0:
msg = " SPEED UP! "
stdscr.attron(curses.color_pair(5) | curses.A_BOLD | curses.A_BLINK)
stdscr.addstr(height // 2 - 2, width // 2 - len(msg) // 2, msg)
stdscr.attroff(curses.color_pair(5) | curses.A_BOLD | curses.A_BLINK)
speedup_frames -= 1
safe_addch(stdscr, apple[0], apple[1], '@',
curses.color_pair(2) | curses.A_BOLD, height, width)
for i, (y, x) in enumerate(snake):
safe_addch(stdscr, y, x, 'O' if i == 0 else 'o',
curses.color_pair(1) | curses.A_BOLD, height, width)
stdscr.refresh()
if quit_game:
return
new_record = score > highscore
if new_record:
highscore = score
save_highscore(highscore)
stdscr.nodelay(False)
stdscr.timeout(-1)
stdscr.erase()
cy, cx = height // 2, width // 2
title = "GAME OVER"
scores = f"Score: {score} | Best: {highscore}"
record = "NEW RECORD !"
options = "[R] Restart [Q] Quit"
stdscr.attron(curses.color_pair(2) | curses.A_BOLD)
stdscr.addstr(cy - 3, cx - len(title) // 2, title)
stdscr.attroff(curses.color_pair(2) | curses.A_BOLD)
stdscr.attron(curses.color_pair(3) | curses.A_BOLD)
stdscr.addstr(cy - 1, cx - len(scores) // 2, scores)
stdscr.attroff(curses.color_pair(3) | curses.A_BOLD)
if new_record:
stdscr.attron(curses.color_pair(3) | curses.A_BOLD)
stdscr.addstr(cy, cx - len(record) // 2, record)
stdscr.attroff(curses.color_pair(3) | curses.A_BOLD)
stdscr.addstr(cy + 2, cx - len(options) // 2, options)
stdscr.refresh()
while True:
key = stdscr.getch()
if key in (ord('r'), ord('R')):
break
if key != -1:
return
if __name__ == "__main__":
curses.wrapper(main)
Pour aller plus loin
- Obstacles fixes : ajouter des murs à éviter en cours de partie
- Plusieurs pommes : faire apparaître 2 ou 3 pommes simultanément
- Mode chrono : ajouter un timer et une pomme bonus qui disparaît
- Tableau des scores : sauvegarder les 5 meilleurs scores avec la date
- Sons : utiliser
pygamepour ajouter des effets sonores