Чтобы написать змейку на PyGame, нужно четыре вещи: поле из клеток вместо пикселей, змейка как очередь координат deque, один ход по таймеру раз в 150 мс и проверка двух столкновений, со стеной и с собственным телом. Ниже я собираю игру по шагам от пустого окна до счёта, ускорения и рестарта, каждый кусок кода запущен на pygame 2.6.1 и Python 3.12.
Если вы ещё не делали ни одной игры на этой библиотеке, начните с разбора игры на PyGame с нуля: там подробно про игровой цикл, а здесь я на нём не останавливаюсь.
SkillboxПрофессия «Python-разработчик»157 335 ₽10 месяцев
НетологияFullstack-разработчик на Python137 000 ₽21 месяц
GeekBrainsДО Профессия Python-разработчик149 001 ₽10 месяцев
SkillFactoryПрофессия Python-разработчик154 044 ₽10 месяцев
Академия СинергияPython-разработчик + ИИ77 900 ₽6 месяцев
ЭдюсонPython-разработчик119 892 ₽9 месяцев
Как устроена змейка и что понадобится

Змейка обманчиво простая: у неё нет ни физики, ни спрайтов. Вся игра строится на одной идее: поле поделено на клетки, и змейка живёт в координатах клеток, а не пикселей. Голова с координатой (15, 10) значит «пятнадцатая клетка слева, десятая сверху», а в пиксели это переводится только при отрисовке.
Из этого следует всё остальное:
- ход змейки это сдвиг головы на одну клетку и удаление хвоста, тело хранить отдельно не нужно;
- еда занимает ровно одну клетку, а значит проверка «съели» это сравнение двух кортежей;
- столкновение с собой это вопрос «есть ли новая клетка головы в списке клеток тела».
Понадобится Python 3 и установленный PyGame 2, я проверял на Python 3.12 и pygame 2.6.1. Если import pygame падает с ModuleNotFoundError, загляните в инструкцию по установке PyGame, там разобраны все типовые причины. Я собираю игру в одном файле snake.py, без классов: для первого проекта функции и словарь состояния читаются проще, а переложить на классы вы сможете потом за полчаса.
Шаг 1: поле, константы и окно
Все размеры задаём в клетках и только один раз переводим в пиксели. Клетка 20 пикселей, поле 30 на 20 клеток, значит окно 600 на 400. Захотите поле больше, поменяете две цифры, и ни одна другая строка не пострадает.
import random
from collections import deque
import pygame
CELL = 20 # размер клетки в пикселях
COLS, ROWS = 30, 20 # поле 30 × 20 клеток
WIDTH, HEIGHT = COLS * CELL, ROWS * CELL # 600 × 400
FPS = 60
START_STEP_MS = 150 # пауза между ходами в начале игры
MIN_STEP_MS = 60 # быстрее этого змейка не ходит
BG = (24, 26, 32)
GRID = (36, 39, 46)
HEAD = (120, 220, 120)
TAIL = (30, 110, 60)
FOOD = (230, 80, 80)
TEXT = (235, 235, 235)
Обратите внимание на две пары констант. FPS это частота кадров, с которой мы перерисовываем экран и опрашиваем клавиатуру. START_STEP_MS это пауза между ходами змейки. Кадры и ходы разведены специально: экран обновляется 60 раз в секунду, а змейка ползёт 6–7 раз в секунду. Если привязать ход к кадру, как делают многие уроки через clock.tick(15), ускорить змейку можно только вместе с частотой кадров, а два быстрых нажатия попадают в один кадр, и первое перезаписывается вторым.
Окно создаём стандартно, как в разборе первого окна PyGame. Шрифт беру системный: SysFont("arial", 22) найдёт Arial на Windows и macOS, а на Linux без него подставит встроенный freesansbold, кириллицу оба показывают.
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Змейка")
clock = pygame.time.Clock()
font = pygame.font.SysFont("arial", 22)
Канал основателя Checkroi Вани Буявца3 700 человек читают мой Телеграм-канал про нейросетиСобрал промпты для Claude Code и ChatGPT, разборы ИИ-инструментов и лайфхаки по продвижению бизнеса в одном местеПрисоединитьсяШаг 2: змейка как очередь клеток

