Skip to content

Game Controllers & Policies: A New Architecture for Game Behavior #2781

Description

@keithharvey

EDIT -- MOVED TO BAR

Moved to beyond-all-reason/Beyond-All-Reason#8018 — the architecture is BAR-owned now that the engine gameEconomy approach is dropped in favor of the game-side gadget:ResourceExcess path. Continuing there.

Game Controllers & Policies: A New Architecture for Game Behavior

Introduction

Right now, the gadget system provides hooks for unit and resource transfers. Games can take full control of these subsystems—nothing in the engine prevents it. But there's no shared infrastructure for doing so cleanly. Each game that wants declarative, testable transfer logic has to reinvent the wheel.

I started my discovery process trying to remove the capture parameter from the AllowUnitTransfer hook, and to move /take out of the engine. Pretty quickly I asked the question:

Why does the engine care about these transfers at all? Why does the synced layer, which owns the configuration and domain logic, not just tell the engine what to do so we can modularize this subsystem?

The answer for why we prefer per-team gadget hooks is mostly because it offers granular control to gadgets, and gadgets are a production-tested framework for devs to control game behavior.

But what if, when a behavioral subsystem called for it and that part of the sim DID belong entirely to the game, we had a different pattern? One where the game is assumed to control the state of that subsystem entirely?

A Note on Scope: Now vs. Next

This document presents two iterations of this architecture:

  1. Now (sharing_tab branch): A working implementation using Controller gadgets and PolicyResult caching. This is what I'm proposing we merge.
  2. Next (prototyped in a separate worktree): A more ambitious DSL-based policy engine that the "Now" architecture directly enables.

I'll walk through the "Now" implementation in detail, then show a glimpse of "Next" to demonstrate where this pattern leads.

UX Goals

  • Cardinal mod options: Each mod option does exactly 1 functional game behavior. No "Nuclear Options" or "Easy Sharing Tax" that incorporate multiple surprising behaviors. Instead: "Unit Sharing Mode", "Tax Rate", "Ally Assist Mode", etc.
  • Sharing modes exist PR: Can disable, hide, lock, and show specific mod options with declarative configuration. This preserves the ability for devs to name their modes while still preserving the cardinality of mod options.

Architecture Goals

  • Synced domain layer: Declarative, idempotent, centralized behavior logic.
  • Performance: Optimized Lua algorithms for redistribution (Waterfill), with optional C++ acceleration.
  • Testability: Logic that can be tested without running the game.

A Word on the Implementation

This branch is a stepping stone. I originally built a more complete framework, realized it was too big a leap, and backported the core patterns without the scaffolding. You'll see functions treated as atomic units with full EmmyLua decorators—this is intentional. When you're not sure where code will live long-term, portability matters.

The goal here is singular: provide infrastructure that makes game-owned subsystems easy. The game can already own subsystems—this PR provides the patterns, caching, and tested code to do it well. The "used once" value objects and explicit typing may look over-engineered for what's merged today, but they're load-bearing for what comes next.


1. The Existing System: "The Spaghetti"

The existing gadget system is powerful and flexible. Games can bypass the default hooks entirely. But if you use the hooks as intended, you end up with implicit coordination between gadgets that's hard to reason about and impossible to test in isolation.

flowchart TB
    subgraph Existing["Existing: Engine-Controlled"]
        E1[Engine]
        GH[GadgetHandler]
        G1[Gadget A]
        G2[Gadget B]
        UI[Unsynced UI]
        BL_S[Business Logic]
        BL_U[Business Logic]
        
        E1 -->|calls| GH
        GH <-->|hook + allow/deny| G1
        GH <-->|hook + allow/deny| G2
        G1 <-.->|coordinate via<br/>globals/state| G2
        G1 --> BL_S
        G2 --> BL_S
        
        UI <-->|ShareResources +<br/>query state| E1
        BL_S -.->|duplicated| BL_U
        BL_U --> UI
    end
Loading

The "Loop of Death"

In the current system, a simple resource transfer request travels through a perilous journey of hooks, return values, and side effects.

sequenceDiagram
    participant UI as Unsynced UI
    participant Eng as Engine
    participant GH as GadgetHandler
    participant G1 as Gadget A
    participant G2 as Gadget B

    UI->>Eng: ShareResources(Team A -> B)
    Eng->>GH: AllowResourceTransfer?
    GH->>G1: AllowResourceTransfer?
    G1->>G2: (Implicit Coupling via Globals)
    G1-->>GH: true
    GH->>G2: AllowResourceTransfer?
    G2-->>GH: true
    GH-->>Eng: true
    Eng->>Eng: Execute Transfer
