Minimal game system skeleton

Smallest possible game system: one command, one event, one state field. For a production reference, see domain/systems/daggerheart/.

Identity, types, and state

package mysystem

import ("github.com/.../domain/command"; "github.com/.../domain/event")

const (
    SystemID      = "mysystem"
    SystemVersion = "1.0.0"
    CommandTypeScoreSet   command.Type = "mysystem.score.set"
    EventTypeScoreChanged event.Type   = "mysystem.score.changed"
)

type SnapshotState struct {
    CampaignID string
    Score      int
}

type scoreSetPayload struct{ Score int `json:"score"` }

State assertion helper

Every system needs a function to recover typed state from any:

func assertState(raw any) (*SnapshotState, error) {
    if raw == nil { return &SnapshotState{}, nil }
    s, ok := raw.(*SnapshotState)
    if !ok { return nil, fmt.Errorf("unexpected state type %T", raw) }
    return s, nil
}

Decider

Uses TypedDecider[S] so the decide function receives *SnapshotState:

func newDecider() module.Decider {
    return module.TypedDecider[*SnapshotState]{
        Assert: assertState,
        Fn: func(s *SnapshotState, cmd command.Command, now func() time.Time) command.Decision {
            var p scoreSetPayload
            if err := json.Unmarshal(cmd.PayloadJSON, &p); err != nil {
                return command.Reject(command.Rejection{
                    Code: command.RejectionCodePayloadDecodeFailed, Message: err.Error()})
            }
            payloadJSON, _ := json.Marshal(p)
            return command.Accept(event.Event{Type: EventTypeScoreChanged, PayloadJSON: payloadJSON})
        },
    }
}

Folder

Uses FoldRouter[S] with HandleFold for automatic payload unmarshaling:

func newFolder() module.Folder {
    r := module.NewFoldRouter[*SnapshotState](assertState)
    module.HandleFold(r, EventTypeScoreChanged,
        func(s *SnapshotState, p scoreSetPayload) error {
            s.Score = p.Score
            return nil
        })
    return r
}

State factory

type stateFactory struct{}

func (f stateFactory) NewSnapshotState(id ids.CampaignID) (any, error) {
    return &SnapshotState{CampaignID: string(id)}, nil
}
func (f stateFactory) NewCharacterState(_ ids.CampaignID, _ ids.CharacterID, _ string) (any, error) {
    return nil, nil // no character state
}

Module (wires everything together)

type Module struct {
    decider domainmodule.Decider
    folder  domainmodule.Folder
    factory domainmodule.StateFactory
}

func NewModule() *Module {
    return &Module{decider: newDecider(), folder: newFolder(), factory: stateFactory{}}
}

func (m *Module) ID() string      { return SystemID }
func (m *Module) Version() string { return SystemVersion }
func (m *Module) RegisterCommands(r *command.Registry) error {
    return r.Register(command.Definition{Type: CommandTypeScoreSet, Owner: command.OwnerSystem})
}
func (m *Module) RegisterEvents(r *event.Registry) error {
    return r.Register(event.Definition{
        Type: EventTypeScoreChanged, Owner: event.OwnerSystem,
        Intent: event.IntentProjectionAndReplay})
}
func (m *Module) EmittableEventTypes() []event.Type { return []event.Type{EventTypeScoreChanged} }
func (m *Module) Decider() domainmodule.Decider           { return m.decider }
func (m *Module) Folder() domainmodule.Folder             { return m.folder }
func (m *Module) StateFactory() domainmodule.StateFactory { return m.factory }

var _ domainmodule.Module = (*Module)(nil)

Manifest entry

Register in domain/systems/manifest/manifest.go:

{
    ID:          mysystem.SystemID,
    Version:     mysystem.SystemVersion,
    BuildModule: func() domainsystem.Module { return mysystem.NewModule() },
    // Add BuildMetadataSystem / BuildAdapter when the system needs
    // API-layer metadata or projection-side event handling.
},

BuildRegistries reads the manifest, registers commands/events, and runs startup validation (decider command coverage, fold event coverage, emittable type registration).