Systems
Systems = Business Logic in ECS
Systems are the behavioral layer of Dojo's Entity Component System (ECS) architecture. They encapsulate business logic, orchestrate state changes, and define how your application evolves over time.
What are Systems?
In Dojo's ECS paradigm, systems represent the logic that operates on data stored in models. While models define what your world contains, systems define how it behaves.
┌─────────────────────────────────────────────────┐
│ ECS Trinity │
├─────────────────────────────────────────────────┤
│ Entities │ Components │ Systems │
│ (Who) │ (What) │ (How) │
├─────────────────────────────────────────────────┤
│ Players │ Position │ Movement Logic │
│ Monsters │ Health │ Combat Logic │
│ Items │ Inventory │ Trading Logic │
└─────────────────────────────────────────────────┘
Systems are stateless functions that:
- Read current world state from models
- Apply business logic and rules
- Write updated state back to models
- Emit events for external observation
System Design Philosophy
Single Responsibility Principle
Each system should have one clear, focused responsibility. This promotes modularity, testability, and maintainability.
Good Examples:MovementSystem
: Handles player/entity movementCombatSystem
: Manages battles and damageInventorySystem
: Manages item collection and usageTradingSystem
: Handles marketplace transactions
GameSystem
: Handles everything (too broad)PlayerSystem
: Manages movement, combat, and inventory (mixed concerns)
Stateless Design
Systems should be stateless, deriving all necessary information from the world state. This ensures predictable behavior and easier testing.
// Good: Stateless system
fn attack(ref self: ContractState, target: ContractAddress) {
let mut world = self.world(@"game");
let attacker = get_caller_address();
// Read current state
let attacker_stats: Combat = world.read_model(attacker);
let mut target_stats: Combat = world.read_model(target);
// Apply business logic
target_stats.health -= attacker_stats.damage;
// Write updated state
world.write_model(@target_stats);
}
Minimal Surface Area
Systems should expose only the necessary functions to external callers. Internal helper functions should be private and focused.
#[starknet::interface]
trait IActions<T> {
// Public interface - minimal and focused
fn spawn(ref self: T);
fn move(ref self: T, direction: Direction);
fn attack(ref self: T, target: ContractAddress);
}
#[generate_trait]
impl InternalImpl of InternalTrait {
// Private helpers - implementation details
fn validate_move(self: @ContractState, from: Vec2, to: Vec2) -> bool;
fn calculate_damage(self: @ContractState, attacker: ContractAddress) -> u32;
}
System Boundaries
What Systems Should Do
- Business Logic: Implement game rules and mechanics
- State Transitions: Orchestrate changes between valid states
- Validation: Ensure actions comply with game rules
- Coordination: Manage interactions between different models
- Event Emission: Signal important state changes
What Systems Should Not Do
- Data Storage: Systems don't store persistent state
- UI Logic: Keep presentation concerns separate
- External Integration: Avoid direct external service calls
- Complex Calculations: Delegate to specialized libraries when possible
System Interaction Models
Direct Model Access
Systems directly read and write models through the world contract. This is the most common and efficient pattern.
fn spawn(ref self: ContractState) {
let mut world = self.world(@"game");
let player = get_caller_address();
// Direct model access
let position = Position { player, vec: Vec2 { x: 0, y: 0 } };
let health = Health { player, value: 100 };
world.write_model(@position);
world.write_model(@health);
}
System Composition
Systems can be composed within contracts to create logical groupings. This allows for shared permissions and coordinated operations.
#[dojo::contract]
mod game_actions {
// Multiple related systems in one contract
impl PlayerActionsImpl of IPlayerActions<ContractState> {
fn move(ref self: ContractState, direction: Direction) { /* ... */ }
fn rest(ref self: ContractState) { /* ... */ }
}
impl CombatActionsImpl of ICombatActions<ContractState> {
fn attack(ref self: ContractState, target: ContractAddress) { /* ... */ }
fn defend(ref self: ContractState) { /* ... */ }
}
}
System Lifecycle
Initialization
Systems don't require explicit initialization - they're stateless functions. However, systems may need to initialize world state on first use.
Execution
Systems execute in response to external calls or internal triggers. Each execution should be atomic and leave the world in a valid state.
Validation
Systems should validate inputs and world state before making changes.
Use Cairo's assert
mechanism for clear error reporting.
fn move(ref self: ContractState, direction: Direction) {
let mut world = self.world(@"game");
let player = get_caller_address();
let moves: Moves = world.read_model(player);
assert(moves.remaining > 0, 'No moves remaining');
assert(moves.can_move, 'Movement disabled');
// Proceed with movement logic
}
Design Patterns
Command Pattern
Systems often implement the command pattern, where each public function represents a discrete action.
// Each function is a command
fn spawn(ref self: ContractState) { /* ... */ }
fn move(ref self: ContractState, direction: Direction) { /* ... */ }
fn attack(ref self: ContractState, target: ContractAddress) { /* ... */ }
State Machine Pattern
Systems can implement state machines for complex entity behaviors.
fn process_turn(ref self: ContractState, player: ContractAddress) {
let mut world = self.world(@"game");
let mut game_state: GameState = world.read_model(player);
match game_state.phase {
GamePhase::Setup => self.handle_setup(player),
GamePhase::Playing => self.handle_playing(player),
GamePhase::Ended => self.handle_ended(player),
}
}
System Testing Philosophy
Systems should be designed for testability:
- Pure Functions: Business logic should be extractable as pure functions
- Dependency Injection: Use world storage abstraction for mocking
- Isolated Testing: Each system should be testable in isolation
- Integration Testing: Test system interactions through the world contract
Best Practices
- Keep Systems Small: A system should fit in your head
- Use Descriptive Names: Function names should clearly indicate their purpose
- Validate Early: Check preconditions before making changes
- Handle Errors Gracefully: Use meaningful error messages
- Document Assumptions: Make implicit requirements explicit
- Test Thoroughly: Systems are critical paths in your application
Next Steps
Understanding system design philosophy is crucial for building robust Dojo applications. Explore the deeper aspects of system implementation:
- System Architecture - Structural patterns and organization
- System Coordination - How systems interact and coordinate
Systems are the heart of your application - design them thoughtfully and they'll serve you well.