Horizon'u Yaparken: Gri Kutulardan Bir Koloniye
Eylül 2026’nın son haftasında Horizon’u App Store’a gönderdim. Horizon bir idle koloni oyunu: dünya 2028’de bitmiş, elinde bir hurda yığını var ve oradan bir medeniyet kuruyorsun. Beş division, her birinde on bir generator. Her generator bir alttakini üretiyor; sayılar birden katrilyonlara, oradan harflere tırmanıyor. Bu yazı o oyunun nasıl bir araya geldiğinin hikayesi: motor, arayüz, görsel hattı ve beni en çok şaşırtan kısım — gerçek bir telefonda ortaya çıkan hatalar.
Önce Motor
Idle oyunlar göründüğünden zor. Oyuncu uygulamayı kapattığında üretim durmuyor. Sekiz saat sonra geri döndüğünde oyun, o sekiz saatin tamamını birkaç yüz milisaniyede hesaplamak zorunda — ve sonuç, oyuncu telefonu hiç bırakmasaydı ne olacaksa birebir o olmalı. Bir kuruş fazla ya da eksik, oyuncunun gözünden kaçmıyor.
Bu yüzden ilk karar arayüzle değil, zamanla ilgiliydi. Horizon’un motoru zamanı akış olarak değil, olay sırası olarak işliyor. İki tür olay var: sabit aralıklı tick’ler ve elle başlatılan üretim döngülerinin bitişleri. Motor her adımda sıradaki en erken olaya atlıyor:
export function advance(s: GameState, ctx: SimContext, dtS: number): AdvanceResult {
const end = s.clock + dtS;
for (;;) {
const nextTick = (Math.floor(s.clock / TICK_S) + 1) * TICK_S;
const nextEnd = nextManualEnd(s);
const at = Math.max(s.clock, Math.min(nextTick, nextEnd));
if (at > end) break;
s.clock = at;
if (nextEnd <= at) completeDueCycles(s, ctx, at);
if (nextTick <= at) step(s, ctx);
}
s.clock = end;
}
Canlı oyun bu fonksiyonu her karede, yaklaşık 16 milisaniyelik adımlarla çağırıyor. Çevrimdışı ilerleme aynı fonksiyonu sekiz saatle çağırıyor. Zaman atlaması da aynısını yapıyor. Üçü de aynı olayları aynı sırayla işlediği için sonuç kare hızından bağımsız. Rastgelelik bile deterministik: her tier her tick’te tam bir kez zar atıyor, kritik üretim o zardan okunuyor.
Bunu bir iddia olarak bırakmak istemedim. Test takımında şöyle testler var: sekiz saati kare kare canlı oynat, aynı sekiz saati tek seferde çevrimdışı hesapla, iki kaydı karşılaştır. Üç saat oyna, kaydet, yükle, beş saat daha oyna — kesintisiz sekiz saatle karşılaştır. Toplam 430 test. Sekiz saatlik yakalama Mac’te 300 milisaniye civarında sürüyor.
Önce İskelet, Sonra Boya
Arayüzde de aynı disiplini uyguladım: renk, doku ve görselden önce yapı. İlk ekranlar gri kutulardan ibaretti. Her kutu nokta cinsinden ölçülmüş, her satırın yüksekliği hesaplanmıştı; ama hiçbir şey “güzel” değildi. Bu bilinçliydi. Boyasız bir ekranda hiyerarşi hatası saklanamaz. Bir sayı yanlış yerdeyse, onu kurtaracak bir parıltı yok.
Aşağıda koloni ekranının dört günlük evrimi var. Soldan sağa: 21 Eylül’deki iskelet; 22 Eylül öğlen, türün klasik düzenine oturmuş ama hâlâ gri hâli; aynı günün akşamı boyalı çerçevelerle; 24 Eylül’de sadeleşmiş satırlar; ve bugün, App Store’daki hâli.

