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ı.
Sistem
İstemciler
Unity oyun istemcisi
Unity · C# · Addressables
Ekip paneli
React · Vite · CASL
Sözleşme
Oyun API'si
ASP.NET Core
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
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
Durum
İlişkisel veritabanı
PostgreSQL · EF Core · Npgsql
Append-only değişim kaydı
78 public static class ConditionEval79 {80 /// <summary>Every clause must hold. ⭐ ANDed and capped at 3 by the schema — there is no OR, and81 /// that is the design rather than a limitation: <c>unless</c> in the UI is82 /// <see cref="Clause.Negate"/>, and a player never sees an operator, a parenthesis or a truth83 /// table.</summary>84 public static bool All(ReadOnlySpan<Clause> clauses, in HookContext ctx)85 {86 for (int i = 0; i < clauses.Length; i++)87 {88 bool held = Holds(clauses[i], in ctx);89 if (clauses[i].Negate) held = !held;90 if (!held) return false;91 }92 return true;93 }9495 static bool Holds(in Clause c, in HookContext ctx)96 {97 switch (c.Atom)98 {99 // ── Self ────────────────────────────────────────────────────────────────────────100 case AtomId.HpBelow: return ctx.SelfHpFrac < c.Value;101 case AtomId.HpAbove: return ctx.SelfHpFrac > c.Value;102 case AtomId.ManaBelow: return ctx.SelfManaFrac < c.Value;103 case AtomId.ManaAbove: return ctx.SelfManaFrac > c.Value;104 case AtomId.EsEmpty: return ctx.SelfEsEmpty;105 case AtomId.IsControlled: return ctx.SelfControlled;106107 // ── Target ──────────────────────────────────────────────────────────────────────108 case AtomId.TargetHpBelow:109 return ctx.HasTarget && ctx.TargetHpFrac < c.Value;110 case AtomId.TargetIsControlled:111 return ctx.HasTarget && ctx.TargetControlled;112 case AtomId.TargetInBackRank:113 return ctx.HasTarget && ctx.TargetInBackRank;114 case AtomId.TargetHasAilment:115 return ctx.HasTarget && StacksOf(in ctx, c.Value) > 0;116 case AtomId.TargetAilmentStacksAtLeast:117 // ⚠ TWO parameters in one clause would need a second Value field on every clause in118 // the game. Instead the TRACK rides the hook's own magnitude and this atom carries119 // the COUNT — the bake pairs them, and a stacks test with no track is refused at120 // authoring time rather than answered arbitrarily here.121 return ctx.HasTarget && TotalStacks(in ctx) >= ToCount(c.Value);122123 // ── Field ───────────────────────────────────────────────────────────────────────124 case AtomId.EnemiesAtLeast: return ctx.LivingEnemies >= ToCount(c.Value);125 case AtomId.EnemiesAtMost: return ctx.LivingEnemies <= ToCount(c.Value);126127 // ── Rhythm ──────────────────────────────────────────────────────────────────────128 case AtomId.BothHandsOnCooldown: return ctx.BothHandsOnCooldown;129 case AtomId.SkillOnCooldown:130 {131 int slot = ToCount(c.Value);132 if (slot < 0 || slot >= ctx.SlotCooldowns.Length) return false;133 return ctx.SlotCooldowns[slot] > Fixed64.Zero;134 }135136 // ── Stage ───────────────────────────────────────────────────────────────────────137 case AtomId.IsLastEncounterOfStage: return ctx.IsLastEncounterOfStage;138139 // ── Equipment (ADR-0174 d3) ──────────────────────────────────────────────────140 case AtomId.DualWielding: return ctx.DualWielding;141 case AtomId.OffhandIs: return ctx.OffhandKind == c.Arg;142143 // ── Charges ───────────────────────────────────────────────────────────────144 case AtomId.ChargeAtLeast:145 {146 // ⚠ Out of range answers FALSE rather than throwing, on the same rule as an absent147 // target: a sim built without a charge store is a smaller world, not a content fault.148 if (c.Arg >= ctx.ChargeStacks.Length) return false;149 return ctx.ChargeStacks[c.Arg] >= ToCount(c.Value);150 }151152 default:153 // ⛔ Unreachable by construction: AtomId is a kernel enum and the shared validator154 // refuses an unregistered atom at authoring time. If it IS reached, the two rosters155 // have drifted, and answering FALSE would hide that behind a rule that never fires.156 throw new ArgumentOutOfRangeException(157 nameof(c), $"condition atom {c.Atom} has no evaluation in the kernel. The atom "158 + "roster and the sim have drifted apart.");159 }160 }161162 static int TotalStacks(in HookContext ctx)163 {164 int n = 0;165 for (int i = 0; i < ctx.TargetAilmentStacks.Length; i++) n += ctx.TargetAilmentStacks[i];166 return n;167 }168169 static int StacksOf(in HookContext ctx, Fixed64 trackOrdinal)170 {171 int t = ToCount(trackOrdinal);172 if (t < 0 || t >= ctx.TargetAilmentStacks.Length) return 0;173 return ctx.TargetAilmentStacks[t];174 }175176 /// <summary>A count or ordinal carried in a Fixed64. ⚠ <see cref="Fixed64.ToInt"/> truncates177 /// toward NEGATIVE INFINITY, not toward zero — which is why every caller here range-checks the178 /// result instead of trusting it. An authored 3 round-trips exactly, and a fractional enemy count179 /// is a content error rather than something to round into a different answer.</summary>180 static int ToCount(Fixed64 v) => v.ToInt();181 }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.