For game developers
Trophies for studios
Give your players soulbound Rare Trophies on Sui: get a GameCap, define bronze to gold trophies and a platinum, and mint unlocks from your game.
Rare Trophies are PlayStation-style trophies on Sui. Each trophy is a soulbound object in the player's wallet, and a shared profile per player keeps their score. Players see them in Rare OS and on their rare.market profile.
On this page, P is the rare_trophies package id, REG is the shared Registry, G is your game's shared Game, PROF is a player's shared Profile and 0x6 is the Sui Clock.
How it fits together
Each game is its own shared object, so unlocks in different games never contend.
Tiers and points
Points are fixed per tier, so no game can inflate scores.
Each game has one platinum. It is never minted directly: it unlocks in the same transaction as the player's last trophy of the platinum set.
Studio issued and self-reported
Attested trophies come from data on the player's device (such as Steam's local achievement files), which the device could fake. They are stored, shown and counted as self-reported. Only your GameCap can issue studio-issued trophies. A platinum counts as self-reported if any trophy in its set was.
Get a GameCap
Rare registers your game and transfers its GameCap to you. Registration creates the shared Game with:
- a unique slug of the form
<platform>:<id>, for exampleweb:actorrare:<title>, - the game's name, platform and image URL,
- whether the game accepts Rare attestations.
Keep the cap safe. If it is lost or leaked, Rare can issue a new one, which makes every earlier cap stop working (EWrongCap).
Define trophies
set_platinumcan be called again to replace the set only until your game's first unlock. After that it is frozen (EPlatinumFrozen), so trophies you add later (DLC) stay outside the set.- A hidden trophy shows as a hidden trophy until the player unlocks it.
image_urlis a Walrus aggregator URL or any https URL. A minted trophy keeps the text and image it was minted with, even if you edit the definition later.- Text limits: key 128 bytes, name 128, description 512, URL 512 (
ETooLong).
Define three trophies and a platinum in one transaction:
import { SuiGrpcClient } from "@mysten/sui/grpc";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Transaction } from "@mysten/sui/transactions";
const P = "0x..."; // rare_trophies package id
const GAME = "0x..."; // your shared Game
const GAME_CAP = "0x..."; // your GameCap
const client = new SuiGrpcClient({ network: "mainnet", baseUrl: "https://fullnode.mainnet.sui.io:443" });
const studio = Ed25519Keypair.fromSecretKey(process.env.STUDIO_KEY!); // the address that holds the GameCap
const BRONZE = 0, SILVER = 1, GOLD = 2;
const trophies = [
{ key: "first_win", name: "First Win", description: "Win your first match.", image: "https://mygame.example/t/first-win.png", tier: BRONZE },
{ key: "ten_wins", name: "Regular", description: "Win ten matches.", image: "https://mygame.example/t/ten-wins.png", tier: SILVER },
{ key: "boss", name: "Giant Slayer", description: "Beat the final boss.", image: "https://mygame.example/t/boss.png", tier: GOLD },
];
const tx = new Transaction();
const cap = tx.object(GAME_CAP);
const game = tx.object(GAME);
for (const t of trophies) {
tx.moveCall({
target: `${P}::game::add_trophy`,
arguments: [cap, game, tx.pure.string(t.key), tx.pure.string(t.name), tx.pure.string(t.description), tx.pure.string(t.image), tx.pure.u8(t.tier), tx.pure.bool(false)],
});
}
// A new game's ids start at 0, so the set is [0, 1, 2].
tx.moveCall({
target: `${P}::game::set_platinum`,
arguments: [cap, game, tx.pure.string("platinum"), tx.pure.string("Completionist"), tx.pure.string("Earn every trophy."), tx.pure.string("https://mygame.example/t/platinum.png"), tx.pure.vector("u64", [0, 1, 2])],
});
const res = await client.signAndExecuteTransaction({ transaction: tx, signer: studio });
const done = res.Transaction ?? res.FailedTransaction;
if (!done.status.success) throw new Error(JSON.stringify(done.status.error));
console.log("defined", done.digest);Mint unlocks
P::game::mint(cap: &GameCap, reg: &Registry, game: &mut Game, profile: &mut Profile, trophy_id: u64, clock: &Clock): IDThe trophy goes to the profile's player, and the call returns the new Trophy id.
The player's profile
A profile is created once per address, and anyone can create it. Look it up in REG.profiles[player]. If the player has none yet, create it, mint, and share it in the same transaction (a Profile has key only, so the transaction that creates it must share it):
p = P::profile::create(REG, player) // EProfileExists if one exists
P::game::mint(cap, REG, G, p, trophy_id, 0x6)
P::profile::share(p)Studio unlock from your server
This finds the player's profile, then mints one trophy, opening the profile first when needed. Your server signs with the address that holds the GameCap, so it pays the gas.
import { bcs } from "@mysten/sui/bcs";
import { SuiGrpcClient } from "@mysten/sui/grpc";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Transaction } from "@mysten/sui/transactions";
import { deriveDynamicFieldID, normalizeSuiAddress } from "@mysten/sui/utils";
const P = "0x..."; // rare_trophies package id
const REG = "0x..."; // shared Registry
const GAME = "0x..."; // your shared Game
const GAME_CAP = "0x..."; // your GameCap
const client = new SuiGrpcClient({ network: "mainnet", baseUrl: "https://fullnode.mainnet.sui.io:443" });
const studio = Ed25519Keypair.fromSecretKey(process.env.STUDIO_KEY!);
const TABLE = bcs.struct("Table", { id: bcs.Address, size: bcs.u64() });
const REGISTRY = bcs.struct("Registry", {
id: bcs.Address,
version: bcs.u64(),
paused: bcs.bool(),
issuer_key: bcs.vector(bcs.u8()),
games: TABLE,
profiles: TABLE,
});
const PROFILE_FIELD = bcs.struct("Field", { id: bcs.Address, name: bcs.Address, value: bcs.Address });
async function content(id: string): Promise<Uint8Array | null> {
try {
const { object } = await client.core.getObject({ objectId: id, include: { content: true } });
return object.content ?? null;
} catch {
return null;
}
}
/** The player's shared Profile id, or null before their first trophy. */
async function profileOf(player: string): Promise<string | null> {
const reg = REGISTRY.parse((await content(REG))!);
const fieldId = deriveDynamicFieldID(reg.profiles.id, "address", bcs.Address.serialize(player).toBytes());
const field = await content(fieldId);
return field ? PROFILE_FIELD.parse(field).value : null;
}
export async function unlock(playerAddress: string, trophyId: number) {
const player = normalizeSuiAddress(playerAddress);
const profileId = await profileOf(player);
const tx = new Transaction();
const reg = tx.object(REG);
const profile = profileId
? tx.object(profileId)
: tx.moveCall({ target: `${P}::profile::create`, arguments: [reg, tx.pure.address(player)] });
tx.moveCall({
target: `${P}::game::mint`,
arguments: [tx.object(GAME_CAP), reg, tx.object(GAME), profile, tx.pure.u64(trophyId), tx.object.clock()],
});
if (!profileId) tx.moveCall({ target: `${P}::profile::share`, arguments: [profile] });
const res = await client.signAndExecuteTransaction({ transaction: tx, signer: studio });
const done = res.Transaction ?? res.FailedTransaction;
if (!done.status.success) throw new Error(JSON.stringify(done.status.error));
return done.digest;
}Before you mint, you can skip players who already hold the trophy by reading game::claimed(G, trophy_id, player), or by checking for the ClaimKey { trophy_id, player } dynamic field on your Game.
Studio unlock from your own Move code
GameCap has store, so your own contract can hold it and mint when your game's on-chain rules say the player earned a trophy. Players then claim in their own transactions.
module my_game::rewards;
use rare_trophies::game::{Self, Game, GameCap};
use rare_trophies::profile::Profile;
use rare_trophies::registry::Registry;
use sui::clock::Clock;
const ENotYourProfile: u64 = 1;
const ENotBeaten: u64 = 2;
/// Holds the studio's GameCap so the game's own rules decide who gets a trophy.
public struct TrophyVault has key {
id: UID,
cap: GameCap,
boss_trophy: u64,
}
/// A player's run, written by the rest of your game.
public struct Run has key {
id: UID,
player: address,
boss_beaten: bool,
}
public fun create_vault(cap: GameCap, boss_trophy: u64, ctx: &mut TxContext) {
transfer::share_object(TrophyVault { id: object::new(ctx), cap, boss_trophy });
}
/// The player claims the boss trophy once their run shows the boss beaten.
public fun claim_boss_trophy(
vault: &TrophyVault,
run: &Run,
reg: &Registry,
game: &mut Game,
profile: &mut Profile,
clock: &Clock,
ctx: &mut TxContext,
): ID {
assert!(profile.player() == ctx.sender() && run.player == ctx.sender(), ENotYourProfile);
assert!(run.boss_beaten, ENotBeaten);
game::mint(&vault.cap, reg, game, profile, vault.boss_trophy, clock, ctx)
}A second unlock of the same trophy for the same player aborts with EAlreadyUnlocked, whichever path the first one used.
Attested unlocks
The attested path lets anyone submit an unlock that Rare's issuer key signed. Rare Launcher uses it to turn Steam achievements read on the device into self-reported trophies. It only works for games registered with attestations on.
Rare's attestation service signs Steam achievements only, and it is not live yet. Studios mint their own trophies with the GameCap.
P::game::mint_attested(reg: &Registry, game: &mut Game, profile: &mut Profile, trophy_id: u64, nonce: u64, expires_ms: u64, signature: vector<u8>, clock: &Clock): ID- Anyone can submit it: the player paying their own gas, a relayer, or a sponsored transaction (player as sender, sponsor as gas owner). The trophy always goes to the profile's player, the address the issuer signed for.
- Checks, in order: the game accepts attestations (
EAttestDisabled), an issuer key is set (ENoIssuer), the clock is beforeexpires_ms(EExpired), the ed25519 signature is valid (EBadSignature), and the (player, nonce) pair is unused in this game (ENonceUsed). Then the usual unlock checks run.
Message format
The issuer signs these raw bytes with ed25519 (no hashing, 64-byte signature):
"RARE_TROPHY_V1" || registry id (32) || player (32) || game id (32) || trophy_id (u64 LE) || nonce (u64 LE) || expires_ms (u64 LE)game::attestation_message(registry, player, game, trophy_id, nonce, expires_ms) returns the same bytes on chain. The registry id ties each signature to one deployment, so a testnet signature can't be replayed on mainnet.
const DOMAIN = new TextEncoder().encode("RARE_TROPHY_V1");
function addressBytes(a: string): Uint8Array {
const hex = a.toLowerCase().replace(/^0x/, "").padStart(64, "0");
return Uint8Array.from({ length: 32 }, (_, i) => parseInt(hex.slice(i * 2, i * 2 + 2), 16));
}
function u64le(v: bigint): Uint8Array {
const out = new Uint8Array(8);
for (let i = 0; i < 8; i++) out[i] = Number((v >> BigInt(8 * i)) & 0xffn);
return out;
}
export function attestationMessage(a: { registry: string; player: string; game: string; trophyId: bigint; nonce: bigint; expiresMs: bigint }): Uint8Array {
const parts = [DOMAIN, addressBytes(a.registry), addressBytes(a.player), addressBytes(a.game), u64le(a.trophyId), u64le(a.nonce), u64le(a.expiresMs)];
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
}Claim transaction
Several attestations for one game can go in one transaction. Rare Launcher builds it like this:
import { Transaction } from "@mysten/sui/transactions";
type Attestation = { trophyId: bigint; nonce: bigint; expiresMs: bigint; signature: Uint8Array };
export function buildClaimTx(o: { pkg: string; registry: string; game: string; player: string; profileId: string | null; attestations: Attestation[] }) {
const tx = new Transaction();
tx.setSender(o.player);
const reg = tx.object(o.registry);
const game = tx.object(o.game);
const profile = o.profileId
? tx.object(o.profileId)
: tx.moveCall({ target: `${o.pkg}::profile::create`, arguments: [reg, tx.pure.address(o.player)] });
for (const a of o.attestations) {
tx.moveCall({
target: `${o.pkg}::game::mint_attested`,
arguments: [reg, game, profile, tx.pure.u64(a.trophyId), tx.pure.u64(a.nonce), tx.pure.u64(a.expiresMs), tx.pure.vector("u8", Array.from(a.signature)), tx.object.clock()],
});
}
if (!o.profileId) tx.moveCall({ target: `${o.pkg}::profile::share`, arguments: [profile] });
return tx;
}Rare can pay the network fee for a new player's trophy claims. See Gas sponsoring.
Reading trophies
Views on Game: slug, name, platform, image_url, attested, paused, trophy_count, platinum, platinum_set, unlocks, players, trophy_def(id), trophy_by_key(key), claimed(id, player), nonce_used(player, nonce) and progress(player) (earned, points, platinum set earned). Rarity is trophy_def(id).unlocked / players.
Off chain, read the tables over gRPC: derive the dynamic field id from the table's id and the BCS key (0x1::string::String for slugs and trophy keys, address for profiles), as profileOf does above.
A minted Trophy has game, game_name, trophy_id, name, description, image_url, tier, tier_name, points, source, source_name, number (1 for the first player to unlock it) and unlocked_at_ms.
Events
Errors
The registry aborts with 101 EOutdated or 102 EPaused when it needs migrating or all unlocks are paused, and 105 EProfileExists when profile::create is called for a player who has a profile.
Gas
Measured net gas per action:
