Dasar-Dasar Game Loop Canvas 2D
Game loop adalah detak jantung game Anda. Loop ini berjalan terus-menerus, memperbarui status game dan menggambar frame secepat yang diizinkan browser. Jika Anda menerapkannya dengan benar, semua hal lainnya menjadi lebih mudah. Jika salah, Anda akan mengejar bug yang hanya muncul di perangkat tertentu.
Cobalah (gunakan tombol panah atau WASD):
Versi Paling Sederhana
Berikut adalah game loop yang disederhanakan hingga hanya menyisakan bagian terpentingnya:
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
function gameLoop() {
update()
render()
requestAnimationFrame(gameLoop)
}
function update() {
// Logika game di sini
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Menggambar di sini
}
gameLoop()Kode ini berfungsi, tetapi memiliki masalah. Fungsi update tidak mengetahui berapa banyak waktu yang telah berlalu sejak frame terakhir. Pada monitor 144 Hz, fungsi tersebut berjalan dua kali lebih cepat dibandingkan pada monitor 60 Hz. Fisika game Anda akan berperilaku berbeda di perangkat yang berbeda.
Delta Time Mengatasi Masalah Frame Rate
Agar game berjalan dengan kecepatan yang sama di semua perangkat, Anda perlu melacak berapa banyak waktu yang berlalu di antara frame. Ini disebut delta time, yang biasanya disingkat dt.
let lastTime = 0
function gameLoop(currentTime) {
const dt = (currentTime - lastTime) / 1000 // konversi ke detik
lastTime = currentTime
update(dt)
render()
requestAnimationFrame(gameLoop)
}
function update(dt) {
player.x += player.speed * dt // kini berfungsi pada frame rate apa pun
}
requestAnimationFrame(gameLoop)Callback requestAnimationFrame menerima timestamp saat ini dalam milidetik. Saya membaginya dengan 1000 untuk mendapatkan detik karena hal itu mempermudah perhitungan. Kecepatan 200 berarti 200 piksel per detik, yang mudah dipahami.
Satu hal yang perlu diperhatikan: pada frame pertama, lastTime bernilai 0, sehingga dt akan sangat besar. Sebagian orang menginisialisasi lastTime dengan performance.now() sebelum memulai loop, atau melewati pembaruan pada frame pertama.
Mengapa requestAnimationFrame dan Bukan setInterval
Anda mungkin bertanya-tanya mengapa kita menggunakan requestAnimationFrame dan bukan setInterval. Ada beberapa alasan.
Pertama, requestAnimationFrame tersinkronisasi dengan refresh rate layar, sehingga Anda mendapatkan animasi yang mulus tanpa tearing. Kedua, fungsi ini otomatis dijeda ketika tab disembunyikan, sehingga menghemat baterai dan CPU. Ketiga, browser dapat mengoptimalkannya dengan lebih baik.
Perilaku jeda tersebut biasanya sesuai dengan yang Anda inginkan, tetapi artinya game akan berhenti ketika pengguna berpindah tab. Jika Anda membuat game multipemain atau sesuatu yang harus terus berjalan, Anda memerlukan pendekatan yang berbeda.
Timestep Tetap untuk Fisika
Delta time variabel berfungsi untuk pergerakan sederhana, tetapi dapat menimbulkan masalah pada simulasi fisika. Jika sebuah frame membutuhkan waktu terlalu lama, karakter Anda mungkin menembus dinding karena pemeriksaan tabrakan melewatkan momen benturan.
Solusinya adalah timestep tetap. Anda mengakumulasi waktu dan menjalankan pembaruan fisika dengan laju tetap, terlepas dari seberapa cepat frame dirender.
const FIXED_DT = 1 / 60 // 60 pembaruan fisika per detik
let accumulator = 0
let lastTime = 0
function gameLoop(currentTime) {
const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
lastTime = currentTime
accumulator += frameTime
while (accumulator >= FIXED_DT) {
update(FIXED_DT)
accumulator -= FIXED_DT
}
render()
requestAnimationFrame(gameLoop)
}Math.min(..., 0.25) tersebut penting. Jika tab sempat disembunyikan lalu dibuka kembali, frameTime bisa menjadi sangat besar. Tanpa batas tersebut, while loop akan menjalankan ratusan pembaruan untuk mengejar ketertinggalan, sehingga game macet. Membatasinya pada 0,25 detik berarti Anda tidak akan pernah menjalankan lebih dari 15 pembaruan fisika per frame.
Mengatur Status Game
Seiring berkembangnya game, Anda memerlukan tempat untuk menyimpan semua data. Saya suka menggunakan satu objek yang menampung semuanya:
const game = {
state: 'playing', // 'menu', 'playing', 'paused', 'gameover'
score: 0,
player: {
x: 100,
y: 100,
vx: 0,
vy: 0,
speed: 200,
width: 32,
height: 32,
},
entities: [],
}Pendekatan ini memudahkan Anda menyimpan dan memuat status game, mereset game ketika pemain mati, serta melakukan debug dengan mencatat game ke konsol. Sebagian orang lebih memilih class, tetapi objek biasa sudah cukup untuk game berukuran kecil hingga menengah.
Menggabungkan Semuanya
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
const FIXED_DT = 1 / 60
let accumulator = 0
let lastTime = 0
const input = { left: false, right: false, up: false, down: false }
const game = {
player: { x: 100, y: 100, speed: 200 },
}
function update(dt) {
const p = game.player
if (input.left) p.x -= p.speed * dt
if (input.right) p.x += p.speed * dt
if (input.up) p.y -= p.speed * dt
if (input.down) p.y += p.speed * dt
// Pertahankan agar tetap di dalam batas
p.x = Math.max(0, Math.min(canvas.width - 32, p.x))
p.y = Math.max(0, Math.min(canvas.height - 32, p.y))
}
function render() {
ctx.fillStyle = '#1a1a2e'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#4ade80'
ctx.fillRect(game.player.x, game.player.y, 32, 32)
}
function gameLoop(currentTime) {
const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
lastTime = currentTime
accumulator += frameTime
while (accumulator >= FIXED_DT) {
update(FIXED_DT)
accumulator -= FIXED_DT
}
render()
requestAnimationFrame(gameLoop)
}
// Penanganan input
window.addEventListener('keydown', (e) => {
if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = true
if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = true
if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = true
if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = true
})
window.addEventListener('keyup', (e) => {
if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = false
if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = false
if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = false
if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = false
})
requestAnimationFrame(gameLoop)Ini adalah game loop lengkap yang dapat langsung dijalankan. Pemain bergerak menggunakan tombol panah atau WASD, tetap berada di dalam batas, dan semuanya berjalan dengan kecepatan konsisten terlepas dari frame rate. Anda dapat menyalin kode ini sebagai titik awal untuk game Canvas 2D apa pun.
Perhatikan bahwa penanganan input dipisahkan dari game loop. Event listener hanya menetapkan flag, lalu fungsi update membaca flag tersebut. Pemisahan ini penting karena event keyboard dipicu pada waktu yang tidak dapat diprediksi, sedangkan Anda ingin logika game berjalan dengan laju yang dapat diprediksi.
Memuat dan Menggambar Sprite
const sprites = {}
async function loadSprite(name, url) {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
sprites[name] = img
resolve(img)
}
img.src = url
})
}
function drawSprite(name, x, y, width, height) {
const sprite = sprites[name]
if (sprite) {
ctx.drawImage(sprite, x, y, width || sprite.width, height || sprite.height)
}
}Gambar dimuat secara asinkron, sehingga Anda perlu menunggunya selesai dimuat sebelum memulai game. Pola yang umum adalah memuat semua sprite dalam fungsi loadAssets yang mengembalikan Promise, lalu memulai game loop hanya setelah Promise tersebut selesai.
Fungsi drawSprite memeriksa apakah sprite tersedia sebelum menggambarnya. Ini mencegah error jika Anda mencoba menggambar sesuatu yang belum selesai dimuat.
Sprite Beranimasi
class AnimatedSprite {
constructor(image, frameWidth, frameHeight, frameCount, fps = 10) {
this.image = image
this.frameWidth = frameWidth
this.frameHeight = frameHeight
this.frameCount = frameCount
this.frameDuration = 1 / fps
this.currentFrame = 0
this.elapsed = 0
}
update(dt) {
this.elapsed += dt
if (this.elapsed >= this.frameDuration) {
this.currentFrame = (this.currentFrame + 1) % this.frameCount
this.elapsed = 0
}
}
draw(ctx, x, y) {
ctx.drawImage(
this.image,
this.currentFrame * this.frameWidth, 0,
this.frameWidth, this.frameHeight,
x, y,
this.frameWidth, this.frameHeight
)
}
}Class ini mengasumsikan sprite sheet Anda memiliki frame yang disusun secara horizontal dalam satu baris. Setiap frame berukuran sama. Method update berpindah antar-frame berdasarkan waktu yang telah berlalu, sedangkan draw menggunakan versi sembilan argumen dari drawImage untuk memotong hanya frame saat ini.
Anda dapat menggunakannya seperti berikut:
const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)
// Di dalam update:
playerWalk.update(dt)
// Di dalam render:
playerWalk.draw(ctx, player.x, player.y)Deteksi Tabrakan
function rectCollision(a, b) {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
)
}
function circleCollision(a, b) {
const dx = a.x - b.x
const dy = a.y - b.y
const distance = Math.sqrt(dx * dx + dy * dy)
return distance < a.radius + b.radius
}
// Di dalam update:
for (const enemy of game.enemies) {
if (rectCollision(game.player, enemy)) {
handleCollision(game.player, enemy)
}
}Tabrakan persegi panjang (juga disebut AABB, singkatan dari axis-aligned bounding box) adalah opsi paling sederhana dan cepat. Gunakan untuk sebagian besar objek. Tabrakan lingkaran lebih cocok untuk objek berbentuk bulat atau ketika Anda ingin tabrakan terasa lebih toleran.
Untuk jumlah entitas yang sedikit, memeriksa setiap pasangan tidak menjadi masalah. Jika Anda memiliki ratusan entitas, Anda sebaiknya menggunakan partisi spasial (quadtree atau grid), tetapi itu adalah topik untuk tutorial lain.
Kamera Sederhana
const camera = {
x: 0,
y: 0,
follow(target) {
this.x = target.x - canvas.width / 2
this.y = target.y - canvas.height / 2
}
}
function render() {
ctx.save()
ctx.translate(-camera.x, -camera.y)
// Gambar dunia
drawWorld()
drawEntities()
ctx.restore()
// Gambar UI (tidak terpengaruh kamera)
drawUI()
}Kuncinya adalah ctx.save() dan ctx.restore(). Anda menyimpan status transformasi saat ini, menerapkan offset kamera dengan translate, menggambar semuanya dalam ruang dunia, lalu memulihkannya agar elemen UI tidak terpengaruh oleh kamera.
Agar gerakan kamera lebih mulus, Anda dapat melakukan lerp menuju target alih-alih langsung berpindah ke posisinya. Kode seperti this.x += (targetX - this.x) * 0.1 akan menghasilkan efek kamera tertinggal yang nyaman.
Menjeda Game
let paused = false
window.addEventListener('keydown', (e) => {
if (e.code === 'Escape') {
paused = !paused
}
})
function gameLoop(currentTime) {
const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
lastTime = currentTime
if (!paused) {
accumulator += frameTime
while (accumulator >= FIXED_DT) {
update(FIXED_DT)
accumulator -= FIXED_DT
}
}
render()
if (paused) {
renderPauseOverlay()
}
requestAnimationFrame(gameLoop)
}
function renderPauseOverlay() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#fff'
ctx.font = '32px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('DIJEDA', canvas.width / 2, canvas.height / 2)
}Inti utamanya adalah Anda menghentikan akumulasi waktu saat game dijeda, tetapi tetap melakukan rendering. Dengan demikian, overlay jeda langsung muncul dan game tetap merespons input untuk melanjutkan permainan.
Anda dapat memperluas pola ini untuk menangani beberapa status game seperti menu, cutscene, dan layar game over. Cukup periksa status saat ini dan arahkan ke fungsi update/render yang berbeda.
Kesalahan Umum
Berikut beberapa hal yang sering saya lihat menjebak kreator:
Lupa membersihkan canvas. Jika Anda tidak memanggil clearRect pada awal render, Anda akan menggambar di atas frame sebelumnya dan menghasilkan efek gambar berbayang.
Tidak membatasi delta time. Jika seseorang berpindah tab lalu kembali, delta time yang sangat besar dapat merusak fisika atau membuat game mencoba “mengejar ketertinggalan” selama beberapa detik.
Menggambar sebelum gambar selesai dimuat. Canvas diam-diam mengabaikan pemanggilan drawImage dengan gambar yang belum selesai dimuat. Sprite Anda tidak akan muncul tanpa pesan error apa pun.
Menggunakan setInterval untuk loop. Fungsi ini tidak tersinkronisasi dengan layar dan tidak dijeda ketika tab disembunyikan. Gunakan requestAnimationFrame.
Mencampur koordinat dunia dan layar. Saat menambahkan kamera, perhatikan apakah suatu posisi berada dalam ruang dunia atau ruang layar. Klik mouse diterima dalam ruang layar dan perlu dikonversi.
Langkah Berikutnya
Tutorial ini membahas dasar-dasarnya. Setelah memahami pola-pola ini, Anda mungkin ingin mempelajari:
Canvas game responsif untuk menangani ukuran layar yang berbeda dan layar dengan DPI tinggi.
Penanganan input game untuk kontrol sentuh, gamepad, dan sistem input yang lebih canggih.
Rendering pixel art untuk menghasilkan grafis piksel yang tajam tanpa blur.
Library fisika game untuk menambahkan fisika benda tegar, tabrakan, dan joint ke game loop Anda.
Jika Anda sedang memilih antara Canvas 2D, WebGL, dan WebGPU untuk proyek Anda, panduan Stack Teknologi Game Web pada 2026 menguraikan kelebihan dan kekurangannya. Perbandingan Engine Game Web membahas framework yang menangani game loop untuk Anda.
Sumber Daya Eksternal
- MDN: requestAnimationFrame — referensi API peramban
- MDN: Canvas API — dokumentasi lengkap Canvas 2D
- MDN: KeyboardEvent — kode tombol dan properti peristiwa
- Perbaiki Timestep Anda! — artikel klasik Glenn Fiedler tentang game loop dengan timestep tetap