Skip to content

How-to: Game Score & Leaderboard API

FT reference: FT259 (NENE2-FT/scorelog) — Game score submission with bulk insert (max 100, all-or-nothing), per-player leaderboard with best_score aggregation and play_count, negative score prevention, pagination, 20 tests PASS.

This guide shows how to build a game scoring system: record individual play scores, bulk-import results, and compute ranked leaderboards per game.

Schema

sql
CREATE TABLE scores (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    player     TEXT    NOT NULL,
    game       TEXT    NOT NULL,
    score      INTEGER NOT NULL CHECK(score >= 0),
    played_at  TEXT    NOT NULL,      -- ISO 8601 date: YYYY-MM-DD
    created_at TEXT    NOT NULL
);

CREATE INDEX idx_scores_game      ON scores (game);
CREATE INDEX idx_scores_player    ON scores (player);
CREATE INDEX idx_scores_played_at ON scores (played_at);

CHECK(score >= 0) prevents negative scores at the DB level. Indexes on game and player support filtered list and leaderboard queries.

Endpoints

MethodPathDescription
POST/scoresSubmit a single score
POST/scores/bulkBulk submit up to 100 scores
GET/scoresList scores (filter + paginate)
GET/scores/leaderboardPer-player leaderboard for a game
GET/scores/{id}Get single score
DELETE/scores/{id}Delete score

Submit a Score

php
POST /scores
{
  "player":    "Alice",
  "game":      "tetris",
  "score":     1500,
  "played_at": "2026-01-15"
}

 201
{
  "id": 1,
  "player": "Alice",
  "game": "tetris",
  "score": 1500,
  "played_at": "2026-01-15",
  "created_at": "..."
}

Multiple scores per player per game are allowed — each play is a separate record.

Validation

php
POST /scores  {"game": "tetris", "score": 100, "played_at": "2026-01-15"}
 422  // player is required

POST /scores  {"player": "Alice", "game": "tetris", "score": -1, "played_at": "2026-01-15"}
 422  // score must be >= 0

POST /scores  {"player": "Alice", "game": "tetris", "score": 100, "played_at": "15/01/2026"}
 422  // played_at must be YYYY-MM-DD format

POST /scores  {"player": "Alice", "game": "tetris", "score": 0, "played_at": "2026-01-15"}
 201  // score = 0 is valid

List Scores

php
// All scores
GET /scores
 200  {"items": [...], "total": 10}

// Filter by game
GET /scores?game=tetris
 200  {"items": [/* only tetris scores */], "total": 3}

// Filter by player
GET /scores?player=Alice
 200  {"items": [/* only Alice's scores */], "total": 2}

// Pagination
GET /scores?limit=2&offset=1
 200  {"items": [/* 2 items, starting from index 1 */], "total": 5}

Bulk Submit

php
POST /scores/bulk
{
  "scores": [
    {"player": "Alice", "game": "tetris", "score": 1000, "played_at": "2026-01-15"},
    {"player": "Bob",   "game": "tetris", "score": 2000, "played_at": "2026-01-16"},
    {"player": "Carol", "game": "snake",  "score": 500,  "played_at": "2026-01-15"}
  ]
}

 201
{
  "created": 3,
  "scores": [
    {"id": 1, "player": "Alice", ...},
    {"id": 2, "player": "Bob",   ...},
    {"id": 3, "player": "Carol", ...}
  ]
}

Bulk Validation Rules

php
// Empty array
POST /scores/bulk  {"scores": []}
 422  // at least 1 entry required

// Any invalid entry fails the whole batch
POST /scores/bulk
{"scores": [
  {"player": "Alice", "game": "tetris", "score": 1000, "played_at": "2026-01-15"},
  {"player": "",      "game": "tetris", "score": 500,  "played_at": "2026-01-15"}
]}
 422  // "player" cannot be empty — no records are inserted

// Over 100 entries
POST /scores/bulk  {"scores": [...101 entries...]}
 422  // max 100 entries per bulk request

All-or-nothing: validate all entries before inserting any. Use a DB transaction to ensure atomicity.

Bulk Implementation

php
public function bulkSubmit(array $entries): array
{
    // Validate all entries first
    foreach ($entries as $i => $entry) {
        $this->validate($entry, "scores[{$i}]");
    }

    // Insert all in a transaction
    $this->db->beginTransaction();
    try {
        $ids = [];
        foreach ($entries as $entry) {
            $ids[] = $this->repo->insert($entry['player'], $entry['game'], $entry['score'], $entry['played_at'], $now);
        }
        $this->db->commit();
        return $this->repo->findByIds($ids);
    } catch (\Throwable $e) {
        $this->db->rollback();
        throw $e;
    }
}

Leaderboard

php
GET /scores/leaderboard?game=tetris

 200
{
  "game":    "tetris",
  "top":     10,
  "entries": [
    {"rank": 1, "player": "Alice", "best_score": 3000, "play_count": 2},
    {"rank": 2, "player": "Bob",   "best_score": 2000, "play_count": 1},
    {"rank": 3, "player": "Carol", "best_score": 500,  "play_count": 1}
  ]
}

Each player appears once — with their best (highest) score across all plays, plus a play_count.

Top-N Limit

php
GET /scores/leaderboard?game=tetris&top=3
 200  {"entries": [...3 players...], "top": 3}

GET /scores/leaderboard?game=tetris&top=0
 422  // top must be >= 1

GET /scores/leaderboard          // missing game
 422

Leaderboard SQL

sql
SELECT
    player,
    MAX(score)   AS best_score,
    COUNT(*)     AS play_count,
    RANK() OVER (ORDER BY MAX(score) DESC) AS rank
FROM scores
WHERE game = ?
GROUP BY player
ORDER BY best_score DESC
LIMIT ?

RANK() OVER (ORDER BY MAX(score) DESC) assigns the same rank to tied players (gaps in subsequent ranks). If you prefer no gaps, use DENSE_RANK().


What NOT to do

Anti-patternRisk
Allow negative scores at application layer onlyCHECK(score >= 0) DB constraint is the final guard; application validation can be bypassed
Insert bulk entries one by one without transactionPartial failure leaves half the batch in DB; impossible to distinguish committed from uncommitted
Validate bulk entries inside the insert loopFirst N entries are inserted before validation fails; partial data in DB
Use score = MAX(score) without GROUP BYAggregates entire table without player grouping; wrong leaderboard results
Return all players in leaderboard without LIMITFull table scan and sort with no cap; DoS risk for large score tables
Compute best_score by fetching all scores in PHPO(N) per player; use SQL MAX() aggregation

Released under the MIT License.