Коротко: «Змейку» на Python можно собрать на стандартном модуле turtle, без каких-либо дополнительных библиотек: готовая игра занимает 169 строк вместе с пустыми и комментариями. Собираем её за семь шагов: поле и голова, управление стрелками, движение по таймеру ontimer, яблоко в случайной клетке, хвост из списка квадратиков, счёт на экране, конец игры с рестартом. После каждого шага программа запускается и что-то умеет.

Вам понадобится только Python. Модуль turtle входит в стандартную библиотеку: с установщиком с python.org на Windows и macOS он работает сразу, а в некоторых сборках Linux графическую часть (tkinter) ставят отдельным пакетом, в Ubuntu это python3-tk. Как поставить сам Python, расписано в инструкции для Windows. Если черепашка вам совсем в новинку, сначала поиграйте с рисунками черепашкой: там те же goto, penup и color.

Как змейка устроена внутри

Поле поделено на клетки 20×20 пикселей. Голова — квадратик-черепашка, который раз в 150 миллисекунд прыгает на соседнюю клетку. Стрелки голову не двигают, они только меняют направление. Шаг делает игровой цикл. Хвост — список таких же квадратиков, повторяющих путь головы. Яблоко — красный кружочек. Остальная игра состоит из трёх проверок: голова на яблоке, голова в стене, голова в собственном хвосте.

Шаг 1. Поле и голова

Создаём окно 600×400, рисуем белую рамку и ставим голову в центр. Строка screen.tracer(0) выключает автоматическую анимацию: без неё каждое движение черепашки рисуется отдельно, и игра мигает. Кадр теперь показываем сами, командой screen.update().

import turtle

STEP = 20  # размер клетки: голова прыгает на 20 пикселей

screen = turtle.Screen()
screen.title("Змейка")
screen.bgcolor("black")
screen.setup(600, 400)
screen.tracer(0)  # выключаем автоанимацию, кадр показываем сами

# рамка поля
pen = turtle.Turtle()
pen.hideturtle()
pen.color("white")
pen.penup()
pen.goto(-250, -150)
pen.pendown()
for side in [500, 300, 500, 300]:
    pen.forward(side)
    pen.left(90)

# голова змейки
head = turtle.Turtle()
head.shape("square")
head.color("lime")
head.penup()
head.direction = "stop"

screen.update()
screen.mainloop()

Запустите файл, и вы увидите чёрное окно с рамкой и зелёным квадратиком посередине. head.direction — наша собственная переменная, прикреплённая к черепашке. Python позволяет добавить объекту любой атрибут, и в нём голова помнит, куда ползёт. Пока никуда: "stop".

Шаг 2. Управление стрелками через onkey

Функция move() сдвигает голову на одну клетку в сторону, записанную в head.direction. Четыре функции go_... меняют направление, а screen.onkey() привязывает их к стрелкам. Вставьте блок перед двумя последними строками шага 1:

def move():
    if head.direction == "up":
        head.sety(head.ycor() + STEP)
    elif head.direction == "down":
        head.sety(head.ycor() - STEP)
    elif head.direction == "left":
        head.setx(head.xcor() - STEP)
    elif head.direction == "right":
        head.setx(head.xcor() + STEP)

def go_up():
    head.direction = "up"
    move()             # временно: один шаг на нажатие
    screen.update()

def go_down():
    head.direction = "down"
    move()
    screen.update()

def go_left():
    head.direction = "left"
    move()
    screen.update()

def go_right():
    head.direction = "right"
    move()
    screen.update()

screen.listen()
screen.onkey(go_up, "Up")
screen.onkey(go_down, "Down")
screen.onkey(go_left, "Left")
screen.onkey(go_right, "Right")

На этом шаге каждое нажатие делает ровно один шаг, чтобы вы видели, что стрелки работают. Не реагирует на клавиши? Щёлкните мышкой по окну. Нажатия приходят только в окно, которое в фокусе, а screen.listen() включает их приём.

Шаг 3. Движение по таймеру: почему ontimer, а не while True

Настоящая змейка ползёт сама, без ваших нажатий. Уберите из каждой функции go_... строки move() и screen.update(), добавьте вверху DELAY и функцию loop(), а внизу вместо screen.update() вызовите loop(). Три точки в коде обозначают строки, которые не изменились:

DELAY = 150  # пауза между шагами, мс
...
def go_up():
    head.direction = "up"
...
def loop():
    move()
    screen.update()
    screen.ontimer(loop, DELAY)  # «вызови loop ещё раз через 150 мс»
...
loop()
screen.mainloop()

ontimer(loop, DELAY) означает «вызови loop ещё раз через 150 мс». Функция не крутится бесконечно. Она делает один шаг и заказывает следующий, а между шагами управление у mainloop(): это внутренний цикл turtle, который принимает клавиши и перерисовывает окно.

