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.

KeyRuns on my serversThird-partyDirect path
  1. Clients

    • Unity game client

      Unity · C# · Addressables

    • Staff panel

      React · Vite · CASL

  2. Contract

    • Game API

      ASP.NET Core

  3. 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

  4. 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

  5. State

    • Relational store

      PostgreSQL · EF Core · Npgsql

    • Append-only change log

Condition evaluationC#From Shared rules kernel
    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();    }
The rule engine that decides whether a skill may fire. Pure, allocation-free and total: every clause is ANDed with its own negation, every atom answers, and an atom whose context is absent answers false rather than throwing, because a rule reading the target when nothing is alive is an ordinary moment in a fight and not a content fault. The context arrives as a stack-only struct holding spans over the simulation's own buffers, so it cannot be stored, boxed or captured. An unregistered atom is the one case that throws, and it says why: the authoring roster and the kernel have drifted apart, which is worth a crash rather than a rule that quietly never fires.

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.