이전 포스팅에서 웹 브라우저에서 바로 즐길 수 있는 레트로 뱀 게임(Snake game)을 소개해 드렸습니다. 단순해 보이는 아케이드 게임이지만, 그 이면에는 자바스크립트의 배열(Array) 조작과 HTML5 Canvas API를 활용한 효율적인 렌더링 로직이 숨어 있습니다.
오늘은 프론트엔드 개발 관점에서 뱀 게임이 어떻게 구현되었는지, 어떤 알고리즘과 자료구조가 사용되었는지 실제 코드를 보며 심층 분석해 보겠습니다.
데이터 구조 설계: 뱀의 몸통을 어떻게 표현할까?
뱀 게임을 구현할 때 가장 먼저 고민해야 할 것은 “길어지는 뱀의 몸통을 어떤 데이터로 관리할 것인가?”입니다. 이 프로젝트에서는 객체(Object)를 담은 배열(Array)을 사용했습니다.
// 게임 설정 및 초기화
var tileCount = 17; // 17x17 격자
var tileSize = 20; // 한 칸당 20px
// 뱀과 먹이 데이터
var snake = [
{ x: 8, y: 8 }, // 머리 (Head)
{ x: 8, y: 9 }, // 몸통
{ x: 8, y: 10 } // 꼬리 (Tail)
];
var food = { x: 0, y: 0 };
var dx = 0; // x축 이동 방향
var dy = -1; // y축 이동 방향 (초기값: 위쪽)
- 알고리즘 분석: 화면을
x, y좌표계로 나누고, 뱀의 각 마디를 좌표 객체{x, y}로 저장합니다. 인덱스가0인 요소(snake[0])가 항상 뱀의 ‘머리’ 역할을 하며, 이동 방향은dx,dy변수를 통해 제어됩니다.
이동 알고리즘: Unshift와 Pop의 마법
뱀이 앞으로 전진할 때, 뱀의 모든 마디 좌표를 일일이 다음 칸으로 업데이트하려면 계산이 복잡해집니다. 자바스크립트의 배열 메서드인 unshift()와 pop()을 활용하면 이 이동 로직을 단 세 줄로 우아하게 처리할 수 있습니다.
function gameLoop() {
// 1. 현재 머리 위치에 이동 방향(dx, dy)을 더해 새로운 머리 좌표 계산
var headX = snake[0].x + dx;
var headY = snake[0].y + dy;
// 2. 새로운 머리를 배열 맨 앞(unshift)에 추가
snake.unshift({ x: headX, y: headY });
// 3. 먹이를 먹었는지 확인
if (headX === food.x && headY === food.y) {
score += 1;
placeFood(); // 먹이를 먹었다면 꼬리를 자르지 않음 (길이 1 증가)
} else {
// 먹이를 먹지 않았다면 배열 맨 끝 꼬리(pop)를 제거하여 이동하는 것처럼 연출
snake.pop();
}
}
- 엔지니어링 포인트: 뱀이 이동하는 시각적 효과는 사실 “새로운 위치에 머리를 만들고, 기존의 꼬리를 자르는 과정”의 연속입니다. 먹이를 먹었을 때만
pop()을 생략하여 자연스럽게 몸통의 길이를 늘이는 것이 이 알고리즘의 핵심입니다.
모드별 충돌 처리 로직 (일반 vs 벽 통과 모드)
이번 뱀 게임의 묘미는 벽에 닿으면 반대편으로 튀어나오는 ‘벽 통과(Pass) 모드’입니다. if 조건문을 통해 모드에 따라 충돌 처리를 다르게 적용했습니다.
// 벽 충돌 처리 로직
if (gameMode === "pass") {
// 벽 통과 모드: 범위를 벗어나면 반대편 좌표로 순간이동
if (headX === -1) { headX = tileCount - 1; }
else if (headX === tileCount) { headX = 0; }
if (headY === -1) { headY = tileCount - 1; }
else if (headY === tileCount) { headY = 0; }
} else {
// 일반 모드: 보드 밖으로 나가면 게임 오버
if (headX === -1 || headX === tileCount || headY === -1 || headY === tileCount) {
endGame();
return;
}
}
// 자기 몸통 충돌 검사
for (var i = 1; i !== snake.length; i++) {
if (snake[i].x === headX && snake[i].y === headY) {
endGame();
return;
}
}
실무 팁: 워드프레스 파서(Parser) 충돌 방어
이번 프로젝트를 워드프레스에 배포하면서 겪었던 가장 큰 이슈는 자바스크립트의 문법 오류(Uncaught SyntaxError)였습니다. 워드프레스의 텍스트 에디터는 보안 및 자동 포맷팅을 위해 스크립트 내부의 부등호(<, >)를 HTML 태그로 오인하여 코드를 망가뜨리는 치명적인 고질병이 있습니다.
이를 해결하기 위해 코드 내의 모든 부등호를 제거하는 방탄(Bulletproof) 코딩 기법을 적용했습니다.
// [기존 코드] 모바일 스와이프 계산 (부등호 사용 - 워드프레스 에러 발생 가능성 높음)
// if (Math.abs(diffX) > 30) { ... }
// [개선된 코드] Math.sign()과 일치 연산자(===) 활용 (부등호 0%)
var absX = Math.abs(diffX);
var isSigX = Math.sign(absX - 30) === 1; // absX - 30이 양수(1)인지 판별
if (isSigX === true) {
// 이동 방향 판별 시에도 부등호 제거
if (Math.sign(diffX) === 1 && dx !== -1) { dx = 1; dy = 0; } // 오른쪽 스와이프
}
- 엔지니어링 포인트: 반복문에서도
for (var i = 0; i < length; i++)대신for (var i = 0; i !== length; i++)를 사용하고, 값의 크기 비교가 필요할 때는Math.sign()을 활용해 양수(1), 음수(-1)를 판별했습니다. 이는 CMS 환경에서 프론트엔드 코드를 안전하게 배포하기 위한 매우 유용한 팁입니다.
📝 마치며
뱀 게임(Snake Game)은 자바스크립트의 배열 메서드, 좌표계의 이해, 게임 루프(setInterval), 그리고 이벤트 리스너 처리까지 프론트엔드 개발의 기초 체력을 다지기에 가장 완벽한 프로젝트입니다.
특히 코드를 작성하는 것을 넘어, 배포 환경(워드프레스)의 특성을 이해하고 예외 처리를 해나가는 과정은 개발자로서 한 단계 성장하는 좋은 경험이 됩니다.
본 포스팅에서 분석한 전체 코드가 어떻게 작동하는지 직접 플레이하며 확인해 보세요!
전체 코드
<div id="snake-game-wrapper">
<div class="snake-header">
<button id="snake-pause-btn" title="Pause / Play (Spacebar)">
<svg id="icon-pause" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
<svg id="icon-play" class="snake-hidden" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<select id="snake-diff-sel" class="snake-select" title="Difficulty">
<option value="9">😊 Easy</option>
<option value="17" selected>😐 Normal</option>
<option value="25">🥵 Hard</option>
</select>
<select id="snake-mode-sel" class="snake-select" title="Game Mode">
<option value="normal" selected>🧱 Normal</option>
<option value="pass">🌀 Pass</option>
</select>
<div class="snake-score-container">
<span style="margin-right: 10px; font-size: 16px; display: flex; align-items: center;">🍉</span>
<span id="snake-score-val">0</span>
</div>
</div>
<div class="snake-canvas-container">
<canvas id="snake-board"></canvas>
<div id="snake-start-popup" class="snake-modal">
<h2 style="color: #ff66b2; margin: 0 0 10px 0; text-shadow: 0 0 10px rgba(255,102,178,0.5); font-size: 42px;">SNAKE</h2>
<p style="font-size: 14px; color: #cccccc; margin-bottom: 25px;">Eat the watermelon! 🍉</p>
<button id="snake-start-btn" class="snake-btn">Start Game</button>
</div>
<div id="snake-pause-popup" class="snake-modal snake-hidden">
<h2 style="color: #ffffff; margin: 0 0 15px 0;">Paused</h2>
<button id="snake-resume-btn" class="snake-btn">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 5px;"><path d="M8 5v14l11-7z"/></svg> Resume
</button>
</div>
<div id="snake-countdown-popup" class="snake-modal snake-hidden">
<h2 id="snake-countdown-val" style="color: #ff66b2; font-size: 80px; margin: 0; text-shadow: 0 0 20px rgba(255,102,178,0.8);">3</h2>
</div>
<div id="snake-game-over" class="snake-modal snake-hidden">
<h2 style="color: #ffffff; margin: 0 0 15px 0;">Game Over!</h2>
<button id="snake-try-again-btn" class="snake-btn">Try Again</button>
</div>
</div>
<div class="snake-controls">
<button id="snake-btn-up" class="snake-dir-btn" style="grid-area: up;">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</button>
<button id="snake-btn-left" class="snake-dir-btn" style="grid-area: left;">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
</button>
<button id="snake-btn-down" class="snake-dir-btn" style="grid-area: down;">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M19 12l-7 7-7-7"/></svg>
</button>
<button id="snake-btn-right" class="snake-dir-btn" style="grid-area: right;">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>
<style>
#snake-game-wrapper, #snake-game-wrapper * { box-sizing: border-box !important; margin: 0; padding: 0; }
#snake-game-wrapper {
max-width: 420px; width: 100%; margin: 20px auto !important;
font-family: 'Segoe UI', Tahoma, Geneva, sans-serif !important;
text-align: center; color: #ffffff !important;
background-color: #111111 !important;
padding: 15px !important; border-radius: 12px !important;
user-select: none; -webkit-tap-highlight-color: transparent;
}
#snake-game-wrapper .snake-header {
display: flex; justify-content: space-between; align-items: stretch;
gap: 6px !important; margin-bottom: 15px !important; height: 42px;
}
#snake-game-wrapper button, #snake-game-wrapper select {
appearance: none !important; -webkit-appearance: none !important;
outline: none !important; font-family: inherit !important;
}
#snake-game-wrapper #snake-pause-btn {
background-color: #2a2a2a !important; color: #ffffff !important;
border: none !important; border-radius: 6px !important;
width: 42px !important; flex-shrink: 0;
display: flex; justify-content: center; align-items: center;
cursor: pointer !important; transition: 0.2s; position: relative; z-index: 10;
}
#snake-game-wrapper #snake-pause-btn:active { transform: scale(0.95); }
#snake-game-wrapper .snake-select {
background-color: #2a2a2a !important; color: #ffffff !important;
border: 1px solid #444 !important; border-radius: 6px !important;
padding: 0 5px !important; font-size: 13px !important; font-weight: bold !important;
cursor: pointer !important; flex-grow: 1; text-align: center; text-align-last: center;
}
#snake-game-wrapper .snake-score-container {
background-color: #2a2a2a !important; color: #ffffff !important;
padding: 0 12px !important; border-radius: 6px !important;
font-size: 20px !important; font-weight: bold !important;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; min-width: 70px;
}
#snake-game-wrapper #snake-score-val { color: #ff66b2 !important; }
#snake-game-wrapper .snake-canvas-container {
position: relative; width: 100%; aspect-ratio: 1 / 1;
border-radius: 8px !important; overflow: hidden;
border: 2px solid #ff66b2 !important; background-color: #1a1a1a !important;
}
#snake-game-wrapper canvas { display: block; width: 100% !important; height: 100% !important; image-rendering: pixelated; }
#snake-game-wrapper .snake-modal {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.85) !important;
display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 10;
}
#snake-game-wrapper .snake-hidden { display: none !important; }
#snake-game-wrapper .snake-btn {
background-color: #ff66b2 !important; color: white !important;
border: none !important; padding: 12px 30px !important;
font-size: 18px !important; font-weight: bold !important;
border-radius: 5px !important; cursor: pointer !important; transition: 0.2s;
display: flex; align-items: center; justify-content: center;
}
#snake-game-wrapper .snake-btn:active { transform: scale(0.95); }
/* [NEW] 하단 방향키 패드 스타일 */
#snake-game-wrapper .snake-controls {
display: grid;
grid-template-areas:
". up ."
"left down right";
gap: 8px;
margin-top: 15px;
justify-content: center;
}
#snake-game-wrapper .snake-dir-btn {
background-color: #2a2a2a !important; color: #ff66b2 !important;
border: 2px solid #444 !important; border-radius: 12px !important;
width: 60px !important; height: 60px !important;
display: flex; justify-content: center; align-items: center;
cursor: pointer !important; transition: 0.1s;
-webkit-tap-highlight-color: transparent;
}
#snake-game-wrapper .snake-dir-btn:active {
background-color: #ff66b2 !important; color: #ffffff !important;
border-color: #ff66b2 !important; transform: scale(0.9);
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
var canvas = document.getElementById("snake-board");
var ctx = canvas.getContext("2d");
var startPopup = document.getElementById("snake-start-popup");
var pausePopup = document.getElementById("snake-pause-popup");
var countdownPopup = document.getElementById("snake-countdown-popup");
var gameOverPopup = document.getElementById("snake-game-over");
var scoreElement = document.getElementById("snake-score-val");
var countdownElement = document.getElementById("snake-countdown-val");
var btnStart = document.getElementById("snake-start-btn");
var btnPause = document.getElementById("snake-pause-btn");
var btnResume = document.getElementById("snake-resume-btn");
var btnTryAgain = document.getElementById("snake-try-again-btn");
var diffSel = document.getElementById("snake-diff-sel");
var modeSel = document.getElementById("snake-mode-sel");
var iconPause = document.getElementById("icon-pause");
var iconPlay = document.getElementById("icon-play");
var btnUp = document.getElementById("snake-btn-up");
var btnDown = document.getElementById("snake-btn-down");
var btnLeft = document.getElementById("snake-btn-left");
var btnRight = document.getElementById("snake-btn-right");
var tileCount = 17;
var tileSize = 20;
var gameMode = "normal";
var gameSpeed = 130;
var snake = [];
var food = { x: 0, y: 0 };
var dx = 0;
var dy = 0;
var score = 0;
var gameInterval = null;
var countdownTimer = null;
var isGameActive = false;
var isPaused = false;
var isCountingDown = false;
var changingDirection = false;
function placeFood() {
var valid = false;
while (valid === false) {
food.x = Math.floor(Math.random() * tileCount);
food.y = Math.floor(Math.random() * tileCount);
valid = true;
for (var i = 0; i !== snake.length; i++) {
if (snake[i].x === food.x && snake[i].y === food.y) {
valid = false;
}
}
}
}
function hideAllPopups() {
startPopup.classList.add("snake-hidden");
pausePopup.classList.add("snake-hidden");
countdownPopup.classList.add("snake-hidden");
gameOverPopup.classList.add("snake-hidden");
}
function updatePauseButtonUI() {
if (isPaused === true) {
iconPause.classList.add("snake-hidden");
iconPlay.classList.remove("snake-hidden");
} else {
iconPause.classList.remove("snake-hidden");
iconPlay.classList.add("snake-hidden");
}
}
function applySettings() {
tileCount = parseInt(diffSel.value);
gameMode = modeSel.value;
if (tileCount === 9) gameSpeed = 200;
else if (tileCount === 17) gameSpeed = 130;
else if (tileCount === 25) gameSpeed = 80;
canvas.width = tileCount * tileSize;
canvas.height = tileCount * tileSize;
}
function initGame() {
applySettings();
var center = Math.floor(tileCount / 2);
snake = [
{ x: center, y: center },
{ x: center, y: center + 1 },
{ x: center, y: center + 2 }
];
dx = 0; dy = -1;
score = 0;
scoreElement.innerText = score;
changingDirection = false;
isGameActive = true;
isPaused = false;
isCountingDown = false;
updatePauseButtonUI();
clearInterval(countdownTimer);
placeFood();
hideAllPopups();
if (gameInterval !== null) clearInterval(gameInterval);
gameInterval = setInterval(gameLoop, gameSpeed);
}
function togglePause() {
if (isGameActive === false || isCountingDown === true) return;
if (isPaused === false) {
isPaused = true;
updatePauseButtonUI();
pausePopup.classList.remove("snake-hidden");
} else {
startCountdown();
}
}
function startCountdown() {
pausePopup.classList.add("snake-hidden");
isCountingDown = true;
countdownPopup.classList.remove("snake-hidden");
var count = 3;
countdownElement.innerText = count;
countdownTimer = setInterval(function() {
count--;
if (count === 0) {
clearInterval(countdownTimer);
countdownPopup.classList.add("snake-hidden");
isCountingDown = false;
isPaused = false;
updatePauseButtonUI();
} else {
countdownElement.innerText = count;
}
}, 1000);
}
function gameLoop() {
if (isGameActive === false || isPaused === true || isCountingDown === true) return;
changingDirection = false;
var headX = snake[0].x + dx;
var headY = snake[0].y + dy;
if (gameMode === "pass") {
if (headX === -1) { headX = tileCount - 1; }
else if (headX === tileCount) { headX = 0; }
if (headY === -1) { headY = tileCount - 1; }
else if (headY === tileCount) { headY = 0; }
} else {
if (headX === -1 || headX === tileCount || headY === -1 || headY === tileCount) {
endGame(); return;
}
}
for (var i = 0; i !== snake.length; i++) {
if (snake[i].x === headX && snake[i].y === headY) {
endGame(); return;
}
}
snake.unshift({ x: headX, y: headY });
if (headX === food.x && headY === food.y) {
score += 1;
scoreElement.innerText = score;
placeFood();
} else {
snake.pop();
}
drawBoard();
}
function drawBoard() {
for (var r = 0; r !== tileCount; r++) {
for (var c = 0; c !== tileCount; c++) {
if ((r + c) % 2 === 0) ctx.fillStyle = "#3a3a3a";
else ctx.fillStyle = "#2c2c2c";
ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
}
}
ctx.font = "16px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("🍉", food.x * tileSize + (tileSize / 2), food.y * tileSize + (tileSize / 2));
for (var i = 0; i !== snake.length; i++) {
ctx.fillStyle = (i === 0) ? "#ff3399" : "#ff66b2";
ctx.fillRect(snake[i].x * tileSize, snake[i].y * tileSize, tileSize - 1, tileSize - 1);
}
}
function endGame() {
isGameActive = false;
isPaused = false;
isCountingDown = false;
clearInterval(gameInterval);
clearInterval(countdownTimer);
gameOverPopup.classList.remove("snake-hidden");
}
// --- 조작 방향 함수 분리 ---
function goUp() {
if (isGameActive === false || isPaused === true || isCountingDown === true) return;
if (changingDirection === true) return;
if (dy !== 1) { dx = 0; dy = -1; changingDirection = true; }
}
function goDown() {
if (isGameActive === false || isPaused === true || isCountingDown === true) return;
if (changingDirection === true) return;
if (dy !== -1) { dx = 0; dy = 1; changingDirection = true; }
}
function goLeft() {
if (isGameActive === false || isPaused === true || isCountingDown === true) return;
if (changingDirection === true) return;
if (dx !== 1) { dx = -1; dy = 0; changingDirection = true; }
}
function goRight() {
if (isGameActive === false || isPaused === true || isCountingDown === true) return;
if (changingDirection === true) return;
if (dx !== -1) { dx = 1; dy = 0; changingDirection = true; }
}
// 키보드 조작
window.addEventListener("keydown", function(e) {
var key = e.key;
var isArrowKey = (key === "ArrowUp" || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight");
if (isArrowKey === true || key === " ") {
if (document.activeElement && (document.activeElement.tagName === "SELECT" || document.activeElement.tagName === "BUTTON")) {
document.activeElement.blur();
}
}
if (isArrowKey === true) { e.preventDefault(); }
if (key === " ") { e.preventDefault(); togglePause(); return; }
if (key === "ArrowUp") goUp();
if (key === "ArrowDown") goDown();
if (key === "ArrowLeft") goLeft();
if (key === "ArrowRight") goRight();
}, { capture: true });
function addControlEvent(btn, handler) {
btn.addEventListener("touchstart", function(e) {
e.preventDefault(); // 화면 더블 탭 확대나 기본 동작 차단
handler();
}, {passive: false});
btn.addEventListener("click", function(e) {
e.preventDefault();
handler();
});
}
addControlEvent(btnUp, goUp);
addControlEvent(btnDown, goDown);
addControlEvent(btnLeft, goLeft);
addControlEvent(btnRight, goRight);
// 셀렉트 박스 및 상단 버튼들
diffSel.addEventListener("change", function() {
this.blur();
if (isGameActive === false) initGame();
});
modeSel.addEventListener("change", function() {
this.blur();
if (isGameActive === true) {
gameMode = modeSel.value;
} else {
initGame();
}
});
btnStart.addEventListener("click", initGame);
btnTryAgain.addEventListener("click", initGame);
btnPause.addEventListener("click", function() {
if (document.activeElement !== document.body) document.activeElement.blur();
togglePause();
});
btnResume.addEventListener("click", startCountdown);
// 초기 렌더링
applySettings();
drawBoard();
});
</script>
1. 또 다른 자바스크립트 알고리즘이 궁금하신가요? 타일이 미끄러지며 병합되는 로직이 궁금하시다면 아래 글을 참고해 주세요!
👉 studyformyself 2048 게임 스크립트 알고리즘 분석
2. 코드 리팩토링이나 기능 추가에 대한 아이디어가 있으신가요? 장애물 추가, 꼬리 색상 그라데이션 등 여러분만의 재미있는 기획이 있다면 댓글로 남겨주세요!
2 thoughts on “자바스크립트 뱀 게임(Snake Game) 만들기: 핵심 알고리즘 및 코드 리뷰”