Contracts
Trophies contract
The rare_trophies Move package: soulbound game trophies, per-game trophy lists, studio and attested unlocks, player profiles, events and PTB examples.
The rare_trophies package powers trophies in Rare OS. Each trophy is a soulbound object in the player's wallet. A shared Profile per player keeps the score, and earning every trophy in a game's platinum set unlocks its platinum automatically. The package depends only on the Sui framework.
On this page, PACKAGE_ID is the latest package id, REGISTRY_ID is the shared Registry, a Game is the shared object for one title and 0x6 is the Clock. For how studios get set up, see Trophies for studios.
Modules
Shared objects and caps
Each game is its own shared object, so unlocks in different games never wait on each other. The registry is only written when a game or a profile is created.
Tiers and points
Points are fixed per tier, so no game can inflate scores.
There is one platinum per game. It is never minted directly: it unlocks in the same transaction as the last trophy of its set, and emits its own TrophyUnlocked.
Sources
Attested trophies come from data on the player's device, such as Steam's local achievement files or web game state, and the device can fake that data. An attestation proves only that Rare's service agreed to sign. These trophies are stored, shown and counted as self-reported: Profile.self_reported and Profile.self_reported_points hold their share of the totals. A platinum counts as self-reported if any trophy in its set was. Only a studio's GameCap can issue studio trophies.
The Trophy object
public struct Trophy has key {
id: UID,
game: ID,
game_name: String,
trophy_id: u64,
name: String,
description: String,
image_url: String,
tier: u8,
tier_name: String,
points: u64,
source: u8,
source_name: String,
number: u64, // the player was the number-th to unlock it
unlocked_at_ms: u64,
}A trophy keeps the text it was minted with, even if the studio edits the definition later. image_url is a Walrus aggregator URL or any https URL.
Display fields: name, description, image_url, thumbnail_url (same as the image), project_url (https://rare.market), creator (Rare Trophies), game, tier, points, source, number, unlocked_at_ms.
Profiles
public fun create(reg: &mut Registry, player: address, ctx: &mut TxContext): Profile
public fun share(p: Profile)A profile is created once per address (105 if one exists). Anyone can create it, so a studio, a sponsor or the player can open it in the same PTB as the first unlock. Profile has key only, so that PTB must call share after the unlock.
To find an existing profile, look up the player in Registry.profiles, or call the registry::profile_id(reg, player): Option<ID> view. Then pass the shared Profile and skip create and share.
Profile fields: player, points, trophies, bronze, silver, gold, platinum, games, self_reported, self_reported_points, last_unlock_ms. Each has a view of the same name.
Unlocks
Studio unlock
public fun mint(
cap: &GameCap,
reg: &Registry,
game: &mut Game,
profile: &mut Profile,
trophy_id: u64,
clock: &Clock,
ctx: &mut TxContext,
): IDMints trophy_id to the profile's player and returns the new Trophy id. Aborts: 203 the cap is not the game's current cap, plus the shared checks below.
Attested unlock
public fun mint_attested(
reg: &Registry,
game: &mut Game,
profile: &mut Profile,
trophy_id: u64,
nonce: u64,
expires_ms: u64,
signature: vector<u8>,
clock: &Clock,
ctx: &mut TxContext,
): IDAnyone 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 (
207). - An issuer key is set (
208). clock < expires_ms(209).- The ed25519 signature is valid (
210). - The (player, nonce) pair is unused in this game (
211). This is the replay guard. - The shared checks below.
Attestation 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 never be replayed on mainnet.
Shared unlock checks
- The registry is current and not paused (
101,102). - The game is current and not paused (
201,202). - The trophy exists (
204) and is not the platinum (206). - One per player per trophy: a second unlock of the same (game, trophy, player) aborts with
205, whichever path the first one used.
The unlock then increments the trophy's unlock count, the game's unlocks and (for a new player) players, updates the player's progress and profile, emits TrophyUnlocked and sends the Trophy to the player.
Studio calls
These take the game's GameCap.
public fun add_trophy(cap: &GameCap, game: &mut Game, key: String, name: String, description: String, image_url: String, tier: u8, hidden: bool): u64
public fun update_trophy(cap: &GameCap, game: &mut Game, trophy_id: u64, name: String, description: String, image_url: String, hidden: bool)
public fun set_platinum(cap: &GameCap, game: &mut Game, key: String, name: String, description: String, image_url: String, set: vector<u64>): u64
public fun set_info(cap: &GameCap, game: &mut Game, name: String, image_url: String)add_trophyadds a bronze, silver or gold trophy (206for platinum,401for an unknown tier) and returns its id. Ids are assigned in order from 0.keyis the game's own id for the trophy (the Steam API name for Steam titles) and must be unique in the game (215).update_trophyedits text, image and the hidden flag. The tier never changes.set_platinumdefines the platinum and its set: 1 to 2048 existing, distinct, non-platinum ids (213). It can be called again to replace the set only until the game's first unlock. After that it is frozen (212), so DLC trophies added later stay outside the set.set_infoedits the game's name and image.
Text limits: slug 96 bytes, platform 32, key 128, name 128, description 512, URL 512 (214).
Games are created by the platform with create_game, which shares the Game and returns its GameCap. Slugs look like steam:620, web:<id> or rare:<title> and are unique.
Views
registry:issuer_key,paused,version,game_count,profile_count,game_id(slug),profile_id(player)game:slug,name,platform,image_url,attested,paused,valid_cap,trophy_count,platinum,platinum_set,unlocks,players,trophy_def(id)with thedef_*getters,trophy_by_key(key),claimed(id, player): Option<ID>,nonce_used(player, nonce),progress(player): (earned, points, set_earned)profile: every fieldtrophy: every field, pluspoints_for(tier),tier_name(tier),source_name(source)
Rarity of a trophy is def_unlocked(trophy_def(id)) / players(game).
Off chain you can read the tables directly. 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.
Fees
The package charges no fees. Unlocks cost gas only, and anyone (a studio, a sponsor or the player) can pay it.
Events
All events are in PACKAGE_ID::events. Indexers key on TrophyUnlocked.
Error codes
Examples
These use @mysten/sui with the Transaction builder.
Studio unlock, opening the profile if needed
import { Transaction } from "@mysten/sui/transactions";
const PACKAGE_ID = "0x..."; // latest rare_trophies package id
const REGISTRY_ID = "0x..."; // shared registry::Registry
export function studioUnlock(
tx: Transaction,
u: {
gameCapId: string;
gameId: string;
player: string;
profileId: string | null; // from registry::profile_id, or null for a first unlock
trophyId: number;
},
) {
const profile = u.profileId
? tx.object(u.profileId)
: tx.moveCall({
target: `${PACKAGE_ID}::profile::create`,
arguments: [tx.object(REGISTRY_ID), tx.pure.address(u.player)],
});
tx.moveCall({
target: `${PACKAGE_ID}::game::mint`,
arguments: [
tx.object(u.gameCapId),
tx.object(REGISTRY_ID),
tx.object(u.gameId),
profile,
tx.pure.u64(u.trophyId),
tx.object("0x6"),
],
});
if (!u.profileId) {
tx.moveCall({ target: `${PACKAGE_ID}::profile::share`, arguments: [profile] });
}
}The sender must own the GameCap. To unlock several trophies, repeat the game::mint call before share.
Submit an attested unlock
import { fromBase64 } from "@mysten/sui/utils";
// From Rare's attestation: the issuer signed for this registry, player, game and trophy.
export function attestedUnlock(
tx: Transaction,
a: { gameId: string; profileId: string; trophyId: number; nonce: bigint; expiresMs: bigint; signature: string },
) {
tx.moveCall({
target: `${PACKAGE_ID}::game::mint_attested`,
arguments: [
tx.object(REGISTRY_ID),
tx.object(a.gameId),
tx.object(a.profileId),
tx.pure.u64(a.trophyId),
tx.pure.u64(a.nonce),
tx.pure.u64(a.expiresMs),
tx.pure.vector("u8", Array.from(fromBase64(a.signature))),
tx.object("0x6"),
],
});
}Any address can send this transaction, and it can be sponsored. The trophy always goes to the profile's player. For a first unlock, create the profile with profile::create before the call and profile::share after it, as in the studio example.
Look up a game and a profile
const tx = new Transaction();
tx.moveCall({
target: `${PACKAGE_ID}::registry::game_id`,
arguments: [tx.object(REGISTRY_ID), tx.pure.string("steam:620")],
});
tx.moveCall({
target: `${PACKAGE_ID}::registry::profile_id`,
arguments: [tx.object(REGISTRY_ID), tx.pure.address(player)],
});
// Simulate it (devInspect or dry run). Each call returns a BCS Option<ID>.