#Architecture

The arcade is a static site plus a meta-API, both served same-origin so a single sign-in and the SDK reach every game with no CORS.

arcade.gamesight.io ──→ CloudFront
   ├── default behavior ─→ S3 (private, OAC)  arcade-www-prod
   │      └ viewer-request CloudFront Function: append index.html to "/"-terminated
   │        and extensionless paths (same logic as scripts/preview.mjs)
   ├── /sdk/*    same S3 origin (just a path)
   └── /api/* ─→ HTTP API Lambda Function URL (Node 24, ARM64) ─→ DynamoDB

<api>.execute-api.<region>.amazonaws.com/prod  (wss) ─→ WS Lambda ─→ DynamoDB
   the realtime socket, reached DIRECTLY, not through CloudFront

Same-origin is load-bearing: landing, every game, the SDK, and the HTTP API all live on one origin, so the session cookie set at sign-in is visible to every game and there's no CORS in production. The one deliberate exception is the realtime WebSocket: it's a cross-origin execute-api URL the cookie can't reach, so it authenticates with a short-lived token minted by /api/realtime/info instead (see multiplayer).

#Build → deploy split

  • Static content (scripts/build-all.mjsdist/) is aws s3 sync'd to the bucket. dist/ mirrors the bucket exactly (see local-development.md).
  • The API (packages/api) is bundled by esbuild → packages/api/dist/{lambda.mjs, ws-lambda.mjs} (two ESM entry points; @aws-sdk/* external, jose + the Sightlines engine bundled in) and deployed as two Lambdas sharing one CodeUri: the HTTP API behind a Function URL, and the realtime WS handler behind an API Gateway WebSocket API.
  • Cloud resources are AWS SAM / CloudFormation. See ../infra/README.md.

The same API code runs locally with zero AWS: scripts/preview.mjs and scripts/dev-api.mjs mount the handler in-process with a JSON-file store and dev-auth (no Google). The store and auth swap by config, not by code.

#API shape (packages/api)

A transport-agnostic core (core.mjs) dispatches a normalized request; two adapters feed it: node-adapter.mjs (local http) and lambda.mjs (Function URL v2). Routes:

Route Module Notes
/api/auth/* auth.mjs Shared login (Google OIDC), sessions, signed cookie
/api/realtime/info core.mjs WebSocket URL + a short-lived realtime auth token
/api/invites/* invites.mjs Player-to-player invites (the notification bell inbox)
/api/accounts/search core.mjs Player typeahead for invites
/api/games/{slug}/scores leaderboard.mjs Shared leaderboards
/api/games/{slug}/achievements/* achievements.mjs Achievements + Gamesight Score
/api/achievements/me achievements.mjs The caller's GS across every game
/api/achievements/recent achievements.mjs Newest unlocks across every game (public; drives the landing feed)
/api/{slug}/* games/<slug>.mjs A game's own backend (HTTP routes + optional realtimeMatch)

Persistence goes through one small store interface (store/index.mjs): get/put/delete/query/scan/mutate, with a JsonStore (dev) and a DynamoStore (prod) behind it. store.game("<slug>") gives a game a handle scoped to its own tables. Optimistic concurrency (a version attribute + mutate) prevents lost updates.

#DynamoDB tables (arcade-*-prod, PAY_PER_REQUEST, SSE)

Shared:

  • arcade-accounts-prod: pk = email; one row per signed-in user.
  • arcade-sessions-prod: pk = sessionId; TTL on expiresAt.
  • arcade-leaderboards-prod: pk = LB#<slug>, sk = S#+zeroPad(1e9−score)+…; one ascending Query = top-N. No GSI, no scan.
  • arcade-achievements-prod: four row shapes: A#<accountId> (what a player owns), R#<slug>#<id> (who owns one, oldest first), C#<slug> (unlock tallies), and F#recent (one global activity feed, read backwards for the landing page). PITR on.
  • arcade-invites-prod: pk = INV#<recipientAccountId>, sk = inviteId; the invite inbox.
  • arcade-ws-connections-prod: realtime connection/room/presence registry (the prod swap-in for the dev in-memory map); expiresAt TTL backstops a missed $disconnect.

Per game (declared in game.json backend.tables, provisioned automatically):

  • arcade-<slug>-<table>-prod, e.g. arcade-sightlines-players-prod.

See guides/game-databases.md.

#Auth (Google Workspace OIDC)

GET /api/auth/login → Google (hd=gamesight.io, signed CSRF state cookie) → GET /api/auth/callback verifies the id_token (issuer/audience/JWKS via jose, plus hd + email_verified), upserts the account, creates a session row, and sets an HttpOnly SameSite=Lax cookie. The cookie value is sessionId + an HMAC (key from Secrets Manager); the session row is the source of truth. Identity is the Workspace email, used as the account pk and as each game's per-player row key.

Locally the API runs in dev-auth mode (no Google): /api/auth/login mints a session immediately. The switch is automatic: google mode whenever the OAuth client secret is configured, dev otherwise.

#Realtime (multiplayer)

A transport-agnostic relay (realtime.mjs) owns rooms, presence, and message fan-out behind a pluggable registry: an in-memory map locally (one ws process), a DynamoDB registry (realtime-store.mjs, the ws-connections table) in prod. Dev runs a local ws server at /ws (scripts/lib/realtime-ws.mjs, cookie-authed); prod is an API Gateway WebSocket API → ws-lambda.mjs ($connect/$disconnect/$default), pushing to clients via ApiGatewayManagementApi.postToConnection.

The prod socket is cross-origin, so it can't use the session cookie: /api/realtime/info mints a short-lived HMAC token from the live session (realtime-token.mjs), the SDK passes it on the wss URL, and $connect verifies it, so signed-out players get no token and can't connect. Server-authoritative game logic is opt-in: the relay routes match actions to a backend module's realtimeMatch(ctx, data), which validates + applies moves and broadcasts state.

#Deploy pipeline

GitHub Actions, prod-only: push to any branch runs validate (manifests, engine + anti-cheat tests, API self-test) and build-check (static build + Lambda bundle + sam validate). Push to main additionally deploys: assume the CI role via OIDC → sam deployaws s3 sync dist (HTML no-cache, assets long-immutable) → CloudFront invalidation → a Slack notification. The CI role's trust is scoped to the prod GitHub environment, so only main can touch AWS. Full setup in ../infra/README.md.

The validate job also fails the build if packages/api/src/achievements.generated.mjs has drifted from games/*/achievements.json. The Lambda can't read games/ at runtime, so that file is the API's only copy of the catalog.

View docs/technical/architecture.md on GitHub ↗