Тело змейки это упорядоченный список клеток, где первый элемент голова. На каждом ходу мы добавляем клетку в начало и убираем с конца. Обычный список для этого плох: insert(0, x) сдвигает все элементы и стоит O(n). collections.deque делает appendleft() и pop() за O(1) с обоих концов, это прямо написано в документации Python. На поле 600 клеток разницу вы не почувствуете, но привычка выбирать структуру под операцию дороже любой змейки.
Всё состояние партии я храню в одном словаре, который создаёт функция new_game(). Так рестарт превращается в одну строку: вызвали функцию ещё раз, получили чистую партию.
def new_game():
"""Состояние новой партии: змейка из трёх клеток в центре, смотрит вправо."""
cx, cy = COLS // 2, ROWS // 2
snake = deque([(cx, cy), (cx - 1, cy), (cx - 2, cy)])
return {
"snake": snake,
"direction": RIGHT,
"next_direction": RIGHT,
"food": spawn_food(snake),
"score": 0,
"alive": True,
"paused": False,
}
Змейка стартует из трёх клеток в центре и смотрит вправо. Кортеж направления это сдвиг по осям: RIGHT = (1, 0), UP = (0, -1). Минус у «вверх» не опечатка, в PyGame ось Y растёт вниз, как и во всех оконных библиотеках.
Отрисовка тоже простая: клетка переводится в прямоугольник умножением на CELL. Сегменты я крашу градиентом от светлой головы к тёмному хвосту, это две строки и сразу видно, куда змейка ползёт. Функция lerp смешивает два цвета в пропорции от 0 до 1.
def lerp(a, b, t):
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
def draw(screen, font, state):
screen.fill(BG)
for x in range(0, WIDTH, CELL):
pygame.draw.line(screen, GRID, (x, 0), (x, HEIGHT))
for y in range(0, HEIGHT, CELL):
pygame.draw.line(screen, GRID, (0, y), (WIDTH, y))
fx, fy = state["food"]
pygame.draw.rect(screen, FOOD, (fx * CELL, fy * CELL, CELL, CELL), border_radius=6)
n = len(state["snake"])
for i, (x, y) in enumerate(state["snake"]):
color = lerp(HEAD, TAIL, i / max(n - 1, 1))
pygame.draw.rect(screen, color, (x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2), border_radius=4)
screen.blit(font.render(f"Счёт: {state['score']}", True, TEXT), (10, 8))
border_radius у pygame.draw.rect скругляет углы, параметр есть с версии 2.0. Отступ в один пиксель по краям клетки даёт зазор между сегментами, без него змейка сливается в сплошную полосу.
Шаг 3: движение и запрет разворота на 180°
Управление стрелками и WASD сводится к словарю «клавиша → направление». Но тут же прячется самая частая ошибка змейки, из-за которой игрок умирает от одного нажатия: змейка ползёт вправо, игрок жмёт «влево», голова идёт во вторую клетку тела, игра окончена. Разворот на 180° надо запрещать.
Запрещать нужно правильно. Направление я храню в двух полях: direction это куда змейка сделала последний ход, next_direction это что нажал игрок. Проверка разворота сравнивает нажатие с direction, а не с предыдущим нажатием.
UP, DOWN, LEFT, RIGHT = (0, -1), (0, 1), (-1, 0), (1, 0)
KEYS = {
pygame.K_UP: UP, pygame.K_w: UP,
pygame.K_DOWN: DOWN, pygame.K_s: DOWN,
pygame.K_LEFT: LEFT, pygame.K_a: LEFT,
pygame.K_RIGHT: RIGHT, pygame.K_d: RIGHT,
}
def turn(state, new_dir):
"""Запоминаем поворот, но запрещаем разворот на 180°."""
dx, dy = state["direction"]
if new_dir == (-dx, -dy):
return
state["next_direction"] = new_dir
Почему не сравнивать с next_direction? Змейка ползёт вправо, игрок за один ход успевает нажать «вверх» и «влево». Если проверять против последнего нажатия, «влево» пройдёт, ведь оно не противоположно «вверх». Но ход ещё не сделан, змейка всё ещё смотрит вправо, и на следующем ходу она развернётся в себя. У быстрых игроков это случается постоянно и выглядит как «игра глючит».
Сам ход начинается с того, что direction принимает накопленное нажатие, и только потом считается новая голова:
def step(state):
"""Один ход змейки. Возвращает True, если змейка жива."""
state["direction"] = state["next_direction"]
dx, dy = state["direction"]
hx, hy = state["snake"][0]
Шаг 4: еда, рост и подсчёт очков
Еда это одна клетка. Спавнить её через randrange по всему полю нельзя: рано или поздно яблоко появится внутри змейки, и игрок не сможет его взять. Правильный способ: собрать список свободных клеток и выбрать случайную из них.
Рост делается бесплатно за счёт очереди. На каждом ходу голова добавляется в начало. Если голова попала на еду, хвост мы не удаляем, и змейка становится на клетку длиннее. Если не попала, хвост уходит, длина сохраняется. Никаких отдельных счётчиков «сколько ещё расти».
def spawn_food(snake):
"""Случайная СВОБОДНАЯ клетка: еда никогда не появится на теле."""
free = [(x, y) for x in range(COLS) for y in range(ROWS) if (x, y) not in snake]
return random.choice(free)
state["snake"].appendleft(new_head)
if new_head == state["food"]:
state["score"] += 1
state["food"] = spawn_food(state["snake"])
else:
state["snake"].pop()
return True
Счёт это число съеденных яблок, оно лежит в том же словаре состояния и выводится в углу через font.render. Если хотите вывести его крупнее или добавить рекорд, механика та же, что в любом цикле while на Python: одна переменная, одно место, где она меняется.
Шаг 5: столкновения со стеной и с собой
Со стеной всё очевидно: новая голова должна лежать в диапазоне от 0 до COLS - 1 по X и от 0 до ROWS - 1 по Y. В клетках эта проверка занимает одну строку, в пикселях пришлось бы возиться с rect.right и rect.bottom, как в разборе Rect и столкновений в PyGame.
С собой чуть тоньше. Проверять надо не всё тело, а тело без последней клетки: хвост в этот же ход освободит свою клетку, и заход на неё законен. Если проверять всё тело целиком, змейка, свернувшаяся плотным кольцом, будет умирать там, где должна была проскочить.
new_head = (hx + dx, hy + dy)
hit_wall = not (0 <= new_head[0] < COLS and 0 <= new_head[1] < ROWS)
# хвост в этот ход уйдёт с клетки, поэтому его из проверки исключаем
body = list(state["snake"])[:-1]
if hit_wall or new_head in body:
state["alive"] = False
return False
Проверку я сделал до того, как голова добавлена в очередь, поэтому при проигрыше змейка остаётся на экране целой, а не «въехавшей» в стену. Ситуацию с хвостом я прогнал отдельным тестом: змейка из четырёх клеток поворачивает на клетку уходящего хвоста и остаётся жива, а та же змейка длиной семь, где клетка занята серединой тела, погибает.
Шаг 6: скорость, пауза, конец игры и рестарт
Змейка, которая ползёт с одной скоростью, надоедает через минуту. Каждое яблоко срезает 3 мс с паузы между ходами: старт 150 мс, к тридцатому яблоку 60 мс, дальше не ускоряется. Это и есть выигрыш от того, что ход отвязан от кадра: скорость меняется одной функцией, а клавиатура по-прежнему опрашивается 60 раз в секунду.
Таймер ходов строится на clock.tick(FPS): метод возвращает миллисекунды с прошлого кадра, мы их накапливаем, и как только накопилось больше паузы, делаем ход и обнуляем счётчик.
Рестарт это state = new_game() по клавише R. Ни одной глобальной переменной сбрасывать не нужно, потому что всё состояние живёт в словаре. Пауза это флаг, при котором ходы не считаются, а экран продолжает рисоваться.
def step_ms(score):
"""Пауза между ходами: каждое яблоко ускоряет змейку на 3 мс."""
return max(MIN_STEP_MS, START_STEP_MS - score * 3)
if state["paused"] and state["alive"]:
msg = font.render("Пауза — P, чтобы продолжить", True, TEXT)
screen.blit(msg, msg.get_rect(center=(WIDTH // 2, HEIGHT // 2)))
if not state["alive"]:
msg = font.render(f"Игра окончена, счёт {state['score']}. R — заново, Esc — выход", True, TEXT)
screen.blit(msg, msg.get_rect(center=(WIDTH // 2, HEIGHT // 2)))
pygame.display.flip()
state = new_game()
since_move = 0
running = True
while running:
dt = clock.tick(FPS)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
elif event.key == pygame.K_r and not state["alive"]:
state = new_game()
since_move = 0
elif event.key == pygame.K_p:
state["paused"] = not state["paused"]
elif event.key in KEYS:
turn(state, KEYS[event.key])
if state["alive"] and not state["paused"]:
since_move += dt
if since_move >= step_ms(state["score"]):
since_move = 0
step(state)
draw(screen, font, state)
pygame.quit()
if __name__ == "__main__":
main()
Клавиша R работает только когда змейка мертва, иначе случайное нажатие в разгар партии обнулит счёт. Esc закрывает игру в любой момент, крестик окна тоже, за него отвечает событие QUIT.
Полный код игры
Вот весь snake.py целиком, 155 строк. Сохраните в файл (как завести и запустить его, показано в инструкции про файл Python в VS Code), запустите python snake.py, управление стрелками или WASD, P пауза, R новая партия, Esc выход. Если строк слишком много, чтобы разобрать их в одиночку, такой код удобно проходить с наставником на курсах по Python: змейка есть в программе почти каждого из них как первый проект.
import random
from collections import deque
import pygame
CELL = 20 # размер клетки в пикселях
COLS, ROWS = 30, 20 # поле 30 × 20 клеток
WIDTH, HEIGHT = COLS * CELL, ROWS * CELL # 600 × 400
FPS = 60
START_STEP_MS = 150 # пауза между ходами в начале игры
MIN_STEP_MS = 60 # быстрее этого змейка не ходит
BG = (24, 26, 32)
GRID = (36, 39, 46)
HEAD = (120, 220, 120)
TAIL = (30, 110, 60)
FOOD = (230, 80, 80)
TEXT = (235, 235, 235)
UP, DOWN, LEFT, RIGHT = (0, -1), (0, 1), (-1, 0), (1, 0)
KEYS = {
pygame.K_UP: UP, pygame.K_w: UP,
pygame.K_DOWN: DOWN, pygame.K_s: DOWN,
pygame.K_LEFT: LEFT, pygame.K_a: LEFT,
pygame.K_RIGHT: RIGHT, pygame.K_d: RIGHT,
}
def new_game():
"""Состояние новой партии: змейка из трёх клеток в центре, смотрит вправо."""
cx, cy = COLS // 2, ROWS // 2
snake = deque([(cx, cy), (cx - 1, cy), (cx - 2, cy)])
return {
"snake": snake,
"direction": RIGHT,
"next_direction": RIGHT,
"food": spawn_food(snake),
"score": 0,
"alive": True,
"paused": False,
}
def spawn_food(snake):
"""Случайная СВОБОДНАЯ клетка: еда никогда не появится на теле."""
free = [(x, y) for x in range(COLS) for y in range(ROWS) if (x, y) not in snake]
return random.choice(free)
def turn(state, new_dir):
"""Запоминаем поворот, но запрещаем разворот на 180°."""
dx, dy = state["direction"]
if new_dir == (-dx, -dy):
return
state["next_direction"] = new_dir
def step(state):
"""Один ход змейки. Возвращает True, если змейка жива."""
state["direction"] = state["next_direction"]
dx, dy = state["direction"]
hx, hy = state["snake"][0]
new_head = (hx + dx, hy + dy)
hit_wall = not (0 <= new_head[0] < COLS and 0 <= new_head[1] < ROWS)
# хвост в этот ход уйдёт с клетки, поэтому его из проверки исключаем
body = list(state["snake"])[:-1]
if hit_wall or new_head in body:
state["alive"] = False
return False
state["snake"].appendleft(new_head)
if new_head == state["food"]:
state["score"] += 1
state["food"] = spawn_food(state["snake"])
else:
state["snake"].pop()
return True
def step_ms(score):
"""Пауза между ходами: каждое яблоко ускоряет змейку на 3 мс."""
return max(MIN_STEP_MS, START_STEP_MS - score * 3)
def lerp(a, b, t):
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
def draw(screen, font, state):
screen.fill(BG)
for x in range(0, WIDTH, CELL):
pygame.draw.line(screen, GRID, (x, 0), (x, HEIGHT))
for y in range(0, HEIGHT, CELL):
pygame.draw.line(screen, GRID, (0, y), (WIDTH, y))
fx, fy = state["food"]
pygame.draw.rect(screen, FOOD, (fx * CELL, fy * CELL, CELL, CELL), border_radius=6)
n = len(state["snake"])
for i, (x, y) in enumerate(state["snake"]):
color = lerp(HEAD, TAIL, i / max(n - 1, 1))
pygame.draw.rect(screen, color, (x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2), border_radius=4)
screen.blit(font.render(f"Счёт: {state['score']}", True, TEXT), (10, 8))
if state["paused"] and state["alive"]:
msg = font.render("Пауза — P, чтобы продолжить", True, TEXT)
screen.blit(msg, msg.get_rect(center=(WIDTH // 2, HEIGHT // 2)))
if not state["alive"]:
msg = font.render(f"Игра окончена, счёт {state['score']}. R — заново, Esc — выход", True, TEXT)
screen.blit(msg, msg.get_rect(center=(WIDTH // 2, HEIGHT // 2)))
pygame.display.flip()
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Змейка")
clock = pygame.time.Clock()
font = pygame.font.SysFont("arial", 22)
state = new_game()
since_move = 0
running = True
while running:
dt = clock.tick(FPS)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
elif event.key == pygame.K_r and not state["alive"]:
state = new_game()
since_move = 0
elif event.key == pygame.K_p:
state["paused"] = not state["paused"]
elif event.key in KEYS:
turn(state, KEYS[event.key])
if state["alive"] and not state["paused"]:
since_move += dt
if since_move >= step_ms(state["score"]):
since_move = 0
step(state)
draw(screen, font, state)
pygame.quit()
if __name__ == "__main__":
main()
Что я проверял перед публикацией, чтобы вы не проверяли сами: разворот на 180° игнорируется, еда ни разу не появилась на теле, съеденное яблоко удлиняет змейку ровно на одну клетку, выход за стену и заход в середину тела заканчивают партию, R создаёт чистое состояние. Полный цикл прогнан с заскриптованными событиями: поворот, пауза, снятие паузы, второй поворот, рестарт, выход.
Частые ошибки и что с ними делать
Эти пять ошибок я видел в чужих змейках чаще всего, включая популярные уроки. По каждой: симптом и лечение.
Змейка умирает от одного нажатия
Разворот на 180° не запрещён или запрещён неправильно, против последнего нажатия вместо последнего хода:
# так делать нельзя: сравниваем с последним НАЖАТИЕМ, а не с последним ходом
if new_dir != (-next_direction[0], -next_direction[1]):
next_direction = new_dir
Лечение из шага 3: два поля направления, проверка против direction.
Яблоко появляется внутри змейки
# еда может появиться внутри змейки
food = (random.randrange(COLS), random.randrange(ROWS))
Со змейкой в три клетки это случается редко, с длинной постоянно. Выбирайте из списка свободных клеток, как в spawn_food.
После нескольких рестартов игра тормозит или падает с RecursionError
# рекурсия: каждая новая партия вкладывается в предыдущую
def game_loop():
...
if key == pygame.K_r:
game_loop()
Каждая новая партия открывает ещё один уровень вложенности, и старые циклы никуда не деваются. Рестарт это пересоздание состояния внутри того же цикла, а не повторный вызов функции с циклом.
Змейку нельзя ускорить, а быстрые повороты не срабатывают
Ход привязан к кадру через clock.tick(10). Скорость змейки и частота опроса клавиатуры это одно число, и два нажатия за один кадр схлопываются в последнее. Разделите частоту кадров и паузу между ходами, как в шаге 1.
Змейка идёт вверх при нажатии «вниз»
Перепутан знак по оси Y. Вниз это (0, 1), вверх (0, -1): начало координат в левом верхнем углу окна.
Кириллица в счёте выводится квадратами
Загружен шрифт без русских символов, обычно через pygame.font.Font("какой-то.ttf"). SysFont("arial") и встроенный Font(None, 28) кириллицу поддерживают, я проверил обе строки в этой игре.
Что добавить, когда змейка работает

База в 155 строк это фундамент, на котором удобно пробовать идеи. Три доработки, каждая на 10–20 минут.
| Доработка | Что менять | Сложность |
|---|---|---|
| Проход сквозь стены | Одна строка в step(): голова считается по модулю размера поля, проверка стены убирается |
Простая |
| Препятствия на поле | Множество клеток-стен, они рисуются как сегменты и добавляются в проверку столкновений и в исключения при спавне еды | Средняя |
| Таблица рекордов | Читать и писать число в файл рядом со скриптом, показывать рядом со счётом | Простая |
| Звук при поедании | pygame.mixer.Sound и вызов play() в ветке, где растёт счёт |
Простая |
| Второй игрок | Второй словарь состояния, вторая раскладка клавиш, проверка столкновений между двумя телами | Сложная |
Проход сквозь стены выглядит так:
new_head = ((hx + dx) % COLS, (hy + dy) % ROWS) # вышли справа, вошли слева
Рекорд между запусками хранится в файле рядом со скриптом. Три строки на чтение и одна на запись в момент проигрыша, я проверил их отдельно:
from pathlib import Path
RECORD = Path("record.txt")
best = int(RECORD.read_text()) if RECORD.exists() else 0
...
if not state["alive"] and state["score"] > best:
RECORD.write_text(str(state["score"]))
Препятствия чуть длиннее, но идея та же: клетки-стены живут в множестве и участвуют в проверке рядом с телом.
walls = {(5, y) for y in range(3, 12)} | {(x, 15) for x in range(10, 24)}
...
if hit_wall or new_head in body or new_head in walls:
Когда доработки закончатся, змейка перестанет быть учебным заданием и станет первой строчкой портфолио. Как из таких проектов складывается путь до работы, я разбирал в статье как стать разработчиком игр, а о выборе движка после PyGame в обзоре языков и движков для игр.
Где освоить Python и разработку игр системно
Змейка учит трём вещам: хранить состояние в одном месте, отделять логику от отрисовки и проверять граничные случаи вроде уходящего хвоста. Дальше по этой дороге лежат структуры данных, ООП и настоящие движки, и здесь уже удобнее идти по программе, а не по разрозненным урокам. В каталоге собраны курсы по Python с проектами в портфолио, а для тех, кто нацелен именно на игры, отдельная подборка курсов по разработке игр.
| Курс | Школа | Стоимость со скидкой | В рассрочку | Длительность | Обзор курса от Checkroi |
|---|---|---|---|---|---|
| Профессия «Python-разработчик» Перейти на сайт курса | 157 335 ₽ | 5987 ₽/мес. | 10 месяцев | Обзор курса | |
| Fullstack-разработчик на Python Перейти на сайт курса | 137 000 ₽ | 7125 ₽/мес. | 21 месяц | Обзор курса | |
| ДО Профессия Python-разработчик Перейти на сайт курса | 149 001 ₽ | 3167 ₽/мес. | 10 месяцев | Обзор курса | |
| Автоматизированное тестирование на Python Перейти на сайт курса | 122 226 ₽ | 4108 ₽/мес. | 9 месяцев | Обзор курса | |
| Профессия Python-разработчик Перейти на сайт курса | 154 044 ₽ | 4279 ₽/мес. | 10 месяцев | Обзор курса |
Больше программ — в полном каталоге курсов по Python




