Pac-Man On-Chain Game
Transitional example: This example uses an older Cougr pattern and is preserved for compatibility reference. For the current recommended approach, see
snake.
A practical example demonstrating how to use the cougr-core package to implement on-chain game logic on the Stellar blockchain via Soroban.
Overview
This example implements a complete Pac-Man game as a Soroban smart contract. The focus is exclusively on the smart contract logic (without graphical interface), showcasing how cougr-core simplifies on-chain game development.
Features
- 10x10 maze with walls, pellets, and power pellets
- Pac-Man movement with direction control
- 4 ghosts with simplified AI (chase and frightened modes)
- Score tracking and lives system
- Win/lose conditions
- Complete test coverage
Architecture
The example follows a modular architecture, separating domain types, ECS components, game systems, and contract entrypoints.
Module Layout
src/lib.rs: Main contract entrypoints and orchestrationsrc/types.rs: Game enums, state structures, and storage keyssrc/components.rs: ECS-inspired data structures (Position, Ghost)src/maze.rs: Maze layout and generation logicsrc/systems.rs: Core game loop and logicsrc/test.rs: Comprehensive test suiteREADME.md: This fileCargo.toml: Soroban SDK dependencies
┌─────────────────────────────────────────────────────────────┐
│ Pac-Man Contract (lib.rs) │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ GameState │ │ Maze │ │ Ghosts[4] │ │
│ │ (types.rs) │ │ (maze.rs) │ │ (components.rs) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ GameSystems (systems.rs) │ │
│ │ - move_pacman │ │
│ │ - move_ghosts │ │
│ │ - check_collisions │ │
│ └───────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Contract Functions │
│ ┌──────────────┐ ┌────────────────┐ ┌──────────────────┐ │
│ │ init_game │ │change_direction│ │ update_tick │ │
│ └──────────────┘ └────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Maze Layout
The game uses a fixed 10x10 maze:
##########
#P......P#
#.##.##..#
#.#...#..#
#...#....#
#.#.#.##.#
#.#......#
#.##.###.#
#P......P#
##########
Legend:
# = Wall
. = Pellet (10 points)
P = Power Pellet (50 points)
Prerequisites
Required Tools
-
Rust (1.88.0 or later)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh rustup update -
Stellar CLI
cargo install stellar-cli --locked stellar --version -
WASM Target
rustup target add wasm32v1-none
Verify Installation
rustc --version # Should be 1.88.0 or later
cargo --version
stellar --version # Should show Stellar CLI version
Setup
1. Clone the Repository
git clone https://github.com/salazarsebas/Cougr.git
cd Cougr/examples/pac_man
2. Update Dependencies
cargo update
3. Build the Contract
# Debug build
cargo build
# Release build (optimized for WASM)
cargo build --release
4. Build for Soroban
stellar contract build
This generates the WASM file at:
target/wasm32v1-none/release/pac_man.wasm
Testing
Run All Tests
cargo test
Run Tests with Output
cargo test -- --nocapture
Run Specific Test
cargo test test_init_game
cargo test test_pellet_collection
cargo test test_ghost_ai
Expected Output
running 25 tests
test test::test_init_game ... ok
test test::test_maze_layout ... ok
test test::test_ghosts_initialized ... ok
test test::test_change_direction_up ... ok
test test::test_change_direction_down ... ok
...
test result: ok. 25 passed; 0 failed; 0 ignored
Code Walkthrough
1. Contract Types
The game uses Soroban-compatible types with #[contracttype]:
#![allow(unused)] fn main() { #[contracttype] #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u32)] pub enum Direction { Up = 0, Down = 1, Left = 2, Right = 3, } #[contracttype] #[derive(Clone, Debug)] pub struct GameState { pub pacman_pos: Position, pub pacman_dir: Direction, pub ghosts: Vec<Ghost>, pub maze: Vec<CellType>, pub score: u32, pub lives: u32, pub game_over: bool, // ... } }
2. Storage Pattern
Game state is stored using Soroban's instance storage:
#![allow(unused)] fn main() { #[contracttype] pub enum DataKey { GameState, Initialized, } // Writing state env.storage().instance().set(&DataKey::GameState, &state); // Reading state let state: GameState = env.storage() .instance() .get(&DataKey::GameState) .expect("Game not initialized"); }
3. Cougr-Core Integration
The contract uses cougr-core for ECS patterns and event handling:
#![allow(unused)] fn main() { use cougr_core::component::{ComponentTrait, Position as CorePosition}; use cougr_core::event::{CollisionEvent, Event, EventTrait}; }
Position Component: Game positions integrate with cougr_core's Position:
#![allow(unused)] fn main() { impl Position { /// Convert to cougr_core Position for ECS integration pub fn to_core_position(&self) -> CorePosition { CorePosition::new(self.x, self.y) } } }
Collision Events: Ghost collisions use cougr_core's event system:
#![allow(unused)] fn main() { // Each ghost has an entity_id for collision tracking pub struct Ghost { pub entity_id: u64, // ... } // Create collision events using cougr_core let collision_event = CollisionEvent::new( PACMAN_ENTITY_ID, ghost.entity_id, symbol_short!("ghost"), ); }
Query Functions: Get serialized component data:
#![allow(unused)] fn main() { // Get Pac-Man's position as serialized cougr_core component pub fn get_serialized_pacman_position(env: Env) -> Bytes { let core_pos = state.pacman_pos.to_core_position(); core_pos.serialize(&env) // Uses ComponentTrait } }
This provides:
- Component patterns: Position, Velocity components with serialization
- Entity IDs: Unique identifiers for Pac-Man and ghosts
- Event system: CollisionEvent for standardized collision handling
- ComponentTrait: Serialization/deserialization for on-chain storage
4. Game Loop
The update_tick function implements the main game loop:
#![allow(unused)] fn main() { pub fn update_tick(env: Env) -> GameState { let mut state = Self::get_state(&env); // 1. Move Pac-Man Self::move_pacman(&env, &mut state); // 2. Check pellet collection Self::check_pellet_collection(&env, &mut state); // 3. Move ghosts Self::move_ghosts(&env, &mut state); // 4. Check collisions Self::check_ghost_collisions(&env, &mut state); // 5. Update timers if state.power_mode_timer > 0 { state.power_mode_timer -= 1; } // 6. Check win condition if state.pellets_remaining == 0 { state.game_over = true; state.won = true; } env.storage().instance().set(&DataKey::GameState, &state); state } }
Deployment
1. Generate a Stellar Identity
stellar keys generate --global pacman-deployer --network testnet
2. Fund the Account
stellar keys fund pacman-deployer --network testnet
Or use Friendbot:
curl "https://friendbot.stellar.org?addr=$(stellar keys address pacman-deployer)"
3. Deploy the Contract
stellar contract deploy \
--wasm target/wasm32v1-none/release/pac_man.wasm \
--source pacman-deployer \
--network testnet
Save the returned contract ID (e.g., <CONTRACT_ID>).
4. Initialize the Game
stellar contract invoke \
--id <CONTRACT_ID> \
--source pacman-deployer \
--network testnet \
-- \
init_game
5. Play the Game
Change direction:
stellar contract invoke \
--id <CONTRACT_ID> \
--source pacman-deployer \
--network testnet \
-- \
change_direction \
--direction 2 # 0=Up, 1=Down, 2=Left, 3=Right
Update game state:
stellar contract invoke \
--id <CONTRACT_ID> \
--source pacman-deployer \
--network testnet \
-- \
update_tick
6. Query Game State
Get score:
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- \
get_score
Get full state:
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- \
get_game_state
Check if game is over:
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- \
check_game_over
Troubleshooting
Common Errors
"Game not initialized"
Call init_game before any other function.
"Game already initialized"
Each contract instance can only be initialized once. Deploy a new instance to start a fresh game.
"Game is over"
The game has ended. Deploy a new contract to play again.
Rust Version Errors
rustup update
rustup default stable
WASM Build Fails
rustup target add wasm32v1-none
Dependency Resolution Issues
cargo clean
cargo update
cargo build
Stellar CLI Not Found
cargo install stellar-cli --locked
Debug Tips
-
Verbose Build
cargo build --verbose -
Check Contract Size
ls -la target/wasm32v1-none/release/pac_man.wasmShould be under 64KB.
-
Simulate Before Deploy
stellar contract invoke \ --id <CONTRACT_ID> \ --source pacman-deployer \ --network testnet \ --sim \ -- \ init_game
Game Mechanics
Scoring
| Action | Points |
|---|---|
| Regular Pellet | 10 |
| Power Pellet | 50 |
| Eat Ghost | 200 |
Lives
- Start with 3 lives
- Lose 1 life when caught by a ghost in chase mode
- Game over when lives reach 0
Power Mode
- Lasts 10 ticks
- All ghosts enter frightened mode
- Eating a ghost awards 200 points and respawns the ghost
Ghost AI
- Chase Mode: Ghosts move toward Pac-Man using Manhattan distance
- Frightened Mode: Ghosts move away from Pac-Man
Win Condition
Collect all pellets (regular and power) to win.
Testnet Deployment
The contract has been successfully deployed to Stellar Testnet:
Contract ID: <CONTRACT_ID>
Explorer Link: https://stellar.expert/explorer/testnet/contract/<CONTRACT_ID>
Verified Invocations
-
init_game - Successfully initialized with:
- Score: 0
- Lives: 3
- Pellets: 47
- 4 ghosts in chase mode
-
change_direction - Successfully changed Pac-Man direction to Down
-
update_tick - Successfully processed game tick:
- Pac-Man moved from (1,1) to (1,2)
- Collected pellet (+10 points)
- Score updated to 10
- Ghosts moved toward Pac-Man
-
get_score - Read-only query returned: 10
References
- Soroban Documentation
- Soroban Hello World Tutorial
- Stellar CLI Documentation
- Cougr Repository
- Rust Testing Guide
License
MIT OR Apache-2.0