Skip to Content

Asset Management

$asset tracks per-account balances of fungible (interchangeable) things — currencies, points, resources, consumables. Balances sync to clients automatically and persist across sessions.

When to Use Assets

  • Use assets for anything you count rather than identify: currencies (gold, gems), points and XP, resources (wood, energy), fungible tokens, consumables and stackable items (potions, arrows).
  • Not for unique items with their own properties — a specific weapon, an NFT — which belong in user state or a collection; nor for profiles or room / world state.

Server API

  • $asset.get(assetId: string, account?: string): Promise<number> - Gets specific asset amount
  • $asset.getAll(account?: string): Promise<Record<string, number>> - Gets all assets
  • $asset.getMany(assetIds: string[], account?: string): Promise<Record<string, number>> - Gets balances for a subset of asset ids (cheaper than getAll when only a few are needed; absent ids are omitted, not zero-filled)
  • $asset.has(assetId: string, amount: number, account?: string): Promise<boolean> - Checks if user has sufficient assets
  • $asset.mint(assetId: string, amount: number, account?: string): Promise<Record<string, number>> - Creates new assets
  • $asset.burn(assetId: string, amount: number, account?: string): Promise<Record<string, number>> - Destroys assets
  • $asset.transfer(toAccount: string, assetId: string, amount: number): Promise<Record<string, number>> - Moves assets from the caller to toAccount and returns the caller’s balances. Throws on a non-positive amount, on transferring to yourself, and on insufficient balance — you do not need to re-check those. No trailing account parameter: the sender is always the caller.
  • $asset.clear(assetIds?: string[], account?: string): Promise<Record<string, number>> - Deletes assets and returns the post-deletion asset map. Persists synchronously, so the return value implies durable persistence. clear() / clear(undefined) wipes every asset on the account, clear([ids]) deletes only the listed ids, and clear([]) is a no-op.

When account is omitted, operations apply to the current caller ($sender.account). Specify account to operate on another user’s assets (e.g., server-side reward distribution). Use clear for seasonal resets or event-token cleanup.

server/src/server.ts
export class Server { async getMyAssets() { return await $asset.getAll(); } // Fetch only the currencies the UI actually displays, instead of all // balances. Absent ids are omitted (not zero-filled) — use // `Object.hasOwn(result, id)` to tell "asset never minted" from "balance is 0". async getCurrencies() { return await $asset.getMany(['gold', 'gems']); // → { gold: 100, gems: 7 } (no 'wood', no 'energy' if the user never had them) } getMissionReward(missionId) { const rewardTable = { 'mission_tutorial': 50, 'mission_daily': 100, 'mission_boss': 500 }; return rewardTable[missionId] || 0; } async completeMission(missionId) { const reward = this.getMissionReward(missionId); if (reward === 0) throw new Error('Invalid mission'); return await $asset.mint('gold', reward); } }
đźš«

Never expose $asset.mint or $asset.burn to clients directly. Every Server method is a public endpoint, so a method that mints what the client asks for is unlimited currency. Let the server decide amounts from game rules.

⚠️

Each mint / burn on one account is atomic, and every operation emits a change notification to connected clients. Operations with invalid amounts (negative, non-integer, or non-finite) throw.

transfer is the exception: it is a burn followed by a mint, not one atomic two-account operation. The burn’s failure is caught (you get an Insufficient … error and nothing moves), but if the mint fails after the burn succeeded the amount is gone. Prefer it over hand-rolled burn/mint — it is still the safest built-in — and reconcile balances out of band if your game cannot tolerate that window.

Durability differs too: a write takes effect on the call, but is persisted on a 5s trailing throttle. clear is the only operation that persists synchronously.

Client API