Loading

Problems

  1. Loop-back pattern: AllowResourceShare returns to caller; order of gadget execution matters.
  2. Gadget coupling: Gadgets must know about each other to coordinate policies.
  3. Duplicated logic: UI and gadgets both implement "can share?" checks.
  4. Untestable: You cannot test Gadget A without running Spring, Gadget B, and UI.

2. The Solution: Controllers & Policies

To solve this, we invert the control. We introduce a Service Layer (Controllers) and a Strategy Pattern (Policies).

The Service Layer (Controllers)

Instead of the engine asking "Can I do this?", the game tells the engine "Do this." The game owns the logic; the engine executes the mutations.

The goal is to route all economy operations through the controller: gadgets call GG.Function instead of engine APIs directly. GG.Function → Controller → Engine. This gives us a centralized place for cache updates, policy evaluation, and eventually an economy state machine if we add native modules.

The game registers a synced controller on the gadget:ResourceExcess callin, gated by Game.nativeExcessSharing = false. The engine hands it each frame's per-team overflow; the controller owns redistribution from there.

The Strategy Pattern (Policies)

Because the game controls execution, it can expose that execution to "policies" as swappable components.

flowchart LR
    subgraph Inputs
        ModOpts((Mod Options))
        Context[Team Context<br/>resources, storage,<br/>allied status]
    end
    
    subgraph Policy["Policy Function (Strategy)"]
        Calc[Calculate<br/>PolicyResult]
    end
    
    subgraph Output["PolicyResult (ViewModel)"]
        Result[canShare<br/>amountSendable<br/>amountReceivable<br/>taxedPortion<br/>untaxedPortion]
    end
    
    subgraph Consumers
        Cache[(TeamRulesParam<br/>Cache)]
        Gadgets[Other Gadgets]
        UI[Unsynced UI]
    end
    
    ModOpts --> Calc
    Context --> Calc
    Calc --> Result
    Result --> Cache
    Cache --> Gadgets
    Cache --> UI
Loading

A Policy is a pure function. Given mod options and team context, it produces a PolicyResult. This result is cached in TeamRulesParams and acts as both a ViewModel (cached state for consumers) and a Mediator (the object through which policies, actions, and UI interact without knowing about each other).

Dynamic Behavior

Because policies are just functions, they can incorporate runtime state—not just static mod options. Want sharing to unlock when both players build a storage building? That's a policy that checks game state. Want tax rates to scale with game time? That's a policy too.

This is enabled by the Context Factory, which gathers runtime state before policy execution.

context_factory.lua

---@type PolicyContext
local ctx = {
  senderTeamId = senderTeamID,
  receiverTeamId = receiverTeamID,
  sender = senderResources,
  receiver = receiverResources,
  springRepo = springRepo,
  areAlliedTeams = springRepo.AreTeamsAllied(senderTeamID, receiverTeamID),
  -- ... can be extended with any game state
}

The factory provides a shared place to batch expensive state checks (e.g., reading resources, checking alliances) once per frame.

However, policies aren't limited to the context object. They are passed the springRepo and can query specific game state directly when needed (e.g., "does this team have a Pinpointer?"). This hybrid approach allows optimization for hot-path data while keeping policy-specific logic encapsulated within the policy itself.

The architecture enables this today. We don't have concrete examples in the sharing_tab branch yet, but Section 6 shows what this looks like: policies like building_unlocks_sharing.lua that gate behavior on predicates like hasBothStorages().


3. Deep Dive: A Policy In Action

Let's look at the actual tax policy from the sharing_tab branch.

resource_transfer_synced.lua — The core policy logic.

---@param ctx PolicyContext
---@param resourceType ResourceType
---@return ResourcePolicyResult
local function calcResourcePolicyResult(ctx, resourceType)
  local receiverCapacity = receiverData.storage - receiverData.current
  local cumulativeSent = Shared.GetCumulativeSent(ctx.senderTeamId, resourceType, ctx.springRepo)
  local threshold = getThreshold(resourceType)
  local allowanceRemaining = math.max(0, threshold - cumulativeSent)
  local senderBudget = math.max(0, senderData.current)

  local untaxedPortion = math.min(allowanceRemaining, senderBudget)
  local effectiveRate = (taxRate < 1) and taxRate or 1
  local taxedSendable = math.max(0, (senderBudget - untaxedPortion) * (1 - effectiveRate))

  return {
    canShare = receiverCapacity > 0 and amountSendable > 0,
    amountSendable = amountSendable,
    taxedPortion = taxedPortion,
    untaxedPortion = untaxedPortion,
    taxRate = effectiveRate,
    -- ... other fields
  }