İskelet aşamasının asıl kazancı hızdı. Boya henüz yokken bir satırı yeniden düzenlemek dakikalar sürüyor. Boya geldikten sonra aynı değişiklik bir görseli yeniden kesmek, dilimleri yeniden ölçmek demek.
Çerçeveler: 9-Slice
Oyundaki metal paneller, düğmeler ve üretim çubukları tek bir görselden, her boyuta uzayabilecek şekilde çiziliyor. Teknik adı 9-slice: görsel dokuz parçaya bölünüyor, köşeler sabit kalıyor, kenarlar ve orta uzuyor. Web’de bunun karşılığı border-image:
.bar.track {
border-style: solid;
border-width: 0;
border-image: url("ui-bar-track.webp") 36 52 29 52 fill / 9px 15px 7px 15px stretch;
}
İlk dört sayı dilim çizgilerinin kaynak görseldeki yeri, piksel cinsinden. Bu küçük detay büyük bir kısıt doğuruyor: bu görseller asla yeniden boyutlandırılamaz. Görseli yarıya indirirsen dilimler yanlış yere düşer ve köşeler uzamaya başlar. Görsel hattının bu dosyalar için tek bir kuralı var: boyuta dokunma, yalnızca biçimi değiştir.
Görsel Hattı
Oyunda 250’den fazla boyalı görsel var: 95 tarihî figürün portresi, 55 generator, paketler, ikonlar, arka planlar. Hepsi aynı üslupta olmalı ve hepsi aynı kurallarla oyuna girmeli. Bunun için tek bir betik yazdım. Kaynak görseller bir klasöre giriyor, betik dosya adının önekine bakıp ne yapacağına karar veriyor:
| Önek | Ne | Kural |
|---|---|---|
asp- | Portre | Kartın kadrajına kırp (1 : 1,15), 570 × 656’ya küçült |
ui- | 9-slice parça | Boyut değişmez, yalnızca biçim |
bg- | Zemin | Boyut değişmez, alfa kanalı yok |
char- | Karakter | Figürü bul, ayaklardan hizala, kareye yerleştir |
| diğerleri | Nesne | Kenardan dolan düz zemini sil, kenarı yumuşat |
Betik yalnızca değişen dosyaları yeniden üretiyor ve her geliştirme ile derleme öncesinde kendiliğinden çalışıyor. Bir görseli değiştirmek, dosyayı aynı adla klasöre bırakmaktan ibaret.
Aynı süreç diğer ekranlarda da işledi. Mağaza:

Ve 95 figürün toplandığı koleksiyon ekranı. Burada iskelet bir liste olarak başladı, sonra karta dönüştü. Kart formu kazandı çünkü oyuncunun asıl baktığı şey isim değil, yüz:

