uurstudios
Tüm yazılar
Oyun GeliştirmeJavaScriptCanvas

Deterministik simülasyonla birkaç KB'lık oyun tekrarı nasıl kurulur

30 Eylül 2026 7 dk okumaUğur Can Elsun

Bir oyunun tekrarını kaydetmenin iki yolu var. Birincisi her karede dünyanın durumunu (konumlar, hızlar, animasyonlar) yazmak. Basit ama birkaç dakikalık oyun megabaytlara çıkar. İkincisi yalnızca oyuncunun hangi karede hangi tuşa bastığını yazmak ve tekrarı izlerken oyunu baştan yeniden oynatmak. Bu yol birkaç kilobayta sığar, ama tek bir şartı var: simülasyon deterministik olmalı. Aynı başlangıç ve aynı girdiler, her makinede, her seferinde birebir aynı sonucu üretmeli.

Portföyümdeki Hopscraper adlı tarayıcı oyununda (sonsuz bir kulede kat atlayarak tırmandığınız bir platform oyunu) ikinci yolu seçtim. Tekrarlar ileri geri sarılabiliyor, hızlandırılabiliyor ve HOPR1-… ile başlayan bir kod olarak arkadaşınıza gönderilebiliyor. Aşağıda sistem parça parça anlatılıyor.

Simülasyonu çizimden ayırın

Oyunun tamamı web/game.js adlı tek dosyada, ama dosyanın en üstündeki bölüm tarayıcıdan ve çizimden tamamen bağımsız. Bölümün başındaki yorum da bunu söylüyor: buradaki her şey deterministik kalmak zorunda, çünkü tekrarlar bu kodu tohum ve kayıtlı girdilerle yeniden çalıştırıyor.

Oyun durumu tek bir düz nesne. newGame(seed, rejump) onu oluşturuyor, stepGame(g, bits) bir kare ilerletiyor. Ses çalmak, parçacık saçmak, ekranı sarsmak bu fonksiyonun işi değil. Simülasyon yalnızca olay listesine not düşüyor (wall, cstep gibi), döngü her adımdan sonra listeyi okuyup sesi ve efekti tetikliyor, sonra listeyi boşaltıyor. Tekrar izlerken aynı olaylar aynı sırayla geliyor, 4x ve 8x hızda ise ses tamamen susturuluyor.

Bu ayrımı korumak için simülasyon bölümünde uyulan kurallar şunlar:

  • Math.random(), Date.now() ya da performance.now() çağrısı yok.
  • Ekran boyutu, kare hızı ya da ayar okunmuyor. Sonucu etkileyen tek ayar (tuş basılı tutulunca yeniden zıplama) oyun durumuna yazılıyor ve tekrarla birlikte saklanıyor.
  • Math.sin, Math.cos, Math.pow gibi fonksiyonlar kullanılmıyor. ECMAScript bu fonksiyonların sonucunu yaklaşık olarak tanımlar, farklı JavaScript motorları son basamakta farklı sonuç verebilir. Toplama, çarpma ve bölme ise IEEE 754 sayesinde her yerde aynıdır. Hopscraper'ın simülasyonunda yalnızca abs, floor, ceil, min, max ve imul var.

Sabit zaman adımı

requestAnimationFrame her ekranda farklı aralıkla çalışır: 60 Hz, 120 Hz, arada takılan bir kare. Fiziği bu değişken süreyle ilerletirseniz iki makinede aynı girdiler farklı sonuç verir. Çözüm, simülasyonu her zaman aynı adımla ilerletmek ve gerçek zamanı bir biriktiricide tutmak:

const STEP = 1 / 60;
let acc = 0, lastT = 0;

function frame(now) {
  requestAnimationFrame(frame);
  let dt = (now - lastT) / 1000;
  lastT = now;
  if (dt > 0.1) dt = 0.1;           // sekme arka plandan dönünce
  acc += dt;
  let n = 0;
  while (acc >= STEP && n < 6) { tickPlay(); acc -= STEP; n++; }
  if (n >= 6) acc = 0;              // yetişemiyorsan geride kalanı at
  render(acc / STEP);               // iki adım arası ara değer
}