Во многих примерах в интернете вы найдёте другой вариант:

import time

while True:
    move()
    screen.update()
    time.sleep(0.15)

Голову это тоже двигает, но забирает управление у черепашки. Пока идёт time.sleep(), окно стоит: нажатия ждут в очереди до следующего update(). Забыли update() в цикле — окно зависает совсем. Закрыли окно — цикл пытается рисовать в то, чего уже нет, и в консоли вылетает ошибка. А mainloop() после такого цикла не выполнится никогда. С ontimer ждёт сама черепашка, и скорость игры задаёт одно число.

Голова пока пролетает сквозь рамку и исчезает за краем. Стены появятся на шаге 7.

Шаг 4. Яблоко в случайном месте и столкновение

Яблоко — ещё одна черепашка, только круглая и красная. Функция place_food() бросает её в случайную клетку, а loop() после каждого шага проверяет, стоит ли голова на яблоке:

import random
...
# яблоко
food = turtle.Turtle()
food.shape("circle")
food.color("red")
food.penup()

def place_food():
    # только в клетки сетки: координаты кратны STEP
    x = random.randint(-12, 12) * STEP
    y = random.randint(-7, 7) * STEP
    food.goto(x, y)
...
def loop():
    move()
    if head.distance(food) < 15:  # голова на яблоке
        place_food()
    screen.update()
    screen.ontimer(loop, DELAY)
...
place_food()
loop()
screen.mainloop()

Зачем координаты умножаем на STEP? Голова стоит только на числах, кратных 20: 0, 20, 40 и так далее. randint(-12, 12) * 20 даёт от −240 до 240, то есть клетку внутри рамки. Если же бросить яблоко на произвольное число, скажем в точку (35, 5), оно ляжет между клетками. Голова на (20, 0) уже накрывает его краем, но distance() показывает 15,8, и яблоко не съедено. Вы смотрите на голову, наехавшую на яблоко, а счёт стоит. Для игрока это баг.

head.distance(food) считает расстояние между двумя черепашками. Порог 15 меньше клетки, поэтому срабатывает только тогда, когда голова стоит точно на яблоке.

Шаг 5. Хвост: список сегментов

Хвост храним в обычном списке. Если у вас ещё путаются append и range, загляните в справочник о списках и словарях. Каждое яблоко добавляет один квадратик: он рождается за пределами окна, в точке (1000, 1000), и уже на следующем шаге встаёт на своё место. Движение хвоста дописываем в начало move(), перед шагом головы:

segments = []  # квадратики хвоста
...
def place_food():
    # ищем клетку, где нет ни головы, ни хвоста
    while True:
        x = random.randint(-12, 12) * STEP
        y = random.randint(-7, 7) * STEP
        food.goto(x, y)
        free = True
        for t in [head] + segments:
            if t.distance(food) < 15:
                free = False
        if free:
            return

def add_segment():
    s = turtle.Turtle()
    s.shape("square")
    s.color("green")
    s.penup()
    s.goto(1000, 1000)  # за полем: на своё место встанет следующим шагом
    segments.append(s)
...
def move():
    # 1. хвост: с конца, каждый квадратик встаёт на место соседа впереди
    for i in range(len(segments) - 1, 0, -1):
        x = segments[i - 1].xcor()
        y = segments[i - 1].ycor()
        segments[i].goto(x, y)
    # 2. первый квадратик встаёт туда, где была голова
    if segments:
        segments[0].goto(head.xcor(), head.ycor())
    # 3. голова делает шаг
    ...

В loop(), в блоке с яблоком, перед place_food() добавьте строку add_segment(). Порядок в move() принципиален: сначала хвост, с последнего квадратика к первому, и только потом голова. range(len(segments) - 1, 0, -1) считает назад: для хвоста из четырёх квадратиков это 3, 2, 1. Нулевой квадратик отдельно встаёт туда, где только что стояла голова.

place_food() тоже изменилась: теперь она ищет клетку, свободную от змейки. Мы прогнали игру с подменённым random, который всегда возвращает 0, и старая версия проигрывала сама. Яблоко падало под голову на старте, «съедалось» до первого нажатия, первый квадратик хвоста вставал на место неподвижной головы, а после шага 7 это уже укус и конец игры.

Типичная ошибка: хвост двигают от головы, и сегменты слипаются

Чаще всего хвост пишут «по-человечески», от головы к концу, и вы, скорее всего, тоже так попробуете: for i in range(1, len(segments)). Вот та же логика на обычных координатах, без окна. Её можно запустить где угодно, даже онлайн, без установки Python:

head = (60, 0)
tail = [(40, 0), (20, 0), (0, 0)]

wrong = tail.copy()
for i in range(1, len(wrong)):         # с головы к хвосту
    wrong[i] = wrong[i - 1]
