İçeriğe geç
codebiy
← Yazı masasına dön

Oyun ekonomisini hisle değil, altı botla ayarladık

Pazar tezgâhı oyunumuzun ilk denge raporunda başlangıç borcunu hiçbir bot kapatamıyordu. Sebep botların oynayışıydı: iyi bot düzgün oynamaya başlayınca borç fazla kolay kapandı. Sonunda mal ucuzlamadı, pahalandı.

Bu yazı Pazarcı'daki bazı sonlara nasıl ulaşıldığını açık ediyor.

Pazarcı, iPhone ve iPad için yaptığımız sıra tabanlı bir oyun. Konusu semt pazarında tezgâh işletmek: şafakta halde pazarlık, tezgâh dizme, gün boyu satış, akşam defter. Kampanya 24 gün sürüyor, oyuncu dört taksitte ödenecek ₺60.000 borçla başlıyor ve beş farklı son var. Dürüst, ortalama bir oyuncunun o borcu kapatıp kapatamayacağı bir tasarım kararı. 24 günlük kampanyayı her değişiklikten sonra kırk kez oynayacak kimse de yok. Onu altı bot oynuyor. İlk raporlarına göre borcu hiçbir oyun tarzı kapatamıyordu. Sebep oyundan çok botlardı: az mal alıyor, toptancının istediği fiyatı ödüyorlardı.

Oyun Swift 6 ve SwiftUI ile, iOS 18 ve sonrası için yazıldı. Burada anlattığımız denge ayarını 28 Eylül 2026'da yaptık. 1.0 sürümü ertesi gün App Review'a gitti, oyun App Store'da yayında.

Pazarcı'da 1. günün defteri: kasaya net ₺670 girmiş, ₺8.000'lik taksite 5 gün var, kalan borç ₺60.000. 1. günü kapatan defter. Bütün tutarlar oyun içi para.

Arayüz olmadan çalışan kural motoru

Oyunun kuralları, arayüzden haberi olmayan bir çekirdek motorda duruyor. Oyun bir değer, hamle bir komut. Engine.apply(command, to: &state) de o komutun doğurduğu olayları döndürüyor. Uygulama içi satın almalar yalnızca kozmetik ve bu katmana hiç girmiyor, yani botlar, oyunu satın alan herkesin oynadığı oyunu oynuyor.

Para tam sayı: typealias Kurus = Int. ₺1.800 kodda 180_000 diye yazılıyor ve hiçbir bakiyede kuruşun küsuratı olmuyor.

Bu ayrım sayesinde bütün kampanya komut satırından derlenip çalışıyor. Tek bir script, çekirdeği de içeriği de botları da swiftc derleyicisine verip çıkan programı çalıştırıyor. Xcode projesi de simülatör de gerekmiyor. Gerilim oyunumuz Üst Kat da aynı şekilde kurulu ve bir bulmaca kilidini uygulamayı açmadan böyle test ettik.

Bot dediğimiz şey küçük bir protokol. Oyunun durumuna bakıp sıradaki komutu seçiyor, yapacak işi yoksa nil döndürüyor.

protocol Bot {
    var name: String { get }
    func next(_ s: RunState) -> Command?
}

Sürücü kod, oyunu bir seed'den (rastgeleliği belirleyen başlangıç sayısı) başlatıyor ve günler bitene kadar bottan komut istiyor. Aynı seed ve aynı bot, aynı kampanyayı veriyor.

Altı bot ve oyun tarzları

Her bot bir oyun tarzını temsil ediyor, hiçbiri zorluk seviyesi değil.

  • PassiveBot dürüst bir tezgâh kurar, sonra yalnızca bekler. Hiçbir müşteriye bakmaz.
  • HonestBot dürüst, ortalama oyuncudur. Kuyruktaki müşteriye bakar, halde kanıtla pazarlık yapar. Ama hep aynı sabit miktarı alır ve hiç geliştirme almaz.
  • PlannerBot iyi oyuncudur. Büyük partilerin dibine bakar, bulduğu çürüğü pazarlıkta kanıt olarak kullanır, alımını satışa göre ayarlar, tabelayı alır ve oyunun önerdiği taksiti öder.
  • SelinBot, bir karakterin hikâyesini sonuna kadar izleyen PlannerBot'tur.
  • CheaterBot hile yapar: ayarlı terazi, iyilerin üste dizildiği bir vitrin, uydurma menşe etiketi.
  • KenanBot PlannerBot gibi kazanır, karanlık sona giden yolda da önüne gelen her şaibeli teklifi kabul eder.

