Oyun

Roads of Toorn

Bir mobil oyun ve arkasındaki sunucu. İstemci ile sunucu aynı kural kodunu çalıştırıyor, bu yüzden biri diğerinden ayrışamıyor.

Ortak kural çekirdeği

Sunucu istemciye güvenmiyor, ama onu yeniden de yazmıyor. Deterministik kurallar tek bir C# çekirdeği ve bu çekirdek hem Unity istemcisine hem .NET sunucusuna git submodule olarak derleniyor. İstemci onunla tahmin yürütüyor. Sunucu aynı kodu çalıştırıp kendi sonucunu tutuyor.

Anti-tamper sonradan eklediğim bir özellik değil. Değiştirilmiş bir istemci, sunucunun asla ulaşmadığı bir sonuç üretiyor; çünkü sunucu da hesapladı.

GöstergeBenim sunucumda çalışıyorÜçüncü partiDoğrudan yol
  1. İstemciler

    • Unity oyun istemcisi

      Unity · C# · Addressables

    • Ekip paneli

      React · Vite · CASL

  2. Sözleşme

    • Oyun API'si

      ASP.NET Core

  3. Uygulama

    • Ortak kural çekirdeği

      C#

      Neden

      Git submodule olarak istemciye de sunucuya da derleniyor. Sunucu, istemcinin bildirdiğine güvenmek yerine her sonucu yeniden hesaplıyor.

    • Otoriter simülasyon

      .NET

  4. Servisler

    • Kimlik ve token

      .NET

      Neden

      Anonim cihaz hesabına, bu backend'in kendi imzalayıp doğruladığı bir token veriliyor. Kimlik sağlayıcısı yok, yani her oyuncunun oynamadan önce ihtiyaç duyduğu şey için kullanıcı başına fatura da yok.

    • Satın alma doğrulama

      .NET

      Neden

      Apple ve Google fiş doğrulamasını kendim yürütüyorum. İşverenin platformunda aynı iş dışarıdan alınıyor, çünkü orada mağaza uç durumlarının bakımını maaşlı bir ekip satın alabiliyor.

    • Uzaktan config ve live ops

      .NET

    • Asset barındırma

      Cloudflare R2

      Neden

      Üçüncü parti, ve değiyor. Bir oyunun asset paketlerinin bant genişliği zaten başarıyla büyüyen bir fatura, R2 ise egress ücreti almıyor.

    • Reklam mediation

      LevelPlay

  5. Durum

    • İlişkisel veritabanı

      PostgreSQL · EF Core · Npgsql

    • Append-only değişim kaydı

