Skip to main content

Notifications and categories

An inbox every app shows (notifications rows), and a push to the player's devices that says something landed in it. src/lib/notifications.ts (inbox, devices, preferences), src/lib/push.ts (the senders), src/lib/digest.ts (the daily ones).

KindWhenTapping it opensdata
announcementA game's author sends one in its channel: every other member, at onceThe game's channel{gameId, messageId}
changesOnce a day at most: new versions landed, by someone else, on games the player made or helped build (a change they asked for, or a draft of their own)"What changed" (GET /api/app/changes?since=){since}
new_gamesOnce a day at most, for players with a device: games whose first version landedThe games feed{since}

The daily two are sent by an hourly cron (vercel.ts → GET /api/cron/notifications, CRON_SECRET) to players who are due and for whom it is between 9:00 and 20:59 (their device's time zone, users.timezone; UTC when unknown). A player can turn each kind off; an off kind makes no row and no push.

API​

RouteWhat
GET /api/app/notifications{notifications: [{id, kind, title, body, data, read, createdAt}], unread, prefs: {announcements, changes, newGames}}, newest first
POST /api/app/notifications/read{ids: […]}, or no body for all → {unread}
PUT /api/app/notifications/prefsAny of {announcements, changes, newGames} (booleans) → {prefs}
POST /api/app/devicesRegisters this device (the platform is X-Crescent-Client). iOS {token: <APNs hex>, environment: "production" | "sandbox", timezone}; Android {token: <FCM>, timezone}; web {token: <subscription endpoint>, keys: {p256dh, auth}, timezone}. timezone is an IANA name. Call it on every launch once notifications are allowed.
DELETE /api/app/devices{token}: notifications were turned off on this device
GET /api/app/changes[?since=<ISO>]{since, total, games: [{game: PublicGame, yours, changes: [{buildId, version, by, summary, features, createdAt}]}]}: at most a week back, a day back without since
GET /api/app/confignow also push: {vapidPublicKey} (null when Web Push is off)

What a push carries​

Flat strings: kind, id (the inbox row) and the row's data fields.

  • iOS (APNs): at the top level of the payload, next to aps (aps.alert.title/body, thread-id = the kind).
  • Android (FCM): in data; the title and body are in notification, on the Android channel named after the kind.
  • Web (Web Push): the payload is {title, body, data} as JSON; the service worker (public/sw.js) shows it and opens the app on click.

Keys​

Each sender is on only when its keys are set (pushConfigured()); without any, the inbox still fills.

PlatformEnvWhere it comes from
iOSAPNS_KEY_ID, APNS_TEAM_ID, APNS_PRIVATE_KEY (the .p8's text), APNS_TOPIC (defaults to the app's bundle id)An APNs auth key from the Apple developer account; the App ID needs the Push Notifications capability and the app the aps-environment entitlement
AndroidFCM_SERVICE_ACCOUNT (the service account JSON)The Firebase project of the Android app; the key is a service account key from its Service accounts page
WebVAPID_PUBLIC_KEY, VAPID_PRIVATE_KEYnpx web-push generate-vapid-keys

Categories​

Every game lands in one category (src/lib/categories.ts: Arcade, Runner, Puzzle, Racing, Action, Shooter, Platformer, Adventure, Strategy, Builder, Board & cards, Sports). The categorizer (src/lib/categorizer.ts, Claude Haiku 5.5 with a fixed answer format) reads the game's title, first request, latest notes and design document and picks one when the game's first public version lands (sortGame in the run workflow); a game keeps its category after that. npm run categories:backfill -- [--all] [--game <id>] [--dry-run] sorts older games.

Every game the API hands out carries category: {id, name} | null, and GET /api/app/feed adds categories: [{id, name, games}], the ones that have games, in the list's order. The apps filter the feed on the device.