wrong[0] = head
print("неправильно:", wrong)

right = tail.copy()
for i in range(len(right) - 1, 0, -1):  # с хвоста к голове
    right[i] = right[i - 1]
right[0] = head
print("правильно:  ", right)

Результат запуска:

неправильно: [(60, 0), (40, 0), (40, 0)]
правильно:   [(60, 0), (40, 0), (20, 0)]

В первом варианте второй квадратик переехал на место первого, а третий взял уже новую позицию второго. Где второй стоял раньше, никто не запомнил. Хоть из десяти квадратиков, такой хвост занимает две клетки: все, кроме первого, слипаются в одну. Движение с конца даёт каждому квадратику ещё не сдвинутого соседа. Другие ошибки, которые ломают первые программы, собраны в статье о типичных ошибках новичков.

Шаг 6. Счёт на экране

Для надписи заводим отдельную невидимую черепашку hud вверху поля. show_score() стирает старый текст и пишет новый:

# счёт вверху
score = 0
hud = turtle.Turtle()
hud.hideturtle()
hud.penup()
hud.color("white")
hud.goto(0, 160)
...
def show_score():
    hud.clear()  # без clear() новые цифры ложатся поверх старых
    hud.write(f"Счёт: {score}", align="center", font=("Arial", 16, "bold"))
...
def loop():
    global score
    move()
    if head.distance(food) < 15:  # голова на яблоке
        score += 1
        add_segment()
        show_score()
        place_food()
    screen.update()
    screen.ontimer(loop, DELAY)
...
show_score()
place_food()
loop()

Счёт меняется внутри loop(), поэтому в начале функции стоит global score. Без него Python решит, что score — локальная переменная, и на первом же яблоке упадёт с UnboundLocalError. Почему так, объясняет справочник о функциях. Вызов show_score() внизу файла нужен, чтобы вы видели счёт с первой секунды. А если убрать hud.clear(), надписи «Счёт: 0», «Счёт: 1», «Счёт: 2» лягут одна на другую.

Шаг 7. Конец игры: стены, укус себя и рестарт

Осталось научить игру проигрывать. Голова в стене, когда модуль x больше 240 или модуль y больше 140: рамка стоит на ±250 и ±150, так что крайние клетки ещё внутри. Укус — когда голова ближе чем на 10 пикселей к любому квадратику хвоста. После проигрыша loop() не заказывает следующего шага, и змейка замирает.

С поворотами есть ловушка. Запретить разворот на 180° кажется легко:

def go_left():
    if head.direction != "right":  # ловушка: смотрим на direction
        head.direction = "left"

Но змейка делает шаг раз в 150 мс, а пальцы быстрее. Вы ползёте вправо и быстро жмёте «вверх» и «влево». Первая стрелка меняет direction на up, вторая видит up и разрешает left, и на следующем шаге голова разворачивается прямо в хвост. В нашем прогоне змейка с тремя квадратиками хвоста проигрывала именно так. Поэтому сравниваем с head.last, направлением, куда голова на самом деле пошла прошлым шагом. Оно обновляется в конце move().

Рестарт на клавишу R прячет квадратики, очищает список, возвращает голову в центр и снова запускает loop(). Проверка if playing: return не даёт нажать R посреди игры: тогда работали бы две цепочки таймеров одновременно, и змейка ползла бы вдвое быстрее. Полный код игры:

import turtle
import random

STEP = 20    # размер клетки: голова прыгает на 20 пикселей
DELAY = 150  # пауза между шагами, мс

screen = turtle.Screen()
screen.title("Змейка")
screen.bgcolor("black")
screen.setup(600, 400)
screen.tracer(0)  # выключаем автоанимацию, кадр показываем сами

# рамка поля
pen = turtle.Turtle()
pen.hideturtle()
pen.color("white")
pen.penup()
pen.goto(-250, -150)
pen.pendown()
for side in [500, 300, 500, 300]:
    pen.forward(side)
    pen.left(90)

# голова змейки
head = turtle.Turtle()
head.shape("square")
head.color("lime")
head.penup()
head.direction = "stop"  # куда повернуть на следующем шаге
head.last = "stop"       # куда голова на самом деле пошла прошлым шагом

segments = []  # квадратики хвоста

# яблоко
food = turtle.Turtle()
food.shape("circle")
food.color("red")
food.penup()

# счёт вверху
score = 0
best = 0
playing = True
hud = turtle.Turtle()
hud.hideturtle()
hud.penup()
hud.color("white")
hud.goto(0, 160)

def show_score():
    hud.clear()  # без clear() новые цифры ложатся поверх старых
    hud.write(f"Счёт: {score}   Рекорд: {best}", align="center", font=("Arial", 16, "bold"))