120 Hz ekranda bazı karelerde hiç adım atılmaz, 30 Hz'de bir karede iki adım atılır. Oyuncu farkı görmez, çünkü çizim bir önceki konumla şimdiki arasında ara değer alıyor. Simülasyon her adımda eski konumu (px, py) saklıyor, çizim de bu iki değer arasında acc / STEP oranında konum hesaplıyor.

Sabit adımın bir yan etkisi de simülasyonun saniye bilmemesi. Yer çekimi kare başına 0.45 piksel, kombo süresi 180 kare (3 saniye), kulenin hızlandığı tur 1.800 kare (30 saniye). Her şey kare cinsinden.

Karşılaştırma için: portföyümdeki Inkway oyunu değişken dt ile çalışıyor (1/30 saniye ile sınırlı). O oyunda tekrar olmadığı için bu yeterli. Tekrar olsaydı işe yaramazdı.

Tohumlu rastgele sayı üreteci

Kule rastgele üretiliyor, ama Math.random ile değil. Oyun başlarken 32 bitlik bir tohum seçiliyor, bu tohum tekrara yazılıyor ve bundan sonraki tüm "rastgelelik" tohumdan türüyor. Üreteç olarak mulberry32 kullanıyorum: küçük, hızlı ve tamsayı işlemleriyle çalıştığı için her motorda aynı diziyi üretiyor.

