FastAPI backend for start.ng. Postgres + SQLAlchemy + Alembic. Email/password and Google sign-in (returns a JWT).
GET /healthPOST /auth/register,POST /auth/login,POST /auth/google({id_token}),GET /auth/mePUT /auth/me/character: save the signed-in user's character ({name, style});GET /auth/mereturns it ascharacterPOST /games/publish:{repo_url, name?, branch?}publishes a game from a public GitHub repo at<name>.start.ng. Signed in, it goes live on your account. Without signing in, a repo that's already live updates straight away; a new one is checked and returns 202 with anapprove_url(<SITE_URL>/approve/<code>): whoever opens it signs in with start.ng and approves, and it goes live on their account, linking the repo to it for good. Unapproved ones expire after 30 days; new ones are limited to 20 an hour per addressPOST /games/check:{repo_url, name?, branch?}runs every publish check without publishing: 200 withwarnings, or 422 with every problem. Publish and check refuse with{ok: false, detail, errors: [{code, message, file}], warnings}(app/checks.pyhas the content checks: malware signatures, miners, hidden or obfuscated code, password fields, redirects, disguised programs). A failed auto-update keeps the last version and shows the owner why in/games/mineGET /games/publish/{code}: what's waiting at an approve link;POST /games/publish/{code}/approve(signed in): put it liveGET /games: published games;DELETE /games/{name}(owner only): take a game offlineGET /games/mine(signed in): your own games, newest first (the profile's "Your games")POST /games/{name}/scores(signed in):{score}saves a score; only each player's best per game is kept. Returns{best, improved, rank}GET /games/{name}/leaderboard?limit=10: a game's top players[{rank, player_id, name, color, score}]. Games don't call these directly: they ask start.ng's/connectpage, which adds the player's sign-inPOST /games/{name}/ticket(signed in): a one-minute ticket into the game's live room (games get it through/connect). A ticket only opens that room; it never works as a sign-inWS /games/{name}/live?ticket=…: the live room for multiplayer games (e.g. the Hangout). Players see each other join, move, wave and chat; names and looks come from the database. A room remembers its last 50 chat messages for players who come in later. Messages are described inapp/live.pyPUT /games/{name}/cover(owner only): the picture as the raw request body (PNG, JPEG or WebP, up to 2 MB) sets the game's card picture;DELETE /games/{name}/coverremoves it. Both return the game.GET /gamesand/games/minegive each game'scover_url(null= no picture)GET /tournaments/current: today's tournament (games, live standings, whether you've entered), ornullwhen fewer than 3 games qualify.POST /tournaments/current/enter(start.ng sign-in, not a game session) enters it.GET /tournaments/recent?limit=7: finished tournaments with their winner and top 3.GET /tournaments/leaderboard?period=all|week&limit=50: the overall leaderboard. See "Daily tournaments"
app/admin.py, app/routers/admin.py; the website's page is start.ng/admin.
- Who: super admins are
SUPER_ADMIN_EMAILS(every permission; only they add admins and make roles). Other admins are added by email with a role: a named set of permissions (GET /admin/permissionslists them). There's no admin sign-up: admins sign in to start.ng with Google as that email. An account with a password never gets admin access, because registering with a password doesn't prove the email is yours. Non-admins get 404 from every/admincall. - Reports:
POST /games/{name}/report(signed in){reason, details?}with reasonbroken,obscene,offensive,harmful,stolenorother(details needed). The game stays up until an admin approves the report (POST /admin/reports/{id}/close{outcome: "approved" | "dismissed"}); approving hides it (not in/games, its page or the sitemap, swapped out of tournaments that haven't started and dropped from the running one, and its own address<name>.start.ngshows an error page: the game store writes_hidden/<name>and the Worker checks it). One open report per player per game,REPORTS_PER_DAY(10) a day. Owners seehiddenon their games in/games/mine. - Admins:
GET /admin/games/review(games with reports waiting, then hidden games),POST /admin/games/{name}/flag(hides at once),POST /admin/games/{name}/restore. - Developers: admins record each time they reach out (
POST /admin/developers/{id}/contacts). AfterCONTACTS_BEFORE_BLOCK(3) issues they can block one (/block: can't sign in or publish, games hidden;/unblock) or delete one with all their games (DELETE /admin/developers/{id}). Admins can't be blocked or deleted. - Tournaments:
GET /admin/tournamentsgives the current tournament and the next one (made ahead of time, so admins see it first) as game cards, and every eligible game.PUT /admin/tournaments/{id}/gamesorPOST .../rerollchange one that hasn't started. Players getnext_starts_atin/tournaments/currentfor a countdown, not the next games. - Activity:
GET /admin/log?kind=player|developer|admin|systemis everything that happens on start.ng: accounts made, games published, updated, auto-updated or removed, covers, Creators listings, reports, tournaments made, entered and finished, and every admin action (app/activity.record).
app/tournaments.py. Every day a tournament of 5 random games runs for 24 hours and ends at 18:00 Nigeria time
(TOURNAMENT_GAMES, TOURNAMENT_MIN_GAMES, TOURNAMENT_END_HOUR, TOURNAMENT_UTC_OFFSET).
- Games: ones with a leaderboard (a
startng.json, or saved scores) that don't fail the game standards, never the Hangout, and not ones from the last 3 tournaments while there are enough others. Fewer than 3: no tournament.games.meets_standardsis saved on every publish and update; games published before the standards (null) can still be picked for now. - Automatic: nothing runs on a timer. The day's tournament is made the first time anyone asks for it, and one that has ended is settled (points saved, winner named) the next time anyone reads tournaments.
- Entering: the website enters players when they share the tournament on X (it can't check the post).
- Scores: games need no changes. While a player has entered, every run sent to
POST /games/{name}/scoresfor a tournament game also counts there; only runs inside the 24 hours count, and only the best one per game. - Points: on each game, 10 / 7 / 5 / 3 for the top four, 1 for everyone else who played it. Most points wins the day; ties go to more first places, then whoever got there first. Points only come from tournaments. The overall leaderboard adds them up, all time or this week (from Monday 00:00 Nigeria time).
- While the server runs it checks each game's branch on GitHub and republishes the game when there's a new commit
(
app/auto_update.py), so a push is all a builder needs to do. Games updated in the last day are checked every minute, the last 30 days every 5 minutes (UPDATE_CHECK_SECONDS), older ones hourly - Each check asks only for the latest commit id and sends GitHub's fingerprint (ETag) of the last answer, so an
unchanged repo costs nothing. Set
GITHUB_TOKEN(read-only, public repos) for 5,000 checks an hour instead of 60 - A commit that doesn't publish (no
index.html, too big, ...) is logged and skipped; the live game stays as it was - A game with no recorded commit (published before auto-update existed, or its lookup failed) is republished the first time it's checked, once, so it can't stay stale. At most 5 games are downloaded per 30-second pass
GET /games/minealso gives the ownerchecked_at,live_commit(7 characters) andupdates_paused_until(set while GitHub's limit has paused checking), shown on each game card in Your gamesAUTO_UPDATE=falseturns it off (tests do)
- An uploaded cover is opened, cropped to 640 x 400 and saved again as WebP (
app/covers.py), so nothing hidden in the file is kept. It goes in the game's folder asstartng-cover.webpand the Worker serves it. - Without an upload, a
cover.png/.jpg/.jpeg/.webpat the top of the published game is used. An uploaded cover wins, and republishing never replaces it. cover_urlends in?v=<time>, which changes with the cover, so nobody sees an old picture.
start.ng's own multiplayer game runs inside the front end at /hangout, but its live room needs a hangout
row in games. Migration 0007 adds it, owned by a start.ng system account (system@start.invalid) that
nobody can sign in to, so the Hangout belongs to no person. It runs with the rest of the migrations on deploy.
If someone already published a game called hangout, it is left alone. GET /games lists it as by "start.ng".
POST /games/publish downloads the repo as a zip from GitHub (nothing in it is run), finds a ready-to-play
index.html (in dist/, build/, docs/, out/ or the top), checks it and uploads it to <name>/ in the bucket.
- Client-side games only. A repo that still needs building (its
index.htmlloads/src/*.ts(x)) is refused: builders commit their built files. - Names and paths use
app/storage.py's rules (the Worker's), and names are at most 40 characters. The first person to publish a name owns it (gamestable); only they can update or remove it. Folders already in the bucket with no owner (likedave/) count as taken. - Hidden files,
node_modulesand links are left out, file types the Worker doesn't serve are skipped, and games are limited to 2000 files and 100 MB (25 MB a file). - Game files are checked against
malware-patterns.txt, the same signatures as the CI gate. - Files go to the bucket through
app/storage.py(see below). Files an older version had and a new one dropped stay in the bucket; the Worker only serves what the game links to. - Trying it without the R2 keys: set
LOCAL_GAMES_DIRand games are written to that folder, which the Worker'sdev-server.jscan serve. With neither set, publishing answers 503.
Tests: pip install -r requirements-dev.txt, then pytest.
Games on <name>.start.ng call the API directly (CORS allows *.start.ng, and *.start.localhost for testing).
They never get the player's sign-in token. Instead start.ng gives them a game session: a key that only works
for that game's player API, expires after 2 hours, and is never a sign-in.
POST /games/{name}/session(start.ng sign-in):{session, expires_in, player}. start.ng calls this and hands the session to the game (sign-in redirect#startng_session=..., or/connect).GET /games/{name}/player(game session):{id, name, style}, never the email.POST /games/{name}/scores(game session, or a start.ng sign-in):{score}→{best, improved, rank}.GET /games/{name}/leaderboard?limit=10(public):[{rank, player_id, name, color, score}].
A session for one game is refused by every other game and by /auth/*.
A game gets a leaderboard by shipping a startng.json (in its game folder, or at the top of the repo), read on publish:
{"leaderboard": {"stats": [
{"key": "waves", "label": "Waves survived", "order": "highest"},
{"key": "kills", "label": "Enemies killed"},
{"key": "time", "label": "Time", "order": "lowest", "format": "time"}
]}}- 1–5 stats;
keylowercase letters/digits/_ ;orderhighest(default) orlowest;formatnumber(default) ortime(seconds). - Ranked by the first stat, then the next ones break ties, then whoever got there first. Each player's best run is kept.
- Games send
POST /games/{name}/scores{"stats": {"waves": 5, "kills": 10, "time": 312}}: exactly the declared stats, whole numbers 0–1,000,000,000. Nothing else is stored (no text). GET /gamesreturns each game'sleaderboardcolumns, ornullwhen it has none (nostartng.jsonand no scores). Games without astartng.jsonkeep the plain{"score": n}board.DELETE /games/{name}/scores/{player_id}(owner): remove a player's score.
app/storage.py reads and writes the private R2 bucket (startng-games) that the start.ng Worker serves.
A game named dave is stored under dave/ and served at https://dave.start.ng.
from app import storage
storage.put_game("dave", {"index.html": b"<h1>hi</h1>", "save/index.html": b"..."})
storage.list_files("dave"); storage.get_file("dave", "index.html"); storage.delete_game("dave")Names and paths follow the Worker rules (lowercase letters/digits/hyphens, no .., no hidden files, reserved
names refused). Needs R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY (see .env.example).
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
alembic upgrade head
uvicorn app.main:app --reloadDockerfile build pack, port 8000. The container runs alembic upgrade head before starting.
Set the variables from .env.example. GitHub secrets: COOLIFY_WEBHOOK_URL (optional COOLIFY_TOKEN);
the Deploy (Coolify) workflow runs only after Malware Scan (CI Gate) passes on main.