An in-game inbox for rewards and messages
3 min read
Every game eventually grows a dozen reasons to hand a player something: daily bonuses, quest payouts, achievement unlocks, a gift from a friend. The naive version, granting the item the instant the event fires, is where economies quietly break. The network hiccups and the client retries. The player double-taps the claim button. A background tab replays the request. Suddenly one reward became three, and your currency sink is a fountain.
The fix is to stop granting at the source. Instead, every reward escrows into the player's inbox, and the player claims it. Claims are keyed by the notification id, so claim applies a message's rewards exactly once: a retry or a frantic double-tap lands on the same id and grants nothing extra. This is the one hardened grant path in Triggair; route daily, quest, achievement, and gift rewards all through it and you get a single, safe, unified flow.
Listing what's waiting
Start by reading the inbox. Each message carries whatever the player earned or was sent, ready to be claimed.
// Pull everything waiting for this player.
const messages = await tg.inbox.list();
for (const msg of messages) {
console.log(`${msg.kind} - id ${msg.id}`);
}
Claiming: exactly once, always
claim takes the message id and applies that message's rewards. The exactly-once guarantee lives on the server and is keyed by that id, so you don't need client-side guards, debounces, or "already claimed?" bookkeeping. Fire it as many times as the UI happens to fire it; only the first one grants.
async function claim(id: string) {
const result = await tg.inbox.claim(id);
// Safe to call again on retry - the id has already been consumed,
// so no reward is ever granted twice.
return result;
}
Rendering an inbox with a claim button
Because claiming is idempotent, your UI can stay dumb. Render the list, wire a button straight to claim, and refresh. No optimistic-locking dance, no disabling the button in a panic, because the server is the source of truth.
async function renderInbox() {
const messages = await tg.inbox.list();
container.innerHTML = messages.map(msg => `
<div class="inbox-item">
<span>${msg.kind}</span>
<button data-id="${msg.id}">Claim</button>
</div>
`).join('');
container.querySelectorAll('button').forEach(btn =>
btn.addEventListener('click', async () => {
await tg.inbox.claim(btn.dataset.id);
renderInbox(); // claimed message drops off the list
})
);
}
Player-to-player gifting
The same inbox is how players send each other things. gifts.send escrows a tradable item into the recipient's inbox. It doesn't touch their balance directly. They see it as a normal inbox message and pull it in with the same claim call. One grant path for everything, whether the sender is your reward system or another player.
// Send 5 of an item to another player.
await tg.economy.gifts.send('player_abc', 'gold_coin', { qty: 5 });
// It's now escrowed in their inbox, waiting for tg.inbox.claim(id).
Gotchas
- Never grant rewards directly. If it's a reward, escrow it to the inbox and let the player claim it. Bypassing the inbox is how you lose exactly-once and re-open the double-grant hole.
- The id is the idempotency key. Don't generate your own dedupe tokens; the notification id already is one. Same id, at most one grant, forever.
- Gifts are escrowed, not instant. A sent gift lands in the recipient's inbox and only becomes theirs on
claim, which keeps trades auditable and reversible before pickup. - Errors are typed. A failed claim comes back with a
code, a humanmessage, and anagentHint, so a stale or already-claimed id tells you exactly what happened.
Funnel every reward and gift through the inbox and the hardest problem in game economies, granting things exactly once, becomes something you get for free.