Building a Game
Source: game-components — the Cairo component library used throughout this guide.
This guide walks you through making your Starknet game compatible with the Embeddable Game Standard. By the end, your game will be mintable, scoreable, and embeddable in any EGS platform.
What Your Game Must Do
At minimum, your game contract must:
- Implement
IMinigameTokenData- Exposescore()andgame_over()so the token contract can read your game's state - Register
IMINIGAME_IDvia SRC5 - So the token contract can discover your game's capabilities - Register with the game Registry - So platforms can find and display your game
- Call
pre_actionandpost_action- Wrap every player-facing action function with these hooks to validate playability and sync state back to the token
Optionally, your game can also implement:
IMinigameTokenSettings- Named difficulty configurations (Easy, Medium, Hard)IMinigameTokenObjectives- Trackable achievements (First Win, Perfect Game)IMinigameDetails- Rich game state for display (move history, board state)
Key Concept: Token-Keyed Storage
In a traditional on-chain game, you key storage by player address:
// Traditional pattern — keyed by player address
scores: Map<ContractAddress, u64>,In EGS, all game state is keyed by token_id instead of player address. Each minted token represents a unique game session, and the token ID is the universal key across the entire system — your game contract, the token contract, platforms, and the SDK all reference the same token ID.
// EGS pattern — keyed by token ID
scores: Map<felt252, u64>,This means a single player can have multiple concurrent game sessions (one per token), and tokens can be transferred between players. To verify the caller owns the token they're acting on, use the assert_token_ownership helper in every player-facing function:
// Verify the caller owns this token before allowing any action
self.minigame.assert_token_ownership(token_id);This check calls the Denshokan token contract to confirm ownerOf(token_id) == get_caller_address().
Prerequisites
- Scarb 2.15.0+
- Starknet Foundry 0.54.1+
- Familiarity with Cairo and
#[starknet::component]patterns
Dependency Setup
Add game-components to your Scarb.toml:
[dependencies]
starknet = "2.15.1"
game_components_embeddable_game_standard = { git = "https://github.com/Provable-Games/game-components", tag = "v1.1.0" }
game_components_interfaces = { git = "https://github.com/Provable-Games/game-components", tag = "v1.1.0" }
openzeppelin_introspection = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v3.0.0" }
[dev-dependencies]
snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry", tag = "v0.55.0" }Minimal Contract Skeleton
Here's the minimum viable game contract:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IMyGame<TContractState> {
fn play(ref self: TContractState, token_id: felt252);
}
#[starknet::contract]
mod MyGame {
use game_components_embeddable_game_standard::minigame::interface::{IMINIGAME_ID, IMinigameTokenData};
use game_components_embeddable_game_standard::minigame::minigame_component::MinigameComponent;
use openzeppelin_introspection::src5::SRC5Component;
use starknet::ContractAddress;
component!(path: MinigameComponent, storage: minigame, event: MinigameEvent);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
#[abi(embed_v0)]
impl MinigameImpl = MinigameComponent::MinigameImpl<ContractState>;
impl MinigameInternalImpl = MinigameComponent::InternalImpl<ContractState>;
#[abi(embed_v0)]
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
minigame: MinigameComponent::Storage,
#[substorage(v0)]
src5: SRC5Component::Storage,
scores: Map<felt252, u64>,
game_overs: Map<felt252, bool>,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
MinigameEvent: MinigameComponent::Event,
#[flat]
SRC5Event: SRC5Component::Event,
}
#[constructor]
fn constructor(ref self: ContractState) {
// Register the minigame interface so the token can discover us
self.src5.register_interface(IMINIGAME_ID);
}
// Required: implement IMinigameTokenData
#[abi(embed_v0)]
impl MinigameTokenDataImpl of IMinigameTokenData<ContractState> {
fn score(self: @ContractState, token_id: felt252) -> u64 {
self.scores.read(token_id)
}
fn game_over(self: @ContractState, token_id: felt252) -> bool {
self.game_overs.read(token_id)
}
fn score_batch(self: @ContractState, token_ids: Span<felt252>) -> Array<u64> {
let mut results = array![];
for token_id in token_ids {
results.append(self.scores.read(*token_id));
};
results
}
fn game_over_batch(self: @ContractState, token_ids: Span<felt252>) -> Array<bool> {
let mut results = array![];
for token_id in token_ids {
results.append(self.game_overs.read(*token_id));
};
results
}
}
// Your game logic
#[abi(embed_v0)]
impl MyGameImpl of super::IMyGame<ContractState> {
fn play(ref self: ContractState, token_id: felt252) {
// Verify caller owns this token
self.minigame.assert_token_ownership(token_id);
// Validate token is playable (exists, not expired, not game_over)
self.minigame.pre_action(token_id);
// Your game logic here
self.scores.write(token_id, 100);
self.game_overs.write(token_id, true);
// Sync score and game_over back to the token contract
self.minigame.post_action(token_id);
}
}
}Next Steps
- Quick Start - Hands-on tutorial building a Number Guess game
- Score & Game Over - Deep dive on
IMinigameTokenData - Settings & Objectives - Add configurable difficulty and achievements
- Lifecycle & Playability - Token lifecycle, timing, and soulbound tokens