cardinal

package
v0.20.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: LGPL-3.0 Imports: 62 Imported by: 0

Documentation

Overview

Package cardinal runs a World Engine game shard. A World holds the game's ECS state and runs its registered systems once per tick. It accepts commands from clients, delivers events to them, exchanges commands with other shards, and snapshots its state. Tests drive it with TestWorld, RunDST and RunE2E.

Index

Constants

View Source
const (
	// PreUpdate runs before the main update.
	PreUpdate = ecs.PreUpdate
	// Update runs during the main update phase.
	Update = ecs.Update
	// PostUpdate runs after the main update.
	PostUpdate = ecs.PostUpdate
	// Init runs once during world initialization.
	Init = ecs.Init
)
View Source
const MinEpochFrequency = 10

Variables

View Source
var (
	ErrSingleNoResult       = eris.New("expected exactly 1 result, got 0")
	ErrSingleMultipleResult = eris.New("expected exactly 1 result, got more than 1")
)
View Source
var ErrWorldStarted = eris.New(
	"cannot register after the world has started; register before StartGame and outside systems")

ErrWorldStarted is the panic value of every Register* method once init has run the first system. Registration mutates tables that systems read (component IDs, command queues, event and system event registries), so it must finish before StartGame and cannot happen inside a system. This is a plain panic, not assert.That, so it also fires in release builds.

Functions

func RequireDeterministic added in v0.18.0

func RequireDeterministic(t testing.TB, setup func(w *World), script func(w *TestWorld))

RequireDeterministic runs script on two fresh worlds built by setup and fails the test at the first step after which they differ in world state, events, shard commands or emitted system events, or when they differ after script returns.

Step 0 is the world after init. Step n is the nth RunSystem or Tick the script makes. Ticks the script runs with w.World.Tick(timestamp) are not steps, so their effect is compared only when script returns.

The check compares two runs in one process, so it catches nondeterminism only when those runs differ. Package state, unseeded randomness and nanosecond clock reads almost always do. Iteration over a map of a few keys, or a clock read at second granularity, often does not: range over slices.Sorted(maps.Keys(m)) and read w.Timestamp() instead of time.Now.

func RunDST added in v0.10.2

func RunDST(t *testing.T, setup DSTSetupFunc, preTestCommands []Command)

RunDST executes a deterministic simulation test (DST): a game-logic-agnostic fuzzer and structural state checker. The setup function registers game-specific systems; the harness handles everything else: randomized engine config, command generation from the registered command types, ticking, restart/restore and snapshot operations with randomized weights, and structural ECS invariant checking after every tick. Game logic correctness is irrelevant; only engine correctness matters.

Usage from a game shard's test directory:

func TestDST(t *testing.T) {
    cardinal.RunDST(t, func(w *cardinal.World) {
        w.RegisterComponent[component.MyComponent]()
        w.RegisterSystem(&system.MySystem{})
        // ... register all components and systems
    }, []cardinal.Command{system.BootstrapCommand{Seed: 42}})
}

preTestCommands are enqueued before randomized fuzz operations begin. This supports worlds that require deterministic bootstrap commands before entering an active state.

The number of ticks defaults to 1000 and can be overridden with -dst.ticks:

go test ./pkg/cardinal/... -dst.ticks=5000

func RunE2E added in v0.10.2

func RunE2E(t *testing.T, setup E2ESetupFunc)

RunE2E executes an end-to-end test. The setup function creates the world the same way production does; the harness transparently injects the in-memory test NATS URL. The harness starts the world's run loop in a background goroutine and sends randomized commands over ConnectRPC from the test goroutine.

E2E tests are skipped by default. Use the -e2e.run flag to enable them:

go test ./pkg/cardinal/... -e2e.run

The run duration defaults to 30s and can be overridden with -e2e.duration:

go test ./pkg/cardinal/... -e2e.run -e2e.duration=2m

Types

type ArgusAuthenticator added in v0.19.0

type ArgusAuthenticator struct {
	// contains filtered or unexported fields
}

