Skip to content

Games & leaderboards

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.

  • 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().
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/main.go
// 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() {}
Terminal window
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".

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/src/lib.rs
//! 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:

Terminal window
hivec build
ndsr 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.

  1. Referee: upload match_resolver.hbc and register a project with task_source: {"kind": "api"}, functions: ["resolve"], a max_gas_limit around 2,000,000 and a short round_secs (for example 20) for snappy matches. Register a project
  2. Leaderboard: upload leaderboard.hbc; it is called directly through validators. Keep its address in your backend.
finish-match.mjs (your game backend, Node 18+)
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)
  • 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 submitScore safe 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.