Skip to content

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
Terminal window
npm install @arkiv-network/sdk viem
  1. Set up the clients

    We will first setup two clients:

    • one publicClient for read-only queries
    • one walletClient for 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 const
    const 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}`),
    })
  2. 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 writing
    if (!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 none
    await 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 name
    await walletClient.patchEntity({
    entityKey: existing.key,
    payload: jsonToPayload({ name }),
    contentType: "application/json",
    set: { score: i32(score) },
    })
    }
  3. 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)
    }
  4. 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}`)
    }

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:

trusted-scores.ts
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.

Because the score is a numeric attribute, you can narrow the query on the server instead of fetching everything:

high-scores.ts
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
}

Use the function executeBatch to submit many scores at once.

seed-scores.ts
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),
})),
})

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 expires
await walletClient.extendEntity({
entityKey,
expires: ExpirationTime.fromDays(30),
})
// Or keep an all-time record for good
expires: ExpirationTime.permanent()

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.

submit-safely.ts
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
}
}
  • Add a season attribute 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.