If you are unfamiliar with Entity Component System (ECS), we recommend reading Introduction to ECS before proceeding.

If you are unfamiliar with the concept of game loop and tick, we recommend reading Loop-driven Runtime before proceeding.

Systems are where game logic is implemented in Cardinal. Each system is executed once at every tick and is responsible for handling messages and updating the state of the game.

In Cardinal, systems are implemented as regular Go functions with the following signature:

func System(worldCtx cardinal.WorldContext) error

Example:

  • A RegenSystem that increments the current health of all entities that have the Health component.
  • An AttackSystem that handles the AttackPlayerMsg message and reduces the health of the target player.

Key concepts and principles

Before we implement our systems, there are high-level concepts that you need to know to write idiomatic systems in Cardinal.

Systems are always executed once per tick

In Cardinal, systems are executed once per tick regardless of whether there are user message/transactions.

If you are coming from EVM development background, you might notice that this behavior is in stark contrast to how smart contracts work.

In smart contracts, game state can only be updated when a transaction calls a function of the contract. In Cardinal, game state is updated via systems at every tick regardless of whether there are transactions.

This makes it easy to implement state updates (e.g. regeneration, gravity, etc.) that need to consistently happen at every time step/interval which EVM smart contracts are not able to do seamlessly.

System Execution Order

Systems are executed sequentially in the order they are registered. This order is critical for game logic as it determines the sequence of state updates within each tick. For example:

// Systems execute in this order:
// 1. InputSystem processes player inputs
// 2. MovementSystem updates positions
// 3. CollisionSystem checks for collisions
// 4. CombatSystem resolves combat
cardinal.RegisterSystems(w,
    system.InputSystem,
    system.MovementSystem,
    system.CollisionSystem,
    system.CombatSystem,
)

Carefully consider the dependencies between your systems when determining their execution order. For example, collision detection should typically run after movement updates.

All game state must be stored in components

As a general rule of thumb, systems should not store any game state in global variables as it will not be persisted. Systems should only store & read game state to & from components.


Creating Systems

By convention, systems are defined in the system directory, with each system being its own separate file.

You can easily create a new system and register it to the world by following these steps:

1

Implement the system function

A system is defined as a Go function that takes in the WorldContext and returns an error (or nil, if there is none).

/system/regen.go
package system

func RegenSystem(worldCtx cardinal.WorldContext) error {
    // ...
    return nil
}
2

Register the system in the World

System must be registered in the world to be executed. This is done by calling the RegisterSystems function.

main.go
package main

func main() {
    w, err := cardinal.NewWorld()
    if err != nil {
        log.Fatal().Err(err).Msg("failed to create world")
    }

    // Register systems
    // Each system executes sequentially in the order they are added.
    // Systems should be registered in dependency order:
    // 1. Input processing systems
    // 2. Game logic systems
    // 3. Output/Effect systems
    err := cardinal.RegisterSystems(w,
        system.InputSystem,    // Process player inputs first
        system.MovementSystem, // Update positions based on inputs
        system.CombatSystem,   // Resolve combat after movement
    )
    if err != nil {
        log.Fatal().Err(err).Msg("failed to register systems")
    }

    // ...
}

Common System Patterns

Creating Entities

/system/enemy_spawner.go
package system

// MonsterSpawnerSystem spawns monster at every tick
// This provides an example of a system that creates a new entity.
func MonsterSpawnerSystem(worldCtx cardinal.WorldContext) error {
    id, err := cardinal.Create(worldCtx,
        component.Enemy{Nickname: "Scary Monster"},
        component.Health{Current: 100, Maximum: 100},
    )
    if err != nil {
        return err
    }

	return nil
}

Updating Components

/system/regen.go
package system

// RegenSystem replenishes the player's HP at every tick.
// This provides an example of a system that doesn't rely on a message to update a component.
func RegenSystem(worldCtx cardinal.WorldContext) error {
	// Searches for all entities that have the Player and Health component.
	err := cardinal.
        NewSearch(filter.Exact(component.Player{}, component.Health{})).
        Each(func(id types.EntityID) bool {
            // Iterate through all entities that have the Player and Health component.
            // Returning true from the callback function will continue the iteration.
            // Returning false from the callback function will stop the iteration.

            // Get the player's current Health component.
            health, err := cardinal.GetComponent[component.Health](worldCtx, id)
            if err != nil {
                 return true
            }

            // Increment the player's HP by 1 if it is not at maximum.
            if (health.Current + 1) <= health.Maximum {
                health.Current += 1
            } else {
                 return true
            }

            // Update the player's Health component.
            if err := cardinal.SetComponent[component.Health](worldCtx, id, health); err != nil {
                 return true
            }

            return true
        })
	if err != nil {
		return err
	}
	return nil
}

Error Handling Best Practices

When implementing systems, proper error handling is crucial for maintaining game stability and debugging:

  1. Component Operations
// Always check errors from component operations
health, err := cardinal.GetComponent[component.Health](worldCtx, id)
if err != nil {
    // Log the error with context
    log.Error().Err(err).
        Str("component", "Health").
        Uint64("entity", uint64(id)).
        Msg("failed to get component")
    return fmt.Errorf("failed to get Health component: %w", err)
}
  1. Message Handling
// Handle message processing errors gracefully
return cardinal.EachMessage[msg.AttackMsg, msg.AttackMsgReply](worldCtx,
    func(attack cardinal.TxData[msg.AttackMsg]) (msg.AttackMsgReply, error) {
        if err := validateAttack(attack); err != nil {
            // Return meaningful error responses to clients
            return msg.AttackMsgReply{
                Success: false,
                Error: "invalid attack parameters",
            }, nil
        }
        // Process valid attack
        // PLACEHOLDER: attack processing logic
    },
)
  1. Entity Creation
// Proper error handling for entity creation
id, err := cardinal.Create(worldCtx,
    component.Player{Name: "Player1"},
    component.Health{Current: 100, Maximum: 100},
)
if err != nil {
    return fmt.Errorf("failed to create player entity: %w", err)
}

Always wrap errors with context using fmt.Errorf and include relevant entity IDs and component names in error messages for easier debugging.

Use structured logging (e.g., zerolog) to include additional context in error logs, making it easier to diagnose issues in production.

Handling Messages