Game di browser adalah sebuah loop: baca input, majukan dunia game sedikit, gambar satu frame, kira-kira 60 kali per detik. Wasm cocok untuk bagian memajukan dunia (gerak, tabrakan, ratusan sampai ribuan benda), sedangkan JavaScript dan Canvas yang menggambar. Bayangin pertunjukan wayang kulit. Dalang menggerakkan semua wayang di balik kelir mengikuti ketukan gamelan. Blencong, lampu minyaknya, memproyeksikan bayangan ke kelir, dan penonton cuma melihat bayangan itu. Dalangnya modul Wasm yang menyimpan dan menggerakkan semua posisi, blencongnya JavaScript yang membaca posisi itu lalu menggambar, kelirnya canvas, dan ketukan gamelannya fixed timestep: dalang bergerak per ketukan, bukan per kedip lampu.
🧪 Tebakan cepat: di game "Tangkap Kabut", kabut jatuh paling cepat 150 piksel per detik dan tampah Bu Sri tebalnya 8 piksel. HP tamu tersendat setengah detik. Kalau game dimajukan 0,5 detik sekaligus, ada kabut yang menembus tampah? Kalau dimajukan per 1/60 detik? Tebak dulu, jawabannya ada di bagian "Fixed timestep".
Game loop: input, update, gambar
requestAnimationFrame(fn) meminta browser memanggil fn tepat sebelum layar digambar ulang: 60 kali per detik di layar 60 Hz, 120 kali di layar 120 Hz, dan berhenti saat tab-nya di belakang. fn menerima waktu sekarang dalam milidetik.
Kenapa Wasm populer untuk game: gerak, fisika, dan tabrakan adalah hitungan angka yang sama berulang-ulang, dan Wasm menjalankannya mendekati kecepatan native dengan waktu yang stabil di semua browser. Engine game besar yang ditulis di C++, seperti Godot dan runtime Unity, di-compile ke Wasm dengan Emscripten, jadi game-nya bisa dibawa ke web tanpa ditulis ulang. Menggambarnya tetap lewat API browser: Canvas 2D, WebGL, atau WebGPU.
State di linear memory
Game "Tangkap Kabut": kabut turun dari Gunung Prau, dan tampah Bu Sri di bawah menangkapnya. Seluruh state-nya hidup di memori modul Wasm tangkap-kabut (Rust no_std, 1.690 byte setelah cargo build --release):
const MAKS: usize = 2000; // paling banyak 2.000 kabut
/// Tiap kabut 5 angka f32 berurutan: x, y, vx, vy, r.
static mut KABUT: [f32; MAKS * 5] = [0.0; MAKS * 5];
/// Dibaca JS tiap frame: tampah_x, lalu skor, lolos, tembus, langkah.
#[repr(C)]
pub struct Status { tampah_x: f32, skor: u32, lolos: u32, tembus: u32, langkah: u32 }
/// Maju sejauh dt detik. TAMPAH_Y = 176, tebal 8, lebar 56; LEBAR x TINGGI = 320 x 200.
#[unsafe(no_mangle)]
pub extern "C" fn langkah_dt(dt: f32) {
unsafe {
let s = &mut *core::ptr::addr_of_mut!(STATUS);
let k = &mut *core::ptr::addr_of_mut!(KABUT);
let mut tx = s.tampah_x + ARAH as f32 * TAMPAH_LAJU * dt;
if tx < 0.0 { tx = 0.0; }
if tx > LEBAR - TAMPAH_LEBAR { tx = LEBAR - TAMPAH_LEBAR; }
s.tampah_x = tx;
for i in 0..JUMLAH {
let o = i * 5;
let (x0, y0, r) = (k[o], k[o + 1], k[o + 4]);
let mut x = x0 + k[o + 2] * dt;
let y = y0 + k[o + 3] * dt;
if x < 0.0 { x += LEBAR; }
if x >= LEBAR { x -= LEBAR; }
k[o] = x;
k[o + 1] = y;
let sejajar = x + r > tx && x - r < tx + TAMPAH_LEBAR;
if sejajar && y + r >= TAMPAH_Y && y - r <= TAMPAH_Y + TAMPAH_TEBAL {
s.skor += 1; // menyentuh tampah di posisi barunya
lahirkan(i, true); // muncul lagi di atas
} else if sejajar && y0 + r < TAMPAH_Y && y - r > TAMPAH_Y + TAMPAH_TEBAL {
s.tembus += 1; // melompati tampah dalam satu langkah
} else if y - r > TINGGI {
s.lolos += 1;
lahirkan(i, true);
}
}
s.langkah += 1;
}
}
- Kabut ditaruh berurutan (x, y, vx, vy, r), jadi JS bisa membaca semuanya sebagai satu
Float32Array. #[repr(C)]membuat urutan fieldStatustetap, jadi JS tahuskorada di byte ke-4.- Export lainnya:
langkah()(sama denganlangkah_dt(1/60)),mulai(jumlah, benih)untuk menyiapkan kabut dengan angka acak yang bisa diulang (xorshift),arah(-1 | 0 | 1)untuk input,taruh_tampah(x)untuk sentuhan,kabut_ptr()danstatus_ptr()untuk alamatnya.
WebAssembly.instantiate(bytes).then(({ instance }) => {
const x = instance.exports;
x.mulai(200, 2026); // 200 kabut, benih 2026
const kabut = new Float32Array(x.memory.buffer, x.kabut_ptr(), 200 * 5);
const tampahX = new Float32Array(x.memory.buffer, x.status_ptr(), 1);
const angka = new Uint32Array(x.memory.buffer, x.status_ptr(), 5); // [1] skor, [2] lolos, [3] tembus
function gambar() {
ctx.clearRect(0, 0, 320, 200);
ctx.beginPath();
for (let i = 0; i < 200; i++) {
const o = i * 5;
ctx.moveTo(kabut[o] + kabut[o + 4], kabut[o + 1]);
ctx.arc(kabut[o], kabut[o + 1], kabut[o + 4], 0, Math.PI * 2);
}
ctx.fill(); // satu fill untuk semua kabut
ctx.fillRect(tampahX[0], 176, 56, 8);
ctx.fillText('Skor ' + angka[1], 8, 14);
}
});
- View
Float32Arraydibuat sekali dan menunjuk langsung ke memori Wasm. Tiap frame JS cuma membacanya; gak ada yang disalin. - Semua kabut masuk satu path dan satu
fill(). Menggambar juga punya ongkos per panggilan. - Ini arsitektur yang umum: Wasm menghitung state game dan menyiapkan apa yang perlu digambar, JS yang memanggil Canvas 2D atau WebGL. Di sini "kiriman draw call"-nya berupa daftar kabut di memori; game lain mengirim daftar perintah gambar atau buffer vertex untuk WebGL dengan cara yang sama.
Satu view, bukan satu panggilan per kabut
Cara lain membaca posisi: tiga fungsi export, kabut_x(i), kabut_y(i), kabut_r(i). Untuk 2.000 kabut itu 6.000 penyeberangan per frame, lawan 0 lewat view. Diukur:
| Per frame, 2.000 kabut | Waktu |
|---|---|
| Membaca lewat view (Node 22) | 0,04 ms |
| Membaca lewat 6.000 panggilan (Node 22) | 0,08 ms |
langkah() di Wasm |
0,013 ms |
Menggambar 2.000 lingkaran sampai jadi piksel (Chromium 147 headless, dipaksa dengan getImageData) |
sekitar 5 ms |
Di game ini, penyeberangan bukan yang paling mahal: export yang cuma mengembalikan angka murah sekali per panggilan (ingat pelajaran "Ongkos menyeberang": 960.000 panggilan 8 ms). Yang berat justru menggambar: merekam perintah Canvas-nya murah, tapi mengubahnya jadi piksel butuh sekitar 5 ms dari anggaran 16,7 ms per frame, jauh di atas 0,08 ms tadi. Polanya tetap: satu panggilan langkah() per langkah, state dibaca lewat view, gambar dikumpulkan jadi sesedikit mungkin draw call. Pola per entitas jadi mahal kalau tiap panggilan membuat objek JS, atau kalau Wasm memanggil Canvas sendiri untuk tiap kabut lewat glue seperti web-sys.
Kalau modulnya memanggil memory.grow, view lama putus. Panjangnya jadi 0, kabut[0] diam-diam undefined, dan method-nya melempar TypeError: Cannot perform %TypedArray%.prototype.fill on a detached ArrayBuffer. Buat view baru kalau kabut.buffer !== x.memory.buffer.
Fixed timestep
Frame gak pernah datang dengan jarak yang sama. Kalau tiap frame game dimajukan sejauh jarak frame itu (dt variabel), hasilnya bergantung pada HP-nya. Fixed timestep memajukan dunia dengan langkah tetap 1/60 detik, sebanyak yang perlu untuk mengejar waktu:
const DT = 1 / 60;
let sisa = 0;
let sebelumnya = performance.now();
function frame(sekarang) {
sisa += Math.min((sekarang - sebelumnya) / 1000, 0.25); // paling banyak 15 langkah sekali kejar
sebelumnya = sekarang;
while (sisa >= DT) {
x.langkah();
sisa -= DT;
}
gambar();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
sisamenyimpan waktu yang belum dijalankan. Di layar 120 Hz, satu langkah tiap dua frame; di HP yang lambat, beberapa langkah per frame.- Batas 0,25 detik mencegah "spiral of death": kalau satu frame telat lama, game gak mencoba mengejar ratusan langkah sekaligus, cukup melambat sebentar.
Diukur dengan 200 kabut dan benih 2026, tampah diam:
| Cara memajukan | Skor | Lolos | Tembus |
|---|---|---|---|
| 10 detik, fixed 1/60 (600 langkah) | 147 | 556 | 0 |
| 10 detik, dt variabel di 20 fps (200 langkah) | 145 | 554 | 0 |
| 10 detik, dt variabel di 120 fps | 148 | 556 | 0 |
| 1 detik, lalu satu langkah dt 0,5 detik | 23 | 83 | 8 |
| 1 detik, lalu lag 0,5 detik dengan kode di atas (15 langkah 1/60) | 26 | 61 | 0 |
Jadi jawaban tebakannya: dengan dt 0,5 detik, ya, 8 kabut menembus tampah. Kabut 150 piksel per detik melompat 75 piksel dalam satu langkah, jadi di posisi lama dia di atas tampah dan di posisi baru sudah di bawahnya. Dengan fixed timestep, langkahnya paling jauh 2,5 piksel, gak ada yang tembus; karena batas 0,25 detik, cuma 15 langkah yang dijalankan dan game melambat sebentar. Tiga baris pertama juga menunjukkan dt variabel memberi skor beda di HP yang beda.
Input: keyboard dan sentuhan
const tombol = { ArrowLeft: -1, ArrowRight: 1 };
addEventListener('keydown', (e) => { if (e.key in tombol && !e.repeat) x.arah(tombol[e.key]); });
addEventListener('keyup', (e) => { if (e.key in tombol) x.arah(0); });
canvas.addEventListener('pointermove', (e) => {
const kotak = canvas.getBoundingClientRect();
x.taruh_tampah(((e.clientX - kotak.left) * 320) / kotak.width); // piksel layar ke koordinat game
});
- Input dikirim ke Wasm saat berubah, bukan tiap frame.
e.repeatmengabaikan keydown berulang saat tombol ditahan. - Pointer events menangani mouse, jari, dan stylus sekaligus. Beri canvas CSS
touch-action: nonesupaya jari di atasnya gak menggulung halaman. - Canvas yang tajam di layar HP:
canvas.width = 320 * devicePixelRatio, laluctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0).
📌 Engine: Godot, Unity, Bevy
- Godot 4: export web sejak 4.3 bawaannya single-threaded, jadi gak butuh header cross-origin isolation; versi multithread butuh. Rendering-nya WebGL 2. Menurut blog Godot (Mei 2024),
.wasmbuild web 4.3 sekitar 40 MB, 5 MB setelah brotli. - Unity 6 (platform Web): file build dikompres gzip (bawaan) atau Brotli, dan server harus mengirim header
Content-Encodingyang cocok, kecuali kamu menyalakan Decompression Fallback. - Bevy (Rust, ECS): di-build ke
wasm32-unknown-unknownlalu wasm-bindgen, rendering WebGL 2 (WebGPU masih eksperimental), dan di web semuanya satu thread. - Pakai engine kalau butuh 3D, fisika lengkap, editor, audio, dan banyak platform. Tulis sendiri seperti Tangkap Kabut (1.690 byte) kalau game-nya kecil dan ukuran unduhan penting.
📌 HP: layar, baterai, tab di belakang
requestAnimationFramemengikuti layar (60, 90, atau 120 kali per detik) dan berhenti di tab belakang. Fixed timestep menjaga kecepatan game sama di semua layar, dan batas 0,25 detik menjaga saat tab dibuka lagi setelah puluhan detik.- Di
devicePixelRatio3, canvas 320 × 200 berisi 9 kali lebih banyak piksel. Kalau menggambar terlalu berat, turunkan resolusi dalamnya. - Hentikan loop saat game ditutup atau halaman disembunyikan (
visibilitychange), supaya baterai gak terkuras.
⚠️ Jebakan yang sering ketemu
- dt variabel tanpa batas. Kabut menembus tampah setelah satu frame lambat, dan skornya beda di tiap HP.
- Satu panggilan per entitas, atau satu
fill()per kabut. Kumpulkan: satulangkah(), satu view, sesedikit mungkin draw call. - View lama setelah
memory.grow. Panjangnya 0 dan isinyaundefinedtanpa error. Cekkabut.buffer !== x.memory.buffer. - Canvas buram atau halaman ikut tergulung di HP. Skalakan dengan
devicePixelRatio, dan pasangtouch-action: none. - Loop yang jalan terus. Simpan id dari
requestAnimationFramedan panggilcancelAnimationFramesaat game berhenti.
TL;DR: Game browser adalah loop requestAnimationFrame: input, update, gambar. Wasm populer untuk game karena gerak, fisika, dan tabrakan jalan mendekati native, dan engine C++ (Godot, runtime Unity) bisa di-compile ke Wasm dengan Emscripten; menggambar tetap lewat Canvas, WebGL, atau WebGPU. Arsitekturnya: Wasm menghitung state di linear memory (Tangkap Kabut, 1.690 byte, kabut sebagai deret f32 x, y, vx, vy, r), JS membaca view Float32Array sekali per frame dan menggambar semua kabut dengan satu path. Penyeberangan per entitas (6.000 panggilan, 0,08 ms) bukan yang paling mahal di sini; menggambar (sekitar 5 ms) yang mahal. Pakai fixed timestep 1/60 dengan batas 0,25 detik: satu langkah 0,5 detik membuat 8 kabut menembus tampah, langkah tetap membuatnya 0, dan hasilnya sama di semua layar. Input dikirim saat berubah (arah, taruh_tampah), canvas diskalakan devicePixelRatio dengan touch-action: none. Buat view baru setelah memory.grow. Pakai engine (Godot, Unity, Bevy) untuk game besar; untuk game kecil, modul sendiri jauh lebih ringan.