Lập trình game rắn săn mồi
Game đã có sẵn Canvas engine, collision, level tăng tốc, high-score lưu localStorage, particle effect, âm thanh Web Audio API, pause, countdown, keyboard và mobile controls.
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#080b12">
<meta name="description" content="Snake Arena - Game rắn săn mồi hiện đại">
<title>Snake Arena — Rắn Săn Mồi</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="page-shell">
<header class="topbar">
<div class="brand">
<div class="brand-mark">
<span></span>
<span></span>
<span></span>
</div>
<div>
<strong>SNAKE</strong>
<small>ARENA</small>
</div>
</div>
<div class="top-status">
<span class="status-dot"></span>
<span>GAME ONLINE</span>
</div>
</header>
<main class="game-layout">
<section class="game-panel">
<div class="panel-head">
<div>
<span class="eyebrow">ARCADE / CLASSIC</span>
<h1>Rắn Săn Mồi</h1>
</div>
<div class="panel-actions">
<button class="icon-btn" id="soundBtn" type="button" aria-label="Âm thanh">
<span id="soundIcon">♫</span>
</button>
<button class="icon-btn" id="pauseBtn" type="button" aria-label="Tạm dừng">
<span>Ⅱ</span>
</button>
</div>
</div>
<div class="stats-grid">
<div class="stat-card score-card">
<span class="stat-label">SCORE</span>
<strong id="score">00000</strong>
</div>
<div class="stat-card">
<span class="stat-label">HIGH SCORE</span>
<strong id="highScore">00000</strong>
</div>
<div class="stat-card">
<span class="stat-label">LEVEL</span>
<strong id="level">01</strong>
</div>
<div class="stat-card">
<span class="stat-label">SPEED</span>
<strong id="speedValue">01</strong>
</div>
</div>
<div class="arena-wrap">
<div class="arena-frame">
<canvas id="gameCanvas" width="600" height="600"></canvas>
<div class="scanlines"></div>
<div class="arena-vignette"></div>
<div class="game-message" id="gameMessage">
<div class="message-icon">◆</div>
<span class="message-kicker" id="messageKicker">
READY PLAYER
</span>
<h2 id="messageTitle">
SNAKE ARENA
</h2>
<p id="messageText">
Ăn thức ăn, lớn lên và sống sót lâu nhất có thể.
</p>
<button class="primary-btn" id="startBtn" type="button">
<span>▶</span>
BẮT ĐẦU GAME
</button>
</div>
<div class="countdown" id="countdown"></div>
</div>
<div class="arena-footer">
<div class="keyboard-hint">
<span class="key">W</span>
<span class="key">A</span>
<span class="key">S</span>
<span class="key">D</span>
<span>hoặc</span>
<span class="key key-wide">← ↑ ↓ →</span>
</div>
<div class="fps">
<span></span>
LIVE
</div>
</div>
</div>
</section>
<aside class="sidebar">
<div class="side-card objective-card">
<div class="card-heading">
<span class="card-number">01</span>
<div>
<span class="eyebrow">MISSION</span>
<h3>Mục tiêu</h3>
</div>
</div>
<p>
Điều khiển chú rắn thu thập thức ăn.
Mỗi lần ăn sẽ giúp rắn dài hơn và tăng điểm.
</p>
<div class="objective-list">
<div>
<span class="mini-icon food-icon"></span>
<span>Ăn thức ăn</span>
</div>
<div>
<span class="mini-icon star-icon">★</span>
<span>Nhận điểm thưởng</span>
</div>
<div>
<span class="mini-icon shield-icon">◆</span>
<span>Tránh va chạm</span>
</div>
</div>
</div>
<div class="side-card controls-card">
<div class="card-heading">
<span class="card-number">02</span>
<div>
<span class="eyebrow">CONTROL</span>
<h3>Điều khiển</h3>
</div>
</div>
<div class="control-layout">
<div class="key-group">
<div class="key-row">
<button class="control-key" data-direction="up">↑</button>
</div>
<div class="key-row">
<button class="control-key" data-direction="left">←</button>
<button class="control-key" data-direction="down">↓</button>
<button class="control-key" data-direction="right">→</button>
</div>
</div>
<div class="control-info">
<strong>SPACE</strong>
<span>Tạm dừng</span>
<strong>ENTER</strong>
<span>Bắt đầu lại</span>
</div>
</div>
</div>
<div class="side-card progression-card">
<div class="progress-head">
<div>
<span class="eyebrow">PROGRESSION</span>
<h3>Tiến trình</h3>
</div>
<strong id="progressPercent">0%</strong>
</div>
<div class="progress-track">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="progress-meta">
<span>Level hiện tại</span>
<strong id="progressLevel">01</strong>
</div>
</div>
<div class="side-card tip-card">
<span class="tip-label">PRO TIP</span>
<p id="tipText">
Đừng chỉ nhìn đầu rắn. Hãy luôn để ý khoảng trống phía trước.
</p>
</div>
</aside>
</main>
<footer class="footer">
<span>SNAKE ARENA</span>
<span>•</span>
<span>CLASSIC ARCADE EXPERIENCE</span>
</footer>
</div>
<div class="mobile-controls" aria-label="Điều khiển mobile">
<button data-direction="up">↑</button>
<div>
<button data-direction="left">←</button>
<button data-direction="down">↓</button>
<button data-direction="right">→</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
:root {
--bg: #07090e;
--panel: #0d1119;
--panel-2: #111722;
--line: rgba(255,255,255,.09);
--line-strong: rgba(255,255,255,.15);
--text: #f4f7fb;
--muted: #7f8999;
--accent: #b7ff3c;
--accent-2: #65d7ff;
--orange: #ff9b54;
--danger: #ff5577;
--shadow: 0 25px 80px rgba(0,0,0,.45);
--radius: 22px;
}
* {
box-sizing: border-box;
}
html {
min-height: 100%;
background: var(--bg);
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background:
radial-gradient(circle at 15% 10%, rgba(101,215,255,.07), transparent 27%),
radial-gradient(circle at 85% 85%, rgba(183,255,60,.055), transparent 25%),
#07090e;
font-family: "Inter", sans-serif;
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: .3;
background-image:
linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px);
background-size: 40px 40px;
}
button {
font: inherit;
}
.page-shell {
position: relative;
width: min(1380px, calc(100% - 42px));
margin: 0 auto;
padding: 25px 0 35px;
}
.topbar {
height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
position: relative;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
border: 1px solid var(--line-strong);
border-radius: 11px;
background: linear-gradient(145deg, #151b25, #0a0d13);
box-shadow: 0 8px 30px rgba(0,0,0,.3);
}
.brand-mark span {
display: block;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px rgba(183,255,60,.5);
}
.brand-mark span:nth-child(2) {
width: 13px;
border-radius: 4px;
}
.brand strong {
display: block;
font-family: "Barlow Condensed", sans-serif;
font-size: 25px;
line-height: .8;
letter-spacing: .08em;
}
.brand small {
display: block;
margin-top: 5px;
color: var(--muted);
font-size: 8px;
font-weight: 800;
letter-spacing: .4em;
}
.top-status {
display: flex;
align-items: center;
gap: 8px;
color: #9da7b5;
font-size: 10px;
font-weight: 800;
letter-spacing: .15em;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 14px var(--accent);
animation: pulse 1.8s infinite;
}
.game-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 22px;
align-items: start;
}
.game-panel {
min-width: 0;
padding: 27px;
border: 1px solid var(--line);
border-radius: var(--radius);
background:
linear-gradient(145deg, rgba(255,255,255,.035), transparent 35%),
rgba(12,16,24,.88);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
}
.eyebrow {
display: block;
color: var(--muted);
font-size: 9px;
font-weight: 800;
letter-spacing: .22em;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
margin: 0;
}
.panel-head h1 {
margin-top: 4px;
font-family: "Barlow Condensed", sans-serif;
font-size: clamp(34px, 4vw, 50px);
line-height: .95;
letter-spacing: -.02em;
}
.panel-actions {
display: flex;
gap: 8px;
}
.icon-btn {
width: 42px;
height: 42px;
border: 1px solid var(--line);
border-radius: 12px;
color: #aab3c0;
background: rgba(255,255,255,.035);
cursor: pointer;
transition: .25s ease;
}
.icon-btn:hover {
color: var(--text);
border-color: rgba(183,255,60,.4);
background: rgba(183,255,60,.06);
transform: translateY(-2px);
}
.icon-btn.active {
color: var(--accent);
border-color: rgba(183,255,60,.35);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 9px;
margin-bottom: 17px;
}
.stat-card {
min-height: 78px;
padding: 13px 15px;
border: 1px solid var(--line);
border-radius: 13px;
background: rgba(255,255,255,.025);
}
.stat-label {
display: block;
color: #707b8b;
font-size: 8px;
font-weight: 800;
letter-spacing: .17em;
}
.stat-card strong {
display: block;
margin-top: 7px;
font-family: "Barlow Condensed", sans-serif;
font-size: 27px;
letter-spacing: .04em;
}
.score-card strong {
color: var(--accent);
}
.arena-wrap {
position: relative;
}
.arena-frame {
position: relative;
width: min(100%, 720px);
margin: 0 auto;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid rgba(255,255,255,.12);
border-radius: 18px;
background: #05070a;
box-shadow:
inset 0 0 0 1px rgba(0,0,0,.7),
0 25px 70px rgba(0,0,0,.35);
}
#gameCanvas {
display: block;
width: 100%;
height: 100%;
image-rendering: auto;
}
.scanlines {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
opacity: .1;
background: repeating-linear-gradient(
to bottom,
transparent 0,
transparent 3px,
rgba(255,255,255,.04) 4px
);
}
.arena-vignette {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
box-shadow: inset 0 0 100px rgba(0,0,0,.7);
}
.game-message {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 30px;
text-align: center;
background:
radial-gradient(circle, rgba(10,15,22,.7), rgba(4,6,9,.94));
backdrop-filter: blur(5px);
transition: opacity .35s ease, visibility .35s ease;
}
.game-message.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.message-icon {
width: 54px;
height: 54px;
display: grid;
place-items: center;
margin-bottom: 17px;
color: var(--accent);
border: 1px solid rgba(183,255,60,.25);
border-radius: 15px;
background: rgba(183,255,60,.06);
box-shadow: 0 0 40px rgba(183,255,60,.08);
animation: float 3s ease-in-out infinite;
}
.message-kicker {
color: var(--accent);
font-size: 9px;
font-weight: 800;
letter-spacing: .3em;
}
.game-message h2 {
margin: 7px 0 8px;
font-family: "Barlow Condensed", sans-serif;
font-size: clamp(42px, 7vw, 72px);
line-height: .85;
letter-spacing: -.02em;
}
.game-message p {
max-width: 390px;
margin-bottom: 24px;
color: #8893a3;
font-size: 13px;
line-height: 1.7;
}
.primary-btn {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 0 21px;
border: 0;
border-radius: 10px;
color: #10150b;
background: var(--accent);
box-shadow: 0 10px 35px rgba(183,255,60,.14);
font-size: 10px;
font-weight: 900;
letter-spacing: .13em;
cursor: pointer;
transition: .25s ease;
}
.primary-btn:hover {
transform: translateY(-3px);
box-shadow: 0 15px 45px rgba(183,255,60,.25);
}
.countdown {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
pointer-events: none;
color: var(--accent);
font-family: "Barlow Condensed", sans-serif;
font-size: 130px;
font-weight: 800;
opacity: 0;
text-shadow: 0 0 45px rgba(183,255,60,.35);
}
.countdown.show {
animation: countdown .7s ease both;
}
.arena-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 13px;
}
.keyboard-hint {
display: flex;
align-items: center;
gap: 6px;
color: #687384;
font-size: 9px;
}
.key {
min-width: 22px;
height: 22px;
display: inline-grid;
place-items: center;
padding: 0 5px;
border: 1px solid var(--line);
border-bottom-color: rgba(255,255,255,.18);
border-radius: 5px;
color: #b7bfca;
background: #111620;
font-size: 9px;
font-weight: 800;
}
.key-wide {
padding: 0 8px;
}
.fps {
display: flex;
align-items: center;
gap: 6px;
color: #667181;
font-size: 8px;
font-weight: 800;
letter-spacing: .12em;
}
.fps span {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent);
}
.sidebar {
display: flex;
flex-direction: column;
gap: 12px;
}
.side-card {
padding: 20px;
border: 1px solid var(--line);
border-radius: 17px;
background:
linear-gradient(145deg, rgba(255,255,255,.035), transparent 50%),
rgba(12,16,24,.86);
box-shadow: 0 15px 45px rgba(0,0,0,.18);
}
.card-heading {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.card-number {
display: grid;
width: 31px;
height: 31px;
place-items: center;
flex: 0 0 auto;
border: 1px solid var(--line);
border-radius: 8px;
color: #798495;
font-size: 9px;
font-weight: 800;
}
.card-heading h3,
.progress-head h3 {
margin-top: 3px;
font-family: "Barlow Condensed", sans-serif;
font-size: 22px;
line-height: 1;
}
.objective-card > p {
color: #818c9b;
font-size: 11px;
line-height: 1.75;
}
.objective-list {
display: grid;
gap: 8px;
margin-top: 17px;
}
.objective-list > div {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: 9px;
background: rgba(255,255,255,.025);
color: #aeb7c3;
font-size: 10px;
}
.mini-icon {
width: 18px;
height: 18px;
display: grid;
place-items: center;
}
.food-icon {
border-radius: 50%;
background: var(--orange);
box-shadow: 0 0 15px rgba(255,155,84,.35);
}
.star-icon {
color: #ffd86b;
}
.shield-icon {
color: var(--accent-2);
}
.control-layout {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
}
.key-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.key-row {
display: flex;
justify-content: center;
gap: 4px;
}
.control-key {
width: 35px;
height: 32px;
border: 1px solid var(--line);
border-radius: 7px;
color: #aab3c0;
background: #111620;
cursor: pointer;
transition: .18s ease;
}
.control-key:hover,
.control-key:active {
color: var(--accent);
border-color: rgba(183,255,60,.4);
transform: translateY(-2px);
}
.control-info {
display: grid;
grid-template-columns: auto 1fr;
gap: 5px 9px;
color: #687384;
font-size: 8px;
}
.control-info strong {
color: #a5aeba;
font-size: 8px;
}
.progress-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-head > strong {
color: var(--accent);
font-family: "Barlow Condensed", sans-serif;
font-size: 23px;
}
.progress-track {
height: 7px;
margin: 19px 0 9px;
overflow: hidden;
border-radius: 10px;
background: #191f29;
}
.progress-bar {
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), #ddff91);
box-shadow: 0 0 15px rgba(183,255,60,.25);
transition: width .4s ease;
}
.progress-meta {
display: flex;
justify-content: space-between;
color: #687384;
font-size: 9px;
}
.progress-meta strong {
color: #aab3c0;
}
.tip-card {
position: relative;
overflow: hidden;
border-color: rgba(101,215,255,.12);
}
.tip-card::before {
content: "";
position: absolute;
top: -50px;
right: -50px;
width: 110px;
height: 110px;
border-radius: 50%;
background: rgba(101,215,255,.08);
filter: blur(20px);
}
.tip-label {
display: block;
margin-bottom: 9px;
color: var(--accent-2);
font-size: 8px;
font-weight: 900;
letter-spacing: .2em;
}
.tip-card p {
position: relative;
color: #8994a3;
font-size: 10px;
line-height: 1.7;
}
.footer {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-top: 22px;
color: #515b6b;
font-size: 8px;
font-weight: 800;
letter-spacing: .15em;
}
.mobile-controls {
display: none;
}
@keyframes pulse {
0%,100% {
opacity: .55;
transform: scale(.8);
}
50% {
opacity: 1;
transform: scale(1.15);
}
}
@keyframes float {
0%,100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
@keyframes countdown {
0% {
opacity: 0;
transform: scale(.55);
}
35% {
opacity: 1;
}
100% {
opacity: 0;
transform: scale(1.15);
}
}
@media (max-width: 1050px) {
.game-layout {
grid-template-columns: 1fr;
}
.sidebar {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
.tip-card {
grid-column: span 2;
}
}
@media (max-width: 680px) {
.page-shell {
width: min(100% - 22px, 600px);
padding-top: 14px;
}
.topbar {
height: 52px;
margin-bottom: 15px;
}
.top-status {
display: none;
}
.game-panel {
padding: 14px;
border-radius: 16px;
}
.panel-head {
margin-bottom: 15px;
}
.panel-head h1 {
font-size: 35px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.stat-card {
min-height: 65px;
}
.stat-card strong {
font-size: 24px;
}
.keyboard-hint {
display: none;
}
.sidebar {
display: grid;
grid-template-columns: 1fr;
}
.tip-card {
grid-column: auto;
}
.mobile-controls {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
width: 150px;
margin: 18px auto 0;
}
.mobile-controls > div {
display: flex;
gap: 5px;
}
.mobile-controls button {
width: 45px;
height: 40px;
border: 1px solid var(--line);
border-radius: 9px;
color: #9ba5b3;
background: #10151e;
font-size: 17px;
cursor: pointer;
touch-action: manipulation;
}
.mobile-controls button:active {
color: var(--accent);
border-color: rgba(183,255,60,.4);
background: rgba(183,255,60,.06);
}
.footer {
margin-bottom: 15px;
}
}
@media (max-width: 400px) {
.panel-actions {
gap: 4px;
}
.icon-btn {
width: 36px;
height: 36px;
}
.game-message h2 {
font-size: 45px;
}
}
"use strict";
/*
* ============================================================
* SNAKE ARENA
* Vanilla JavaScript Game Engine
* ============================================================
*/
(() => {
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const scoreEl = document.getElementById("score");
const highScoreEl = document.getElementById("highScore");
const levelEl = document.getElementById("level");
const speedEl = document.getElementById("speedValue");
const progressBar = document.getElementById("progressBar");
const progressPercent = document.getElementById("progressPercent");
const progressLevel = document.getElementById("progressLevel");
const gameMessage = document.getElementById("gameMessage");
const messageKicker = document.getElementById("messageKicker");
const messageTitle = document.getElementById("messageTitle");
const messageText = document.getElementById("messageText");
const startBtn = document.getElementById("startBtn");
const pauseBtn = document.getElementById("pauseBtn");
const soundBtn = document.getElementById("soundBtn");
const soundIcon = document.getElementById("soundIcon");
const countdownEl = document.getElementById("countdown");
const tipText = document.getElementById("tipText");
/* --------------------------------------------------------
* CONFIG
* ----------------------------------------------------- */
const CONFIG = {
grid: 30,
baseSpeed: 145,
minSpeed: 55,
speedStep: 8,
pointsPerFood: 10,
levelEvery: 50,
storageKey: "snakeArenaHighScore"
};
const CELL = canvas.width / CONFIG.grid;
/* --------------------------------------------------------
* GAME STATE
* ----------------------------------------------------- */
let snake = [];
let food = null;
let direction = { x: 1, y: 0 };
let nextDirection = { x: 1, y: 0 };
let score = 0;
let highScore = Number(localStorage.getItem(CONFIG.storageKey)) || 0;
let level = 1;
let speed = CONFIG.baseSpeed;
let running = false;
let paused = false;
let gameOver = false;
let animationFrame = null;
let lastTime = 0;
let accumulator = 0;
let soundEnabled = true;
let audioContext = null;
let particles = [];
let foodPulse = 0;
const tips = [
"Đừng chỉ nhìn đầu rắn. Hãy luôn để ý khoảng trống phía trước.",
"Khi rắn dài hơn, hãy ưu tiên di chuyển theo vòng rộng.",
"Đừng ép mình vào góc khi tốc độ bắt đầu tăng.",
"Hãy giữ khoảng trống để có đường thoát khi cần.",
"Một cú quay đầu đúng lúc có thể cứu cả ván chơi.",
"Tập trung vào nhịp di chuyển thay vì chỉ săn điểm."
];
/* --------------------------------------------------------
* INITIALIZE
* ----------------------------------------------------- */
function init() {
highScoreEl.textContent = formatNumber(highScore);
setupInitialSnake();
spawnFood();
updateUI();
draw();
bindEvents();
}
function setupInitialSnake() {
snake = [
{ x: 14, y: 15 },
{ x: 13, y: 15 },
{ x: 12, y: 15 },
{ x: 11, y: 15 }
];
direction = { x: 1, y: 0 };
nextDirection = { x: 1, y: 0 };
}
/* --------------------------------------------------------
* EVENTS
* ----------------------------------------------------- */
function bindEvents() {
document.addEventListener("keydown", handleKeyboard);
startBtn.addEventListener("click", () => {
if (gameOver) {
resetGame();
}
if (!running) {
startGame();
}
});
pauseBtn.addEventListener("click", togglePause);
soundBtn.addEventListener("click", toggleSound);
document.querySelectorAll("[data-direction]").forEach(button => {
button.addEventListener("click", () => {
const dir = button.dataset.direction;
changeDirection(dir);
});
});
document.addEventListener("visibilitychange", () => {
if (document.hidden && running && !paused) {
pauseGame();
}
});
}
function handleKeyboard(event) {
const key = event.key.toLowerCase();
if (
[
"arrowup",
"arrowdown",
"arrowleft",
"arrowright",
"w",
"a",
"s",
"d",
" ",
"enter"
].includes(key)
) {
event.preventDefault();
}
if (key === "arrowup" || key === "w") {
changeDirection("up");
}
if (key === "arrowdown" || key === "s") {
changeDirection("down");
}
if (key === "arrowleft" || key === "a") {
changeDirection("left");
}
if (key === "arrowright" || key === "d") {
changeDirection("right");
}
if (key === " ") {
togglePause();
}
if (key === "enter") {
if (gameOver) {
resetGame();
startGame();
} else if (!running) {
startGame();
}
}
}
/* --------------------------------------------------------
* GAME FLOW
* ----------------------------------------------------- */
async function startGame() {
if (running) return;
running = true;
paused = false;
gameOver = false;
gameMessage.classList.add("hidden");
await countdown();
lastTime = performance.now();
accumulator = 0;
animationFrame = requestAnimationFrame(gameLoop);
}
function resetGame() {
cancelAnimationFrame(animationFrame);
setupInitialSnake();
food = null;
score = 0;
level = 1;
speed = CONFIG.baseSpeed;
running = false;
paused = false;
gameOver = false;
particles = [];
spawnFood();
updateUI();
draw();
messageKicker.textContent = "READY PLAYER";
messageTitle.textContent = "SNAKE ARENA";
messageText.textContent =
"Ăn thức ăn, lớn lên và sống sót lâu nhất có thể.";
startBtn.innerHTML = "<span>▶</span> BẮT ĐẦU GAME";
gameMessage.classList.remove("hidden");
}
function gameOverHandler() {
running = false;
gameOver = true;
cancelAnimationFrame(animationFrame);
if (score > highScore) {
highScore = score;
localStorage.setItem(
CONFIG.storageKey,
String(highScore)
);
}
playSound("gameover");
createExplosion(
snake[0].x * CELL + CELL / 2,
snake[0].y * CELL + CELL / 2,
28
);
messageKicker.textContent = "GAME OVER";
messageTitle.textContent = "HẾT VÁN";
messageText.textContent =
`Bạn đạt ${formatNumber(score)} điểm ở Level ${level}.`;
startBtn.innerHTML = "<span>↻</span> CHƠI LẠI";
gameMessage.classList.remove("hidden");
updateUI();
}
function togglePause() {
if (!running || gameOver) return;
if (paused) {
resumeGame();
} else {
pauseGame();
}
}
function pauseGame() {
if (!running || gameOver) return;
paused = true;
cancelAnimationFrame(animationFrame);
messageKicker.textContent = "GAME PAUSED";
messageTitle.textContent = "TẠM DỪNG";
messageText.textContent =
"Nhấn SPACE hoặc nút pause để tiếp tục.";
startBtn.innerHTML = "<span>▶</span> TIẾP TỤC";
gameMessage.classList.remove("hidden");
pauseBtn.classList.add("active");
}
function resumeGame() {
paused = false;
gameMessage.classList.add("hidden");
pauseBtn.classList.remove("active");
lastTime = performance.now();
accumulator = 0;
animationFrame = requestAnimationFrame(gameLoop);
}
/* --------------------------------------------------------
* COUNTDOWN
* ----------------------------------------------------- */
function countdown() {
return new Promise(resolve => {
let count = 3;
countdownEl.textContent = count;
countdownEl.classList.remove("show");
requestAnimationFrame(() => {
countdownEl.classList.add("show");
});
const interval = setInterval(() => {
count--;
if (count > 0) {
countdownEl.textContent = count;
countdownEl.classList.remove("show");
requestAnimationFrame(() => {
countdownEl.classList.add("show");
});
playSound("tick");
} else {
clearInterval(interval);
countdownEl.textContent = "GO!";
countdownEl.classList.remove("show");
requestAnimationFrame(() => {
countdownEl.classList.add("show");
});
playSound("start");
setTimeout(resolve, 500);
}
}, 700);
});
}
/* --------------------------------------------------------
* GAME LOOP
* ----------------------------------------------------- */
function gameLoop(timestamp) {
if (!running || paused) return;
const delta = timestamp - lastTime;
lastTime = timestamp;
accumulator += delta;
foodPulse += delta;
while (accumulator >= speed) {
update();
accumulator -= speed;
}
updateParticles(delta);
draw();
animationFrame = requestAnimationFrame(gameLoop);
}
/* --------------------------------------------------------
* GAME UPDATE
* ----------------------------------------------------- */
function update() {
direction = { ...nextDirection };
const head = {
x: snake[0].x + direction.x,
y: snake[0].y + direction.y
};
if (hasCollision(head)) {
gameOverHandler();
return;
}
snake.unshift(head);
const ateFood =
food &&
head.x === food.x &&
head.y === food.y;
if (ateFood) {
eatFood();
} else {
snake.pop();
}
}
function hasCollision(head) {
/* Wall collision */
if (
head.x < 0 ||
head.x >= CONFIG.grid ||
head.y < 0 ||
head.y >= CONFIG.grid
) {
return true;
}
/*
* Self collision.
*
* Ignore last tail cell because it moves away
* during the same tick.
*/
const bodyLength = snake.length - 1;
for (let i = 0; i < bodyLength; i++) {
if (
snake[i].x === head.x &&
snake[i].y === head.y
) {
return true;
}
}
return false;
}
function eatFood() {
score += CONFIG.pointsPerFood;
if (score > highScore) {
highScore = score;
localStorage.setItem(
CONFIG.storageKey,
String(highScore)
);
}
const foodX =
food.x * CELL +
CELL / 2;
const foodY =
food.y * CELL +
CELL / 2;
createExplosion(foodX, foodY, 14);
playSound("eat");
spawnFood();
updateLevel();
updateUI();
}
function updateLevel() {
const calculatedLevel =
Math.floor(score / CONFIG.levelEvery) + 1;
if (calculatedLevel !== level) {
level = calculatedLevel;
speed = Math.max(
CONFIG.minSpeed,
CONFIG.baseSpeed -
((level - 1) * CONFIG.speedStep)
);
playSound("level");
tipText.textContent =
tips[(level - 1) % tips.length];
}
}
/* --------------------------------------------------------
* DIRECTION
* ----------------------------------------------------- */
function changeDirection(newDirection) {
if (!running || paused || gameOver) return;
const directions = {
up: { x: 0, y: -1 },
down: { x: 0, y: 1 },
left: { x: -1, y: 0 },
right: { x: 1, y: 0 }
};
const next = directions[newDirection];
if (!next) return;
/*
* Prevent 180-degree turns.
*/
if (
next.x === -direction.x &&
next.y === -direction.y
) {
return;
}
nextDirection = next;
}
/* --------------------------------------------------------
* FOOD
* ----------------------------------------------------- */
function spawnFood() {
const freeCells = [];
for (let y = 0; y < CONFIG.grid; y++) {
for (let x = 0; x < CONFIG.grid; x++) {
const occupied = snake.some(
segment =>
segment.x === x &&
segment.y === y
);
if (!occupied) {
freeCells.push({ x, y });
}
}
}
if (!freeCells.length) {
return;
}
food =
freeCells[
Math.floor(
Math.random() * freeCells.length
)
];
}
/* --------------------------------------------------------
* DRAW
* ----------------------------------------------------- */
function draw() {
drawBackground();
drawGrid();
drawFood();
drawSnake();
drawParticles();
}
function drawBackground() {
const gradient =
ctx.createLinearGradient(
0,
0,
canvas.width,
canvas.height
);
gradient.addColorStop(0, "#070b10");
gradient.addColorStop(.5, "#080d12");
gradient.addColorStop(1, "#05070a");
ctx.fillStyle = gradient;
ctx.fillRect(
0,
0,
canvas.width,
canvas.height
);
/*
* Ambient glow.
*/
const glow =
ctx.createRadialGradient(
canvas.width * .5,
canvas.height * .5,
30,
canvas.width * .5,
canvas.height * .5,
450
);
glow.addColorStop(
0,
"rgba(183,255,60,.035)"
);
glow.addColorStop(
1,
"rgba(183,255,60,0)"
);
ctx.fillStyle = glow;
ctx.fillRect(
0,
0,
canvas.width,
canvas.height
);
}
function drawGrid() {
ctx.save();
ctx.strokeStyle =
"rgba(255,255,255,.035)";
ctx.lineWidth = 1;
for (let i = 0; i <= CONFIG.grid; i++) {
const position = i * CELL;
ctx.beginPath();
ctx.moveTo(position, 0);
ctx.lineTo(position, canvas.height);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, position);
ctx.lineTo(canvas.width, position);
ctx.stroke();
}
ctx.restore();
}
function drawFood() {
if (!food) return;
const centerX =
food.x * CELL + CELL / 2;
const centerY =
food.y * CELL + CELL / 2;
const pulse =
Math.sin(foodPulse / 160) * 1.8;
ctx.save();
/*
* Glow
*/
ctx.shadowColor = "#ff9b54";
ctx.shadowBlur = 22;
ctx.fillStyle = "#ff9b54";
ctx.beginPath();
ctx.arc(
centerX,
centerY,
CELL * .25 + pulse,
0,
Math.PI * 2
);
ctx.fill();
ctx.shadowBlur = 0;
/*
* Core
*/
ctx.fillStyle = "#ffd29b";
ctx.beginPath();
ctx.arc(
centerX - 1,
centerY - 1,
CELL * .09,
0,
Math.PI * 2
);
ctx.fill();
/*
* Orbit ring
*/
ctx.strokeStyle =
"rgba(255,155,84,.2)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(
centerX,
centerY,
CELL * .42 + pulse,
0,
Math.PI * 2
);
ctx.stroke();
ctx.restore();
}
function drawSnake() {
for (let i = snake.length - 1; i >= 0; i--) {
const segment = snake[i];
const x =
segment.x * CELL;
const y =
segment.y * CELL;
const isHead = i === 0;
const padding = isHead ? 2 : 3;
const size =
CELL - padding * 2;
const radius = isHead ? 5 : 4;
ctx.save();
if (isHead) {
ctx.shadowColor =
"rgba(183,255,60,.55)";
ctx.shadowBlur = 15;
}
const gradient =
ctx.createLinearGradient(
x,
y,
x + CELL,
y + CELL
);
if (isHead) {
gradient.addColorStop(
0,
"#d7ff76"
);
gradient.addColorStop(
1,
"#7ed324"
);
} else {
const alpha =
Math.max(
.32,
1 - (i / snake.length) * .55
);
gradient.addColorStop(
0,
`rgba(183,255,60,${alpha})`
);
gradient.addColorStop(
1,
`rgba(102,185,25,${alpha})`
);
}
ctx.fillStyle = gradient;
roundRect(
ctx,
x + padding,
y + padding,
size,
size,
radius
);
ctx.fill();
/*
* Segment highlight
*/
if (!isHead) {
ctx.fillStyle =
"rgba(255,255,255,.08)";
roundRect(
ctx,
x + padding + 2,
y + padding + 2,
size - 4,
2,
1
);
ctx.fill();
}
if (isHead) {
drawSnakeEyes(x, y);
}
ctx.restore();
}
}
function drawSnakeEyes(x, y) {
const eyeOffset = 7;
let eye1;
let eye2;
if (direction.x === 1) {
eye1 = {
x: x + CELL - 7,
y: y + eyeOffset
};
eye2 = {
x: x + CELL - 7,
y: y + CELL - eyeOffset
};
} else if (direction.x === -1) {
eye1 = {
x: x + 7,
y: y + eyeOffset
};
eye2 = {
x: x + 7,
y: y + CELL - eyeOffset
};
} else if (direction.y === -1) {
eye1 = {
x: x + eyeOffset,
y: y + 7
};
eye2 = {
x: x + CELL - eyeOffset,
y: y + 7
};
} else {
eye1 = {
x: x + eyeOffset,
y: y + CELL - 7
};
eye2 = {
x: x + CELL - eyeOffset,
y: y + CELL - 7
};
}
ctx.fillStyle = "#10170a";
ctx.beginPath();
ctx.arc(
eye1.x,
eye1.y,
2,
0,
Math.PI * 2
);
ctx.arc(
eye2.x,
eye2.y,
2,
0,
Math.PI * 2
);
ctx.fill();
}
function roundRect(
context,
x,
y,
width,
height,
radius
) {
context.beginPath();
context.roundRect(
x,
y,
width,
height,
radius
);
}
/* --------------------------------------------------------
* PARTICLES
* ----------------------------------------------------- */
function createExplosion(x, y, amount) {
for (let i = 0; i < amount; i++) {
const angle =
Math.random() *
Math.PI *
2;
const velocity =
Math.random() * 2.8 + .7;
particles.push({
x,
y,
vx: Math.cos(angle) * velocity,
vy: Math.sin(angle) * velocity,
life: 1,
size: Math.random() * 2.5 + 1
});
}
}
function updateParticles(delta) {
const factor = delta / 16.67;
particles.forEach(particle => {
particle.x +=
particle.vx * factor;
particle.y +=
particle.vy * factor;
particle.life -=
.035 * factor;
particle.vx *= .98;
particle.vy *= .98;
});
particles =
particles.filter(
particle => particle.life > 0
);
}
function drawParticles() {
ctx.save();
particles.forEach(particle => {
ctx.globalAlpha =
Math.max(0, particle.life);
ctx.fillStyle = "#b7ff3c";
ctx.beginPath();
ctx.arc(
particle.x,
particle.y,
particle.size,
0,
Math.PI * 2
);
ctx.fill();
});
ctx.restore();
}
/* --------------------------------------------------------
* UI
* ----------------------------------------------------- */
function updateUI() {
scoreEl.textContent =
formatNumber(score);
highScoreEl.textContent =
formatNumber(highScore);
levelEl.textContent =
String(level).padStart(2, "0");
speedEl.textContent =
String(
Math.min(
99,
Math.floor(
(CONFIG.baseSpeed - speed) /
CONFIG.speedStep
) + 1
)
).padStart(2, "0");
progressLevel.textContent =
String(level).padStart(2, "0");
const levelProgress =
(score % CONFIG.levelEvery) /
CONFIG.levelEvery *
100;
progressBar.style.width =
`${levelProgress}%`;
progressPercent.textContent =
`${Math.floor(levelProgress)}%`;
}
function formatNumber(number) {
return String(number)
.padStart(5, "0");
}
/* --------------------------------------------------------
* SOUND
* ----------------------------------------------------- */
function toggleSound() {
soundEnabled = !soundEnabled;
soundIcon.textContent =
soundEnabled ? "♫" : "×";
soundBtn.classList.toggle(
"active",
soundEnabled
);
if (soundEnabled) {
playSound("tick");
}
}
function getAudioContext() {
if (!audioContext) {
const AudioCtx =
window.AudioContext ||
window.webkitAudioContext;
if (!AudioCtx) return null;
audioContext =
new AudioCtx();
}
if (
audioContext.state ===
"suspended"
) {
audioContext.resume();
}
return audioContext;
}
function playSound(type) {
if (!soundEnabled) return;
const audio =
getAudioContext();
if (!audio) return;
const oscillator =
audio.createOscillator();
const gain =
audio.createGain();
oscillator.connect(gain);
gain.connect(audio.destination);
let frequency = 300;
let duration = .06;
switch (type) {
case "eat":
frequency = 520;
duration = .08;
break;
case "level":
frequency = 720;
duration = .16;
break;
case "gameover":
frequency = 110;
duration = .35;
break;
case "start":
frequency = 620;
duration = .1;
break;
case "tick":
frequency = 340;
duration = .05;
break;
}
oscillator.frequency.setValueAtTime(
frequency,
audio.currentTime
);
oscillator.type = "sine";
gain.gain.setValueAtTime(
.0001,
audio.currentTime
);
gain.gain.exponentialRampToValueAtTime(
.045,
audio.currentTime + .01
);
gain.gain.exponentialRampToValueAtTime(
.0001,
audio.currentTime + duration
);
oscillator.start();
oscillator.stop(
audio.currentTime + duration + .02
);
}
/* --------------------------------------------------------
* START
* ----------------------------------------------------- */
init();
})();
Ctrl1 HTML
Ctrl2 CSS
Ctrl3 JS
Ctrl↵ Reload