Xây dựng trò chơi Snake trong React: Giải quyết đóng băng trạng thái với useRef và requestAnimationFrame

07 tháng 4, 2026·5 phút đọc

Trò chơi Snake tưởng đơn giản nhưng khi dựng bằng React lại gặp phải vấn đề đóng băng trạng thái do useState trong vòng lặp requestAnimationFrame. Bài viết trình bày cách sử dụng useRef để tránh đóng băng, điều khiển tốc độ dựa trên timestamp, xử lý gói tường (wall wrap) và hỗ trợ điều khiển cảm ứng.

Xây dựng trò chơi Snake trong React: Giải quyết đóng băng trạng thái với useRef và requestAnimationFrame

Xây dựng trò chơi Snake trong React: Giải quyết đóng băng trạng thái với useRef và requestAnimationFrame

Trò chơi Snake có vẻ rất đơn giản: di chuyển, ăn thức ăn, dài ra rồi lặp lại. Tuy nhiên, khi xây dựng trò chơi này trong React, một vấn đề phổ biến dễ gặp là đóng băng giá trị trạng thái (stale closure) khi dùng useState trong vòng lặp requestAnimationFrame.

Bài viết dưới đây trình bày một cách tiếp cận hiệu quả để xây dựng Snake bằng React, giữ mọi trạng thái game có thể thay đổi trong useRef nhằm tránh stale closure, sử dụng điều khiển tốc độ dựa trên timestamp, có thêm tính năng gói tường (wall wrap) và hỗ trợ điều khiển cảm ứng cho thiết bị di động.

Vấn đề đóng băng trạng thái (Stale Closure) trong React

Khi sử dụng useState bên trong callback requestAnimationFrame (RAF), mỗi callback được tạo ra sẽ "đóng băng" (capture) giá trị state tại thời điểm đó. Ví dụ, nếu bạn dùng:

const [snake, setSnake] = useState([]);

và đọc biến snake trong callback RAF, bạn sẽ luôn thấy mảng trống ban đầu dù rắn đã được kéo dài sau nhiều lượt đi. Đó là vì callback RAF đã lấy snapshot giá trị lúc callback được tạo, không phản ánh cập nhật mới.

Giải pháp: dùng useRef để lưu trữ trạng thái mutable

Để tránh stale closure, tất cả dữ liệu game cần truy cập trong vòng lặp game (RAF) được lưu trong các ref mutable, ví dụ:

const snakeRef = useRef<Pt[]>([]);
const dirRef = useRef<Dir>("RIGHT");
const nextDirRef = useRef<Dir>("RIGHT");
const foodRef = useRef<Pt>({ x: 5, y: 5 });
const scoreRef = useRef(0);
const speedRef = useRef(BASE_SPEED);
const lastTickRef = useRef(0);
const statusRef = useRef<Status>("idle");
const rafRef = useRef<number>(0);

Mỗi ref.current luôn cho giá trị mới nhất, tránh đóng băng như useState. Còn useState chỉ được dùng cho những thứ cần trigger render lại giao diện như score hoặc status hiển thị.

Vòng lặp game với requestAnimationFrame và điều khiển tốc độ theo thời gian

Thay vì dùng setInterval, vòng lặp game sử dụng requestAnimationFrame kết hợp điều kiện thời gian so sánh timestamp để kiểm soát tốc độ chuyển động:

const tick = useCallback((ts: number) => {
  if (statusRef.current !== "playing") return;
  rafRef.current = requestAnimationFrame(tick);
  
  if (ts - lastTickRef.current < speedRef.current) {
    draw();  // chỉ vẽ mà không cập nhật trạng thái
    return;
  }
  
  lastTickRef.current = ts;
  
  // Xử lý cập nhật trạng thái game, di chuyển rắn, va chạm...
}, [draw, placeFood]);

Mỗi frame RAF chạy ~60fps, nhưng logic game chỉ cập nhật khi đủ thời gian (cách nhau speedRef.current ms), tách biệt tốc độ render và tốc độ game để giảm tải.

Xử lý gói tường (Wall Wrap)

Khi đầu rắn vượt khỏi biên, nó sẽ xuất hiện lại ở bên kia màn:

const next: Pt = {
  x: (head.x + (dir === "RIGHT" ? 1 : dir === "LEFT" ? -1 : 0) + COLS) % COLS,
  y: (head.y + (dir === "DOWN" ? 1 : dir === "UP" ? -1 : 0) + ROWS) % ROWS,
};