end

This is a pure function. It takes context, returns a result. No side effects—policies may read engine state but never mutate it. No global state changes. This is the core insight: the policy doesn't do the transfer, it describes the boundaries of what a transfer can be.

The Waterfill Solver

One major concern with moving logic to Lua is performance. Resource sharing involves solving a "Waterfill" problem (distributing excess resources fairly) across all allied teams every SlowUpdate.

economy_waterfill_solver.lua — The algorithm.

The solver is implemented in optimized Lua 5.1. While we are bound by the runtime, we have structured the data flow to be efficient using object pooling and cache-friendly iteration. Tracy results are promising—SlowUpdate is a little faster in the new branch.

sequenceDiagram
    participant Engine
    participant LuaCtrl as GameResourceController
    participant Solver as Waterfill Solver
    participant Teams as Team Handler

    Engine->>LuaCtrl: ProcessEconomy(frame)
    LuaCtrl->>Solver: Solve(teamData)
    Note over Solver: Lua (or C++ if available)
    Solver-->>LuaCtrl: Results (Transfers)
    LuaCtrl->>Teams: Apply Transfers
Loading

Because it's a pure function, we can test it in isolation. Here's an actual test from game_resource_transfer_controller_spec.lua:

it("balances metal between teams without tax", function()
  local teamA = Builders.Team:new()
    :WithMetal(800)
    :WithMetalStorage(1000)
    :WithMetalShareSlider(50)
  local teamB = Builders.Team:new()
    :WithMetal(200)
    :WithMetalStorage(1000)
    :WithMetalShareSlider(50)

  normalizeAllies({ teamA, teamB }, teamA.allyTeam)

  local spring = buildSpring({ taxRate = 0 }, { teamA, teamB })
  local teamsList = buildTeamsTable({ teamA, teamB })
  
  local results = WaterfillSolver.SolveToResults(spring, teamsList)
  
  -- ... extract teamAMetal, teamBMetal from results ...
  
  assert.is_near(500, teamAMetal.current, 0.1)
  assert.is_near(500, teamBMetal.current, 0.1)
  assert.is_near(300, teamAMetal.sent, 0.1)
  assert.is_near(300, teamBMetal.received, 0.1)
end)

The test uses builder patterns to construct mock Spring state, then asserts on the solver's pure output. No engine required.


4. The Controller: ProcessEconomy

The controller is the glue. It runs on the gadget:ResourceExcess cadence: solve redistribution, apply the result, refresh the policy cache.

game_resource_transfer_controller.lua

local function ProcessEconomy(frame)
  local teams = buildSnapshot()                       -- live engine state + accumulated overflow
  local results = WaterfillSolver.SolveToResults(springRepo, teams)

  for i = 1, #results do
    local r = results[i]
    springRepo.SetTeamResource(r.teamId, r.resourceType, teams[r.teamId][r.resourceType].current)
    springRepo.AddTeamResourceExcessStats(r.teamId, r.resourceType, r.excess)
  end
  ShareStats.Publish(springRepo, results)             -- sent/received are Lua-owned

  ResourceTransfer.UpdatePolicyCache(springRepo, frame, ...)
end

The game owns its stats, too. SetTeamResource moves the pools but bypasses the engine's resource ledger, so the controller reports the parts that matter back across the boundary: excess to the engine — the one quantity that legitimately leaves the ledger — via Spring.AddTeamResourceExcessStats, and sent/received to a Lua ShareStats module that publishes them as team rules params. Same principle, one layer up: the game decides what its stats mean.


5. Comparison Summary

flowchart TB
    subgraph Existing["Existing: Bidirectional Everywhere"]
        E1[Engine]
        S1[Synced]
        U1[Unsynced]
        
        E1 <-->|hooks| S1
        E1 <-->|commands +<br/>query state| U1
        S1 -.->|duplicated logic| U1
    end
    
    subgraph New["New: Synced as Single Authority"]
        E2[Engine]
        S2[Synced]
        C2[(Cache)]
        U2[Unsynced]
        
        S2 -->|commands| E2
        S2 -->|publish| C2
        C2 -->|read only| U2
        U2 -->|user intent| S2
    end
Loading

Benefits of the New Architecture

  1. Inversion of Control: Engine calls Controller, not gadgets intercepting engine.
  2. Single Source of Truth: Policy cache is read by all consumers.
  3. Decoupled Gadgets: Other gadgets query cache, they don't fight each other.
  4. Performance: Optimized Lua with optional C++ acceleration.
  5. Quality Assurance: Fully unit-testable Lua logic.

