Status: Proposed Date: 2026-05-13 Deciders: Asser Fahrenholz
Gameplay tuning (ship stats, weapon parameters, prize tunables, spawn radii) needs to satisfy four constraints simultaneously:
- Operator-editable without rebuilding. Numbers like
MaxEnergy=1000are tuned by playing the game and watching the feel; rebuilding code for every nudge is a non-starter. This pulls toward storing tuning in editable files (today: Groovy fragments underzone/conf/<preset>/). - Per-entity divergence is real gameplay. A ship's effective
MaxEnergyis not just the Warbird default — it is the default, plus any prize-acquired upgrades, plus any active buff modifier, minus damage caps. Two Warbirds in the same arena routinely have different effective stats. The data model has to carry "this entity's current value", not just "this ship class's default". - Hot-path code reads tuning every tick.
PlayerDriverreads thrust and speed limits 60 times per second per ship; the energy-drain logic for Cloak readscloak.energyPerSecondevery tick a cloak is active. Going through a per-arena map lookup keyed on ship-type for every per-tick read is fighting the framework. - The client needs to see the effective value. A HUD energy bar shows current energy and current max. The max changes when a prize raises it. The wire format has to carry the effective per-entity value, not the class default.
Two single-tier approaches fail:
- Templates-only (read tuning records on the hot path each tick). Forces every consumer to do
registry.forArena(arenaId).get(ShipType).maxEnergy()per read. Solves operator-editability and gives a clean "what are the Warbird defaults?" answer, but cannot model per-entity divergence: there is no place to store this Warbird's prize-bumped max. The class default is the only value the system knows. - Components-only (every tuning value is a Zay-ES component). Per-entity divergence works trivially. But the "what are the defaults for ship class X" question now requires walking every entity of that class, live-reload has to push edits onto N live entities, and there is no answer for "what would a freshly-spawned Warbird have?" except by spawning one.
Subspace Infinity has been calling the resolution "Pattern 4" for over a year — a name carried over from the slice numbering of the original migration PRD. The name has stopped earning its keep: new contributors do not know what "Pattern 4" refers to without reading three layers of .scratch/ history. .claude/rules/config-pattern.md documents the resolution as "Template vs Instance", which is descriptive but generic. This ADR formalises the decision and renames it.
Tuning data splits into a *Config template tier and an ECS component tier; spawn systems are the sole seam that projects template → component. The pattern's name is Config-Component Projection (CCP).
- Config template — an immutable Java record in
api/src/main/java/infinity/config/(e.g.ShipConfig,BombConfig,PrizeWeightsConfig). One per type, registry-owned, server-only, read at spawn / reload / admin paths. - ECS component — an immutable Zay-ES
EntityComponentinapi/src/main/java/infinity/es/(e.g.EnergyStats,Thrust,SpeedStats). One per entity, per-entity-mutable across lifetime, network-synced to clients. - Projection — the operation that copies effective values from a Config template into per-entity ECS components at spawn time. Spawn systems own this seam.
The three names compose into the pattern name: Config-Component Projection.
Names rejected: "Prefab" (Flecs prefabs are themselves entities inherited from via INSTANCEOF; Infinity's templates are records in a registry, not entities); "Archetype" (collides with the ECS storage-layout meaning); "Assemblage" (technically accurate per ECS-FAQ but obscure); "Blueprint" (Unreal-flavored, not Simsilica vocabulary); "Spec" (already taken by SpawnerSpec / infinity.sim.specs); "Pattern 4" (opaque without history).
Config template is:
- A Java
record(or record-shaped class) inapi/src/main/java/infinity/config/. - Immutable — every field final, no setters, no mutating methods.
- Self-contained — composes only api/-side types (other
*Config/*Statsrecords, primitives,@Nullablefor "ship cannot carry this gear" semantics). - One per type — one
ShipConfigperShipenum entry; oneBombConfigper arena; etc. - Server-only — clients never deserialize Config records. They see per-entity effective values via Zay-ES sync.
- Loaded by an adapter under the Settings layer (see
.claude/rules/settings-pipeline.md) and stored inConfigRegistrykeyed by arena.
ECS component is:
- A Zay-ES
EntityComponentinapi/src/main/java/infinity/es/. - Immutable per the rules in
.claude/rules/components.md(final fields, no setters, no-arg constructor for sync). - Per-entity — one instance per entity that carries it; missing component = "ship does not have this aspect".
- Mutable across the entity's lifetime via canonical writers (see ADR-0001).
- Network-synced — clients see the effective per-entity value automatically.
Spawn systems are the only place where template values cross into the live entity world.
ShipSpawnSystemreadsShipConfigfromConfigRegistry.forArena(arenaId), projects its fields onto a freshly-created ship entity (EnergyStats,Thrust,SpeedStats,RotationStats,BombStats,BulletStats, …).ShipStatusProjectorandShipWeaponsProjectorare sub-projectors that own the projection of specific Config slices onto the same entity.- Prize spawners read
PrizeWeightsConfigto pick a prize type, thenPrizeConfigfor the spawned prize's tunables. The spawned prize entity carries components, not Config refs. - Live-reload reconcilers (
ArenaSystem.handleShipsScriptReload()→ShipSpawnSystem.reprojectAll()) re-read templates and re-project onto already-live entities. This is the one post-spawn path that crosses the seam, by design.
No other server system reads *Config types. Hot-path consumers (PlayerDriver, EnergySystem drain, WeaponsProjectileSpawnSystem projectile spawn parameters, ship-tick logic) read components only.
| Code location | Reads template? | Reads components? |
|---|---|---|
| Hot-path consumers (PlayerDriver, EnergySystem tick, gravity integration) | No | Yes |
| Spawn systems / projectors | Yes | Writes |
| Prize appliers | No — they emit *Change entities, see ADR-0001 |
Reads via Change-entity flow |
| Live-reload reconcilers | Yes | Writes (reprojectAll) |
Admin commands (~setstat, ~reload) |
Yes | Writes |
| Clients (HUD, bars, UI) | No (template is server-only) | Yes, via Zay-ES sync |
api/ factories (ShipFactory, WeaponFactory) |
No — structural composition only | Writes structural components only |
Hot-path discipline: server systems under infinity-server/src/main/java/infinity/systems/ must not import infinity.config.* unless they are a spawn / projector / reload path. The api-side factories in infinity.sim.* compose only structural components and let projectors handle the tuned values (see api-contracts.md).
The projection seam is one-shot at spawn, with two explicit exceptions:
- Ships re-project on
ships.groovyedits.ArenaReloadWatcherdetects mtime change, callsShipSpawnSystem.reprojectAll(), which walks live ships in the arena and re-applies the Config without resetting Continuous pools (current energy, current ammo). This is the operator's primary tuning loop. - Weapon / prize / arena fragment edits do NOT re-flow into live entities. Editing
bullet.groovyupdatesConfigRegistrybut does not retroactively change projectiles already in flight, nor re-derive components on already-spawned ships from non-ship fragments. Next-spawned entities pick up the change.
This is deliberate, not an oversight. A bullet in flight that suddenly gets a new damage value mid-flight is worse gameplay than a one-spawn delay. A ship's MaxBombs should not retroactively raise the live BombsHeld cap. The seam is at spawn for a reason; live-reload of in-flight values is not the same operation.
CCP is the rule for the spawn-time crossing from template to component; ADR-0001 is the rule for how the resulting components are mutated thereafter. Spawn is exempt from ADR-0001's canonical-writer rule precisely because it is CCP's projection seam.
- Per-entity divergence is free. A prize that raises
MaxEnergymutates the component, not the template. Two ships of the same class can have wildly different effective stats; no special-casing needed. - Hot path stays hot. Tick-rate consumers read components only. No registry lookup, no per-tick map traversal, no cross-arena indirection.
- Network sync is automatic. Zay-ES syncs components, not templates. Clients see effective per-entity values without ever knowing Config exists.
- Operator-editable. Numbers live in Groovy files; live-reload re-projects onto live ships for
ships.groovy. The "I want to tune this" workflow isedit-Groovy → save → see effectwith no rebuild. - One canonical answer to "what are the defaults?" The Config record. No walking entities to reconstruct.
- No duplication on the hot path. Template is shared (1 per type, ~28 records today); components are per-entity but only carry the fields the entity actually needs.
- Clear seam. Spawn systems are the only crossing. Search for
infinity.configimports ininfinity-server/src/main/java/infinity/systems/and the result should equal the spawn / projector / reload set.
- Two tiers to keep in sync. Adding a new tuning field touches the
*Configrecord, the matching component, and the projector. The settings pipeline tracker (.scratch/settings-pipeline.md) exists specifically to keep these aligned. - Spawn is a load-bearing seam. A bug in
ShipSpawnSystemprojection silently affects every ship spawned thereafter. Mitigation: the spawn-projection test harness PRD (.scratch/spawn-projection-test-harness/) andShipSpawnSystemTestcover this; expanding coverage is on the architectural-review punch list. - Live-reload coverage is partial by design. Editing weapon/prize fragments only affects new spawns. Operators occasionally find this surprising; documented in CONTRIBUTING / runbook.
- Hot-path-import discipline is currently audited by hand. Until the ArchUnit guard lands, only code review and grep enforce it. Mitigation: mechanise as a static rule; current specifics tracked in the architectural-review punch list, not here.
- Templates as Zay-ES entities (Flecs-style prefabs) — not adopted. Would require representing each template as a long-lived entity and using
INSTANCEOF-style sharing. Zay-ES does not have entity inheritance as a first-class primitive; emulating it would fight the framework's grain. The record-in-registry shape is straightforward and the projection cost is bounded. - Dangling
ConfigRegistryslots. Two records (ThorConfig,GravBombConfig) have registered slots but no fragment adapter — they fall back toDEFAULTSalways. Intentional today (Thor is per-ship viaships.groovy; GravBomb has not diverged from Bomb). Documented as the third open work item below.
- Templates-only (read
*Configon the hot path each tick). Rejected — cannot model per-entity divergence (prize bumps, buff modifiers, damage caps); forces a per-tick map lookup that the framework grain does not support. - Components-only (no templates; tuning is just a component you write at spawn). Rejected — loses the "what are the defaults" answer, forces live-reload to walk N entities, leaves no place for arena-global tuning that does not need to ride per-entity.
- Templates as entities (Flecs
INSTANCEOFprefab style). Rejected — fights Zay-ES grain; emulating entity-inheritance with EntitySets and Parent components would be more complex than the record-in-registry shape we have. - Single tier with copy-on-write divergence (one map of "current effective values" per entity per stat, no separate template). Rejected — re-invents the component tier with weaker tooling.
- "Prefab" / "Archetype" naming. Rejected on terminology-collision grounds; see Naming above.
- Pattern name: Config-Component Projection (CCP). Tiers: Config template, ECS component. Operation: projection.
- Templates: immutable Java records in
api/src/main/java/infinity/config/. - Components: Zay-ES
EntityComponentinapi/src/main/java/infinity/es/. - Projection seam: spawn systems +
reprojectAllfor ships-tier live-reload only. - Hot-path rule: no
infinity.config.*imports frominfinity.systems..except the spawn-tier exempt set. - Storage: per-arena
ConfigRegistryowned byConfigRegistrySystem. - Client visibility: components only, never Config.
The decision is fully described above. What remains for the decision to be enforced rather than audited by hand:
- Rename the rule file.
.claude/rules/config-pattern.md→.claude/rules/config-component-projection.md. The file's header has been updated to reference this ADR but the filename rename is deferred (cascades to many cross-references).
.claude/rules/config-pattern.md— the rule this ADR formalises (to be renamed; see Open work)..claude/rules/api-contracts.md— api/-layer purity; factories ininfinity.sim.*compose structural components only and let projectors handle tuned values..claude/rules/settings-pipeline.md— the Groovy →*Configloading pipeline that feeds CCP's templates.docs/adr/0001-ecs-component-model.md— Continuous + Stats components are component-tier; ADR-0001 exempts spawn from the canonical-writer rule because spawn is CCP's projection seam.CLAUDE.mdRule 3 — tuning knobs in Groovy, not Java. CCP is the mechanism that gets those Groovy-authored values into the live entity world.ShipConfig/ShipStatinapi/src/main/java/infinity/config/— canonical shape of a Config template.EnergyStats,SpeedStats,Thrustinapi/src/main/java/infinity/es/ship/— canonical shape of a projected component.- Sander Mertens, "Building an ECS #1: Types, Hierarchies and Prefabs" (Flecs author) — establishes the ECS-community meaning of "prefab" as an entity-shaped template, which is why this ADR does not adopt that term.
- Flecs ECS-FAQ — defines "archetype" as component-composition layout, reinforcing the terminology-collision rejection of that name.
- Wikipedia ECS article — defines "assemblage" as "a template or recipe for building entities"; technically accurate but rejected as obscure.