ArgusAuthenticator authenticates game tokens issued by Argus Auth for one organization/project.

func NewArgusAuthenticator added in v0.19.0

func NewArgusAuthenticator(argusAuthURL, organization, project string) (*ArgusAuthenticator, error)

NewArgusAuthenticator fetches Argus Auth's signing keys and returns an authenticator that accepts only EdDSA game tokens with aud equal to organization/project, an unexpired exp, and a non-empty sub. Pass its Authenticate method to authn.NewMiddleware.

func (*ArgusAuthenticator) Authenticate added in v0.19.0

func (a *ArgusAuthenticator) Authenticate(_ context.Context, req *http.Request) (any, error)

Authenticate returns the *Player named by the request's bearer token. It satisfies authn.AuthFunc; every rejection is a connect.CodeUnauthenticated error.

type AuthMode added in v0.12.0

type AuthMode uint8

AuthMode selects the authentication mode for the client-facing ConnectRPC service.

const (
	AuthModeUndefined AuthMode = iota
	AuthModeArgus
	AuthModeDev
)

func ParseAuthMode added in v0.12.0

func ParseAuthMode(s string) (AuthMode, error)

func (AuthMode) IsValid added in v0.12.0

func (a AuthMode) IsValid() bool

func (AuthMode) String added in v0.12.0

func (a AuthMode) String() string

type Command added in v0.10.0

type Command = command.Payload

type CommandContext added in v0.10.0

type CommandContext[T Command] struct {
	Payload T
	// contains filtered or unexported fields
}

CommandContext is a command and its sender. The sender is a player, whose ID Cardinal takes from the client's auth token, or another shard that called SendToShard. Ask which with Player or Shard:

if player, ok := cmd.Player(); ok {
	w.SendTo(player, Result{OK: true})
}

func (CommandContext[T]) Player added in v0.19.0

func (c CommandContext[T]) Player() (string, bool)

Player returns the ID of the player who sent the command, or false if a shard sent it.

func (CommandContext[T]) Sender added in v0.19.0

func (c CommandContext[T]) Sender() string

Sender returns the player ID or the sending shard's address. Use it when any sender will do, such as in logs.

func (CommandContext[T]) Shard added in v0.19.0

func (c CommandContext[T]) Shard() (string, bool)

Shard returns the sending shard's address ("region.realm.org.project.shard"), or false if a player sent it.

type DSTSetupFunc added in v0.10.2

type DSTSetupFunc func(w *World)

DSTSetupFunc registers systems, components, and commands on a World. It is called once during fixture creation, before the first tick.

type E2ESetupFunc added in v0.10.2

type E2ESetupFunc func() *World

E2ESetupFunc builds and returns the world used by the E2E harness. The harness injects a test NATS URL via environment variables before invoking setup.

type Entity added in v0.16.9

type Entity struct {
	// contains filtered or unexported fields
}

Entity is a runtime handle to an entity in one world. Store ID() in components, commands, and snapshots, then bind that ID with World.Entity when needed. Like EntityID, a handle must not be retained after destruction or a world reset: numeric IDs can be reused. Entity methods must run on the world's system goroutine.

func (Entity) Alive added in v0.16.9

func (e Entity) Alive() bool

Alive reports whether the entity exists in its world. A zero handle is not alive.

func (Entity) Destroy added in v0.16.9

func (e Entity) Destroy() bool

Destroy removes the entity and all its components. It returns false if absent.

func (Entity) Get added in v0.16.9

func (e Entity) Get[T ecs.Component]() T

Get returns a copy of a component. It panics if the entity or component is absent. Use Has when the component is optional, and Set to write a modified copy back.

func (Entity) Has added in v0.16.9

func (e Entity) Has[T ecs.Component]() bool

Has reports whether this entity has the requested component.

func (Entity) ID added in v0.16.9

func (e Entity) ID() EntityID

ID returns the numeric identifier used by storage, commands, and physics.

func (Entity) Remove added in v0.16.9

