Créer un jeu Snake en Python avec curses (terminal interactif complet)

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 : curses n'est pas disponible nativement sous Windows. Utilise WSL (Windows Subsystem for Linux) ou installe windows-curses via pip 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 :

  1. On calcule la nouvelle tête selon la direction
  2. On l'insère en début de liste
  3. 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 pygame pour ajouter des effets sonores

Laisser un commentaire

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