İlk rapor ve değiştirdiklerimiz

20 seed'lik ilk rapor iki şey gösteriyordu. Hiçbir bot, hiçbir seed'de borcu kapatamıyordu. İyi bot ise 20 oyunun 18'inde pazarı kurtarıyordu: pazarın yerinde kalıp kalmayacağını belirleyen dilekçede, 150 imzalık hedefe karşılık 240 ile 325 arasında imza topluyordu.

Önce pazarlıktaki üç açığı kapattık, çünkü açık dururken ayarlanan sayıyı, açık kapanınca yeniden ayarlamak gerekir. Pazarlıktan kalkıp geri dönmek artık pazarlığı sıfırlamıyor. Aynı kanıtı toptancıya ikinci kez göstermek güvenini ikinci kez değiştirmiyor. Toptancının geri dönen oyuncuya verdiği fiyat da tam sayı hesabında artık oyuncunun aleyhine yuvarlanıyor.

Sonra oyunda dört şey değişti:

  • Halin ortalama fiyatı, adil fiyatın 0,55'inden 0,60'ına çıktı.
  • Dilekçede, alışveriş yapan müşteri artık ün/200 değil, ün/1.100 olasılıkla imza atıyor.
  • İki müdavimin uğrama oranı %50'den %30'a düştü.
  • Önerilen taksit artık kasada ₺5.000 işletme sermayesi bırakıyor. Eskisi kasanın tamamını borca yatırıyordu.

Botlar da değişti. PlannerBot o güne kadar, kasasında yedek para tutan bir HonestBot gibi oynuyordu: her gün aynı sabit miktarı, toptancının istediği fiyattan alıyordu. O gün oyunu bilen biri gibi oynamayı öğrendi: kanıtla pazarlık, satışa göre alım, tabela, önerilen taksit.

Yani iki rapor kontrollü bir karşılaştırma değil. Aralarında kurallar da, en iyi bot da, seed sayısı da değişti. 28 Eylül 2026'daki ikinci rapor, 40 seed'de:

Bot Borcu kapattı Öteki sonuç
PlannerBot 40'ta 28 21'inde pazar kurtuldu
SelinBot 40'ta 29 27'sinde Selin'in sonu
HonestBot 40'ta 16 12'sinde pazar kurtuldu
PassiveBot 40'ta 0
CheaterBot 40'ta 0 40'ında da ceza yedi
KenanBot 40'ta 1 17'sinde karanlık son

Borcu ödenebilir kılan değişiklik

Borcu ödenebilir kılan, dört kural değişikliğinin hiçbiri değildi. Pahalanan mal, yavaşlayan dilekçe ve seyrekleşen müdavimler oyunu zorlaştırıyor. Yeni taksit önerisi de iyi botun işini kolaylaştırmıyor: eski öneriyle borcu 40 seed'in 30'unda, yenisiyle 28'inde kapatıyor. Neyin işe yaradığını görmek için Ekim 2026'da, bu yazıyı hazırlarken raporu yayındaki oyunun kurallarıyla yeniden çalıştırdık. PlannerBot'un becerilerini birer birer kapattık, son satırda da hal fiyatını eski 0,55'e geri aldık:

PlannerBot, 40 seed Borcu kapattı
Yayındaki hâliyle 28
Tabela olmadan 23
Kanıtsız, istenen fiyatı ödeyerek 1
Stoku yalnızca ilk rapordaki sabit miktarlara tamamlayarak 0
Yayındaki hâliyle, mal eski fiyatta (0,55) 38