function mulberry32(a) {
  a = a >>> 0;
  return function () {
    a = (a + 0x6D2B79F5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

Asıl önemli karar, tek bir üreteci sırayla tüketmemek. Tek üreteç kullanılsaydı, bir yerde fazladan tek bir r() çağrısı sonraki bütün katları kaydırırdı. Bunun yerine her kat kendi üretecini alıyor: mulberry32(hash32(seed ^ Math.imul(n + 1, 0x9E3779B1))). Böylece 250. kat, hangi sırayla üretilirse üretilsin hep aynı. Katları önbellekten silip yeniden üretmek de güvenli.

Parçacıklar ve ekran sarsıntısı ise bilerek Math.random kullanıyor. Oyunun sonucunu etkilemeyen her şey simülasyonun dışında kalabilir.

Girdi kaydı ve RLE

Her karede oyuncunun girdisi üç bit: sol 1, sağ 2, zıplama 4. Yani her kare 0 ile 7 arasında bir sayı. tickPlay bu sayıyı hem simülasyona veriyor hem de kayıt dizisine ekliyor.

Burada küçük ama önemli bir ayrıntı var. Oyun başladığında menüden basılı kalan zıplama tuşu maskeleniyor. Kayda ham tuş durumu değil, maskelenmiş değer yazılıyor. Kural basit: simülasyona hangi değer girdiyse kayda o girer.

60 kare/saniyede beş dakikalık bir oyun 18.000 kare eder. Kare başına bir bayt bile 18 KB tutar. Ama girdiler uzun seriler halinde tekrar ediyor: saniyelerce sağa basılı tutmak gibi. Bu yüzden run-length encoding (RLE) çok iyi çalışıyor. Her seri bir harf (A–H, girdinin değeri) ve 36 tabanında uzunluktan oluşuyor:

const RLE_A = 'ABCDEFGH';
function encodeInputs(a) {
  let s = '', prev = -1, run = 0;
  for (let i = 0; i < a.length; i++) {
    const b = a[i] & 7;
    if (b === prev) run++;
    else { if (run) s += RLE_A[prev] + run.toString(36); prev = b; run = 1; }
  }
  if (run) s += RLE_A[prev] + run.toString(36);
  return s;
}

90 kare sağa, 3 kare sağa ve zıplama, 40 kare yine sağa basmak C2iG3C14 olarak yazılıyor. Harfler büyük, sayılar küçük harfli olduğu için ayraç gerekmiyor. Çözerken /([A-H])([0-9a-z]+)/g ile seriler okunup bir Uint8Array dolduruluyor.

Tekrar kodu ve doğrulama

Tekrar nesnesi şunları taşıyor: simülasyon sürümü, tohum, yeniden zıplama ayarı, karakter, oyuncu adı, tarih, skor, kat, kombo, kare sayısı ve RLE girdisi. Paylaşmak için JSON'a çevrilip UTF-8 baytlarına, oradan base64'e dönüyor ve başına HOPR1- ekleniyor. Araya TextEncoder koymamın sebebi btoa'nın yalnızca Latin-1 karakter kabul etmesi. Oyuncu adında "Ş" varsa doğrudan btoa hata verir.

Yapıştırılan kod dışarıdan gelen veri, ona öyle davranmak gerekiyor. replayFromCode önce öneki kontrol ediyor, sonra validReplay ile sürümün eşleştiğine, tohumun sayı olduğuna, girdinin ^([A-H][0-9a-z]+)*$ desenine uyduğuna ve makul uzunlukta olduğuna bakıyor. Ad 12 karaktere kırpılıyor, karakter numarası 0–3 aralığına sıkıştırılıyor.

Sürüm alanı göründüğünden önemli. Fizikte tek bir sabiti değiştirirseniz eski tekrarlar başka bir yere varır: karakter aynı tuşlara basar ama bu sefer uçuruma düşer. Sürümü uymayan bir tekrarı reddetmek, yanlış oynatmaktan iyidir.

Koddaki skor yalnızca listede göstermek için var. Gerçek sonuç tekrar oynatılınca ortaya çıkıyor. Hopscraper'ın skor tablosu yerel, ama çevrim içi bir tabloda aynı fikirle sunucu gelen tekrarı oynatıp skoru kendisi doğrulayabilir.

İleri geri sarma

Deterministik simülasyon yalnızca ileri gider. Geri sarmak için oynatıcı her 300 karede (5 saniye) oyun durumunun bir kopyasını alıyor. Durum düz bir nesne olduğu için kopya JSON.parse(JSON.stringify(g)) kadar basit. Belirli bir kareye atlarken hedeften önceki en yakın kopya yükleniyor ve oradan hedefe kadar ses ve efekt olmadan hızla adım atılıyor. Ok tuşları 300 kare sarıyor, hız 0.25x ile 8x arasında değişiyor. Yüksek hızda bir karede en fazla 90 adım atılıyor, tarayıcı kilitlenmesin diye.

Testle kilitleyin

Determinizm sessizce bozulur. Birisi simülasyona bir Math.random ekler, oyun gayet güzel oynanır, sadece bütün eski tekrarlar yanlış yere gider. Hopscraper'ın 6 Playwright testinden üçü bu yüzden var: aynı tohum aynı kuleyi ve sonucu üretiyor mu, farklı tohum farklısını üretiyor mu; oyunun kendi botu 3.000 kare oynadıktan sonra girdileri koda çevrilip geri okununca birebir aynı skora ulaşıyor mu; bozuk kodlar reddediliyor mu.

Kendi oyununuza tekrar eklemeyi düşünüyorsanız kontrol listesi şöyle:

  • Simülasyon tek bir fonksiyon ve düz bir durum nesnesi. Çizim ve ses yalnızca olay okuyor.
  • Sabit zaman adımı, çizimde ara değer.
  • Rastgelelik tek bir tohumdan türüyor, mümkünse nesne başına türetilmiş tohumla.
  • Simülasyonda motorlar arası farklı sonuç verebilecek matematik fonksiyonu yok.
  • Kayda simülasyona giren girdi yazılıyor.
  • Tekrar formatında sürüm numarası var ve dışarıdan gelen kod doğrulanıyor.
  • "Aynı girdi, aynı skor" testi her değişiklikte çalışıyor.

İlgili yazılar

Bir projeniz mi var?

Fikrinizi anlatın, 24 saat içinde size özel bir teklif ve yol haritasıyla dönüş yapayım.

Teklif Al
WhatsApp