Game Systems Architecture
Canonical architecture for extending Fracturing.Space with a game system.
Reading order
- Adding a command/event/system (how-to)
- Event-driven system (write-path invariants)
- This page (architecture boundaries and extension surfaces)
- Daggerheart references:
Purpose
Core campaign/session infrastructure stays system-agnostic while each ruleset owns its mechanics. This separation allows:
- deterministic replay and projection behavior
- independent system evolution by
system_id + system_version - clear ownership of command/event definitions
Ownership boundaries
- Core-owned commands/events: campaign/session/participant/invite/character lifecycle.
- System-owned commands/events: mechanics specific to a game system.
Non-negotiable invariants:
- Core must not emit system-owned events.
- Systems must not emit core-owned events.
- System-owned envelopes must include
system_idandsystem_version. - System-owned types must use
sys.<system_id>.*naming. - Request handlers must mutate through commands/events only.
Event intent policy
Every event must declare an intent. Most system events should use projection + replay intent. Audit-only events must stay journal-only.
Startup validation enforces coverage:
- fold coverage for replay-relevant events
- adapter coverage for projection-relevant events
- no fold handlers for audit-only events
Extension surfaces and registry wiring
Adding a game system requires four registries, all wired from one SystemDescriptor in domain/systems/manifest/manifest.go. If a system is present in one registry and missing in another, startup validation fails.
flowchart TD
SD["SystemDescriptor\n(manifest.go)"]
SD -->|BuildModule| MOD["Module Registry\n(write path)"]
SD -->|BuildAdapter| ADP["Adapter Registry\n(projection)"]
SD -->|BuildMetadataSystem| META["Metadata Registry\n(API surface)"]
MOD -->|"commands → deciders\nevents → folders"| ENGINE["Domain Engine"]
ADP -->|"system events → stores"| PROJ["Projection Applier"]
META -->|"contracts, state handlers"| GRPC["gRPC Transport"]
VALStartup Parity\nValidation
MOD --- VAL
ADP --- VAL
META --- VAL
VAL -->|"all three agree"| OK["Server starts"]
VAL -->|"mismatch"| FAIL["Fatal error"]
| Registry | Scope | File | What it provides |
|---|---|---|---|
| Module | Write path | domain/module/registry.go | Routes commands to deciders, events to folders during replay |
| Adapter | Projection | domain/systems/adapter_registry.go | Applies system events to projection stores |
| Metadata | API surface | domain/systems/registry_bridge.go | Transport-facing contracts, state handler factories, outcome appliers |
| Manifest | Glue | domain/systems/manifest/manifest.go | Single descriptor that wires the other three together |
Metadata registry contracts are domain-owned (SystemID, metadata status enums`). gRPC/API adapters map those values to protobuf enums at transport boundaries so domain packages remain independent from generated API code.
Optional session-start behavior belongs to the module surface. Systems that need custom readiness gates or first-session bootstrap events implement the bound provider contracts in domain/module/registry.go, so runtime code binds typed helpers once and never executes those rules through raw any state. If the read side must load projected state first, the metadata system may also expose SessionStartReadinessStateProvider in domain/systems/.
Startup validation order
BuildRegistries() in domain/engine/registries_builder.go keeps this order explicit through separate collaborators for core-domain registration, system-module registration, and post-registration contract validation:
- Register core domains — core commands, events, and aliases.
- Register system modules — run module-scoped registration pipeline (
register commands,register events,validate type namespace,validate emittable events). - Validate write-path contracts — fold, decider, state factory, and readiness coverage.
- Validate projection contracts — handler coverage, no stale handlers, adapter events.
- Three-way parity check — module, metadata, and adapter registries must agree on which systems exist.
Common startup failures
| Mistake | Error |
|---|---|
| Event not handled by folder | system emittable events missing folder fold handlers: <types> |
| Command not in decider | system commands missing decider handlers: <types> |
| Adapter missing for event | system emittable events missing adapter handlers: <types> |
| Module without metadata | metadata missing for module <id>@<version> |
| Metadata without adapter | adapter missing for metadata <id>@<version> |
| Non-deterministic state factory | state factory determinism check failed for <id> |
| Fold handler for audit-only event | fold handlers registered for audit-only events (dead code): <types> |
Package layout contract
Reference layout for a system implementation:
internal/services/game/domain/systems/<system>/module.go(registration).../decider.go(command decisions).../folder.go(replay fold).../adapter.go(projection apply).../{projectionstore,contentstore}/(system-owned store contracts; keep system vocabulary out of sharedinternal/services/game/storage).../event_types.goand typed payload/profile contract files (contracts)
Keep handlers thin and avoid transport logic in domain packages.
Authoring invariants
- Deciders and folders must be deterministic.
- Adapter
Applybehavior must be idempotent under replay. - Event payloads should capture resulting state (absolute values), not deltas.
- System-specific character profiles belong to the system module. Prefer typed system-owned commands/events such as
sys.<system>.character_profile.replaceover coremap[string]anyenvelopes. - Rejection codes should be stable, machine-readable constants.
- Multi-consequence mechanics should prefer single-command atomic emission patterns.
Minimum review checklist
- Command and event registrations are explicit and tested.
- Replay fold and projection adapter coverage exists for new event types.
- Mutating paths use shared command execution orchestration.
- Generated event catalogs are updated (
docs/events/). - At least one happy-path and one rejection-path test exists per new command/event pair.