  • server.subscribeAsset(account: string, (assets: Record<string, number>) => {}): UnsubscribeFunction
  • const { assets, getAsset, hasAsset } = useAsset() — the caller’s balances, live; subscribes for you. Read-only: every change goes through a server function.

Sync Assets with React Hooks

import { useAsset, useGameServer } from "@agent8/gameserver"; function AssetDisplay() { const { connected, server } = useGameServer(); const { assets, getAsset, hasAsset } = useAsset(); // Asset modifications must go through server functions — see buyItem in Example 1 below const handleBuyItem = async () => { try { const result = await server.remoteFunction("buyItem", ["sword"]); console.log(result.message); } catch (error: any) { console.error("Purchase failed:", error.message); } }; if (!connected) return <div>Connecting...</div>; return ( <div> <h2>My Assets</h2> {Object.entries(assets).map(([assetId, amount]) => ( <div key={assetId}>{assetId}: {amount}</div> ))} <p>Gold: {getAsset("gold")}</p> <p>Can afford sword (100 gold): {hasAsset("gold", 100) ? "Yes" : "No"}</p> <button onClick={handleBuyItem} disabled={!hasAsset("gold", 100)} > Buy Sword (100 gold) </button> </div> ); }

Example 1: Game Shop System

A shop where players buy and sell items for gold.

server/src/server.ts
export class Server { // Starting gold, granted once per player. Gate it on a flag the server owns: // "gold is 0" would re-grant to anyone who spends down to zero. async initializePlayer() { return $lock(`init:${$sender.account}`, async () => { const { initialized } = await $global.getMyState(['initialized']); if (initialized) return { message: 'Welcome back!' }; await $asset.mint('gold', 1000); await $global.updateMyState({ initialized: true }); return { message: 'Welcome! You received 1000 gold!' }; }); } // Shop system async buyItem(itemId) { const prices = { 'sword': 100, 'shield': 80, 'potion': 25, 'armor': 200 }; const price = prices[itemId]; if (!price) { throw new Error('Item not found'); } // Check if player has enough gold if (!await $asset.has('gold', price)) { throw new Error(`Insufficient gold. Need ${price} gold.`); } // Execute the purchase await $asset.burn('gold', price); await $asset.mint(itemId, 1); return { success: true, message: `Purchased ${itemId} for ${price} gold!`, remainingGold: await $asset.get('gold') }; } // Sell items back to shop async sellItem(itemId) { const sellPrices = { 'sword': 50, 'shield': 40, 'potion': 12, 'armor': 100 }; const sellPrice = sellPrices[itemId]; if (!sellPrice) { throw new Error('Cannot sell this item'); } if (!await $asset.has(itemId, 1)) { throw new Error('You do not own this item'); } // Execute the sale await $asset.burn(itemId, 1); await $asset.mint('gold', sellPrice); return { success: true, message: `Sold ${itemId} for ${sellPrice} gold!`, totalGold: await $asset.get('gold') }; } }

On the client, read balances with useAsset() and trigger these functions with server.remoteFunction() — see Sync Assets with React Hooks.


Example 2: Player Trading System

Players send assets to each other with $asset.transfer.

server/src/server.ts
export class Server { async sendGift(targetAccount, assetId, amount, message) { // `transfer` also rejects a transfer to yourself and an insufficient // balance, so those checks are not repeated here. if (!targetAccount || !assetId || amount <= 0) { throw new Error('Invalid gift parameters'); } // Optional: `transfer` would throw anyway. This runs first only to produce a // message the UI can show the player. if (!await $asset.has(assetId, amount)) { throw new Error(`Insufficient ${assetId}. You need ${amount} but have ${await $asset.get(assetId)}.`); } // Execute the transfer await $asset.transfer(targetAccount, assetId, amount); // Log the transaction (you could also use Collections to store transaction history) console.log(`Gift sent: ${$sender.account} → ${targetAccount}: ${amount} ${assetId}`); return { success: true, message: `Successfully sent ${amount} ${assetId} to ${targetAccount}`, remainingBalance: await $asset.get(assetId) }; } async getPlayerAssets(targetAccount) { return await $asset.getAll(targetAccount); } }

For a daily reward, gate the mint on server-owned state inside a lock — see Distributed Lock.


Best Practices

  1. Use Descriptive Asset IDs

    • Use clear names like "gold", "experience_points", "crafting_materials"
    • Avoid generic names like "item1" or "currency"
  2. Let the API Do the Validating

    • mint, burn and transfer already reject invalid amounts and insufficient balances (see the contract above), so re-checking them is not needed for correctness
    • Call $asset.has() when you want a game-specific error message, or to disable a button before the call — that is what buyItem above does
  3. Gate a Mint on State the Server Owns

    • A remote function that mints on every call is one loop away from infinite currency. Keep the amount server-decided, and gate the mint on state only the server can write: stamp a lastClaimDate in global user state and refuse a second call inside the cooldown, with the read-modify-write inside $lock

CROSS Mini Hub

Players can exchange game items, coins and CROSS through CROSS Mini Hub. Balances changed there arrive as ordinary $asset updates — nothing to handle on the asset side.

Last updated on