Skip to content

How to Build a Leaderboard (Ranking System) with NENE2

This guide walks through building a leaderboard where users submit scores, see rankings, and check their own rank. Only the best score per user per leaderboard is kept.

Field Trial: FT141
NENE2 version: ^1.5
Covered topics: best-score UPDATE pattern, rank calculation with COUNT(*), score ownership check, query parameter clamping, vulnerability assessment


What we're building

  • POST /leaderboards — create a leaderboard
  • POST /leaderboards/{id}/scores — submit a score (kept only if it's a new personal best)
  • GET /leaderboards/{id}/rankings — top N rankings (descending score, ?limit=N)
  • GET /leaderboards/{id}/rankings/me — caller's own rank and score
  • DELETE /leaderboards/{id}/scores/{userId} — delete own score (owner only)

Database schema

sql
CREATE TABLE leaderboards (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT NOT NULL,
    created_at TEXT NOT NULL
);

CREATE TABLE scores (
    id             INTEGER PRIMARY KEY AUTOINCREMENT,
    leaderboard_id INTEGER NOT NULL,
    user_id        INTEGER NOT NULL,
    score          INTEGER NOT NULL,
    submitted_at   TEXT    NOT NULL,
    UNIQUE (leaderboard_id, user_id),
    FOREIGN KEY (leaderboard_id) REFERENCES leaderboards(id),
    FOREIGN KEY (user_id)        REFERENCES users(id)
);

UNIQUE (leaderboard_id, user_id) — one score row per user per leaderboard; updates replace it.


Best-score UPDATE pattern

php
public function submitScore(int $leaderboardId, int $userId, int $score, string $now): bool
{
    $existing = $this->findScore($leaderboardId, $userId);

    if ($existing === null) {
        $this->executor->execute(
            'INSERT INTO scores (leaderboard_id, user_id, score, submitted_at) VALUES (?, ?, ?, ?)',
            [$leaderboardId, $userId, $score, $now],
        );
        return true;
    }

    if ($score > $existing['score']) {
        $this->executor->execute(
            'UPDATE scores SET score = ?, submitted_at = ? WHERE leaderboard_id = ? AND user_id = ?',
            [$score, $now, $leaderboardId, $userId],
        );
        return true;
    }

    return false;  // Not a new personal best
}

Returns true when the score is a new personal best (useful for UI feedback), false when ignored.


Rank calculation with COUNT(*)

Instead of a window function (RANK() is not available in all SQLite versions), count how many scores are higher:

php
public function getUserRank(int $leaderboardId, int $userId): ?int
{
    $score = $this->findScore($leaderboardId, $userId);

    if ($score === null) {
        return null;
    }

    $row   = $this->executor->fetchOne(
        'SELECT COUNT(*) as cnt FROM scores WHERE leaderboard_id = ? AND score > ?',
        [$leaderboardId, $score['score']],
    );
    $ahead = isset($row['cnt']) ? (int) $row['cnt'] : 0;

    return $ahead + 1;
}

If 0 users have a higher score, rank is 1. If 5 users are higher, rank is 6. Ties get the same rank.


Score ownership check (IDOR prevention)

php
if ($actorId !== $userId) {
    return $this->responseFactory->create(['error' => 'cannot delete another user\'s score'], 403);
}

Always check the caller's identity against the target user before DELETE. Without this check, any authenticated user could delete any score.


Query parameter clamping

php
$limit = isset($query['limit']) && is_numeric($query['limit']) ? (int) $query['limit'] : 10;

if ($limit <= 0 || $limit > 100) {
    $limit = 10;
}

Cap the limit to prevent ?limit=99999 from scanning the entire table.


Vulnerability assessment (FT141)

IDAttackExpectedResult
VULN-AIDOR: delete another user's score403Pass
VULN-BSubmit score for another user200 (allowed)Pass
VULN-CSQL injection in leaderboard name201 (verbatim)Pass
VULN-DMissing X-User-Id on /rankings/me400Pass
VULN-ENon-numeric X-User-Idnot 200Pass
VULN-FNegative leaderboard IDnot 200Pass
VULN-GPHP_INT_MAX as score200 (valid int)Pass
VULN-HFloat score (type confusion)422Pass
VULN-IString score (type confusion)422Pass
VULN-JMissing X-User-Id on DELETE400Pass
VULN-Kuser_id=0 in score submission422Pass
VULN-L?limit=99999 (large limit)200 + clampedPass

All 12 vulnerability tests pass. No vulnerabilities found.


Common pitfalls

PitfallFix
Storing all submitted scores instead of bestfindScore() check before INSERT; UPDATE if higher
Using RANK() which may not exist in SQLiteCOUNT(*) WHERE score > ? gives equivalent rank
IDOR on score DELETECheck $actorId !== $userId → 403
Unclamped limit parameter causes table scanClamp limit to 1–100 range
Float/string score bypasses is_int()!is_int($score) rejects floats and strings in PHP 8 JSON decode

Released under the MIT License.