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.
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.
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 combatcardinal.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.
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.
System must be registered in the world to be executed. This is done by calling the RegisterSystems function.
main.go
package mainfunc 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") } // ...}
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}
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}
When implementing systems, proper error handling is crucial for maintaining game stability and debugging:
Component Operations
// Always check errors from component operationshealth, 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)}
package system// AttackSystem inflict damage to player's HP based on `AttackPlayer` message.// This provides an example of a system that modifies the component of an entity based on a message.func AttackSystem(worldCtx cardinal.WorldContext) error { // Iterate through all `AttackPlayer` messages. return cardinal.EachMessage[msg.AttackPlayerMsg, msg.AttackPlayerMsgReply](worldCtx, func(attack cardinal.TxData[msg.AttackPlayerMsg]) (msg.AttackPlayerMsgReply, error) { // Get the target player's current Health component. playerID, playerHealth, err := queryTargetPlayer(worldCtx, attack.Msg().TargetNickname) if err != nil { return msg.AttackPlayerMsgReply{}, err } // Inflict damage to the target player's HP. playerHealth.HP -= AttackDamage if err := cardinal.SetComponent[component.Health](worldCtx, playerID, playerHealth); err != nil { return msg.AttackPlayerMsgReply{}, err } return msg.AttackPlayerMsgReply{Damage: AttackDamage}, nil }, )}