func (e Entity) Remove[T ecs.Component]()

Remove removes a registered component, doing nothing if it is already absent. It panics if the entity is absent or the component type is unregistered.

func (Entity) Set added in v0.16.9

func (e Entity) Set[T ecs.Component](component T)

Set adds or replaces a registered component. It panics if the entity is absent or the component type was not registered before the world started.

type EntityID added in v0.10.0

type EntityID = ecs.EntityID

type Event added in v0.10.0

type Event = event.Payload

type OtherWorld

type OtherWorld struct {
	Region       string
	Organization string
	Project      string
	ShardID      string
}

OtherWorld is a type that represents the address of an external service.

type Player added in v0.19.0

type Player struct {
	ID string
}

Player is the authenticated gameplay identity supplied to Cardinal handlers.

func PlayerFromContext added in v0.19.0

func PlayerFromContext(ctx context.Context) *Player

type Plugin added in v0.11.0

type Plugin interface {
	Register(w *World)
}

Plugin defines a self-contained extension that can register systems, components, commands, and events with a World. Plugins allow reusable game logic to be packaged and shared across projects.

Nothing registers implicitly: a plugin's Register method must call RegisterComponent, RegisterCommand, RegisterEvent, and RegisterSystemEvent for everything its systems use, including types that belong to another plugin. Registering a type twice is a no-op.

Example:

type MyPlugin struct{ config MyConfig }

func (p *MyPlugin) Register(w *cardinal.World) {
    w.RegisterComponent[MyComponent]()
    w.RegisterCommand[MyCommand]()
    w.RegisterEvent[MyEvent]()
    w.RegisterSystem(&MyInitSystem{}, cardinal.WithHook(cardinal.Init))
    w.RegisterSystem(&MyTickSystem{})
}
type Search struct {
	// contains filtered or unexported fields
}

Search is a resolved, world-bound query returned by World.Contains and World.Exact. It holds component IDs, not entity data, so it remains valid as entities change.

func (Search) Create added in v0.17.0

func (q Search) Create() Entity

Create returns a new entity with the query's components, initialized to zero.

func (Search) GetByID added in v0.17.0

func (q Search) GetByID(eid EntityID) (Entity, error)

GetByID returns a handle if the entity matches the query's archetype.

func (Search) Iter added in v0.17.0

func (q Search) Iter() SearchResult

Iter yields each matching entity as a world-bound handle.

type SearchResult added in v0.11.0

type SearchResult func(yield func(Entity) bool)

SearchResult is a chainable iterator over entities.

func (SearchResult) Filter added in v0.11.0

func (s SearchResult) Filter(predicate func(Entity) bool) SearchResult

Filter returns a new iterator that only yields values that satisfy predicate. A nil predicate returns the original iterator unchanged.

func (SearchResult) Limit added in v0.11.0

func (s SearchResult) Limit(limit uint32) SearchResult

Limit returns a new iterator that yields at most limit values. A limit <= 0 yields no values.

func (SearchResult) Single added in v0.11.0

func (s SearchResult) Single() (Entity, error)

Single returns the single value in the iterator. It returns an error if the iterator yields zero or more than one result.

type Sent added in v0.18.0

type Sent[T Event] struct {
	Recipient string // "" for Broadcast
	Payload   T
}

Sent is an event that a step delivered with Broadcast or SendTo.

type ShardCommand added in v0.18.0

type ShardCommand[T Command] struct {
	To      OtherWorld
	Payload T
}

ShardCommand is a command that a step sent to another shard with SendToShard.

type Slice added in v0.16.7

type Slice[T any] = immutable.Slice[T]

Slice is world-engine's sequence with a private backing array, re-exported so a component can declare one without a second import. It is an alias, so cardinal.Slice[T] and immutable.Slice[T] are the same type and either spelling works.

Its derivations edit in place; see immutable.Slice for what that means at a call site.

func SliceOf added in v0.16.7

func SliceOf[T any](items ...T) Slice[T]

