1기본 게임 화면 (창 + 격자)
뱀 게임은 화면을 칸(cell) 단위 격자로 나눠서 생각합니다. 뱀도, 먹이도 전부 "몇 번째 칸에 있는가"로만 표현할 겁니다. 먼저 칸 크기와 칸 개수를 정하고, 창 크기를 그 둘의 곱으로 계산합니다.
import pygame
pygame.init()
CELL = 30 # 칸 하나의 크기(픽셀)
GRID = 16 # 가로/세로로 몇 칸인지
screen = pygame.display.set_mode((CELL * GRID, CELL * GRID))
pygame.display.set_caption("뱀 게임 만들기")
clock = pygame.time.Clock() # 속도 조절용 시계
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
screen.fill((10, 10, 10)) # 배경을 검은색으로 지우기
# 칸 하나하나에 옅은 테두리 선을 그려서 격자를 표시
for gx in range(GRID):
for gy in range(GRID):
rect = (gx * CELL, gy * CELL, CELL, CELL)
pygame.draw.rect(screen, (35, 35, 35), rect, 1) # 마지막 1은 '테두리만' 그린다는 뜻
pygame.display.flip()
clock.tick(60)
pygame.quit()
앞으로 뱀이나 먹이의 위치는 픽셀 좌표가 아니라 "몇 번째 칸(gx, gy)"으로 다룹니다. 화면에 그릴 때만 gx * CELL처럼 픽셀로 변환합니다. 이렇게 하면 "한 칸 이동"이 그냥 +1, -1이 되어서 계산이 훨씬 쉬워집니다.
CELL을 20으로, GRID를 24로 바꿔서 실행해보세요. 창 크기와 칸 개수가 어떻게 달라지나요?
2뱀 그리기 (좌표 리스트)
뱀은 여러 칸이 이어진 모양입니다. 그래서 튜플 (x, y)의 리스트로 표현합니다. 리스트의 맨 앞(인덱스 0)이 머리입니다.
import pygame
pygame.init()
CELL = 30
GRID = 16
screen = pygame.display.set_mode((CELL * GRID, CELL * GRID))
pygame.display.set_caption("뱀 게임 만들기")
clock = pygame.time.Clock()
snake = [(8, 8), (7, 8), (6, 8)] # (칸x, 칸y) 좌표 3개. 맨 앞 (8,8)이 머리
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
screen.fill((10, 10, 10))
for gx in range(GRID):
for gy in range(GRID):
rect = (gx * CELL, gy * CELL, CELL, CELL)
pygame.draw.rect(screen, (35, 35, 35), rect, 1)
# 뱀의 칸 좌표를 하나씩 꺼내서, 실제 픽셀 사각형으로 그리기
for (sx, sy) in snake:
rect = (sx * CELL + 1, sy * CELL + 1, CELL - 2, CELL - 2) # +1, -2는 칸 사이에 살짝 여백을 주기 위함
pygame.draw.rect(screen, (90, 220, 140), rect)
pygame.display.flip()
clock.tick(60)
pygame.quit()
for (sx, sy) in snake:처럼 튜플을 바로 sx, sy 두 변수로 풀어서 받을 수 있습니다. 리스트 안의 튜플 개수만큼 사각형이 그려지므로, 뱀이 길어지면 snake 리스트에 좌표를 추가하기만 하면 됩니다.
snake 리스트에 좌표를 하나 더 추가해서 (5, 8)도 넣어보세요. 뱀이 한 칸 더 길게 그려지나요?
3방향키로 한 칸씩 움직이기
이동은 두 단계입니다: 새 머리 좌표를 리스트 맨 앞에 추가하고, 꼬리를 하나 제거합니다. 이렇게 하면 몸의 길이는 그대로 유지한 채 앞으로 "미끄러지듯" 이동하는 효과가 납니다. 지금은 매 프레임이 아니라, 방향키를 누를 때마다 한 칸씩 움직이게 만듭니다.
import pygame
pygame.init()
CELL = 30
GRID = 16
screen = pygame.display.set_mode((CELL * GRID, CELL * GRID))
pygame.display.set_caption("뱀 게임 만들기")
clock = pygame.time.Clock()
snake = [(8, 8), (7, 8), (6, 8)]
direction = (1, 0) # (dx, dy) — 지금은 오른쪽(x+1)으로 이동 중
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_UP:
direction = (0, -1)
elif event.key == pygame.K_DOWN:
direction = (0, 1)
elif event.key == pygame.K_LEFT:
direction = (-1, 0)
elif event.key == pygame.K_RIGHT:
direction = (1, 0)
head_x, head_y = snake[0] # 지금 머리 좌표
new_head = (head_x + direction[0], head_y + direction[1]) # 방향만큼 이동한 새 머리
snake.insert(0, new_head) # 새 머리를 리스트 맨 앞에 추가
snake.pop() # 꼬리(맨 뒤)를 하나 제거 → 길이 유지
screen.fill((10, 10, 10))
for gx in range(GRID):
for gy in range(GRID):
rect = (gx * CELL, gy * CELL, CELL, CELL)
pygame.draw.rect(screen, (35, 35, 35), rect, 1)
for (sx, sy) in snake:
rect = (sx * CELL + 1, sy * CELL + 1, CELL - 2, CELL - 2)
pygame.draw.rect(screen, (90, 220, 140), rect)
pygame.display.flip()
clock.tick(60)
pygame.quit()
실행해서 화살표 키를 눌러보면 뱀이 움직이긴 하지만, 키를 누를 때만 움직입니다. 진짜 뱀 게임처럼 가만히 있어도 계속 앞으로 나아가게 하려면, "이동"과 "방향 입력"을 분리해야 합니다. 그게 다음 챕터입니다.
4타이머로 자동으로 계속 움직이기
pygame.time.set_timer()를 쓰면 "몇 밀리초마다 이 이벤트를 발생시켜줘"라고 예약할 수 있습니다. 방향키는 direction만 바꾸고, 실제 이동은 타이머 이벤트가 올 때만 하도록 3번 챕터의 코드를 나눕니다.
import pygame
pygame.init()
CELL = 30
GRID = 16
screen = pygame.display.set_mode((CELL * GRID, CELL * GRID))
pygame.display.set_caption("뱀 게임 만들기")
clock = pygame.time.Clock()
MOVE_EVENT = pygame.USEREVENT + 1 # 나만의 이벤트 번호 만들기
pygame.time.set_timer(MOVE_EVENT, 150) # 150ms(0.15초)마다 MOVE_EVENT 발생
snake = [(8, 8), (7, 8), (6, 8)]
direction = (1, 0)
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN:
# 정반대 방향으로는 못 꺾게 막기 (오른쪽으로 가는 중에 왼쪽 입력 무시)
if event.key == pygame.K_UP and direction != (0, 1):
direction = (0, -1)
elif event.key == pygame.K_DOWN and direction != (0, -1):
direction = (0, 1)
elif event.key == pygame.K_LEFT and direction != (1, 0):
direction = (-1, 0)
elif event.key == pygame.K_RIGHT and direction != (-1, 0):
direction = (1, 0)
# 이동 코드는 여기서 지웠다 → 이제 키를 눌러도 방향만 바뀌고 즉시 움직이지 않는다
elif event.type == MOVE_EVENT: # 타이머가 울릴 때마다(0.15초마다) 딱 한 칸 이동
head_x, head_y = snake[0]
new_head = (head_x + direction[0], head_y + direction[1])
snake.insert(0, new_head)
snake.pop()
screen.fill((10, 10, 10))
for gx in range(GRID):
for gy in range(GRID):
rect = (gx * CELL, gy * CELL, CELL, CELL)
pygame.draw.rect(screen, (35, 35, 35), rect, 1)
for (sx, sy) in snake:
rect = (sx * CELL + 1, sy * CELL + 1, CELL - 2, CELL - 2)
pygame.draw.rect(screen, (90, 220, 140), rect)
pygame.display.flip()
clock.tick(60)
pygame.quit()
이제 "키 입력"과 "이동"이 완전히 분리됐습니다. KEYDOWN은 direction 변수만 바꾸고, MOVE_EVENT가 그 direction 값을 읽어서 실제로 뱀을 옮깁니다. 이 구조 덕분에 5, 6번 챕터에서 "이동할 때 먹이를 먹었는지, 벽에 부딪혔는지"를 MOVE_EVENT 처리 부분 한 곳에만 추가하면 됩니다.
pygame.time.set_timer(MOVE_EVENT, 150)의 150을 80으로 바꿔서 실행해보세요. 숫자가 작을수록 빨라지나요, 느려지나요?
5먹이를 만들고 먹으면 길어지기
먹이도 뱀과 똑같이 "몇 번째 칸"으로 표현합니다. 뱀이 없는 칸 중에서 무작위로 골라야 하므로, while 반복문으로 "뱀 몸과 겹치지 않을 때까지" 다시 뽑습니다. 머리가 먹이 칸과 같아지면, 꼬리를 지우지 않는 것만으로 뱀이 한 칸 길어집니다.
import pygame
import random
pygame.init()
CELL = 30
GRID = 16
screen = pygame.display.set_mode((CELL * GRID, CELL * GRID))
pygame.display.set_caption("뱀 게임 만들기")
clock = pygame.time.Clock()
MOVE_EVENT = pygame.USEREVENT + 1
pygame.time.set_timer(MOVE_EVENT, 150)
# 뱀 몸과 겹치지 않는 칸을 하나 골라서 돌려주는 함수
def random_food(snake):
while True:
pos = (random.randrange(GRID), random.randrange(GRID))
if pos not in snake: # 뱀이 있는 칸이면 다시 뽑기
return pos
snake = [(8, 8), (7, 8), (6, 8)]
direction = (1, 0)
food = random_food(snake) # 첫 번째 먹이 위치
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_UP and direction != (0, 1):
direction = (0, -1)
elif event.key == pygame.K_DOWN and direction != (0, -1):
direction = (0, 1)
elif event.key == pygame.K_LEFT and direction != (1, 0):
direction = (-1, 0)
elif event.key == pygame.K_RIGHT and direction != (-1, 0):
direction = (1, 0)
elif event.type == MOVE_EVENT:
head_x, head_y = snake[0]
new_head = (head_x + direction[0], head_y + direction[1])
snake.insert(0, new_head)
if new_head == food: # 머리가 먹이 칸에 도착했다면
food = random_food(snake) # 새 먹이를 다시 뽑고, pop()은 하지 않는다 → 그만큼 길어짐
else:
snake.pop() # 못 먹었으면 원래대로 꼬리를 지워서 길이 유지
screen.fill((10, 10, 10))
for gx in range(GRID):
for gy in range(GRID):
rect = (gx * CELL, gy * CELL, CELL, CELL)
pygame.draw.rect(screen, (35, 35, 35), rect, 1)
fx, fy = food
pygame.draw.ellipse(screen, (240, 200, 80), (fx * CELL + 4, fy * CELL + 4, CELL - 8, CELL - 8))
for (sx, sy) in snake:
rect = (sx * CELL + 1, sy * CELL + 1, CELL - 2, CELL - 2)
pygame.draw.rect(screen, (90, 220, 140), rect)
pygame.display.flip()
clock.tick(60)
pygame.quit()
핵심은 if/else의 차이입니다. 원래는 이동할 때마다 항상 snake.pop()으로 꼬리를 지웠는데, 먹이를 먹은 경우에만 그 pop()을 건너뜁니다. 새 칸(머리)은 생기고 꼬리는 그대로 있으니 결과적으로 몸이 한 칸 늘어나는 것입니다.
print(len(snake))를 추가해서, 먹을 때마다 정말로 길이가 1씩 늘어나는지 확인해보세요.
6벽/몸에 부딪히면 게임 오버
새 머리 좌표가 격자 바깥으로 나갔거나, 이미 뱀의 몸이 있는 칸이면 충돌입니다. 충돌이 나면 game_over 상태로 바꾸고, 더 이상 이동하지 않도록 합니다.
import pygame
import random
pygame.init()
CELL = 30
GRID = 16
screen = pygame.display.set_mode((CELL * GRID, CELL * GRID))
pygame.display.set_caption("뱀 게임 만들기")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 30)
MOVE_EVENT = pygame.USEREVENT + 1
pygame.time.set_timer(MOVE_EVENT, 150)
def random_food(snake):
while True:
pos = (random.randrange(GRID), random.randrange(GRID))
if pos not in snake:
return pos
# 벽 밖으로 나갔거나(hit_wall) 몸에 부딪혔는지(hit_self) 확인
def is_collision(pos, snake):
x, y = pos
hit_wall = x < 0 or x >= GRID or y < 0 or y >= GRID
hit_self = pos in snake
return hit_wall or hit_self
snake = [(8, 8), (7, 8), (6, 8)]
direction = (1, 0)
food = random_food(snake)
game_over = False
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_UP and direction != (0, 1):
direction = (0, -1)
elif event.key == pygame.K_DOWN and direction != (0, -1):
direction = (0, 1)
elif event.key == pygame.K_LEFT and direction != (1, 0):
direction = (-1, 0)
elif event.key == pygame.K_RIGHT and direction != (-1, 0):
direction = (1, 0)
elif event.type == MOVE_EVENT and not game_over: # 게임 오버 상태면 더 이상 움직이지 않는다
head_x, head_y = snake[0]
new_head = (head_x + direction[0], head_y + direction[1])
if is_collision(new_head, snake):
game_over = True # 충돌! 이동을 취소하고 게임 오버로 전환
else:
snake.insert(0, new_head)
if new_head == food:
food = random_food(snake)
else:
snake.pop()
screen.fill((10, 10, 10))
for gx in range(GRID):
for gy in range(GRID):
rect = (gx * CELL, gy * CELL, CELL, CELL)
pygame.draw.rect(screen, (35, 35, 35), rect, 1)
fx, fy = food
pygame.draw.ellipse(screen, (240, 200, 80), (fx * CELL + 4, fy * CELL + 4, CELL - 8, CELL - 8))
for (sx, sy) in snake:
rect = (sx * CELL + 1, sy * CELL + 1, CELL - 2, CELL - 2)
pygame.draw.rect(screen, (90, 220, 140), rect)
if game_over:
text_img = font.render("게임 오버! (닫으려면 창을 닫으세요)", True, (255, 90, 90))
screen.blit(text_img, (20, 20))
pygame.display.flip()
clock.tick(60)
pygame.quit()
is_collision()을 먼저 확인하고, 충돌이 아닐 때만 snake.insert(0, new_head)를 하는 순서가 중요합니다. 먼저 넣고 나중에 검사하면, 화면에 이미 벽을 뚫고 나간 머리가 한 프레임 그려져 버립니다.
7점수판 UI 만들기 (겹치지 않게 배치하기)
이제 점수를 화면 위쪽에 보여줄 건데, 주의할 점이 하나 있습니다. 지금 게임판(격자)은 화면의 맨 위(y=0)부터 시작합니다. 점수 글자를 그냥 (20, 0) 같은 곳에 그리면, 게임판 위에 글자가 겹쳐서 뱀이나 격자선과 뒤섞여 보입니다.
snake_pygame.py)에도 처음엔 이 버그가 있었습니다 — 점수 칩(SCORE/BEST 상자)과 상태 문구가 같은 y 범위를 차지해서 글씨가 상자 위에 겹쳐 보였습니다.
해결 방법은 "위쪽에 점수판만을 위한 여백을 따로 확보"하는 것입니다. 창 높이를 늘리고, 게임판을 그 여백 아래(BOARD_Y)부터 그리도록 모든 좌표에 BOARD_Y를 더해줍니다.
import pygame
import random
pygame.init()
CELL = 30
GRID = 16
BOARD_Y = 60 # 점수판이 차지할 위쪽 여백(픽셀). 게임판은 이 아래부터 그린다
screen = pygame.display.set_mode((CELL * GRID, CELL * GRID + BOARD_Y)) # 창 높이에 여백만큼 더 확보
pygame.display.set_caption("뱀 게임 만들기")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 30)
MOVE_EVENT = pygame.USEREVENT + 1
pygame.time.set_timer(MOVE_EVENT, 150)
def random_food(snake):
while True:
pos = (random.randrange(GRID), random.randrange(GRID))
if pos not in snake:
return pos
def is_collision(pos, snake):
x, y = pos
hit_wall = x < 0 or x >= GRID or y < 0 or y >= GRID
hit_self = pos in snake
return hit_wall or hit_self
snake = [(8, 8), (7, 8), (6, 8)]
direction = (1, 0)
food = random_food(snake)
game_over = False
score = 0
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_UP and direction != (0, 1):
direction = (0, -1)
elif event.key == pygame.K_DOWN and direction != (0, -1):
direction = (0, 1)
elif event.key == pygame.K_LEFT and direction != (1, 0):
direction = (-1, 0)
elif event.key == pygame.K_RIGHT and direction != (-1, 0):
direction = (1, 0)
elif event.type == MOVE_EVENT and not game_over:
head_x, head_y = snake[0]
new_head = (head_x + direction[0], head_y + direction[1])
if is_collision(new_head, snake):
game_over = True
else:
snake.insert(0, new_head)
if new_head == food:
food = random_food(snake)
score += 10 # 먹을 때마다 10점씩 추가
else:
snake.pop()
screen.fill((10, 10, 10))
# 점수판: 위쪽 여백(0 ~ BOARD_Y) 안에만 그린다 → 게임판과 절대 겹치지 않는다
score_img = font.render(f"SCORE {score}", True, (240, 240, 240))
screen.blit(score_img, (16, (BOARD_Y - score_img.get_height()) // 2)) # 여백 안에서 세로 가운데 정렬
for gx in range(GRID):
for gy in range(GRID):
rect = (gx * CELL, gy * CELL + BOARD_Y, CELL, CELL) # 모든 격자 좌표에 BOARD_Y를 더해서 아래로 밀기
pygame.draw.rect(screen, (35, 35, 35), rect, 1)
fx, fy = food
pygame.draw.ellipse(screen, (240, 200, 80), (fx * CELL + 4, fy * CELL + 4 + BOARD_Y, CELL - 8, CELL - 8))
for (sx, sy) in snake:
rect = (sx * CELL + 1, sy * CELL + 1 + BOARD_Y, CELL - 2, CELL - 2)
pygame.draw.rect(screen, (90, 220, 140), rect)
if game_over:
text_img = font.render("게임 오버! (닫으려면 창을 닫으세요)", True, (255, 90, 90))
screen.blit(text_img, (20, BOARD_Y + 20))
pygame.display.flip()
clock.tick(60)
pygame.quit()
기억할 규칙 한 가지: UI(점수판, 버튼, 상태 문구)를 놓을 자리와 게임판을 놓을 자리를 처음부터 숫자로 딱 나눠두고, 게임판 쪽 좌표에는 항상 그 여백만큼(BOARD_Y)을 더해서 그리세요. "일단 그려보고 겹치면 조금씩 옮기기"보다, 이렇게 영역을 먼저 설계하는 게 훨씬 안전합니다.
BOARD_Y를 60에서 20으로 줄여보세요. 점수 글자와 격자 맨 윗줄이 겹쳐 보이기 시작하나요? 다시 60 이상으로 늘려서 겹침이 사라지는 걸 확인하세요.
8스페이스로 다시 시작하기 + 마무리
마지막으로, 게임 오버 상태에서 스페이스바를 누르면 모든 변수를 처음 상태로 되돌리는 "리셋" 기능을 추가합니다. 반복되는 초기화 코드는 reset() 함수 하나로 묶어두면, 게임을 처음 시작할 때와 다시 시작할 때 모두 같은 함수를 부르기만 하면 됩니다.
import pygame
import random
pygame.init()
CELL = 30
GRID = 16
BOARD_Y = 60
screen = pygame.display.set_mode((CELL * GRID, CELL * GRID + BOARD_Y))
pygame.display.set_caption("뱀 게임 만들기")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 30)
MOVE_EVENT = pygame.USEREVENT + 1
pygame.time.set_timer(MOVE_EVENT, 150)
def random_food(snake):
while True:
pos = (random.randrange(GRID), random.randrange(GRID))
if pos not in snake:
return pos
def is_collision(pos, snake):
x, y = pos
hit_wall = x < 0 or x >= GRID or y < 0 or y >= GRID
hit_self = pos in snake
return hit_wall or hit_self
# 게임을 처음 상태로 되돌리는 함수 — 시작할 때도, 리셋할 때도 이것만 호출
def reset():
global snake, direction, food, game_over, score
snake = [(8, 8), (7, 8), (6, 8)]
direction = (1, 0)
food = random_food(snake)
game_over = False
score = 0
reset() # 프로그램을 시작하자마자 한 번 호출해서 초기값을 만든다
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE and game_over:
reset() # 게임 오버 상태에서 스페이스 → 다시 시작
if event.key == pygame.K_UP and direction != (0, 1):
direction = (0, -1)
elif event.key == pygame.K_DOWN and direction != (0, -1):
direction = (0, 1)
elif event.key == pygame.K_LEFT and direction != (1, 0):
direction = (-1, 0)
elif event.key == pygame.K_RIGHT and direction != (-1, 0):
direction = (1, 0)
elif event.type == MOVE_EVENT and not game_over:
head_x, head_y = snake[0]
new_head = (head_x + direction[0], head_y + direction[1])
if is_collision(new_head, snake):
game_over = True
else:
snake.insert(0, new_head)
if new_head == food:
food = random_food(snake)
score += 10
else:
snake.pop()
screen.fill((10, 10, 10))
score_img = font.render(f"SCORE {score}", True, (240, 240, 240))
screen.blit(score_img, (16, (BOARD_Y - score_img.get_height()) // 2))
for gx in range(GRID):
for gy in range(GRID):
rect = (gx * CELL, gy * CELL + BOARD_Y, CELL, CELL)
pygame.draw.rect(screen, (35, 35, 35), rect, 1)
fx, fy = food
pygame.draw.ellipse(screen, (240, 200, 80), (fx * CELL + 4, fy * CELL + 4 + BOARD_Y, CELL - 8, CELL - 8))
for (sx, sy) in snake:
rect = (sx * CELL + 1, sy * CELL + 1 + BOARD_Y, CELL - 2, CELL - 2)
pygame.draw.rect(screen, (90, 220, 140), rect)
if game_over:
text_img = font.render("게임 오버! 스페이스바로 다시 시작", True, (255, 90, 90))
screen.blit(text_img, (20, BOARD_Y + 20))
pygame.display.flip()
clock.tick(60)
pygame.quit()
여기까지 하면 방향키로 움직이고, 먹이를 먹고 길어지고, 벽/몸에 부딪히면 게임 오버가 되고, 스페이스바로 다시 시작하는 완전히 동작하는 뱀 게임이 완성됩니다. 실제 데모에 쓰인 snake_pygame.py는 여기서 배운 것과 똑같은 원리이고, 배경 그라데이션·점수 칩 디자인·클래스로 정리하기 같은 꾸미기 요소만 더 들어간 버전입니다.
- 먹이를 먹을 때마다
pygame.time.set_timer(MOVE_EVENT, 새로운값)을 다시 호출해서 점점 빨라지게 만들기 best_score변수를 추가해서,reset()을 호출해도 최고 점수는 사라지지 않게 만들기- 뱀 머리와 몸의 색을 다르게 칠해서 어디가 머리인지 더 잘 보이게 만들기