Skip to content
Zain Mahmood
Selected work

VTcade

A browser arcade where the score actually persists. The interesting part was not the games. A cross-origin admin session cookie is silently broken in two major browsers, and that forced the whole deployment topology to change.

Type
Live platform
State
Live · v1.0.0
Role
Solo, end to end
  • Node.js
  • Express
  • Supabase Postgres
  • Vercel
  • Render

At a glance

  • Score races settled inside Postgres
  • 60 route tests and 96 game-logic checks in CI
  • Every admin action written to an audit log

What I owned

Solo project. Frontend, Express API, Postgres schema, auth flows, the three games, the admin panel, and the test suite that runs in CI.

Decisions, and what they cost

  1. Route everything through a same-origin proxy

    The obvious deployment is a static frontend on Vercel calling an API on Render directly. With the browser talking straight to the Render origin, the admin session cookie is a third-party cookie: Safari blocks those outright and Firefox partitions them. Everything now goes through a Vercel /api/* rewrite, so from the browser's point of view there is one origin and the cookie is first-party.

    What I gave upI traded a simple two-service topology for a proxy hop, and that hop has to be counted precisely (see the proxy hop limitation below). The alternative was auth that works on my machine and fails on a reviewer's iPhone.

  2. Resolve score races in Postgres, not in application code

    Two submissions arriving at once can lose a score if the flow is read, compare, write: both read the old high score, both decide they beat it, the lower one writes last. Scores go through a Postgres function that upserts the greater of the old and new value in one statement, so the database decides the winner.

    What I gave upThe rule lives in a migration rather than in JavaScript, so it is less obvious to someone skimming the API code. Correctness under concurrency is worth more than locality.

  3. Never trust the username in the request body

    Score submission does not read the player name from the payload. The server verifies the access token and reads the identity out of it, so naming another player in the body has no effect. Admin tokens pin algorithm, issuer and audience, and only travel in httpOnly SameSite=Strict cookies scoped to /api/admin.

    What I gave upSlightly more server work per request, and the client cannot do anything clever with identity. That is the point.

  4. A 50×30 character board, chosen by arithmetic

    The whole arcade renders in monospaced text. A monospace cell is 0.6em wide and 1em tall, so 50 by 30 characters lands on a 480×480 pixel square. Every sprite is a solid block so nothing breaks grid alignment.

    What I gave upSprite detail is capped at what a character cell can express. In exchange the renderer is trivial and pixel-perfect at any zoom level.

Known limitations

  • TRUST_PROXY_HOPS is fixed at 4, which is only correct for traffic through the Vercel proxy. Someone hitting the Render URL directly can forge X-Forwarded-For; today that is contained by a global rate-limit cap rather than a correct per-IP limit.
  • No build step means no minification. Everything ships as written because the DOM-stubbed test suite depends on it.
  • Guests are read-only: leaderboards and personal bests need an account, and those entries are shown dimmed and marked rather than silently failing.
  • Three games: Snake, Tetris and Flappy Bird.

Where it stands

In production and playable with no install. Email verification and password recovery work, per-game and global leaderboards are live, and the admin panel writes every destructive action to an audit log. 60 route tests against a mocked Supabase and 96 game-logic checks run in CI.