SliceOf returns a Slice holding a copy of items. See immutable.SliceOf.

type System added in v0.16.5

type System interface {
	Run(w *World)
}

System is a unit of game logic that runs once per tick phase. Run receives the world it was registered with; use it for searches, commands, events, entities, and the current tick. Any other state a system needs (a runtime, a config) lives on the implementing struct.

type SystemHook added in v0.10.0

type SystemHook = ecs.SystemHook

SystemHook defines when a system should be executed in the update cycle.

type SystemOption added in v0.10.0

type SystemOption func(*systemConfig)

SystemOption is a function that configures a SystemConfig.

func WithHook

func WithHook(hook SystemHook) SystemOption

WithHook returns an option to set the system hook.

type TestWorld added in v0.18.0

type TestWorld struct {
	*World
	// contains filtered or unexported fields
}

TestWorld runs a game's systems inside a Go test, one step at a time. It embeds *World, so tests seed and read state with the normal API (w.Create, w.Entity, w.Contains, w.EmitSystemEvent).

func TestCombat(t *testing.T) {
    w := cardinal.NewTestWorld(t, game.Register)
    target := w.Create[game.Player]()
    w.Command("alice", game.Attack{Target: target.ID(), Damage: 30})
    w.RunSystem(&system.Combat{})
    assert.Equal(t, game.Health{HP: 70}, target.Get[game.Health]())
}

A step is RunSystem or Tick. Each step drains the queued commands, dispatches and encodes events and shard commands, encodes the world state as a snapshot would, and increments the tick height, exactly as a production tick does. Events and ShardCommands return what the last step dispatched, which is everything sent since the step before it, including sends from Init systems and from the test itself. Emitted returns the system events the last step's systems emitted.

Two methods shadow World's:

  • Tick runs one production tick at the test clock. w.World.Tick(timestamp) runs one at another time but is not a step: Events and ShardCommands add its outputs to the last step's, and Emitted does not record its system events.
  • StartGame fails the test. A TestWorld never connects to NATS.

Everything runs on the test goroutine. A step at tick height h runs at time.Unix(h, 0), and so do Init systems at height 0. The height increments at the end of each step, so between steps Timestamp is the last step's time, one second behind TickHeight.

func NewTestWorld added in v0.18.0

func NewTestWorld(t testing.TB, setup func(w *World)) *TestWorld

NewTestWorld builds a world for testing systems. setup registers components, commands, events, system events and systems, as a game's main does before StartGame. NewTestWorld then runs the init systems, as StartGame does, and closes registration.

The world reads no environment variables and starts no NATS connection, telemetry exporter or snapshot storage. Logs go to the test log.

func (*TestWorld) Command added in v0.18.0

func (w *TestWorld) Command(player string, cmd Command)

Command sends cmd from player the way a client does. The command is encoded, decoded by its queue and read by Commands[T] during the next step only. It fails the test if the command type was not registered in setup.

func (*TestWorld) Emitted added in v0.18.0

func (w *TestWorld) Emitted[T ecs.SystemEvent]() []T

Emitted returns the system events of type T that systems emitted during the last step, in order. Events the test emitted before the step are inputs and are not included.

func (*TestWorld) Events added in v0.18.0

func (w *TestWorld) Events[T Event]() []Sent[T]

Events returns the events of type T that the last step dispatched, in the order they were sent.

func (*TestWorld) RunSystem added in v0.18.0

func (w *TestWorld) RunSystem(s System)

RunSystem runs exactly one system as a step. The system need not be registered. System events emitted before the step are visible to it and are cleared when it returns, as at the end of a tick. It runs under a span named after the system like a registered system, without the hook attribute.

func (*TestWorld) ShardCommands added in v0.18.0

func (w *TestWorld) ShardCommands[T Command]() []ShardCommand[T]

ShardCommands returns the commands of type T that the last step sent with SendToShard, in order.

func (*TestWorld) StartGame added in v0.18.0

func (w *TestWorld) StartGame()

