You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
Now (sharing_tab branch): A working implementation using Controller gadgets and PolicyResult caching. This is what I'm proposing we merge.
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 existPR: 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.
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.
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
Loop-back pattern: AllowResourceShare returns to caller; order of gadget execution matters.
Gadget coupling: Gadgets must know about each other to coordinate policies.
Duplicated logic: UI and gadgets both implement "can share?" checks.
Untestable: You cannot test Gadget A without running Spring, Gadget B, and UI.
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.
---@typePolicyContextlocalctx= {
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.
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.
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
localfunctionProcessEconomy(frame)
localteams=buildSnapshot() -- live engine state + accumulated overflowlocalresults=WaterfillSolver.SolveToResults(springRepo, teams)
fori=1, #resultsdolocalr=results[i]
springRepo.SetTeamResource(r.teamId, r.resourceType, teams[r.teamId][r.resourceType].current)
springRepo.AddTeamResourceExcessStats(r.teamId, r.resourceType, r.excess)
endShareStats.Publish(springRepo, results) -- sent/received are Lua-ownedResourceTransfer.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
Decoupled Gadgets: Other gadgets query cache, they don't fight each other.
Performance: Optimized Lua with optional C++ acceleration.
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:
---@parambuilderDSLlocalfunctionbuildPolicy(builder)
localtaxRate=builder.mod_options[ModOptions.TaxResourceSharingAmount] or0builder:Allied():MetalTransfers():Use(function(ctx)
returncalcResourcePolicyResult(ctx, ResourceType.METAL)
end)
builder:Allied():EnergyTransfers():Use(function(ctx)
returncalcResourcePolicyResult(ctx, ResourceType.ENERGY)
end)
builder:RegisterPostMetalTransfer(function(transferResult, springRepo)
-- Track cumulative sent for tax-free thresholdlocalcurrent=springRepo:GetTeamRulesParam(transferResult.senderTeamId, cumMetal) or0springRepo:SetTeamRulesParam(transferResult.senderTeamId, cumMetal, current+transferResult.sent)
end)
end
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:
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.
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:
The Sharing Tab PR (BAR-only): Controller pattern, PolicyResult caching, testable Lua logic. Works today by hooking GameFrame.
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.
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.
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
captureparameter from theAllowUnitTransferhook, and to move/takeout of the engine. Pretty quickly I asked the question: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:
sharing_tabbranch): A working implementation using Controller gadgets and PolicyResult caching. This is what I'm proposing we merge.I'll walk through the "Now" implementation in detail, then show a glimpse of "Next" to demonstrate where this pattern leads.
UX Goals
Architecture Goals
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 endThe "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 TransferProblems
AllowResourceSharereturns to caller; order of gadget execution matters.Gadget Awithout runningSpring,Gadget B, andUI.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.Functioninstead 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:ResourceExcesscallin, gated byGame.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 --> UIA Policy is a pure function. Given mod options and team context, it produces a
PolicyResult. This result is cached inTeamRulesParamsand 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
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
springRepoand 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_tabbranch yet, but Section 6 shows what this looks like: policies likebuilding_unlocks_sharing.luathat gate behavior on predicates likehasBothStorages().3. Deep Dive: A Policy In Action
Let's look at the actual tax policy from the
sharing_tabbranch.resource_transfer_synced.lua — The core policy logic.
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 TransfersBecause it's a pure function, we can test it in isolation. Here's an actual test from game_resource_transfer_controller_spec.lua:
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:ResourceExcesscadence: solve redistribution, apply the result, refresh the policy cache.game_resource_transfer_controller.lua
The game owns its stats, too.
SetTeamResourcemoves the pools but bypasses the engine's resource ledger, so the controller reports the parts that matter back across the boundary:excessto the engine — the one quantity that legitimately leaves the ledger — viaSpring.AddTeamResourceExcessStats, andsent/receivedto a LuaShareStatsmodule 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 endBenefits of the New Architecture
6. The Future: A Policy DSL
The
sharing_tabbranch 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:
The DSL provides:
builder:Allied():MetalTransfers():Use(...)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:
This is game design expressed as code. No recoil changes required. No hook ordering nightmares. Just declare what you want.
Potential File Layout
The
policy_engineanddslare 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 --> ResultThe knowledge boundaries here are still being refined, but the intent is:
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] endThere are two runtime flows:
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:
GameFrame.gadget:ResourceExcesscallin plusSpring.AddTeamResourceExcessStats, which formalize the IoC pattern. Optional but cleaner—the game can still drive economy fromGameFramewithout them.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_tabgadget:ResourceExcesscallin +Spring.AddTeamResourceExcessStats(optional but cleaner long-term)