Leaderboard
In this guide, you will learn how to create entities on Arkiv to store player scores and read back a top-ten leaderboard.
This guide will show:
- how to store a user’s score as an attribute that can be filtered
- how submitting a user’s score creates an entity
- how updating a user’s score patches the entity
- how to fetch every scores and sort them to display like a leaderboard
Prerequisites
Section titled “Prerequisites”@arkiv-network/sdkinstalledvieminstalled- a wallet funded with testnet tokens from the faucet
- an API key for the Tiramisu RPC endpoint
npm install @arkiv-network/sdk viemCreate & Submit a Score
Section titled “Create & Submit a Score”-
Set up the clients
We will first setup two clients:
- one
publicClientfor read-only queries - one
walletClientfor write operations
The project attribute below scopes every entity you will create to your app only.
arkiv.ts import { createPublicClient, createWalletClient } from "@arkiv-network/sdk"import { tiramisu } from "@arkiv-network/sdk/chains"import { http } from "viem"import { privateKeyToAccount } from "viem/accounts"// Attribute name used to filter this project's entities.export const PROJECT_ATTRIBUTE_NAME = "projectId" as const// Replace with a globally unique string for your own app.export const PROJECT_ATTRIBUTE_VALUE = "leaderboard-demo-7x9k" as constconst rpcUrl = `https://rpc.tiramisu.db-chain.testnet.arkiv.network/${process.env.ARKIV_API_KEY}`export const publicClient = createPublicClient({chain: tiramisu,transport: http(rpcUrl),})export const walletClient = createWalletClient({chain: tiramisu,transport: http(rpcUrl),account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),}) - one
-
Submit a score
submit-score.ts import { ExpirationTime, jsonToPayload } from "@arkiv-network/sdk"import { i32 } from "@arkiv-network/sdk/attr"import { eq } from "@arkiv-network/sdk/query"import {PROJECT_ATTRIBUTE_NAME,PROJECT_ATTRIBUTE_VALUE,publicClient,walletClient,} from "./arkiv"export async function submitScore(player: string, name: string, score: number) {// i32 rejects anything that is not a whole number, so check before writingif (!Number.isInteger(score)) {throw new Error(`Score must be a whole number, received ${score}`)}// Look for an existing entry for the player.const { entities } = await publicClient.select({ key: true, attributes: { score: true } }).where(eq(PROJECT_ATTRIBUTE_NAME, PROJECT_ATTRIBUTE_VALUE),eq("entityType", "score"),eq("player", player),).limit(1).fetch()const existing = entities[0]if (!existing) {// Create one if there is noneawait walletClient.createEntity({payload: jsonToPayload({ name }),contentType: "application/json",attributes: {[PROJECT_ATTRIBUTE_NAME]: PROJECT_ATTRIBUTE_VALUE,entityType: "score",player,score: i32(score),},expires: ExpirationTime.fromDays(30),})return}if (Number(existing.attributes.score?.value ?? 0) >= score) return// Patch it if the new score is higher// Rewrite the payload too, so a player who renamed does not keep the old nameawait walletClient.patchEntity({entityKey: existing.key,payload: jsonToPayload({ name }),contentType: "application/json",set: { score: i32(score) },})} -
Read the top ten
Arkiv always returns matches newest first, and there is no server-side sort. Fetch every score entity, then rank them in JavaScript.
top-scores.ts import { eq } from "@arkiv-network/sdk/query"import {PROJECT_ATTRIBUTE_NAME,PROJECT_ATTRIBUTE_VALUE,publicClient,} from "./arkiv"export async function topScores(count = 10) {const { entities } = await publicClient.select({ payload: true, attributes: true }).where(eq(PROJECT_ATTRIBUTE_NAME, PROJECT_ATTRIBUTE_VALUE),eq("entityType", "score"),).fetch()return entities.map((entity) => ({name: String(entity.toJson().name ?? "anonymous"),player: String(entity.attributes.player?.value),score: Number(entity.attributes.score?.value ?? 0),})).sort((a, b) => b.score - a.score).slice(0, count)} -
Run it
main.ts import { submitScore } from "./submit-score"import { topScores } from "./top-scores"await submitScore("0x1111111111111111111111111111111111111111", "ada", 4200)await submitScore("0x2222222222222222222222222222222222222222", "linus", 3100)for (const [rank, row] of (await topScores()).entries()) {console.log(`${rank + 1}. ${row.name} - ${row.score}`)}
Query examples
Section titled “Query examples”Filtering from legitimate entity creators
Section titled “Filtering from legitimate entity creators”Arkiv is a shared public database, so anyone can write an entity carrying your project attribute and your entityType.
For a leaderboard that means anyone can post themselves a winning score.
If a trusted backend submits every score, filter reads on the creator address as well:
import { eq } from "@arkiv-network/sdk/query"import { PROJECT_ATTRIBUTE_NAME, PROJECT_ATTRIBUTE_VALUE, publicClient,} from "./arkiv"
/** The wallet your backend signs score writes with. */const BACKEND_ADDRESS = "0x3333333333333333333333333333333333333333" as const
export async function trustedScores() { const { entities } = await publicClient .select({ payload: true, attributes: true }) .where( eq(PROJECT_ATTRIBUTE_NAME, PROJECT_ATTRIBUTE_VALUE), eq("entityType", "score"), ) .createdBy(BACKEND_ADDRESS) .fetch()
return entities}$creator is set at creation and never changes, so an injected entity cannot fake it.
Filtering by scores
Section titled “Filtering by scores”Because the score is a numeric attribute, you can narrow the query on the server instead of fetching everything:
import { i32 } from "@arkiv-network/sdk/attr"import { eq, gt } from "@arkiv-network/sdk/query"import { PROJECT_ATTRIBUTE_NAME, PROJECT_ATTRIBUTE_VALUE, publicClient,} from "./arkiv"
export async function highScores(minimum = 1000) { const { entities } = await publicClient .select({ payload: true, attributes: true }) .where( eq(PROJECT_ATTRIBUTE_NAME, PROJECT_ATTRIBUTE_VALUE), eq("entityType", "score"), gt("score", i32(minimum)), ) .fetch()
return entities}Additional examples
Section titled “Additional examples”Submitting many scores at once
Section titled “Submitting many scores at once”Use the function executeBatch to submit many scores at once.
import { ExpirationTime, jsonToPayload } from "@arkiv-network/sdk"import { i32 } from "@arkiv-network/sdk/attr"import { PROJECT_ATTRIBUTE_NAME, PROJECT_ATTRIBUTE_VALUE, walletClient,} from "./arkiv"
const rows = [ { player: "0x1111111111111111111111111111111111111111", name: "ada", score: 4200 }, { player: "0x2222222222222222222222222222222222222222", name: "linus", score: 3100 },]
await walletClient.executeBatch({ creates: rows.map((row) => ({ payload: jsonToPayload({ name: row.name }), contentType: "application/json", attributes: { [PROJECT_ATTRIBUTE_NAME]: PROJECT_ATTRIBUTE_VALUE, entityType: "score", player: row.player, score: i32(row.score), }, expires: ExpirationTime.fromDays(30), })),})Extend scores before expiry
Section titled “Extend scores before expiry”Scores in this recipe expire after thirty days, so an untouched board empties itself. That is the right default while you are building, but decide what you want before you ship.
import { ExpirationTime } from "@arkiv-network/sdk"
// Push an entry further out, before it expiresawait walletClient.extendEntity({ entityKey, expires: ExpirationTime.fromDays(30),})
// Or keep an all-time record for goodexpires: ExpirationTime.permanent()Handling failures
Section titled “Handling failures”The SDK never retries. Every method throws, so a submission can fail because the value did not fit its type, because the wallet has no gas, or because the endpoint was unreachable.
import { InvalidValueError } from "@arkiv-network/sdk"import { submitScore } from "./submit-score"
try { await submitScore("0x1111111111111111111111111111111111111111", "ada", 4200)} catch (error) { if (error instanceof InvalidValueError) { // the score did not fit an i32 console.log("Rejected score:", error.message) } else { // rejected transaction, insufficient gas, or an unreachable RPC endpoint throw error }}Next steps
Section titled “Next steps”- Add a
seasonattribute so you can query one season at a time. - Store per-run history as separate entities with a shorter expiry, and keep only the best score permanently.
- See Best Practices for data modeling and Querying Data for the full query API.