Skip to content

Leaderboard

Nene\Kit\Leaderboard provides score submission with best-score retention, ranked listing, and personal rank lookup.

Schema

CREATE TABLE leaderboard_scores (
    id             INTEGER      PRIMARY KEY AUTOINCREMENT,
    leaderboard_id VARCHAR(255) NOT NULL,
    user_id        VARCHAR(255) NOT NULL,
    score          INTEGER      NOT NULL,
    submitted_at   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE (leaderboard_id, user_id)
);
CREATE INDEX idx_lb_scores ON leaderboard_scores (leaderboard_id, score DESC);

UNIQUE (leaderboard_id, user_id) enforces one row per user per leaderboard at the DB level.

API

Method Returns Description
submit(leaderboardId, userId, score) {is_best, score} Submit score. Kept only if it's a new personal best.
rankings(leaderboardId, limit) array[] Top entries. Tied scores share rank. Limit clamped to 1–100.
rank(leaderboardId, userId) {rank, score}\|null Personal rank and best score. Null if no score.
remove(leaderboardId, userId) bool Remove a user's score.

Basic usage

use Nene\Kit\Leaderboard;

$lb = new Leaderboard();

// Submit a score
$result = $lb->submit('game:tetris', $userId, 42000);
if ($result['is_best']) {
    // New personal best!
}

// Get top 10
$rankings = $lb->rankings('game:tetris', limit: 10);
foreach ($rankings as $entry) {
    echo "#{$entry['rank']} {$entry['user_id']}: {$entry['score']}";
}

// Personal rank
$rank = $lb->rank('game:tetris', $userId);
if ($rank !== null) {
    echo "Your rank: #{$rank['rank']} with score {$rank['score']}";
}

Best-score retention

submit() checks the existing score first: - No existing score → INSERT (first submission) - New score > current best → UPDATE (personal best achieved) - New score ≤ current best → no change, is_best: false

Tied scores

Users with identical scores share the same rank. The rankings() response uses dense-like ranking (the rank after two tied 1st-place entries is 3, not 2).

Leaderboard ID convention

Use a namespaced string to support multiple leaderboards in one table:

$lb->submit('game:tetris:daily', $userId, 1000);
$lb->submit('game:tetris:alltime', $userId, 1000);
$lb->submit('quiz:geography:weekly', $userId, 88);

Score type

Scores must be integers. Reject floats at the input layer:

$score = $requestBody['score'] ?? null;
if (!is_int($score)) {
    http_response_code(422);
    echo json_encode(['error' => 'VALIDATION-FAILED']);
    exit;
}

Limit clamping

rankings() clamps $limit to 1–100 automatically. Passing limit: 99999 does not cause a full table scan — the query is always bounded.