Cihazın Anlattıkları
Tarayıcıda her şey kusursuzdu. Oyunu TestFlight’la kendi telefonuma kurduğum akşam, tarayıcının hiç göstermediği dört hata ortaya çıktı. Hepsinin ortak bir yanı var: kod doğruydu, ama bir varsayım yanlıştı.
Tam Saniyeyi Bekleyen Çubuklar
Otomatik generator’ların üretim çubukları doluyor, bir an dolu bekliyor, sonra sıfırlanıyordu. Daha tuhafı, bütün çubuklar sanki birbirini bekliyormuş gibi aynı anda sıfırlanıyordu.
Neden motordaydı. Otomatik üretim sabit bir saniyelik tick’lerle ödeniyor — determinizmin bedeli bu. 3,5 saniyelik bir döngü 3,5’te değil, 4. saniyedeki tick’te ödeniyor. Çubuk da bu ödemeyi izliyordu: dolup tick’i bekliyordu. Bütün otomatik çubuklar aynı tam saniyelerde sıfırlandığı için ekran senkronize görünüyordu.
Çözüm motoru değil, çubuğu değiştirmekti. Motor tick’te ödemeye devam ediyor; çubuk ise artık kendi döngüsünde sarılıyor:
// önce: döngü dolunca tick'e kadar dolu bekle
return Math.min(1, (t.cycleElapsed + tickPhase(clock)) / cycle);
// sonra: her tier kendi döngüsünde döner
return ((t.cycleElapsed + tickPhase(clock)) % cycle) / cycle;
Tek bir % işareti. Ödeme hâlâ en fazla bir saniye gecikiyor, ama göz bunu fark etmiyor; fark ettiği şey, her çubuğun kendi nefesiyle dolması. Döngüsü bir saniyeden kısa olan generator’larda çubuk artık sürekli dolu kalıyor ve üzerinden bir şerit akıyor. Saniyede on kez sıfırlanan bir çubuk bilgi değil, gürültü.
Kaybolan Çerçeve
Bir generator’dan, üretim çubuğu dolmak üzereyken satın alım yapınca, çubuk ve çerçevesi dolduğu anda bir kare için kayboluyordu.
Bu bir WebKit hatası. Bir elemanın atası transform animasyonundan çıkıp yeniden çizildiğinde, içindeki border-image bazen hiç çizilmiyor. Satın alımda bütün satırı iki piksel zıplatan bir animasyon vardı; aynı anda üretim tetiği ikonu büyütüyordu. İki animasyonun bitişi üst üste binince çerçeve atlanıyordu. Satırın tamamını değil, yalnızca satın alma düğmesini hareket ettirmek sorunu ortadan kaldırdı. Aynı hatayı daha önce koleksiyon kartlarında da yaşamıştım; orada çözüm, kart çerçevesini border-image yerine hazır bir görsele çevirmekti.
İki Koloni
Oyundan çıkıp birkaç dakika sonra geri dönünce oyun her seferinde sordu: “İki kayıt bulundu. Yereldeki mi, buluttaki mi?” İki seçenek de aynı saati gösteriyordu.
Oyun, açılışta buluttaki kaydın zaman damgasını, bu cihazın en son gönderdiği kaydınkiyle karşılaştırıyor. Eşitse bulut bu cihazın devamı, soru yok. Sorun, zamanı tuttuğum saatteydi. Cihaz saatine güvenmemek için oyun kendi saatini performance.now() ile ilerletiyor — ve performance.now() milisaniyenin kesirini veriyor. Buluta giden ISO tarih biçimi ise milisaniyede kesiliyor:
const local = 1790453489849.37; // oyunun saati
const cloud = Date.parse(new Date(local).toISOString());
// → 1790453489849
local === cloud; // false — her açılışta
İki sayı hiçbir zaman eşit olmadı. Oyun her açılışta kendi kaydını başka bir cihazınki sandı. Çözüm, karşılaştırmayı tam milisaniyeyle yapmaktı. Milisaniyenin kesrindeki 0,37, her açılışta bir soru penceresi açtı.
Sessiz Ses
Oyunu arka plana alıp döndüğümde bütün sesler kapanıyordu. Garip olan, bir reklam izledikten sonra seslerin geri gelmesiydi.
iOS arka plandan dönüşte Web Audio bağlamını running durumunda gösteriyordu ama ses çıkmıyordu. resume() çağırmak bir şey yapmıyordu, çünkü bağlam zaten “çalışıyordu”. Reklamın sesi neden geri getirdiğini anlayınca çözüm kendini gösterdi: reklam açılırken oyun bağlamı askıya alıyor, kapanınca yeniden başlatıyordu. Bu kapat-aç döngüsü ses yolunu yeniden kuruyordu. Artık oyun, arka plandan döndüğünde ve ilk dokunuşta aynı döngüyü kendisi yapıyor.
| Belirti | Neden | Çözüm |
|---|---|---|
| Çubuklar dolu bekliyor, hep birlikte sıfırlanıyor | Ödeme tam saniyede, çubuk ödemeyi izliyor | Çubuk kendi döngüsünde sarılıyor (%) |
| Çerçeve bir kare kayboluyor | WebKit, animasyon sonrası border-image çizmiyor | Satır değil, düğme hareket ediyor |
| Her açılışta “iki kayıt” sorusu | Kesirli milisaniye ≠ kesilmiş milisaniye | Tam milisaniyeyle karşılaştırma |
| Arka plandan dönünce ses yok | Bağlam “çalışıyor” ama sessiz | Dönüşte askıya al, yeniden başlat |
Sayılarla Horizon
| Division | 5 |
| Generator | 55 |
| Tarihî figür | 95 |
| Boyalı görsel | 250+ |
| Dil | 10 |
| Ses | 1 müzik, 28 efekt |
| Test | 430 |
| Kod | ~42 bin satır TypeScript ve Svelte |
Son Düşünceler
Bir idle oyunda oyuncunun en çok baktığı şey bir çubuk. Dolar, sıfırlanır, yeniden dolar — saatlerce. O çubuk yarım saniye fazla beklediğinde oyun “bozuk” hissettiriyor, nedenini kimse söyleyemese de.
TestFlight’taki dört hatanın hiçbiri tarayıcıda görünmüyordu. Hepsi bir varsayımın gerçek bir cihazla karşılaşmasıydı: saniyenin tam olduğu, çizimin güvenilir olduğu, milisaniyenin milisaniye olduğu, “çalışıyor”un çalıştığı.
Motor önce geldi, çünkü güven önce gelir. İskelet boyadan önce geldi, çünkü yapı süsten önce gelir. Ve telefon her şeyden sonra geldi — çünkü son sözü o söylüyor.
Çubuk süs değil. O, oyunun saati.