Games & leaderboards
The problem
Section titled “The problem”Competitive games need two things players can trust: that a match outcome was computed by the rules (not by a cheating client or a compromised server), and that the leaderboard was not edited. Usually both live on one game server that players must take on faith.
Why Necter fits
Section titled “Why Necter fits”- Match resolution is a pure function of the moves and a seed: a committee of miners resolves each match and must agree, so no single machine decides who won.
- The leaderboard is a stateful module whose every change is a receipt agreed by validators, with events for each new score and rank change. Replays are rejected in the module itself.
- Ties and randomness come from a seed passed in the input (for example a commit-reveal value from both
players, or a scheduled task’s seed), never from
Math.random().
Architecture
Section titled “Architecture”flowchart LR P1[Player A] -- moves --> G[Game backend] P2[Player B] -- moves --> G G -- "task: resolve(match, seed, moves)" --> C[Committee of miners<br/>match_resolver] C -- finalized winner --> G G -- "/v1/execute submitScore(player, score, match)" --> L[(leaderboard<br/>on validators)] W[Web and app clients] -- "top / best (via your API)" --> L
The referee: a stateless match resolver (Go)
Section titled “The referee: a stateless match resolver (Go)”Rock-paper-scissors over several rounds; ties are broken with a coin derived from the seed. It imports only
hive.abort, hive.emit and crypto.hash, so it can be a committee worker.
// match_resolver: a stateless game referee. The same seed and moves always give// the same result, so a committee of miners can agree on who won.package main
import ( "errors" "fmt" "strconv"
hivekit "github.com/necter-network/hivekit-go")
type player struct { Player string `json:"player"` Moves []string `json:"moves"`}
type matchIn struct { Match string `json:"match"` Seed string `json:"seed"` // e.g. the task seed of a scheduled project, or a commit-reveal value A player `json:"a"` B player `json:"b"`}
type matchOut struct { Match string `json:"match"` Winner string `json:"winner"` // "" on a draw Score [2]int `json:"score"` Rounds []string `json:"rounds"`}
// beats[x] is the move x defeats.var beats = map[string]string{"rock": "scissors", "paper": "rock", "scissors": "paper"}
func init() { hivekit.DefineJSON("resolve", func(in matchIn) (matchOut, error) { if in.Match == "" || len(in.Seed) != 66 { return matchOut{}, errors.New("match and a 0x-prefixed 32-byte seed are required") } if len(in.A.Moves) == 0 || len(in.A.Moves) != len(in.B.Moves) || len(in.A.Moves) > 99 { return matchOut{}, errors.New("both players need the same number of moves (1..99)") } out := matchOut{Match: in.Match, Rounds: make([]string, len(in.A.Moves))} for i, ma := range in.A.Moves { mb := in.B.Moves[i] if _, ok := beats[ma]; !ok { return matchOut{}, fmt.Errorf("round %d: unknown move %q", i, ma) } if _, ok := beats[mb]; !ok { return matchOut{}, fmt.Errorf("round %d: unknown move %q", i, mb) } w := "a" switch { case ma == mb: // Ties are broken by a coin derived from the seed (no randomness on a node). coin, _ := strconv.ParseUint(hivekit.Hash([]byte(in.Seed+":"+in.Match+":"+strconv.Itoa(i)))[2:4], 16, 8) if coin%2 == 1 { w = "b" } case beats[mb] == ma: w = "b" } out.Rounds[i] = w if w == "a" { out.Score[0]++ } else { out.Score[1]++ } } if out.Score[0] > out.Score[1] { out.Winner = in.A.Player } else if out.Score[1] > out.Score[0] { out.Winner = in.B.Player } if err := hivekit.Emit("match.resolved", map[string]any{"match": in.Match, "a": out.Score[0], "b": out.Score[1]}); err != nil { return matchOut{}, err } return out, nil })}
func main() {}hivec build .ndsr run dist/match_resolver.hbc resolve --input '{"match":"m-42","seed":"0x6c3e2f1a9b8d7c6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e","a":{"player":"ada","moves":["rock","paper","paper"]},"b":{"player":"bob","moves":["scissors","paper","scissors"]}}'Recorded: success: true, gas_used: 379274, output
{"match":"m-42","winner":"bob","score":[1,2],"rounds":["a","b","b"]}and the event match.resolved {"a":1,"b":2,"match":"m-42"}. An unknown move fails the call with
guest abort: round 1: unknown move "lizard".
The leaderboard: verified state (Rust)
Section titled “The leaderboard: verified state (Rust)”One submission per match id (replays fail), a best score per player, a top-10 table with deterministic tie breaking, and events for every change.
//! Leaderboard with verified state: best score per player, a top-10 table,//! one submission per match id, and events for every change.//!//! Stateful (storage), so it runs on validators through POST /v1/execute.use hivekit::prelude::*;use serde::{Deserialize, Serialize};
const TOP_N: usize = 10;const MAX_SCORE: u64 = 1_000_000_000;
#[derive(Deserialize)]struct Submission { player: String, score: u64, #[serde(rename = "match")] match_id: String,}
#[derive(Serialize, Deserialize, Clone, PartialEq)]struct Entry { player: String, score: u64,}
fn valid_id(s: &str) -> bool { !s.is_empty() && s.len() <= 32 && s.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_' || c == b'-')}
fn load_top() -> Vec<Entry> { storage::get_json("top").unwrap_or_default()}
/// Record a finished match. The same match id can only be submitted once.#[hive_export("submitScore")]fn submit_score(s: Submission) -> Result<Value, String> { if !valid_id(&s.player) || !valid_id(&s.match_id) { return Err("player and match must be 1..=32 chars of a-z, 0-9, _ or -".into()); } if s.score > MAX_SCORE { return Err(format!("score must be <= {MAX_SCORE}")); } let match_key = format!("match:{}", s.match_id); if storage::get(&match_key).is_some() { return Err(format!("match {} was already submitted", s.match_id)); } storage::set_json(&match_key, &json!({ "player": s.player, "score": s.score }));
let best_key = format!("best:{}", s.player); let best: u64 = storage::get_json(&best_key).unwrap_or(0); let personal_best = s.score > best; if personal_best { storage::set_json(&best_key, &s.score); } emit( "score.submitted", &json!({ "player": s.player, "score": s.score, "match": s.match_id }), );
// Update the top table: one row per player, highest score first, ties by name. let before = load_top(); let mut top: Vec<Entry> = before.iter().filter(|e| e.player != s.player).cloned().collect(); top.push(Entry { player: s.player.clone(), score: best.max(s.score) }); top.sort_by(|a, b| b.score.cmp(&a.score).then(a.player.cmp(&b.player))); top.truncate(TOP_N); let changed = top != before; if changed { storage::set_json("top", &top); let rank = top.iter().position(|e| e.player == s.player).map(|i| i + 1); emit("leaderboard.changed", &json!({ "player": s.player, "rank": rank })); } Ok(json!({ "personal_best": personal_best, "leaderboard_changed": changed }))}
/// The current top table.#[hive_export]fn top() -> Value { json!({ "top": load_top() })}
/// Best score of `{"player": …}` (0 when unknown).#[hive_export]fn best(input: Value) -> Result<Value, String> { let player = input["player"].as_str().ok_or("`player` is required")?; let score: u64 = storage::get_json(&format!("best:{player}")).unwrap_or(0); Ok(json!({ "player": player, "best": score }))}
hive_module!(submit_score, top, best);
#[cfg(test)]mod tests { use super::*; use hivekit::testing;
#[test] fn ranks_and_rejects_replays() { testing::reset(); let m = &HIVE_MODULE; m.invoke("submitScore", json!({"player": "ada", "score": 900, "match": "m1"})).unwrap(); m.invoke("submitScore", json!({"player": "bob", "score": 1200, "match": "m2"})).unwrap(); assert!(m.invoke("submitScore", json!({"player": "bob", "score": 5, "match": "m2"})).is_err()); let t = m.invoke("top", json!(null)).unwrap(); assert_eq!(t["top"][0]["player"], "bob"); assert_eq!(t["top"][1]["score"], 900); assert_eq!(testing::events()[0].name, "score.submitted"); }}Run a sequence with persistent state:
hivec buildndsr run dist/leaderboard.hbc submitScore --data-dir .node --input '{"player":"ada","score":900,"match":"m1"}'ndsr run dist/leaderboard.hbc submitScore --data-dir .node --input '{"player":"bob","score":1200,"match":"m2"}'ndsr run dist/leaderboard.hbc submitScore --data-dir .node --input '{"player":"bob","score":5,"match":"m2"}'ndsr run dist/leaderboard.hbc top --data-dir .node| Call | Result | Gas | Events |
|---|---|---|---|
| ada 900 (m1) | {"leaderboard_changed":true,"personal_best":true} |
60 732 | score.submitted, leaderboard.changed |
| bob 1200 (m2) | {"leaderboard_changed":true,"personal_best":true} |
69 554 | score.submitted, leaderboard.changed |
| bob 5 (m2 again) | fails: guest abort: match m2 was already submitted |
10 267 | none (nothing written) |
| top | {"top":[{"player":"bob","score":1200},{"player":"ada","score":900}]} |
27 381 | none |
cargo test runs the included unit test against the SDK’s mock host.
Publish it
Section titled “Publish it”- Referee: upload
match_resolver.hbcand register a project withtask_source: {"kind": "api"},functions: ["resolve"], amax_gas_limitaround 2,000,000 and a shortround_secs(for example 20) for snappy matches. Register a project - Leaderboard: upload
leaderboard.hbc; it is called directly through validators. Keep its address in your backend.
Consume it
Section titled “Consume it”import { Hive } from 'hivejs'
const RPC = 'https://testnet-rpc.necter.network'const r = await fetch(`${RPC}/v1/projects/${process.env.REFEREE_PROJECT}/tasks?wait=60`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${process.env.NECTER_API_KEY}` }, body: JSON.stringify({ function: 'resolve', input: match, idempotency_key: match.match }),})const task = await r.json()const result = JSON.parse(task.output) // {"match","winner","score","rounds"}
const board = new Hive(process.env.LEADERBOARD_MODULE, { gatewayUrl: RPC })if (result.winner) { await board.call('submitScore', { player: result.winner, score: 100 * result.score[result.winner === match.a.player ? 0 : 1], match: match.match })}const top = await board.call('top')console.log(top.data.top)Considerations
Section titled “Considerations”- Who can write? Calls carry no caller identity. Keep writes in your backend, and if the module must accept writes from several parties, verify a signature inside it (see attestation).
- Idempotency: the match id makes
submitScoresafe to retry: a duplicate fails without changing state. - Fair randomness: use commit-reveal (each player commits
hash(secret), then reveals; the seed is the hash of both secrets) or a scheduled project’s seed. - Costs: the referee costs about 0.38 M gas per match (1 compute unit per committee member). Leaderboard writes run on validators and earn nobody units; they are free on the testnet, rate-limited per IP or API key.