Play-to-earn Web3 games combine an off-chain game loop with on-chain settlement: the game server tracks outcomes, then a reward contract or distributor mints or transfers tokens and NFTs to player wallets. The hard engineering problems are not the token design but the settlement path: idempotent claims, nonce management, confirmation depth, and RPC reliability under bursty claim traffic. This article explains the reward pipeline, shows runnable checks for balances, receipts, and contract events, and gives a measurement procedure you can run against any RPC endpoint before committing a game economy to it. Concrete endpoint and provider choices belong in RPC Assistant; this page focuses on the method.
Scope and the play-to-earn settlement model
A play-to-earn Web3 game is two systems joined by a settlement layer. The game loop (matchmaking, physics, scoring, anti-cheat) runs off-chain because per-action on-chain writes are too slow and too expensive. The settlement layer runs on-chain because players need custody of rewards, provable ownership of NFTs, and a public record of payouts. Between them sits a reward service that converts validated game outcomes into signed claims or batched transfers.
This guide assumes you already have a game loop and a reward token or NFT contract, and that you are deciding how to settle rewards and how to read chain state from game clients and backend services. It covers the reward pipeline, verification calls, a measurement procedure for RPC endpoints, and failure modes such as duplicate claims and stale balances. It does not recommend a specific game or token.
- Off-chain: gameplay, scoring, anti-cheat, season logic
- Settlement: reward contract, claim signatures, token or NFT distribution
- Read path: game client and backend query balances, receipts, and events over RPC
- Operational path: monitoring, retries, and reconciliation between game database and chain
Prerequisites and assumptions
The procedures below assume a JSON-RPC endpoint for an EVM-compatible chain, a funded reward distributor address, and a token contract that exposes a standard balanceOf function. For non-EVM chains the same method applies with the equivalent RPC methods; the call names differ but the verification logic does not.
You need an RPC_URL placeholder that you replace with an endpoint from your provider, and a block explorer or indexer for cross-checking. If you have not chosen an endpoint yet, the RPC Assistant guides on choosing a Solana RPC provider and multi-chain RPC endpoints describe the selection criteria; the concrete provider decision is best made there rather than in this article.
How a reward moves from gameplay to wallet
A typical pipeline has five stages. First, the game server finalizes a match and writes an immutable outcome record. Second, the reward service applies economy rules (emission caps, cooldowns, anti-abuse checks) and produces a claim intent with a unique identifier. Third, the claim is either signed for the player to submit or queued for a batched distributor transaction. Fourth, the transaction is broadcast and confirmed to a chosen depth. Fifth, the backend reconciles the on-chain result against its own ledger.
The unique claim identifier is the most important design decision. If the same match can produce two claims, your economy leaks tokens. The common fix is an on-chain mapping from claim ID to boolean, checked inside the reward contract, so replaying a signed claim reverts. For batched distribution, the equivalent is a merkle root per epoch with per-leaf claimed flags.
Confirmation depth is the second decision. A game that credits a player's in-game balance after one confirmation is fast but exposed to reorgs; a game that waits for finality is safe but feels slow. Many teams separate the two: optimistic in-game credit immediately, withdrawable balance only after a configurable depth.
- Claim ID uniqueness prevents double payouts
- Emission caps should be enforced on-chain, not only in the game database
- Withdrawal eligibility should lag behind in-game credit by a confirmation threshold
- Every payout should be reconcilable to a match record
Reading balances and receipts with curl
The first diagnostic for any play-to-earn integration is whether your RPC endpoint returns consistent state. The calls below use placeholders; replace RPC_URL, the token address, and the player address. The balanceOf call uses the standard ABI encoding: the 4-byte selector 0x70a08231 followed by the 32-byte padded address.
Run the balance call twice a few seconds apart. A stable chain should return the same value unless a transfer occurred. A value that changes without a corresponding transaction is a sign you are hitting different backends behind a load balancer with inconsistent state, which is a serious problem for reward accounting.
curl -s -X POST "$RPC_URL" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0xTOKEN_ADDRESS","data":"0x70a0823100000000000000000000000000PLAYER_ADDRESS"},"latest"]}'
curl -s -X POST "$RPC_URL" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"eth_getTransactionReceipt","params":["0xTRANSACTION_HASH"]}'Verifying a payout in JavaScript
Game backends usually need to confirm that a specific reward transaction succeeded before marking a claim as paid. The script below checks the receipt status, decodes the Transfer event topic, and compares the recipient and amount against the expected claim. It uses only the RPC endpoint, so it works without an indexer for low-volume checks.
The expected result is status 0x1, one matching Transfer log from the token contract, and a value equal to the claim amount. If status is 0x0 the transaction reverted and the claim should be retried or flagged; if the log is missing, the transaction succeeded but did not pay the intended recipient, which usually means a bug in the distributor's argument encoding.
const RPC_URL = process.env.RPC_URL;
const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
async function verifyPayout(txHash, token, expectedTo, expectedAmount) {
const res = await fetch(RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getTransactionReceipt', params: [txHash] })
});
const { result } = await res.json();
if (!result) throw new Error('receipt not found; transaction may be pending');
if (result.status !== '0x1') throw new Error('transaction reverted');
const match = result.logs.find(l =>
l.address.toLowerCase() === token.toLowerCase() && l.topics[0] === TRANSFER_TOPIC
);
if (!match) throw new Error('no Transfer log from token contract');
const to = '0x' + match.topics[2].slice(26);
const amount = BigInt(match.data);
if (to.toLowerCase() !== expectedTo.toLowerCase()) throw new Error('wrong recipient');
if (amount !== BigInt(expectedAmount)) throw new Error('amount mismatch');
return { block: parseInt(result.blockNumber, 16), amount: amount.toString() };
}
verifyPayout('0xTRANSACTION_HASH', '0xTOKEN_ADDRESS', '0xPLAYER_ADDRESS', '1000000000000000000')
.then(r => console.log('verified', r))
.catch(e => console.error('claim check failed:', e.message));Measuring RPC reliability before a reward season
Reward seasons create bursty traffic: many players claim within minutes of a reset, and the game client simultaneously refreshes balances. Before committing an economy to an endpoint, run a short measurement against the methods your game actually uses. The procedure below is reproducible and does not require special tooling.
Record the results in a table like the one below, one row per endpoint and method. Do not fill in numbers from this article; the table is a template for your own measurements. Run each method at least 200 times over ten minutes, including at least one period of elevated load, and record p50 and p95 latency, error rate, and the number of responses that disagreed with a reference node.
- Methods to test: eth_call (balanceOf), eth_getTransactionReceipt, eth_getLogs over a recent range, eth_blockNumber
- Record p50 and p95 latency per method, not a single average
- Count JSON-RPC errors separately from HTTP errors
- Compare returned block numbers across endpoints to detect lagging backends
- Repeat during a claim burst if you can simulate one on testnet
Results table template
| Endpoint label | Method | Requests | p50 latency | p95 latency | Error rate | State disagreements |
|---|---|---|---|---|---|---|
| endpoint-a | eth_call | 200 | ||||
| endpoint-a | eth_getTransactionReceipt | 200 | ||||
| endpoint-a | eth_getLogs | 200 | ||||
| endpoint-b | eth_call | 200 | ||||
| endpoint-b | eth_getTransactionReceipt | 200 | ||||
| endpoint-b | eth_getLogs | 200 |
A useful pass criterion is not a universal number but a relationship: the endpoint you use for claim confirmation should have an error rate low enough that retries do not dominate, and a block-height lag small enough that confirmation depth still means what you think it means. If eth_getLogs is much slower than eth_call, move event scanning to an indexer and keep the RPC for point reads.
Troubleshooting failed claims and stale balances
Most play-to-earn support tickets reduce to four causes. A claim transaction reverted because the claim ID was already used, which is correct behavior and should be shown to the player as already paid. A transaction is pending because the distributor's nonce is stuck behind a dropped transaction. A balance looks stale because the client is reading from a lagging backend or caching too aggressively. A receipt is missing because the client queried before the transaction was mined.
For nonce stalls, query the distributor's transaction count with eth_getTransactionCount using the pending tag and compare it with the latest tag. A gap means a transaction was dropped; replacing it with the same nonce and a higher fee is the standard remedy. For stale balances, add a block-number check to the client: request eth_blockNumber alongside the balance and refuse to display a balance older than a configured number of blocks.
- Already-claimed reverts: map the revert reason to a clear player message
- Nonce gaps: compare pending and latest transaction counts
- Stale reads: pin reads to a block number and reject old responses
- Missing receipts: retry with backoff before declaring failure
Limitations and tradeoffs
On-chain settlement gives players custody and auditability, but it also makes every reward a public, irreversible event. That constrains how you can respond to exploits: you can pause a distributor, but you cannot claw back tokens already transferred. Design emission caps and per-account limits accordingly, and treat the reward contract as the final authority rather than the game database.
RPC reliability is a shared dependency. A single endpoint, however fast in a benchmark, is a single point of failure for claims and balance reads. Production games typically run a primary endpoint plus a fallback, with client-side logic that fails over on repeated errors rather than on a single timeout. Dedicated nodes reduce contention with other tenants; shared endpoints are cheaper but noisier. The right balance depends on claim volume and how visible a failed claim is to players.
Next steps for a production reward pipeline
Start by instrumenting the four RPC methods above in your staging environment and filling in the results table with your own numbers. Then add idempotent claim IDs on-chain if you have not already, and a reconciliation job that compares the game database against Transfer events daily. Finally, decide your confirmation depth and make it configurable per chain, because finality assumptions differ.
For endpoint selection and network-specific guidance, use RPC Assistant rather than guessing from benchmarks. Related reading on this site includes blockchain node hosting for the operational side, RPC endpoints for the request model, and the OnFinality API service and RPC pricing pages for how managed access is structured. If your game spans several chains, review supported networks and consider dedicated nodes for the settlement chain where claim bursts are most costly.