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 thangetAllwhen 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 totoAccountand 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 trailingaccountparameter: 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, andclear([])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.
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>) => {}): UnsubscribeFunctionconst { 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.
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.
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
-
Use Descriptive Asset IDs
- Use clear names like
"gold","experience_points","crafting_materials" - Avoid generic names like
"item1"or"currency"
- Use clear names like
-
Let the API Do the Validating
mint,burnandtransferalready 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 whatbuyItemabove does
-
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
lastClaimDatein global user state and refuse a second call inside the cooldown, with the read-modify-write inside$lock
- 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
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.