Leaderboards

Recording a score, and exactly how much anyone should trust it

Every game on the site gets a board. Submitting to it is two lines.

import { useLeaderboard, scores } from "oj"

function GameOver({ points }) {
    const board = useLeaderboard({ limit: 6 })

    useEffect(() => {
        if (scores.available) board.submit(points)
    }, [])

    return (
        <View>
            {board.entries.map((e, i) => (
                <Text key={i}>{`${i + 1}. ${e.name}  ${e.score}`}</Text>
            ))}
        </View>
    )
}

What the board is worth

Read this before you build anything around it.

Submitting requires a short-lived token the site mints when it serves your game's document, checked against the game it was minted for. It is good for six hours and for 40 submissions, so one page load cannot fill a board. That stops a stranger with curl and nothing more. A player can read the token out of their own page and post whatever number they like, and nothing short of running your game's rules on a server would change that.

That is a deliberate line rather than an oversight. Moving the rules off the player's machine would mean a game was two programs in two languages, and "fork this and change it" would stop being true, which is the thing this platform is for.

So: these boards are for bragging. The site says so where players can read it. Do not build anything that needs a score to be true, and do not award anything scarce on the strength of one.

The hook

const board = useLeaderboard({ window: "day", limit: 10 })
board.entries { name, score, at, mine? }[], highest first
board.loading True while the first fetch is in flight
board.error The last thing that went wrong, or null
board.refresh() Fetch again
board.submit(score, options?) Submit, then show the board the server returned

window is "all", "week" or "day"; limit is 1 to 100 and defaults to 10. The entry the player just made is flagged mine, so you can point at it in a long list.

Errors are held, not thrown. A leaderboard that cannot be reached is a reason to show less, not a reason to interrupt a game that is still going, so board.submit never rejects and board.error is where the problem lands. Render around it.

board.submit shows the board the submission produced rather than fetching again, so what the player sees cannot disagree with what the server just recorded.

The direct API

For anything outside a component:

await scores.submit(points)                  // returns the resulting board, or null
await scores.top({ window: "week", limit: 5 })
scores.available                             // false when there is no site

scores.submit resolves to null rather than throwing when there is no site behind the game, so it can be called unconditionally at the end of a run.

Two things to get right

Submit once per run. A frame loop notices the end of a game sixty times a second, and without a guard that is sixty submissions. Put the call behind the state change rather than inside the loop:

const submitted = useRef(false)

useFrame(() => {
    if (!alive && !submitted.current) {
        submitted.current = true
        board.submit(peakScore.current)
    }
})

Names. A signed-in author's handle is used automatically. Everyone else gets whatever you pass as name, trimmed, or "anon". Do not collect a name in your own UI and expect it to override a signed-in player's handle: it will not, and that is intentional, because a board where anybody can appear as somebody else is worse than one of strangers.

After an eject

There is no site behind an ejected game, so scores.available is false, submit resolves to null, and top returns an empty list. Nothing throws.

That means a single-player game built around a board still runs after an eject, with an empty board. If you want to show something else there, scores.available is the flag to branch on.