Koşul değerlendirmeC#Ortak kural çekirdeği düğümünden
    public static class ConditionEval    {        /// <summary>Every clause must hold. ⭐ ANDed and capped at 3 by the schema — there is no OR, and        /// that is the design rather than a limitation: <c>unless</c> in the UI is        /// <see cref="Clause.Negate"/>, and a player never sees an operator, a parenthesis or a truth        /// table.</summary>        public static bool All(ReadOnlySpan<Clause> clauses, in HookContext ctx)        {            for (int i = 0; i < clauses.Length; i++)            {                bool held = Holds(clauses[i], in ctx);                if (clauses[i].Negate) held = !held;                if (!held) return false;            }            return true;        }        static bool Holds(in Clause c, in HookContext ctx)        {            switch (c.Atom)            {                // ── Self ────────────────────────────────────────────────────────────────────────                case AtomId.HpBelow:   return ctx.SelfHpFrac   <  c.Value;                case AtomId.HpAbove:   return ctx.SelfHpFrac   >  c.Value;                case AtomId.ManaBelow: return ctx.SelfManaFrac <  c.Value;                case AtomId.ManaAbove: return ctx.SelfManaFrac >  c.Value;                case AtomId.EsEmpty:      return ctx.SelfEsEmpty;                case AtomId.IsControlled: return ctx.SelfControlled;                // ── Target ──────────────────────────────────────────────────────────────────────                case AtomId.TargetHpBelow:                    return ctx.HasTarget && ctx.TargetHpFrac < c.Value;                case AtomId.TargetIsControlled:                    return ctx.HasTarget && ctx.TargetControlled;                case AtomId.TargetInBackRank:                    return ctx.HasTarget && ctx.TargetInBackRank;                case AtomId.TargetHasAilment:                    return ctx.HasTarget && StacksOf(in ctx, c.Value) > 0;                case AtomId.TargetAilmentStacksAtLeast:                    // ⚠ TWO parameters in one clause would need a second Value field on every clause in                    // the game. Instead the TRACK rides the hook's own magnitude and this atom carries                    // the COUNT — the bake pairs them, and a stacks test with no track is refused at                    // authoring time rather than answered arbitrarily here.                    return ctx.HasTarget && TotalStacks(in ctx) >= ToCount(c.Value);                // ── Field ───────────────────────────────────────────────────────────────────────                case AtomId.EnemiesAtLeast: return ctx.LivingEnemies >= ToCount(c.Value);                case AtomId.EnemiesAtMost:  return ctx.LivingEnemies <= ToCount(c.Value);                // ── Rhythm ──────────────────────────────────────────────────────────────────────                case AtomId.BothHandsOnCooldown: return ctx.BothHandsOnCooldown;                case AtomId.SkillOnCooldown:                {                    int slot = ToCount(c.Value);                    if (slot < 0 || slot >= ctx.SlotCooldowns.Length) return false;                    return ctx.SlotCooldowns[slot] > Fixed64.Zero;                }                // ── Stage ───────────────────────────────────────────────────────────────────────                case AtomId.IsLastEncounterOfStage: return ctx.IsLastEncounterOfStage;                // ── Equipment (ADR-0174 d3) ──────────────────────────────────────────────────                case AtomId.DualWielding: return ctx.DualWielding;                case AtomId.OffhandIs:    return ctx.OffhandKind == c.Arg;                // ── Charges ───────────────────────────────────────────────────────────────                case AtomId.ChargeAtLeast:                {                    // ⚠ Out of range answers FALSE rather than throwing, on the same rule as an absent                    // target: a sim built without a charge store is a smaller world, not a content fault.                    if (c.Arg >= ctx.ChargeStacks.Length) return false;                    return ctx.ChargeStacks[c.Arg] >= ToCount(c.Value);                }                default:                    // ⛔ Unreachable by construction: AtomId is a kernel enum and the shared validator                    // refuses an unregistered atom at authoring time. If it IS reached, the two rosters                    // have drifted, and answering FALSE would hide that behind a rule that never fires.                    throw new ArgumentOutOfRangeException(                        nameof(c), $"condition atom {c.Atom} has no evaluation in the kernel. The atom "                                 + "roster and the sim have drifted apart.");            }        }        static int TotalStacks(in HookContext ctx)        {            int n = 0;            for (int i = 0; i < ctx.TargetAilmentStacks.Length; i++) n += ctx.TargetAilmentStacks[i];            return n;        }        static int StacksOf(in HookContext ctx, Fixed64 trackOrdinal)        {            int t = ToCount(trackOrdinal);            if (t < 0 || t >= ctx.TargetAilmentStacks.Length) return 0;            return ctx.TargetAilmentStacks[t];        }        /// <summary>A count or ordinal carried in a Fixed64. ⚠ <see cref="Fixed64.ToInt"/> truncates        /// toward NEGATIVE INFINITY, not toward zero — which is why every caller here range-checks the        /// result instead of trusting it. An authored 3 round-trips exactly, and a fractional enemy count        /// is a content error rather than something to round into a different answer.</summary>        static int ToCount(Fixed64 v) => v.ToInt();    }
Bir yeteneğin tetiklenip tetiklenmeyeceğine karar veren kural motoru. Saf, tahsis yapmıyor ve eksiksiz: her cümle kendi değillemesiyle birlikte AND'leniyor, her atom cevap veriyor ve bağlamı olmayan bir atom hata fırlatmak yerine false dönüyor. Kimse hayatta değilken hedefi okuyan bir kural, dövüşün sıradan bir anı; içerik hatası değil. Bağlam, simülasyonun kendi tamponları üzerinde span tutan, yalnızca yığında yaşayan bir struct olarak geliyor; saklanamiyor, kutulanamıyor, yakalanamıyor. Tek hata fırlatan durum, kayıtlı olmayan bir atom: sebebini de söylüyor, çünkü yazarlık listesi ile çekirdek ayrışmış demektir ve bu, sessizce hiç tetiklenmeyen bir kuraldan iyidir.

Toorn-sharedb392c0edotnet/Game.Kernel/Combat/ConditionEval.csSatır 78 – 181104 satır

    public static class ConditionEval
    {
        /// <summary>Every clause must hold. ⭐ ANDed and capped at 3 by the schema — there is no OR, and
        /// that is the design rather than a limitation: <c>unless</c> in the UI is
        /// <see cref="Clause.Negate"/>, and a player never sees an operator, a parenthesis or a truth
        /// table.</summary>
        public static bool All(ReadOnlySpan<Clause> clauses, in HookContext ctx)
        {
            for (int i = 0; i < clauses.Length; i++)
            {
                bool held = Holds(clauses[i], in ctx);
                if (clauses[i].Negate) held = !held;
                if (!held) return false;
            }
            return true;
        }

        static bool Holds(in Clause c, in HookContext ctx)
        {
            switch (c.Atom)
            {
                // ── Self ────────────────────────────────────────────────────────────────────────
                case AtomId.HpBelow:   return ctx.SelfHpFrac   <  c.Value;
                case AtomId.HpAbove:   return ctx.SelfHpFrac   >  c.Value;
                case AtomId.ManaBelow: return ctx.SelfManaFrac <  c.Value;
                case AtomId.ManaAbove: return ctx.SelfManaFrac >  c.Value;
                case AtomId.EsEmpty:      return ctx.SelfEsEmpty;
                case AtomId.IsControlled: return ctx.SelfControlled;

                // ── Target ──────────────────────────────────────────────────────────────────────
                case AtomId.TargetHpBelow:
                    return ctx.HasTarget && ctx.TargetHpFrac < c.Value;
                case AtomId.TargetIsControlled:
                    return ctx.HasTarget && ctx.TargetControlled;
                case AtomId.TargetInBackRank:
                    return ctx.HasTarget && ctx.TargetInBackRank;
                case AtomId.TargetHasAilment:
                    return ctx.HasTarget && StacksOf(in ctx, c.Value) > 0;
                case AtomId.TargetAilmentStacksAtLeast:
                    // ⚠ TWO parameters in one clause would need a second Value field on every clause in
                    // the game. Instead the TRACK rides the hook's own magnitude and this atom carries
                    // the COUNT — the bake pairs them, and a stacks test with no track is refused at
                    // authoring time rather than answered arbitrarily here.
                    return ctx.HasTarget && TotalStacks(in ctx) >= ToCount(c.Value);

                // ── Field ───────────────────────────────────────────────────────────────────────
                case AtomId.EnemiesAtLeast: return ctx.LivingEnemies >= ToCount(c.Value);
                case AtomId.EnemiesAtMost:  return ctx.LivingEnemies <= ToCount(c.Value);

                // ── Rhythm ──────────────────────────────────────────────────────────────────────
                case AtomId.BothHandsOnCooldown: return ctx.BothHandsOnCooldown;
                case AtomId.SkillOnCooldown:
                {
                    int slot = ToCount(c.Value);
                    if (slot < 0 || slot >= ctx.SlotCooldowns.Length) return false;
                    return ctx.SlotCooldowns[slot] > Fixed64.Zero;
                }

                // ── Stage ───────────────────────────────────────────────────────────────────────
                case AtomId.IsLastEncounterOfStage: return ctx.IsLastEncounterOfStage;

                // ── Equipment (ADR-0174 d3) ──────────────────────────────────────────────────
                case AtomId.DualWielding: return ctx.DualWielding;
                case AtomId.OffhandIs:    return ctx.OffhandKind == c.Arg;

                // ── Charges ───────────────────────────────────────────────────────────────
                case AtomId.ChargeAtLeast:
                {
                    // ⚠ Out of range answers FALSE rather than throwing, on the same rule as an absent
                    // target: a sim built without a charge store is a smaller world, not a content fault.
                    if (c.Arg >= ctx.ChargeStacks.Length) return false;
                    return ctx.ChargeStacks[c.Arg] >= ToCount(c.Value);
                }

                default:
                    // ⛔ Unreachable by construction: AtomId is a kernel enum and the shared validator
                    // refuses an unregistered atom at authoring time. If it IS reached, the two rosters
                    // have drifted, and answering FALSE would hide that behind a rule that never fires.
                    throw new ArgumentOutOfRangeException(
                        nameof(c), $"condition atom {c.Atom} has no evaluation in the kernel. The atom "
                                 + "roster and the sim have drifted apart.");
            }
        }

        static int TotalStacks(in HookContext ctx)
        {
            int n = 0;
            for (int i = 0; i < ctx.TargetAilmentStacks.Length; i++) n += ctx.TargetAilmentStacks[i];
            return n;
        }

        static int StacksOf(in HookContext ctx, Fixed64 trackOrdinal)
        {
            int t = ToCount(trackOrdinal);
            if (t < 0 || t >= ctx.TargetAilmentStacks.Length) return 0;
            return ctx.TargetAilmentStacks[t];
        }

        /// <summary>A count or ordinal carried in a Fixed64. ⚠ <see cref="Fixed64.ToInt"/> truncates
        /// toward NEGATIVE INFINITY, not toward zero — which is why every caller here range-checks the
        /// result instead of trusting it. An authored 3 round-trips exactly, and a fractional enemy count
        /// is a content error rather than something to round into a different answer.</summary>
        static int ToCount(Fixed64 v) => v.ToInt();
    }
  • Bir çağrı değil. İstemci, sunucunun çalıştırdığı çekirdeğin aynısını git submodule olarak derliyor. Tahmin ile otorite bu yüzden ayrışamıyor.

Yedi depo, tek oyun

DestanGames altında geliştiriliyor. Loot ve ekipman döngüsü olan bir mobil oyun; geliştirmesi sürüyor. 3.934 commit, başka yazar yok.

  • İstemci. Unity, Addressables, yeni Input System.
  • Backend. ASP.NET Core Minimal API, EF Core, PostgreSQL.
  • Ortak çekirdek. Yukarıdaki kurallar, iki tarafta da submodule olarak duruyor.
  • Panel, araçlar, doküman, ops. Ekip paneli, denge hatları, tasarım dokümanı ve 162 karar kaydı.

162

Karar kaydı

Her parçanın neden öyle davrandığını anlatıyor.

7

Depo

İstemci, backend, ortak çekirdek, panel, araçlar, doküman, ops.

92

Spesifikasyon

Anlattıkları koddan önce yazıldı.

Katkı

Yazılan commit
2.435
Kaydedilen karar
167

14 haftanın 14 tanesi aktif · 8 Haz 2026 – 13 Eyl 2026

En uzun seri · 14 hafta · Haz – Eyl

Altı depo birlikte ilerliyor; kural çekirdeğindeki bir değişiklik istemciye ve sunucuya aynı hafta iniyor.

Ölçüm 2026-09-08

Yapmak mı, satın almak mı

Buradaki her üçüncü parti servis varsayılan değil bir karardı, çünkü aylık aktif kullanıcı başına faturalanan her şey başarıyla birlikte büyüyor:

  • Kimlik kendi kendine üretiliyor. Anonim cihaz hesabına, bu backend’in imzalayıp doğruladığı bir token veriliyor.
  • Satın alma doğrulamasını iki mağaza için de kendi sunucumda yapıyorum.
  • Uzaktan config sunucusunu da ben yazdım.
  • Analitik ve asset barındırma üçüncü parti, çünkü ikisi de oyuncu sayısıyla büyüyen bir faturaya dönüşmüyor.

Tam indirme olmadan senkron

İlerleme, tipe göre ayrılmış tablolarda ve yanında append-only bir değişim kaydı duruyor. Kayıt satırı, anlattığı satırla aynı transaction’da yazılıyor. İstemci en son gördüğünü gönderiyor, farkı alıyor.

Aynı kaydı düzenleyen iki cihaz EF Core versiyon kolonunda çarpışıyor. Çakışma sessiz değil, gürültülü oluyor.

Panel bir şablon

Ekip paneli ESLint’te tek bir kural işletiyor: packages/* asla apps/* içinden import edemez. Yeniden kullanılabilir çekirdek bu oyunu sessizce öğrenemiyor. Bir sonraki oyunda hâlâ kullanılabilir olmasını sağlayan tek şey bu kural.

İleriye taşınacak bir çekirdeği olmayan tek deploy’luk bir uygulamaya bu sınır gerekmiyor; ticaret platformu da bu yüzden on dört alanı tek uygulamanın içinde topluyor.