The StarkMole Soroban contract handles four things: session management (start/end game rounds tied to a player's wallet), hit registration (mole clicks scored by type — standard, fast, or golden), daily leaderboards (on-chain ranked scores that reset every 24 hours via block timestamp), and reward distribution (a funded pool that top players claim directly from the contract). All state lives on-chain — no server can alter scores or intercept rewards.
// StarkMole Game Contract — Built on Stellar (Soroban) // File: contracts/starkmole_game.rs
#![no_std] use soroban_sdk::{ contract, contractimpl, contracttype, symbol_short, Address, Env, Map, Symbol, Vec, token, contracterror, };
// ─── Data Types ───────────────────────────────────────────────────────────────
#[contracttype] #[derive(Clone)] pub struct PlayerSession { pub player: Address, pub score: u64, pub start_time: u64, pub active: bool, pub day_id: u64, }
#[contracttype] #[derive(Clone)] pub struct LeaderboardEntry { pub player: Address, pub score: u64, pub rank: u32, }
#[contracttype] pub enum DataKey { Session(Address), DailyScore(Address, u64), DailyLeaderboard(u64), RewardPool(u64), RewardClaimed(Address, u64), Config, Admin, RewardToken, }
#[contracttype] #[derive(Clone)] pub struct Config { pub session_duration: u64, pub max_score: u64, pub top_n: u32, }
// ─── Errors ───────────────────────────────────────────────────────────────────
#[contracterror] #[derive(Copy, Clone)] pub enum GameError { SessionAlreadyActive = 1, NoActiveSession = 2, SessionExpired = 3, Unauthorized = 4, AlreadyClaimed = 5, NoRewardPool = 6, InvalidMole = 7, }
// ─── Contract ────────────────────────────────────────────────────────────────
#[contract] pub struct StarkMoleGame;
#[contractimpl] impl StarkMoleGame {
// ── Initialize ─────────────────────────────────────────────────────────
pub fn initialize(
env: Env,
admin: Address,
reward_token: Address,
) {
admin.require_auth();
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage().instance().set(&DataKey::RewardToken, &reward_token);
env.storage().instance().set(&DataKey::Config, &Config {
session_duration: 60, // 60 seconds per session
max_score: 10_000,
top_n: 100,
});
}
// ── Game Session ───────────────────────────────────────────────────────
/// Start a new game session
pub fn start_session(env: Env, player: Address) -> Result<(), GameError> {
player.require_auth();
let key = DataKey::Session(player.clone());
if let Some(session) = env.storage().temporary().get::<_, PlayerSession>(&key) {
if session.active {
return Err(GameError::SessionAlreadyActive);
}
}
let now = env.ledger().timestamp();
let day_id = now / 86_400;
let session = PlayerSession {
player: player.clone(),
score: 0,
start_time: now,
active: true,
day_id,
};
env.storage().temporary().set(&key, &session);
env.events().publish(
(symbol_short!("session"), symbol_short!("start")),
(player, day_id),
);
Ok(())
}
/// Register a mole hit and award points
pub fn register_hit(
env: Env,
player: Address,
mole_type: u32,
) -> Result<u64, GameError> {
player.require_auth();
let key = DataKey::Session(player.clone());
let mut session: PlayerSession = env
.storage()
.temporary()
.get(&key)
.ok_or(GameError::NoActiveSession)?;
if !session.active {
return Err(GameError::NoActiveSession);
}
let config: Config = env.storage().instance().get(&DataKey::Config).unwrap();
let now = env.ledger().timestamp();
if now - session.start_time > config.session_duration {
session.active = false;
env.storage().temporary().set(&key, &session);
return Err(GameError::SessionExpired);
}
// Points per mole type
let points: u64 = match mole_type {
1 => 10, // Standard mole
2 => 25, // Fast mole
3 => 50, // Golden mole
_ => return Err(GameError::InvalidMole),
};
let new_score = (session.score + points).min(config.max_score);
session.score = new_score;
env.storage().temporary().set(&key, &session);
env.events().publish(
(symbol_short!("mole"), symbol_short!("hit")),
(player, mole_type, new_score),
);
Ok(new_score)
}
/// End session and commit score to leaderboard
pub fn end_session(env: Env, player: Address) -> Result<u64, GameError> {
player.require_auth();
let key = DataKey::Session(player.clone());
let mut session: PlayerSession = env
.storage()
.temporary()
.get(&key)
.ok_or(GameError::NoActiveSession)?;
if !session.active {
return Err(GameError::NoActiveSession);
}
session.active = false;
env.storage().temporary().set(&key, &session);
let final_score = session.score;
let day_id = session.day_id;
// Update personal best for the day
let score_key = DataKey::DailyScore(player.clone(), day_id);
let existing: u64 = env.storage().persistent().get(&score_key).unwrap_or(0);
if final_score > existing {
env.storage().persistent().set(&score_key, &final_score);
Self::_update_leaderboard(&env, player.clone(), final_score, day_id);
}
env.events().publish(
(symbol_short!("session"), symbol_short!("end")),
(player, final_score, day_id),
);
Ok(final_score)
}
// ── Leaderboard ────────────────────────────────────────────────────────
/// Get today's leaderboard (top N players)
pub fn get_leaderboard(env: Env, day_id: u64) -> Vec<LeaderboardEntry> {
let key = DataKey::DailyLeaderboard(day_id);
env.storage()
.persistent()
.get(&key)
.unwrap_or(Vec::new(&env))
}
/// Get a player's best score for a specific day
pub fn get_player_score(env: Env, player: Address, day_id: u64) -> u64 {
let key = DataKey::DailyScore(player, day_id);
env.storage().persistent().get(&key).unwrap_or(0)
}
/// Get current day ID
pub fn get_day_id(env: Env) -> u64 {
env.ledger().timestamp() / 86_400
}
// ── Rewards ────────────────────────────────────────────────────────────
/// Fund the daily reward pool (admin or sponsor)
pub fn fund_pool(env: Env, funder: Address, day_id: u64, amount: i128) {
funder.require_auth();
let token_address: Address = env
.storage()
.instance()
.get(&DataKey::RewardToken)
.unwrap();
let contract_address = env.current_contract_address();
token::Client::new(&env, &token_address)
.transfer(&funder, &contract_address, &amount);
let pool_key = DataKey::RewardPool(day_id);
let current: i128 = env.storage().persistent().get(&pool_key).unwrap_or(0);
env.storage().persistent().set(&pool_key, &(current + amount));
env.events().publish(
(symbol_short!("pool"), symbol_short!("fund")),
(funder, day_id, amount),
);
}
/// Claim reward for a completed day (top players only)
pub fn claim_reward(
env: Env,
player: Address,
day_id: u64,
amount: i128,
) -> Result<(), GameError> {
player.require_auth();
let claim_key = DataKey::RewardClaimed(player.clone(), day_id);
if env.storage().persistent().get::<_, bool>(&claim_key).unwrap_or(false) {
return Err(GameError::AlreadyClaimed);
}
let pool_key = DataKey::RewardPool(day_id);
let pool: i128 = env.storage().persistent().get(&pool_key).unwrap_or(0);
if pool <= 0 {
return Err(GameError::NoRewardPool);
}
// Mark claimed
env.storage().persistent().set(&claim_key, &true);
// Transfer reward token to player
let token_address: Address = env
.storage()
.instance()
.get(&DataKey::RewardToken)
.unwrap();
let contract_address = env.current_contract_address();
token::Client::new(&env, &token_address)
.transfer(&contract_address, &player, &amount);
env.events().publish(
(symbol_short!("reward"), symbol_short!("claim")),
(player, day_id, amount),
);
Ok(())
}
// ── Admin ──────────────────────────────────────────────────────────────
/// Update game config — admin only
pub fn update_config(
env: Env,
caller: Address,
session_duration: u64,
max_score: u64,
) -> Result<(), GameError> {
caller.require_auth();
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
if caller != admin {
return Err(GameError::Unauthorized);
}
let config = Config { session_duration, max_score, top_n: 100 };
env.storage().instance().set(&DataKey::Config, &config);
Ok(())
}
// ── Internal ───────────────────────────────────────────────────────────
fn _update_leaderboard(env: &Env, player: Address, score: u64, day_id: u64) {
let key = DataKey::DailyLeaderboard(day_id);
let mut board: Vec<LeaderboardEntry> = env
.storage()
.persistent()
.get(&key)
.unwrap_or(Vec::new(env));
// Remove existing entry for player if present
let mut updated: Vec<LeaderboardEntry> = Vec::new(env);
for entry in board.iter() {
if entry.player != player {
updated.push_back(entry);
}
}
updated.push_back(LeaderboardEntry {
player,
score,
rank: 0,
});
env.storage().persistent().set(&key, &updated);
}
}
Scarb.toml (Stellar/Soroban config): toml[package] name = "starkmole_game" version = "0.1.0" edition = "2023_11"
[lib]
[dependencies] soroban-sdk = { version = "21.0.0" }
[dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils"] }
[profile.release] opt-level = "z" overflow-checks = true
[profile.test] opt-level = "0"