İlk rapordaki miktarlarla yetinen bot, iyi pazarlık etse de borcu hiçbir seed'de kapatamıyor. Ona engel olan, aldığı malın azlığı: HonestBot da sabit miktar alıyor ama ilk rapordakinin bir buçuk katını alıyor ve borcu 40 seed'in 16'sında kapatıyor. Alımını satışa göre ayarlayan bot da istenen fiyatı öderse borcu kırk seed'in yalnızca birinde kapatıyor. İlk rapordaki sıfır, botlar hakkında bir tespitti.

İllüstrasyon: terazinin bir kefesinde altı kurmalı oyuncak robot duruyor, biri kırmızı; öbür kefede üst üste dizilmiş madenî paralar var, terazi dengede. Altı bot altı oyun tarzını temsil ediyor, borcun kapanıp kapanmadığını onlar gösteriyor.

Malın neden pahalandığını son satır gösteriyor. PlannerBot düzgün oynamaya başlayınca eski hal fiyatında borcu 40 seed'in 38'inde kapattı. Bu fazla kolaydı.

Plana göre dürüst, ortalama oyuncu borcu kampanyaların en az %75'inde kapatacaktı. Sayıları görünce o gün başka bir hedef koyduk: iyi oyuncu borcu kampanyaların %50–80'inde kapatır, dürüst, ortalama oyuncu bazen batar. 0,60'ta iyi bot 40'ta 28'e iniyor.

Assertion olarak yazılan hedefler

Rapor bir tablo basıyor. Oyun dengesinin zamanla kaymasını önleyen ise tablodan sonraki blok: her tasarım hedefi bir aralık ve tutmayan aralık kontrolü başarısız kılıyor. Örnekte share(n), n bölü oyun sayısı. reached, herhangi bir botun ulaştığı sonların kümesi. expect de koşul tutmazsa kontrolü mesajıyla birlikte durduruyor.

expect((0.50...0.85).contains(planner.share(planner.cleared)),
       "PlannerBot borç kapatma %50–85 (\(planner.cleared)/\(planner.runs))")
expect((0.30...0.75).contains(planner.share(planner.saved)),
       "PlannerBot pazar kurtuldu %30–75 (\(planner.saved)/\(planner.runs))")
// …
expect(honest.saved <= planner.saved,
       "dilekçe itibara yatırım yapana (PlannerBot) daha kolay")
// …
for id: EndingID in EndingID.allCases {
    expect(reached.contains(id.rawValue), "son \(id) botlarla ulaşılır")
}

Borç için hedef %50–80. Assertion ise %85'e kadar kabul ediyor: kırk oyunda iki oyunluk pay. Blok şöyle devam ediyor:

  • İyi oyuncunun günlük ortanca neti ilk bölümde ₺1.800–3.200, son bölümde ₺3.000–5.000 arasındadır.
  • Pasif oyuncu borcu seed'lerin en çok %35'inde kapatır. Dürüst, ortalama oyuncu en az iki seed'de, en çok %70'inde kapatır ve hiçbir zaman iyi oyuncudan daha sık kapatmaz.
  • Hileci, seed'lerin en az %80'inde ceza yer, en az %90'ında oyunu 30'un altında bir vicdan puanıyla (oyunun dürüst esnaflık ölçüsü) bitirir ve borcu hiç kapatamaz.
  • Nadir bir son, iyi oyuncunun oyunlarının %5–20'sinde çıkar ve hiçbir bot 24. günden önce takılmaz.

Yayındaki oyun hâlâ bu sayılarla çalışıyor: rapor Ekim 2026'da güncel kodda aynı tabloyu basıyor.

Dürüst bot ceza yiyorsa önce kurala bakın

HonestBot hiçbir konuda yalan söylemiyordu, yine de kampanya başına yaklaşık üç kez yanıltıcı menşe etiketinden ceza yiyordu: her seferinde ₺1.000 ve mala el koyma. Yanlış olan bir sabit değildi. “Hızlı diz” yardımcısı, aynı ürünün dünden kalan partisiyle bugün alınan partisini tek yuvaya, tek etiketle koyuyordu. Menşeleri farklıysa zabıtanın kuralı bunu sahte etiket sayıyordu.

