Skip to content
⌘K

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.

Not yet live on mainnet The package id appears here once the package is published.

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

ObjectOwnerWhat it does
registry::RegistrySharedIndexes every game by slug and every profile by address.
game::GameShared, one per titleYour trophy list, your platinum and its set, counters, and who unlocked what.
game::GameCapYou, the studioDefines and edits your trophies, sets your platinum, and mints unlocks. Only the cap whose id is stored in your Game works.
profile::ProfileShared, one per playerPoints, trophy count, count per tier, games played, and the self-reported share.
trophy::TrophyThe playerSoulbound: it has key only, so it can't be transferred, wrapped or burned.

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.

Tieru8Points
Bronze015
Silver130
Gold290
Platinum3300

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

sourceNameHow it is minted
0Studio issuedgame::mint, with your GameCap
1Self-reportedgame::mint_attested, with a Rare attestation

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 example web:act or rare:<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

CallEffect
game::add_trophy(cap, G, key, name, description, image_url, tier, hidden): u64Adds a bronze, silver or gold trophy. Ids are assigned in order: 0, 1, 2 and so on. key is your own id for it and must be unique in the game.
game::update_trophy(cap, G, id, name, description, image_url, hidden)Edits text, image and the hidden flag. The tier never changes.
game::set_platinum(cap, G, key, name, description, image_url, set: vector<u64>): u64Defines the platinum and its set: 1 to 2048 existing, distinct, non-platinum ids.
game::set_info(cap, G, name, image_url)Edits the game's name and image.
  • set_platinum can 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_url is 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:

TypeScript
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

Move
P::game::mint(cap: &GameCap, reg: &Registry, game: &mut Game, profile: &mut Profile, trophy_id: u64, clock: &Clock): ID

The 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):

Text
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.

TypeScript
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.

Move
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.

Move
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 before expires_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):

Text
"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.

TypeScript
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:

TypeScript
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

EventWhen
TrophyUnlocked { trophy, game, trophy_id, player, tier, points, source, number, unlocked_at_ms }Every unlock, platinum included
ProfileCreated { profile, player }profile::create
TrophyDefined { game, trophy_id, key, name, tier, points, hidden }add_trophy, update_trophy, set_platinum
PlatinumSet { game, trophy_id, set }set_platinum
GameUpdated { game, name, image_url, attested, paused }set_info, and when Rare changes attestations or pauses the game

Errors

CodeNameMeaning
201EOutdatedThe Game needs migrating to the current package version.
202EPausedUnlocks in this game are paused.
203EWrongCapNot this game's current GameCap.
204ENoTrophyNo trophy with that id.
205EAlreadyUnlockedThe player already has this trophy.
206EPlatinumIsAutoThe platinum can't be minted or added directly.
207EAttestDisabledThe game does not accept attestations.
208ENoIssuerNo issuer key is set.
209EExpiredThe attestation has expired.
210EBadSignatureThe attestation signature is not valid.
211ENonceUsedThis nonce was already used for this player.
212EPlatinumFrozenThe platinum set can't change after the first unlock.
213EBadSetThe platinum set is empty, too large, repeats ids or names a missing or platinum trophy.
214ETooLongA text field is over its limit.
215EKeyTakenThat key is already used in this game.

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:

ActionNet gas (SUI)
Define three trophies and a platinum0.0175
Studio mint0.0051
Studio mint that also opens the profile0.0121
Studio mint that also unlocks the platinum0.0102
Attested mint0.0072 to 0.0097
--
TPS:--