def place_food():
    # ищем клетку, где нет ни головы, ни хвоста
    while True:
        x = random.randint(-12, 12) * STEP
        y = random.randint(-7, 7) * STEP
        food.goto(x, y)
        free = True
        for t in [head] + segments:
            if t.distance(food) < 15:
                free = False
        if free:
            return

def add_segment():
    s = turtle.Turtle()
    s.shape("square")
    s.color("green")
    s.penup()
    s.goto(1000, 1000)  # за полем: на своё место встанет следующим шагом
    segments.append(s)

def move():
    # 1. хвост: с конца, каждый квадратик встаёт на место соседа впереди
    for i in range(len(segments) - 1, 0, -1):
        x = segments[i - 1].xcor()
        y = segments[i - 1].ycor()
        segments[i].goto(x, y)
    # 2. первый квадратик встаёт туда, где была голова
    if segments:
        segments[0].goto(head.xcor(), head.ycor())
    # 3. голова делает шаг
    if head.direction == "up":
        head.sety(head.ycor() + STEP)
    elif head.direction == "down":
        head.sety(head.ycor() - STEP)
    elif head.direction == "left":
        head.setx(head.xcor() - STEP)
    elif head.direction == "right":
        head.setx(head.xcor() + STEP)
    head.last = head.direction

# развернуться на 180° нельзя: сравниваем с head.last
def go_up():
    if head.last != "down":
        head.direction = "up"

def go_down():
    if head.last != "up":
        head.direction = "down"

def go_left():
    if head.last != "right":
        head.direction = "left"

def go_right():
    if head.last != "left":
        head.direction = "right"

def game_over():
    global playing, best
    playing = False
    if score > best:
        best = score
    hud.clear()
    hud.write(f"Конец игры! Счёт: {score}. Новая игра: R", align="center", font=("Arial", 16, "bold"))
    screen.update()

def restart():
    global score, playing
    if playing:
        return
    for s in segments:
        s.hideturtle()
    segments.clear()
    head.goto(0, 0)
    head.direction = "stop"
    head.last = "stop"
    score = 0
    playing = True
    show_score()
    place_food()
    loop()

def loop():
    global score
    if not playing:
        return
    move()
    # врезались в рамку?
    if abs(head.xcor()) > 240 or abs(head.ycor()) > 140:
        game_over()
        return
    # укусили себя?
    for s in segments:
        if head.distance(s) < 10:
            game_over()
            return
    if head.distance(food) < 15:  # голова на яблоке
        score += 1
        add_segment()
        show_score()
        place_food()
    screen.update()
    screen.ontimer(loop, DELAY)  # «вызови loop ещё раз через 150 мс»

screen.listen()
screen.onkey(go_up, "Up")
screen.onkey(go_down, "Down")
screen.onkey(go_left, "Left")
screen.onkey(go_right, "Right")
screen.onkey(restart, "r")

show_score()
place_food()
loop()
screen.mainloop()

Если у вас не срабатывает R, переключите раскладку на английскую. onkey(restart, "r") ждёт латинскую букву, а стрелки от раскладки не зависят.

Что поменять, когда игра уже работает

Когда ваша змейка ползает и проигрывает как надо, её легко сделать своей:

  • Ускорение: вместо постоянного DELAY заведите переменную delay и после каждого яблока уменьшайте её, delay = max(60, delay - 5). max не даёт паузе упасть ниже 60 мс.
  • Поле без стен: голова, вышедшая справа, появляется слева.
  • Второе, фиолетовое яблоко, которое отнимает два очка.
  • Змейка для двоих: вторая голова на клавишах w, a, s, d, свой хвост и свой счёт.

На курсе «Python Старт» для 6–10 лет змейку собирают за два занятия: сначала движение и яблоки, потом хвост, рекорд и скорость. Большинство идей из списка выше там дают как задания к урокам.

Чем отличается змейка на Pygame

Pygame — отдельная библиотека, её ставят командой pip install pygame; как работает pip, рассказано в справочнике о библиотеках. Логика змейки та же: список сегментов, движение с хвоста, проверка клеток. Отличается каркас. В Pygame нет mainloop(), который ждёт за вас, поэтому while running: там как раз правильный игровой цикл: на каждом обороте программа сама забирает события из pygame.event.get(), рисует кадр и держит темп через clock.tick(10). Ноль координат — в левом верхнем углу, y растёт вниз, а в turtle ноль в центре и y растёт вверх. Квадратики рисует pygame.draw.rect, столкновения проверяет метод colliderect. Зато с Pygame у вас будут картинки, звук и плавная анимация. Как выглядит первая игра на нём и сколько времени на неё уходит, разобрано в статье о первой игре ребёнка на Pygame. В программе Pygame относится к ступени «Игры и боты» для 10–14 лет.