Düzeltme olarak yuvalara bir kural koyduk: bir yuvada tek menşe durur, yardımcı da ürüne ve menşee göre dizer. Zabıtanın kuralı değişmedi. Yayındaki kurallarda HonestBot 40 kampanyasının hiçbirinde menşe etiketinden ceza yemiyor.

Hiçbir botun ulaşamadığı son

Nadir bir sonun derdi tersiydi. Borç kapandıktan sonra kasada ₺25.000 istiyordu. İyi botun borcu kapattığı 28 seed'de son kasa ₺994 ile ₺21.111 arasındaydı, ortancası ₺10.400 civarındaydı.

İlk kararımız, eşiği usta oyuncunun hedefi olarak bırakmak, sonu da kurgulanmış bir durumla test etmekti. Aynı gün öğleden sonra bu karardan döndük, çünkü simülasyondaki hiçbir oyuncunun ulaşamadığı son, tasarım hatasıdır.

Eşiği ₺13.000'e indirdik. Bu, iyi botun sonuçlarının aşağı yukarı en üst %15'i. PlannerBot o sona bunun ardından 40 seed'in 6'sında ulaştı. %5–20 aralığı da onu orada tutuyor.

Kontrollerin bizi yanılttığı yerler

Aynı şeyi ölçen iki katman birbirini tutmaz oldu. Günlük zarar tabanı kontrol script'inde −₺2.000'den −₺4.000'e çekildi, aynı sınırı ölçen XCTest −₺2.000'de kaldı. Test bir aşama sonra, arayüz işinin ortasında kırıldı. Kural: kontrol script'inde bir eşik değişirse aynı şeyi ölçen test aynı commit'te değişir.

Simülasyon deterministikti, kayıt dosyası değildi. “Aynı seed, aynı kayıt” kontrolü tutmuyordu, çünkü Swift bir Set değerini JSON'a her süreçte başka bir sırayla yazıyor. Kayda giren kümeler artık sıralı dizi olarak tutuluyor, encoder da .sortedKeys kullanıyor. Kontrol script'i aynı bot kampanyasını iki ayrı süreçte çalıştırıp iki kaydın hash'lerini karşılaştırıyor.

Başarısız kontrol sessizdi. Script -O ile derlendiği için tutmayan bir precondition mesajını yazmıyor, geriye yalnızca “Trace/BPT trap” kalıyordu. Hatalar artık fputs ile stderr'e yazılıyor ve süreç exit(1) ile çıkıyor.

Botların gösteremedikleri

Botlar sayıları kanıtlıyor: dikkatli bir stratejinin borcu kapatabildiğini, hile yapanın yakalandığını, her sona ulaşılabildiğini. Pazarlığın keyifli olduğunu ya da bir insanın PlannerBot'a verdiğimiz stratejiyi kendi başına bulabildiğini göstermiyorlar.

Yeniden kursak izleyeceğimiz sıra

  1. Kuralları arayüz olmadan çalışır tutun, parayı tam sayı tutun.
  2. Her oyun tarzına bir bot yazın: hiçbir şey yapmayana ve hile yapana da.
  3. Sayıları değiştirmeden önce açıkları kapatın.
  4. Raporun oyun hakkında söylediğine güvenmeden önce en iyi botun iyi oynadığından emin olun.
  5. Her hedefi aralık olarak yazın, aralığı assertion yapın.
  6. Her sona ulaşıldığını doğrulayın. Ulaşılamayan son hedef değil, hatadır.
  7. Bot kaybediyorsa sabitten önce kurala ve yardımcıya bakın.
  8. Hatalar sebebini yazsın. Aynı oyunu iki süreçte karşılaştırın.
OKUMAYA DEVAM ETSwift bulmaca oyununda hata: doğru şifreyi reddeden kilit ↗AVAudioPlayer ile adaptif oyun müziği: üç döngü, ortak saat ↗