Express API for saving finalized ScoreKings basketball games in MongoDB and rendering shareable player stat cards.
- Node.js 22 or newer (an actively supported Node.js LTS release)
- npm
- MongoDB 7 or newer, running locally or reachable at a configured URI
For a production deployment using Docker and MongoDB Atlas, see DEPLOYMENT.md.
npm install
cp .env.example .envSet the values in .env:
| Variable | Description | Default |
|---|---|---|
PORT |
HTTP port | 5000 |
LOG_LEVEL |
Structured log threshold (fatal, error, warn, info, debug, trace, or silent) |
info |
MONGO_URI |
MongoDB connection string | mongodb://127.0.0.1:27017/scorekings |
MONGODB_URI |
Legacy alias for MONGO_URI |
Unset |
CORS_ORIGIN |
Allowed browser origin | * if unset |
PUBLIC_BASE_URL |
Base URL used in returned graphic links | http://localhost:<PORT> |
OPENAI_API_KEY |
API key used to generate player portraits with OpenAI | Required for flyer generation |
OPENAI_IMAGE_MODEL |
OpenAI image-edit model | gpt-image-2.5-sunburst |
FLYER_STYLE_REFERENCE_PATH |
Optional path to a PNG, JPEG, or WebP flyer style reference | Bundled assets/flyer-style-reference.png |
For a MongoDB Atlas deployment, set MONGO_URI to the Atlas connection string provided for your cluster. MONGODB_URI remains supported as a legacy alias. Keep credentials in the environment file or deployment secret store, not in source control. For a local MongoDB instance using Docker:
docker run --name scorekings-mongo -p 27017:27017 -d mongo:8For local development, the API defaults to mongodb://127.0.0.1:27017/scorekings. Set MONGO_URI to override it, then start the API after MongoDB is available:
npm startThe server waits for a successful MongoDB connection before it starts accepting HTTP requests. A connection or configuration error is logged and exits with a non-zero status.
Application and HTTP request logs are newline-delimited JSON. Each request receives an x-request-id response header; a valid supplied ID is preserved, otherwise the server generates one. The ID is included in request and error logs and is exposed to browser clients through CORS.
npm start— start the APInpm run dev— start with automatic restarts using nodemonnpm test— run validation, player-selection, and route tests (no MongoDB required)
GET /api/health returns:
{ "status": "ok" }POST /api/games/finalize accepts JSON. The frontend-provided gameId is stored and returned unchanged; quarter, clock, and game date are not required. Provide a unique idempotencyKey for each game session and reuse it for retries. If omitted, gameId is used as the key, so it must be unique for that session.
{
"gameId": "game-101",
"idempotencyKey": "finalize-game-101",
"location": "Marietta Rec League",
"teamName": "Red Team",
"teamId": "red-team",
"teamLogo": "data:image/png;base64,<base64-encoded-team-logo>",
"players": [
{
"id": "p1",
"team": "Team A",
"jersey": "3",
"name": "Marcus",
"pts": 14,
"reb": 5,
"ast": 3,
"topPerformer": false,
"stl": 1,
"blk": 0,
"to": 2,
"photos": [
"data:image/jpeg;base64,<base64-encoded-player-photo-1>",
"data:image/jpeg;base64,<base64-encoded-player-photo-2>",
"data:image/jpeg;base64,<base64-encoded-player-photo-3>"
]
}
],
"status": "COMPLETED",
"startedAt": "2026-10-21T11:00:00.000Z",
"completedAt": "2026-10-21T12:00:00.000Z",
"boxScore": [
{
"playerId": "p1",
"firstName": "Marcus",
"lastName": "Example",
"teamId": "red-team",
"jersey": "3",
"PTS": 14,
"REB": 5,
"AST": 3,
"STL": 1,
"BLK": 0,
"TOV": 2
}
],
"events": [
{
"period": "Q1",
"playerId": "p1",
"action": "PTS_2",
"timestamp": "2026-10-21T11:03:00.000Z",
"resultingScore": { "teamA": 2, "teamB": 0 }
}
],
"metadata": {
"testMode": false,
"verifiedForLicensing": true
},
"imageGeneration": {
"prompt": "Add a subtle arena spotlight",
"size": "1024x1536",
"quality": "high"
}
}The players array can include the full five-player lineup, with each player carrying their own stats and optional one to three photos. Supply the single team name as teamName, with optional teamId, teamLogo, and teamLogoUrl fields; a separate opponent is not required. Supply PNG, JPEG, or WebP image data URLs, each no larger than 5 MB. The API saves stats for every player, but generates graphics only for the up to five highest-ranked impressive stat lines that also have one to three photos and a team logo. Rec-league thresholds are adjusted for 40-minute running-clock games: Tier 2 starts at 12+ points, 6+ assists, or a double-double in points/rebounds/assists; Tier 3 starts at 18+ points or topPerformer: true. Players are ranked by points, rebounds, then assists. Generation requests run in parallel. Missing graphic assets do not prevent the game and its stats from being persisted. The existing singular photo field remains accepted for older clients. topPerformer is an optional boolean on a player; setting it to true selects the top visual tier and qualifies the player for a graphic. boxScore is optional; when omitted, player stats are used to build it. events is an optional array of play-by-play objects; the typed fields are period, playerId, action, timestamp, and resultingScore. Existing event properties are retained for older clients. Box-score entries can provide uppercase or lowercase stat keys. metadata optionally stores testMode and verifiedForLicensing booleans. teamId values identify corresponding Team and Player records. imageGeneration is optional: prompt adds compatible art direction, size accepts 1024x1024, 1024x1536, 1536x1024, or auto, and quality accepts low, medium, high, or auto. Defaults are 1024x1536 and high. Uploaded player photos, the team logo, and image-generation settings are saved under media; the team name, players, box scores, and events are stored under gameData; lifecycle dates are stored under timestamps. In MongoDB, the team is stored at gameData.teamName, the logo under media.team, and gameData.players is keyed by player name. Each event is keyed by its action exactly as submitted—for example, gameData.events.PTS_2 contains the event object. Repeated actions get numbered suffixes. The API continues to accept and return players and events as arrays. The API limits the complete stored game payload to 14 MB to stay safely below MongoDB's 16 MB document limit. Image data is omitted from the response to keep it small. Older two-team request payloads are still accepted during migration, but are normalized to the single-team document shape. The saved gameId is a readable location-and-timestamp identifier; idempotencyKey remains the unique key used to safely retry finalization.
The API returns 200 OK with the upserted game and a graphics array containing zero to five generated flyers. Each graphic includes its player's ID and name, generated file path, public preview URL, and stable download URL:
{
"game": {
"_id": "671000000000000000000001",
"gameId": "marietta-rec-league-20261021-110000000Z",
"location": "Marietta Rec League",
"idempotencyKey": "game-101",
"status": "COMPLETED",
"timestamps": {
"startedAt": "2026-10-21T11:00:00.000Z",
"completedAt": "2026-10-21T12:00:00.000Z",
"createdAt": "2026-10-21T12:00:00.000Z",
"updatedAt": "2026-10-21T12:00:00.000Z"
},
"gameData": {
"teamName": "Red Team",
"players": [
{
"id": "p1",
"team": "Team A",
"jersey": "3",
"name": "Marcus",
"pts": 14,
"reb": 5,
"ast": 3,
"stl": 1,
"blk": 0,
"to": 2
}
],
"boxScore": [
{ "playerId": "p1", "PTS": 14, "REB": 5, "AST": 3 }
],
"events": [
{ "type": "shot_made", "period": 1, "playerId": "p1", "points": 2 }
]
},
"media": {
"team": {},
"players": [],
"generatedMedia": [
{
"type": "flyer",
"playerId": "p1",
"publicUrl": "https://scorekings-api.example.com/output/marietta-rec-league-20261021-110000000Z-p1-<unique-id>.jpg",
"downloadUrl": "https://scorekings-api.example.com/api/games/flyers/marietta-rec-league-20261021-110000000Z-p1-<unique-id>.jpg/download"
}
],
"imageGeneration": { "size": "1024x1536", "quality": "high" }
}
},
"graphics": [
{
"playerId": "p1",
"playerName": "Marcus",
"filePath": "/path/to/server-core/output/marietta-rec-league-20261021-110000000Z-p1-<unique-id>.jpg",
"publicUrl": "https://scorekings-api.example.com/output/marietta-rec-league-20261021-110000000Z-p1-<unique-id>.jpg",
"downloadUrl": "https://scorekings-api.example.com/api/games/flyers/marietta-rec-league-20261021-110000000Z-p1-<unique-id>.jpg/download"
}
]
}publicUrl previews the JPEG. downloadUrl is a QR-code-friendly HTTPS endpoint that serves the same JPEG with Content-Disposition: attachment; it does not expire while the flyer remains in storage. Configure PUBLIC_BASE_URL as the externally reachable HTTPS API origin and use durable storage for output/ so downloads remain available after restarts and redeployments. If there are no eligible scorers with the required flyer assets, finalization still saves the game and returns an empty graphics array.
Every valid finalization uses idempotencyKey as an atomic upsert key: sending it again updates that game rather than creating a duplicate. If omitted, idempotencyKey defaults to gameId. A unique sparse MongoDB index enforces this behavior; gameId itself is not unique, so submissions with different idempotency keys do not collide. On startup, the API removes the legacy unique gameId_1 index if it exists. Player career totals (pts, reb, ast, stl, blk, to, and gamesPlayed) are updated atomically with $inc for every finalize request; repeated finalizations increment them again. Team and player records are upserted when team/player IDs are supplied. The response is 200 OK for both first and repeated finalizations.
Input is validated before persistence or rendering. Invalid fields, unexpected keys (including filesystem paths), unsupported player team values, non-numeric or negative stats, and malformed JSON return a 400 JSON error. The current client sends teamName and an optional teamLogo; legacy team-object payloads remain accepted during migration. For example:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid game payload",
"details": ["players[0].team must be \"Team A\" or \"Team B\""]
}
}If persistence or rendering fails, the API returns an error and never reports success. Persistence happens before rendering; therefore, on a rendering failure the saved game remains, and a repeated request can retry generation. Files already created during that failed request are removed where possible; cleanup failures are logged. Older cards from earlier successful requests are not automatically pruned.
For each of the five highest-ranked players with an impressive stat line and the required photos and team logo, the API sends a flyer example, player photos, and team logo to OpenAI's image-edit endpoint in parallel using OPENAI_IMAGE_MODEL (defaults to gpt-image-2.5-sunburst). By default, the example is assets/flyer-style-reference.png; set FLYER_STYLE_REFERENCE_PATH to use another PNG, JPEG, or WebP image. The example guides the gritty monochrome sports-editorial look, torn-paper texture, bold condensed typography, and dramatic contrast. Its athlete, text, logo, and exact layout are not copied. Each prompt requests one accurate, gender-inclusive head-and-shoulders portrait based on that player's submitted photos and does not request a full-body depiction.
The featured stat is REBOUNDS for a rebound double-double (10+ rebounds and either 10+ points or 10+ assists) when points are below 18; otherwise it is POINTS. Only that selected stat and its amount are requested as flyer text.
Single-game stats deterministically set the visual tier before the prompt is sent:
| Tier | Qualification | Visual treatment |
|---|---|---|
| Tier 1 — Standard Run | Does not meet a higher-tier threshold | Baseline composition, neutral lighting, high-contrast dark text, and subtle grid lines |
| Tier 2 — En Fuego | 12+ points, 6+ assists, or a double-double in points/rebounds/assists | Dark-mode overlay, neon-lime (#CCFF00) diagonal accents, motion-blur highlights, and a STAT BREAKOUT badge |
| Tier 3 — King of the Court | 18+ points or topPerformer: true |
Obsidian canvas (#050505), chrome/gold text, subtle crown motifs, and dynamic magazine-cover border breaks |
Tier 3 takes precedence when a player qualifies for multiple tiers. Ranking remains points, then rebounds, then assists, descending; ties retain incoming player order. OpenAI generates the complete flyer image, which the backend saves directly without local image composition. Uploaded player photos and team logos are saved with the game in MongoDB; generated JPEGs are written to output/ (ignored by git) and served at /output/<filename> for preview and /api/games/flyers/<filename>/download as an attachment. Flyer URLs are appended to media.generatedMedia after image generation succeeds. Set PUBLIC_BASE_URL to the externally reachable HTTPS API origin.
Because the full flyer typography and logo placement are generated by an image model, text, statistics, and logos may not render perfectly every time. Review generated flyers before sharing them when exact typography or score accuracy is essential.
Flyers are generated for up to five highest-ranked players who meet at least the adjusted Rec League Tier 2 stat threshold (12+ points, 6+ assists, a double-double, or topPerformer: true). Selection is centralized in src/services/selectPlayersForCards.js and ranks qualifying players by points, then rebounds, then assists, descending. Ties after assists retain their incoming player order. All players' stats are still persisted even when they do not qualify for a flyer.
npm testThe route tests inject a lightweight persistence implementation and a graphics stub, so they do not connect to MongoDB or write generated images.