6. The Future: A Policy DSL

The sharing_tab branch establishes the foundation. But the architecture unlocks something more powerful: a declarative DSL (Domain-Specific Language) for defining game policies.

In a separate prototype, I've explored what this looks like. Here's the same tax policy, reimagined:

---@param builder DSL
local function buildPolicy(builder)
  local taxRate = builder.mod_options[ModOptions.TaxResourceSharingAmount] or 0

  builder:Allied():MetalTransfers():Use(function(ctx)
    return calcResourcePolicyResult(ctx, ResourceType.METAL)
  end)

  builder:Allied():EnergyTransfers():Use(function(ctx)
    return calcResourcePolicyResult(ctx, ResourceType.ENERGY)
  end)

  builder:RegisterPostMetalTransfer(function(transferResult, springRepo)
    -- Track cumulative sent for tax-free threshold
    local current = springRepo:GetTeamRulesParam(transferResult.senderTeamId, cumMetal) or 0
    springRepo:SetTeamRulesParam(transferResult.senderTeamId, cumMetal, current + transferResult.sent)
  end)
end

The DSL provides:

  • Fluent API: builder:Allied():MetalTransfers():Use(...)
  • Declarative intent: Read what the policy means, not how it's wired.
  • Composability: Policies are independent modules that don't know about each other. The policy engine (not Recoil—this is game-side Lua) composes them using the same infrastructure across all behavior modules (economy, combat, etc.).
  • Shared Infrastructure: Common functionality (caching, validation, logging) lives in one place, not copy-pasted across gadgets.
  • Low Barrier to Entry: Adding a new policy is trivial. Intellisense guides you, the DSL constrains you to valid patterns, and you only worry about your policy—not the plumbing. Senior devs build the rails; junior devs ship features.

Dynamic Behavior: Building Unlocks Sharing

This is where it gets exciting. Imagine a mod option where you can only share resources after building specific structures:

---@param builder DSL
local function policyFunction(builder)
    local function hasBothStorages(ctx)
        return hasBuiltCategories(ctx.senderTeamId, {
            BuildingCategories.METAL_STORAGE,
            BuildingCategories.ENERGY_STORAGE
        }, ctx.repositories.springRepo)
    end

    local function hasPinpointer(ctx)
        return hasBuiltCategories(ctx.senderTeamId, {
            BuildingCategories.PINPOINTER,
        }, ctx.repositories.springRepo)
    end

    builder:MetalTransfers():When(hasBothStorages):Allow()
    builder:EnergyTransfers():When(hasBothStorages):Allow()
    builder:UnitTransfers():When(hasPinpointer):Allow()
end

This is game design expressed as code. No recoil changes required. No hook ordering nightmares. Just declare what you want.

Potential File Layout

luarules/modules/
├── policy_engine.lua           # Shared rule pipeline (AST-like)
│
├── team_transfer/              # Resource & unit transfers between teams
│   ├── controller.lua
│   ├── dsl.lua                 # Module-specific fluent builder
│   ├── policies/
│   │   ├── tax_resource_sharing.lua
│   │   ├── building_unlocks_sharing.lua
│   │   ├── allied_assist.lua
│   │   └── unit_sharing_mode.lua
│   ├── actions/
│   │   ├── transfer_resources.lua
│   │   └── transfer_units.lua
│   └── default_results/
│
└── combat/                     # Hypothetical combat module
    ├── controller.lua
    ├── dsl.lua                 # Module-specific fluent builder
    ├── policies/
    │   ├── friendly_fire.lua
    │   ├── capture_rules.lua
    │   └── unit_veterancy.lua
    ├── actions/
    │   ├── apply_damage.lua
    │   ├── capture_unit.lua
    │   └── grant_veterancy.lua
    └── default_results/

The policy_engine and dsl are shared infrastructure. Each domain module (team_transfer, combat, etc.) brings its own controller, policies, and default results—but they all compose through the same pipeline.

How Policies Compose

Each policy file uses the DSL to declare rules. The DSL registers those rules with the policy engine. At runtime, the controller asks the policy engine to evaluate a context, and the engine runs through all registered rules to produce a result.