StartGame fails the test. A TestWorld has no NATS connection or run loop; drive it with Tick or RunSystem.

func (*TestWorld) Tick added in v0.18.0

func (w *TestWorld) Tick()

Tick runs one production tick as a step: every PreUpdate, Update and PostUpdate system in order.

type Tick added in v0.10.0

type Tick struct {
	// contains filtered or unexported fields
}

type World

type World struct {
	// contains filtered or unexported fields
}

World contains the game state and is the main Cardinal API.

func NewWorld

func NewWorld(opts WorldOptions) (*World, error)

NewWorld creates a game world with the specified options.

func (*World) Broadcast added in v0.17.0

func (w *World) Broadcast[T Event](evt T)

Broadcast enqueues an event delivered to every open event stream at the end of the tick. It panics if T was not registered with RegisterEvent.

func (*World) Commands added in v0.17.0

func (w *World) Commands[T Command]() iter.Seq[CommandContext[T]]

Commands yields the commands of type T received for the current tick. It panics if T was not registered with RegisterCommand. Like ErrWorldStarted this is a plain panic, not assert.That: registration is explicit, so a missing RegisterCommand is a user error that must also fail in release builds instead of reading another command's queue.

func (*World) Contains added in v0.17.0

func (w *World) Contains[T any]() Search

Contains returns a search for entities with every component in T, allowing extras. T is an archetype: a struct whose fields are registered component types. It panics if any component in T was not registered with RegisterComponent.

func (*World) Context added in v0.20.0

func (w *World) Context() context.Context

Context returns the trace context of the running system. Spans started from it nest under the system's span in the tick's trace:

ctx, span := otel.Tracer("mygame").Start(w.Context(), "pathfind")
defer span.End()

Pass it to instrumented clients (HTTP, SQL) so their spans join the tick's trace too. It carries trace context only: it is never cancelled and has no deadline. When tracing is disabled or the tick is sampled out, spans started from it are no-ops.

func (*World) Create added in v0.17.0

func (w *World) Create[T any]() Entity

Create creates an entity with the zero-valued components declared by T, an archetype struct whose fields are component types. It panics if any of them was not registered with World.RegisterComponent.

func (*World) EmitSystemEvent added in v0.17.0

func (w *World) EmitSystemEvent[T ecs.SystemEvent](systemEvent T)

EmitSystemEvent emits a system event for systems later in the tick. It panics if T was not registered with RegisterSystemEvent, in release builds too: an unregistered emit dropped silently would leave every later SystemEvents reader empty.

func (*World) Entity added in v0.17.0

func (w *World) Entity(id EntityID) Entity

Entity binds a numeric ID to this world. It does not require the entity to exist. Use Alive or Has to check before accessing an optional entity.

func (*World) Exact added in v0.17.0

func (w *World) Exact[T any]() Search

Exact returns a search for entities with exactly the components in T. T is an archetype: a struct whose fields are registered component types. It panics if any component in T was not registered with RegisterComponent.

func (*World) Logger added in v0.17.0

func (w *World) Logger() *zerolog.Logger

Logger returns the logger for systems in this world. In a traced system its entries carry the system span's trace_id and span_id, so a log line leads to the tick that wrote it.

func (*World) RegisterCommand added in v0.17.0

func (w *World) RegisterCommand[T Command]()

RegisterCommand registers a command type before world startup. Registering it again is a no-op. The service accepts a command from clients only once it is registered here.

func (*World) RegisterComponent added in v0.17.0

func (w *World) RegisterComponent[T ecs.Component]()

RegisterComponent registers a component type before world startup. Every component used by a system, archetype, or snapshot must be registered here; nothing registers components implicitly. Register components before StartGame.

func (*World) RegisterEvent added in v0.17.0

func (w *World) RegisterEvent[T Event]()

RegisterEvent registers an event type before world startup so it appears in introspection metadata and can be sent. Registering it again is a no-op.

func (*World) RegisterPlugin added in v0.16.8

func (w *World) RegisterPlugin(plugin Plugin)

