#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.mjs→dist/) isaws 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 oneCodeUri: 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 onexpiresAt.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), andF#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);expiresAtTTL backstops a missed$disconnect.
Per game (declared in game.json backend.tables, provisioned automatically):
arcade-<slug>-<table>-prod, e.g.arcade-sightlines-players-prod.
#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 deploy →
aws 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.