flowchart LR
    subgraph Policies["Policy Files"]
        P1[tax_resource_sharing.lua]
        P2[building_unlocks.lua]
        P3[allied_assist.lua]
    end
    
    subgraph DSL["DSL Layer"]
        Builder[builder:Allied<br/>:MetalTransfers<br/>:Use/Allow/Deny]
    end
    
    subgraph Engine["Policy Engine"]
        Rules[(Registered Rules)]
        Eval[Evaluate Context]
    end
    
    subgraph Runtime["Runtime"]
        Ctrl[Controller]
        Result[PolicyResult]
    end
    
    P1 --> Builder
    P2 --> Builder
    P3 --> Builder
    Builder -->|registers| Rules
    Ctrl -->|context| Eval
    Rules --> Eval
    Eval --> Result
Loading

The knowledge boundaries here are still being refined, but the intent is:

  • Policies only know about the DSL—they declare rules without knowing how they're composed.
  • DSL registers rules with the policy engine.
  • Controller is the orchestrator. It's thin but has visibility into everything: context factory, DSL/policy engine, actions, cache. It wires the pieces together and exposes the GG.* API entry points.
flowchart TB
    subgraph LoadTime["Load Time"]
        P[Policy Files] -->|declare via| DSL
        DSL -->|registers| PE[Policy Engine]
    end
    
    subgraph CacheFlow["Cache Update (periodic)"]
        SU[SlowUpdate/GameFrame] --> Ctrl1[Controller]
        Ctrl1 -->|builds| Ctx1[Context]
        Ctrl1 -->|evaluates| PE
        PE -->|returns| PR1[PolicyResult]
        Ctrl1 -->|writes| Cache[TeamRulesParams]
    end
    
    subgraph ActionFlow["Action (on-demand)"]
        API[GG.TransferResources] --> Ctrl2[Controller]
        Ctrl2 -->|reads| Cache
        Ctrl2 -->|if allowed| Act[Action]
        Act -->|mutates| Eng[Engine State]
    end
Loading

There are two runtime flows:

  • Cache update: Periodic evaluation writes PolicyResult to TeamRulesParams. All consumers read from here.
  • Action: On-demand requests (like GG.TransferResources) read the cached PolicyResult and execute if allowed. No re-evaluation—just read and act.

The controller orchestrates both flows. Policies don't know about each other. Actions don't know about policies. Each piece is independently testable.

A Note on Lua 5.1 Performance

The architect-minded among you may be thinking: "Isn't Lua 5.1 insanely slow? Don't design patterns thrash the GC because you can't build tables without pooling everything?"

Yes. Yes it is.

The current implementation works around this with aggressive object pooling and cache-friendly iteration. But the architecture is intentionally designed to be runtime-agnostic. If we later transpile these modules to native code (via LLVM or similar), or port to a language with proper value types, the policy/controller separation still holds. The abstractions pay for themselves twice: once in testability today, once in portability tomorrow.

Taken further: native modules could enable Recoil to split into core (minimal simulation substrate) and optional modules (game behavior). The same pattern we're using here—swappable policies composed by a shared engine—could apply at the engine level. Recoil core handles physics, rendering, networking. Game modules handle economy, combat rules, unit transfers. Each game chooses which modules to load. BAR ships its opinionated defaults; other games swap in their own.

High Level Orchestration

Because the module owns the policies, it can also orchestrate them—introducing dependencies between policies, composing outputs, or applying middleware-style transformations. That's a topic for a future doc.


7. Conclusion: Why This Matters

This isn't just about resource sharing. It's about establishing a pattern for how the game can own its own behavior.

The engine is a powerful simulation substrate. It doesn't need to know about "tax rates" or "unit stun durations" or "building unlock requirements." Those are game design decisions. They should live in the game.

What we're proposing:

  1. The Sharing Tab PR (BAR-only): Controller pattern, PolicyResult caching, testable Lua logic. Works today by hooking GameFrame.
  2. Engine changes (Recoil PR): the gadget:ResourceExcess callin plus Spring.AddTeamResourceExcessStats, which formalize the IoC pattern. Optional but cleaner—the game can still drive economy from GameFrame without them.
  3. Future PRs: DSL, policy engine, more subsystems migrated to game control.

Resource Transfers, Unit Transfers, Combat behavior—each of these could follow the same pattern. The game owns the logic; the engine provides the simulation substrate.

The game should be in charge of the game.


Reference: Key Files in sharing_tab

File Purpose
BAR PR - The Sharing Tab Game-side implementation (works standalone via GameFrame hooks)
Recoil PR - ResourceExcess + excess stats Engine changes: gadget:ResourceExcess callin + Spring.AddTeamResourceExcessStats (optional but cleaner long-term)
game_resource_transfer_controller.lua Main controller gadget
resource_transfer_synced.lua Policy calculation logic
economy_waterfill_solver.lua Redistribution algorithm
game_resource_transfer_controller_spec.lua Unit tests

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions