Коротко: «Змійку» на 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 років.