RegisterPlugin registers a plugin with the world. Must be called before StartGame(). Panics if the plugin fails to register, consistent with other registration functions.

func (*World) RegisterSystem added in v0.16.8

func (w *World) RegisterSystem(s System, opts ...SystemOption)

RegisterSystem registers a system for the hook in opts (Update by default). Register the components, commands, events, and system events a system uses before StartGame.

func (*World) RegisterSystemEvent added in v0.17.0

func (w *World) RegisterSystemEvent[T ecs.SystemEvent]()

RegisterSystemEvent registers a system event type before world startup. System events carry data between systems within one tick. Registering it again is a no-op.

Example:

// Define a system event for player deaths.
type PlayerDeath struct{ Nickname string }

func (PlayerDeath) Name() string { return "player-death" }

w.RegisterSystemEvent[PlayerDeath]()

// One system emits it.
func (s *CombatSystem) Run(w *cardinal.World) {
    w.EmitSystemEvent(PlayerDeath{Nickname: "Player1"})
}

// Another system, later in the tick, receives it.
func (s *GraveyardSystem) Run(w *cardinal.World) {
    for death := range w.SystemEvents[PlayerDeath]() {
        // Process the system event.
    }
}

func (*World) SendTo added in v0.17.0

func (w *World) SendTo[T Event](recipient string, evt T)

SendTo enqueues a targeted event that is delivered only to the named recipient (a player ID), provided they have an open event stream subscribed to this event or a pending SendCommandWithReply waiting for it. Otherwise the event is silently dropped. It panics if T was not registered with RegisterEvent.

Example:

if player, ok := cmd.Player(); ok {
	w.SendTo(player, Result{OK: true})
}

func (*World) SendToShard added in v0.17.0

func (w *World) SendToShard(to OtherWorld, cmd command.Payload)

SendToShard sends cmd to another shard, addressed by to. This is the first-class shard-to-shard send: the world performs the send (it owns the event queue) and the address `to` is plain data with no behavior of its own. It mirrors the client-facing SendCommand RPC — a shard sending to a shard is the same operation, initiated in-engine.

Fire-and-forget: cmd is encoded when events flush at end-of-tick, so it must not be mutated after this call — a *Command whose fields change before the flush would send the mutated value. The send then happens in the background, in order per target shard. A send that fails is not returned but is logged at error level, because a dropped shard-to-shard command is serious.

func (*World) StartGame

func (w *World) StartGame()

StartGame runs the game until the process receives a stop signal.

func (*World) SystemEvents added in v0.17.0

func (w *World) SystemEvents[T ecs.SystemEvent]() iter.Seq[T]

SystemEvents yields the system events of type T emitted so far in the current tick. It panics if T was not registered with RegisterSystemEvent, in release builds too.

func (*World) Tick added in v0.10.0

func (w *World) Tick(timestamp time.Time)

Tick advances the world by one step, running every PreUpdate, Update and PostUpdate system.

func (*World) TickHeight added in v0.17.0

func (w *World) TickHeight() uint64

TickHeight returns the height of the tick currently running.

func (*World) Timestamp added in v0.17.0

func (w *World) Timestamp() time.Time

Timestamp returns the timestamp of the tick currently running.

type WorldOptions

type WorldOptions struct {
	Region              string               // Region that contains the shard
	Organization        string               // Organization that owns the world
	Project             string               // Project name within the organization
	ShardID             string               // Unique ID of the world instance
	TickRate            float64              // Number of ticks each second
	SnapshotStorageType snapshot.StorageType // Snapshot storage type
	SnapshotRate        uint32               // Number of ticks between snapshots
	Debug               *bool                // Enables the debug server
	NATSConfig          *micro.NATSConfig    // Optional NATS configuration; nil uses environment values or defaults
	AuthMode            AuthMode             // Authentication mode for the client ConnectRPC service
	ArgusAuthURL        string               // Argus Auth service URL; required when AuthMode is ARGUS
}

Directories

Path Synopsis
internal
ecs

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL