Files
projects-jenz/RaceTimer_2026/racetimer_endpoints

Racetimer backend (2026)

REST API for the UNLOZE racetimer, reading the unloze_racetimer_css_2026 database. Java 8+, Jersey (JAX-RS 2.1), deployed as racetimer_endpoints-1.0.war on Tomcat 8.5, the same as before. All paths below are under https://<backend>/racetimer_endpoints-1.0/api/.

Deploying

  1. Build: mvn package. The output is target/racetimer_endpoints-1.0.war.
  2. Update /opt/tomcat/race_backend_settings.json. It is the same file the old backend used, with more keys; see race_backend_settings.example.json.
    • racetimerURL must now point at the unloze_racetimer_css_2026 database.
    • Add the sourcebans*, gidStaff and gidAdmin keys. They work like SBPP_DB_*, GID_STAFF and GID_ADMIN in the entwatchbans panel.
    • Set jwtSecret to at least 32 random characters. Without it, admins are signed out on every Tomcat restart.
    • Set publicBackendUrl to this backend's public URL.
    • Set frontendUrl to the React site's URL. Steam sends admins back there after they sign in.
    • Move the Steam Web API key into steamApiKey. The old code had it hardcoded in Facade.java, which shipped in the repo, so generate a new key.
  3. Deploy the WAR as before. The data loads on startup and reloads every refreshMinutes (default 30).

The settings path can also be given with -Dracetimer.settings=/path/file.json or the RACETIMER_SETTINGS environment variable.

Category numbers

categoryNumber is counted per map and stage. Categories with exactly the same server_cvars share a number, so the same settings on ZE1 and ZE2 are both Category 1 and are told apart by serverTag. Different settings are numbered in order of their lowest cvars_hash. Hidden servers (dev) never take a number away from ze1/ze2.

Points

Points are calculated per category, from each player's best valid time only. Tied times share a position.

Rule Detail
Who gets points Everyone while a board has fewer than 200 completions. From 200 on, only the faster half (200 → top 100, 210 → top 105).
Base points n − (position − 1): the fastest gets n (the number of completions), each position below gets one less. The slower half gets 0, so on big boards points drop from about n/2 straight to 0 at the cut.
Bonus multiplier Only on boards with 100+ completions. 4× at #1, sliding smoothly to 2× at the top-1% mark, then to 1× at the top-5% mark.
CLASSIC RACETIMER Final points ÷ 10, rounded.
Small-board touch-ups Everyone who gets points gets at least 1, and each of the top 10 places gets at least 1 point more than the place below it (ties share). This only ever raises points, and in practice only changes Classic boards: a 9-record Classic board gives 9, 8, 7 … 1 instead of 1, 1, 1, 1, 1, 0, 0, 0, 0. Raised entries have pointsRaised: true.
Invalid category Still shown with positions, but gives 0 points. The admin and time are public (invalidation).
Invalid record Still listed on the board in time order with isInvalid: true, position 0 and 0 points. Only that one run is removed from the ranking; the player's previous valid time counts again. The admin and time are public (invalidation).
Servers ZE1 and ZE2 points and ranks are separate. Categories from any other tag (e.g. dev) are hidden.

The old flat +2500 bonus for small boards is gone.

GET responses are computed from an in-memory snapshot. Admin changes rebuild it immediately.

In-game plugins

racetimer_rank.sp and toplvl.sp keep working unchanged. player/{steamid} and leaderboard/minified/{offset} still return PlayerPoints (and name). Without ?server=, both endpoints use defaultServerTag (ze1). On ZE2, add ?server=ze2 to those two URLs so levels come from ZE2 points. Levels drop for CLASSIC data, as intended.

Who invalidated what

Run racetimer_invalidation_audit.sql once on the racetimer database. It adds invalidated_by_steam, invalidated_by_name and invalidated_at to timer_records and zone_categories.

  • Invalidating stores the signed-in admin's SteamID, name and the current time. Invalidating something that is already invalid keeps the first admin and time.
  • Restoring sets is_invalid = 0 and clears the three columns.
  • Every invalid run and category in the API has invalidation: {steamID, steamID64, name, at} (at in Unix seconds). Player rows and history also have categoryInvalidation. Rows flagged before the columns existed have all fields null.
  • Until the script is run, the site keeps working (a warning is logged) but cannot say who invalidated what, and the admin buttons return an error asking for the script.

Endpoints

{steamid} accepts STEAM_0:x:y, STEAM_1:x:y, [U:1:n] or a SteamID64. ?server= is ze1 or ze2 and defaults to ze1. Errors are JSON: {"statusCode": 404, "errorMessage": "..."}.

Public

Method & path Returns
GET timers/leaderboard/{offset}?server= 100 players ranked by that server's points. Fields: steamID, steamID64, name, Avatar, Rank, PlayerPoints, Times, UrlBanners, server, servers ({"ze1": {points, rank, times}, "ze2": {...}}), badges.
GET timers/leaderboard/minified/{offset}?server= [{name, PlayerPoints}] (for toplvl.sp)
GET timers/player/{steamid}?server= One player, same fields as the leaderboard. 404 if unknown.
GET timers/player/badges/{steamid} {badgesUrls, badges: [{name, url}]}
GET timers/player/maps/{steamid}/{offset}?server= 50 rows of the player's best per category, plus any faster invalidated run (isInvalid: true, position 0). Fields: recordId, categoryId, mapName, stage, categoryNumber, serverTag, isLegacy, categoryInvalid, isInvalid, time, position, completions, bonusMultiplier, points, recordedAt. Without server, both servers are included.
GET timers/player/history/{steamid}/{offset}?categoryId= 50 improvements, newest first. Fields: time, previousTime, improvedBy (seconds), recordedAt, isInvalid, isCurrentBest, plus the category fields. Legacy records are last, with recordedAt: null.
GET timers/allmaps [{mapName, allCategoriesInvalid, stages: [{stage, allCategoriesInvalid, categories: [category]}]}]
GET timers/map/{mapname} One map in the same shape (case-insensitive).
GET timers/category/{id}/{offset} {category, offset, pageSize, entries: [75], invalidated: [...]}. entries holds valid times and invalidated runs together, fastest first; invalidated ones have isInvalid: true, position: 0, points: 0. invalidated repeats just those runs.
GET timers/searchplayers/{text}?server= Up to 100 players, matched on name or Steam ID.
GET timers/searchmaps/{text} Maps whose name contains the text.

category fields: id, categoryNumber (the same "Category N" as in-game), mapName, stage, serverTag, serverCvars, cvars ([{name, value}], for showing what differs between categories), isLegacy, isInvalid, givesPoints, completions, fastestTime.

Board entries fields: recordId, position, steamID, steamID64, name, avatar, badgesUrls, time, points, bonusMultiplier, recordedAt (Unix seconds, null for legacy), isLegacy.

Admin sign-in (Steam)

  1. The site links the admin to GET auth/steam/login.
  2. After Steam, the backend redirects to frontendUrl with either:
    • #token=<token>
    • or #loginError=not_admin, #loginError=steam_verification_failed, or #loginError=admin_check_unavailable
  3. The site sends the token as Authorization: Bearer <token>. The old x-access-token header also works.
  4. GET auth/me returns {steamID, steamID64, name, role, expiresAt}.
    • The role is admin for gidAdmin groups and staff for gidStaff groups.
    • A missing or expired token gives 401.

Admin actions (staff and admin)

Method & path Body
PUT admin/records/{recordId} {"invalid": true} or {"invalid": false}
PUT admin/categories/{categoryId} same

The response is {id, invalid, changedBy}. The schema has no audit table, so every change is written to the Tomcat log with the admin's name and Steam ID.

Removed endpoints

timers/mapsizecache/... and the old timers/map/{mapname}/{stage}/{offset} are gone. Use category.completions and timers/category/{id}/{offset}. The old username/password login endpoint and the JPA entities are gone too.

Tests

mvn test runs the tests for the points formula, the snapshot builder, history, Steam IDs, tokens, Steam OpenID checks, and the whole API in memory. The API tests use no database.