Cộng thêm COLS hoặc ROWS trước khi lấy modulo tránh lỗi kết quả âm do cách tính modulo của JavaScript.

Tăng tốc độ theo điểm số

Mỗi khi ăn 5 thức ăn, tốc độ di chuyển rắn được tăng (giảm interval):

const newScore = scoreRef.current + 10;
scoreRef.current = newScore;
setScore(newScore);

if ((newScore / 10) % 5 === 0) {
  speedRef.current = Math.max(MIN_SPEED, speedRef.current - SPEED_STEP);
}

Điều này giúp trò chơi trở nên thử thách hơn dần theo thời gian.

Xử lý thay đổi hướng và tránh quay đầu gấp

Hai ref quản lý hướng hiện tại và hướng tiếp theo:

const onKey = (e: KeyboardEvent) => {
  const d = KEY_DIR[e.key];
  if (!d) return;
  if (d !== OPPOSITE[dirRef.current]) nextDirRef.current = d; // cấm quay 180°
};

dirRef.current = nextDirRef.current; // cập nhật ở đầu mỗi tick

Cách này tránh việc nhấn phím nhanh hai lần gây rắn quay đầu mất game.

Hỗ trợ cảm ứng, điều khiển vuốt trên thiết bị di động

Xử lý bắt đầu và kết thúc touch để phát hiện vuốt theo trục ưu tiên:

const onTouchStart = (e: React.TouchEvent) => {
  touchStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
};
const onTouchEnd = (e: React.TouchEvent) => {
  const dx = e.changedTouches[0].clientX - touchStartRef.current.x;
  const dy = e.changedTouches[0].clientY - touchStartRef.current.y;
  if (Math.abs(dx) < 12 && Math.abs(dy) < 12) return;  // loại bỏ tap

  const d: Dir = Math.abs(dx) > Math.abs(dy)
    ? (dx > 0 ? "RIGHT" : "LEFT")
    : (dy > 0 ? "DOWN" : "UP");

  if (d !== OPPOSITE[dirRef.current]) nextDirRef.current = d;
};

Ngưỡng 12px lọc bỏ nhấn nhẹ, hướng vuốt theo chiều dài nhất.

Vẽ trò chơi trên canvas

Trò chơi được vẽ trên canvas 400×400px (20×20 ô, mỗi ô 20px):

  • Nền tối, lưới ô vuông nhẹ nhàng.
  • Thức ăn phát sáng bằng gradient tia sáng đỏ hồng.
  • Rắn có đầu màu tím sáng, đuôi dần tối đi tạo hiệu ứng chuyển màu.

Màu sắc gradient được tính toán toán học, không cần mảng màu.

Lưu điểm cao lên localStorage

Sử dụng localStorage để lưu điểm cao nhất:

useEffect(() => {
  const hs = parseInt(localStorage.getItem("snake-hs") ?? "0");
  if (hs) setHighScore(hs);
}, []);

// Khi game over
const hs = parseInt(localStorage.getItem("snake-hs") ?? "0");
if (scoreRef.current > hs) {
  localStorage.setItem("snake-hs", String(scoreRef.current));
  setHighScore(scoreRef.current);
}

Ghi dữ liệu localStorage đồng bộ nhưng không gây render lại, setHighScore mới chịu trách nhiệm cập nhật giao diện.

Tổng kết: Giữ React state tối giản, tập trung logic game trong refs

Thiết kế chính của trò chơi là:

  • React state chỉ giữ những thứ UI cần cập nhật hiển thị như điểm số, trạng thái game.
  • Các trạng thái game Phức tạp, hay thay đổi nhanh được lưu trong useRef, tránh stale closure, giảm ảnh hưởng vòng render React tới vòng lặp game.
  • Vòng lặp game dùng requestAnimationFrame với kiểm soát theo timestamp cho trải nghiệm mượt mà và tăng tốc theo tiến trình.

Bạn có thể chơi thử phiên bản game này tại ultimatetools.io.


Đây là ví dụ điển hình cho việc áp dụng thành công React hooks để xử lý logic game thời gian thực, đồng thời tận dụng tốt canvas để dựng hình trực quan, phù hợp với các dự án web game nhỏ gọn và hiệu quả.

Bài viết được tổng hợp và biên soạn bằng AI từ các nguồn tin tức công nghệ. Nội dung mang tính tham khảo. Xem bài gốc ↗