Games
Roads of Toorn
A mobile game and the server behind it, where the client and the server run the same rules code so neither can drift from the other.
The shared rules kernel
The server does not trust the client, and it does not reimplement it either. The deterministic rules are one C# kernel, compiled into both the Unity client and the .NET server as a git submodule. The client predicts with it. The server runs the same code and keeps its own answer.
Anti-tamper is not a feature I added. A modified client produces a result the server never arrives at, because the server computed it too.
System
Clients
Unity game client
Unity · C# · Addressables
Staff panel
React · Vite · CASL
Contract
Game API
ASP.NET Core
Application
Shared rules kernel
C#
Why
A git submodule, compiled into the client and the server. The server re-derives every outcome rather than trusting what the client reports.
Authoritative simulation
.NET
Services
Identity and tokens
.NET
Why
An anonymous device account gets a token this backend signs and validates itself. No identity vendor, so no per-user bill for the thing every player needs before they can play at all.
Purchase validation
.NET
Why
Apple and Google receipt validation, self-hosted. On the employer's platform the same job is outsourced, because there a salaried team can buy back the store edge cases.
Remote config and live ops
.NET
Asset hosting
Cloudflare R2
Why
Third-party, and worth it. Bandwidth for a game's asset bundles is a bill that grows with success either way, and R2 does not charge egress.
Ad mediation
LevelPlay
State
Relational store
PostgreSQL · EF Core · Npgsql
Append-only change log
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.csLines 78 to 181104 lines
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();
}- Not a call. The client compiles the same kernel the server runs, as a git submodule, so prediction and authority cannot drift apart.
Seven repositories, one game
Built under DestanGames. A mobile game with a loot and equipment loop, in development. 3,934 commits, no other author.
- Client. Unity, Addressables, the new Input System.
- Backend. ASP.NET Core Minimal APIs, EF Core, PostgreSQL.
- Shared kernel. The rules above, a submodule on both sides.
- Panel, tools, docs, ops. A staff panel, balancing pipelines, the design document, and 162 decision records.
162
Decision records
Why each part behaves as it does.
7
Repositories
Client, backend, shared kernel, panel, tools, docs, ops.
92
Specifications
Written before the code they describe.
Contribution
- Commits authored
- 2,435
- Decisions recorded
- 167
14 of 14 weeks active · 8 Jun 2026 – 13 Sep 2026
Longest run · 14 weeks · Jun – Sep
Six repositories moving together, so a change to the rules kernel lands on the client and the server in the same week.
Measured 2026-09-08
Build or buy
Every third-party service here was a decision rather than a default, because anything billed per monthly active user scales with success:
- Identity is self-issued. An anonymous device account gets a token this backend signs and validates itself.
- Purchase validation is self-hosted, for both stores.
- Remote config is mine.
- Analytics and asset hosting are third-party, because neither turns into a bill that grows with the player count.
Saves that sync without a full download
Progress sits in per-type tables beside an append-only change log, and the log row is written in the same transaction as the row it describes. A client sends what it last saw and gets back the difference.
Two devices editing one save collide on an EF Core version column, so the conflict is loud rather than silent.
The panel is a template
The staff panel enforces one rule in ESLint: packages/* may never
import from apps/*. The reusable core cannot quietly learn about this
game, which is the only reason it will still be reusable for the next one.
A single deployable with no core to carry forward does not need that boundary, which is why the commerce platform groups fourteen domains inside one application instead.