Yoyo's Amazing Bingo / YAB
A gamified bingo/collecting game built for WDCC (Web Development & Consulting Club). Teams compete by completing real-world activities, earning points from a 4×4 bingo board, and collecting cards.
Note: This documentation was mostly generated by Claude. While it has been reviewed, 100% accuracy is not guaranteed — when in doubt, refer to the source code.
Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router) + custom Node.js HTTP server |
| UI | React 19, Tailwind CSS 4, Radix UI |
| 3D | Three.js, React Three Fiber, Drei |
| Data fetching | SWR 2 with custom Zod validation wrapper |
| Database | PostgreSQL (Neon), Drizzle ORM |
| Auth | iron-session (encrypted cookies) |
| Real-time | WebSocket (ws) for cache invalidation |
| Validation | Zod |
| Package manager | pnpm |
| Deployment | Fly.io (Sydney), Docker, GitHub Actions |
Architecture Overview
Browser
│
├── SWR query hooks (src/queries/)
│ └── calls Next.js Server Actions (src/actions/)
│ └── calls service functions (src/services/)
│ └── Drizzle ORM → PostgreSQL
│
└── WebSocket client (useRevalidationSocket)
└── receives invalidation keys → calls swr.mutate(key)
Server
└── Node.js HTTP server (server.ts)
├── Next.js request handler (pages, server actions)
└── WebSocket server on /api/ws
└── receives key → broadcasts to all connected clients
The app runs as a hybrid server: server.ts creates a Node.js HTTP server, attaches the Next.js request handler to it, then mounts a WebSocket server on the same HTTP server at path /api/ws.
The WebSocket layer does not send data — it only sends short invalidation keys. When a client receives a key, it re-fetches via SWR. This keeps the WebSocket implementation trivial and lets SWR handle all caching logic.
Database
Schema defined in src/db/schema.ts. Connection in src/db/connection.ts using pg with SSL required (Neon).
teamsTable
| Column | Type | Notes |
|---|---|---|
id |
varchar(255) | Primary key |
name |
varchar(255) | Display name, editable |
code |
varchar(6) | Unique login code |
specialActivity |
integer | Board index (0–15) for the team's special square |
activitiesTable
| Column | Type | Notes |
|---|---|---|
id |
varchar(255) | Primary key |
name |
varchar(255) | Display name |
code |
varchar(6) | Unique answer code; teams enter this to complete the activity |
cardImageName |
varchar(255) | Must match a key in the cards asset map |
description |
text | Rich text (markdown); editable by admin |
basePoints |
integer | Default 1 |
boardOrder |
integer | Position on the 4×4 board (0–15), left-to-right, top-to-bottom: 0 = top-left, 3 = top-right, 12 = bottom-left, 15 = bottom-right |
teamActivitiesTable
| Column | Type | Notes |
|---|---|---|
teamId |
varchar(255) | FK → teams, composite PK |
activityId |
varchar(255) | FK → activities, composite PK |
isCompleted |
boolean | Default false |
Relations: Team 1→N TeamActivity N→1 Activity
Models (Zod Schemas)
All in src/models/. These are the types used across actions, services, and queries.
Activity
{
id: string // non-empty
name: string // non-empty
code: string // exactly 6 chars
cardImageName: enum // must match a key in the cards map
description: string
basePoints: number // integer >= 0
boardOrder: number // integer, 0–15
}
Team
{
id: string
name: string
code: string // exactly 6 chars
board: TeamActivity[] // exactly 16 items, sorted by boardOrder
specialActivity: number // integer, 0–15
}
TeamActivity
Board
An array of exactly 16 TeamActivity items, sorted by boardOrder. Zod will reject any board that doesn't have exactly 16 entries.
GameStatus
TeamCollection
Commons (src/models/common.ts)
IdSchema— non-empty stringCodeSchema— string of exactly 6 characters
Authentication & Sessions
Implemented with iron-session.
What is iron-session?
iron-session is a session library that stores session data directly in an encrypted, signed cookie on the client. The cookie is sealed using the COOKIE_SECRET so its contents can't be read or tampered with by the client, but the server can decrypt it on every request to retrieve the session data.
The tradeoff vs. a server-side session store: there's no way to invalidate a session from the server (e.g. force-logout all sessions for a user) without rotating the secret, but for this app that's an acceptable constraint.
Session data shape:
Cookie config (src/lib/auth.ts):
- Name:
bingo_session - Max age: 604800s (7 days)
httpOnly: true,sameSite: "lax",secure: true(production only)- Password:
COOKIE_SECRETenv var (min 32 chars)
Login flow (signIn action):
- Looks up team by the 6-char code entered
- If code matches
ADMIN_CODE, setsteamId = ADMIN_IDin session → redirects to/admin - Otherwise sets
teamId = team.id→ redirects to/board
Authorization pattern:
auth()action returns{ teamId }from session- Admin check:
teamId === env.ADMIN_ID - Self-or-admin check:
teamId === targetId || teamId === env.ADMIN_ID - Client pages redirect immediately on wrong role
Environment Variables
Validated at startup via Zod in src/lib/env.ts. The app will throw on missing/invalid values.
| Variable | Type | Description |
|---|---|---|
APP_URL |
URL string | Base URL (e.g. http://localhost:3000) |
DB_URL |
string | PostgreSQL connection string |
COOKIE_SECRET |
string (≥32 chars) | iron-session encryption key |
ADMIN_ID |
string | Session teamId assigned to admin login |
ADMIN_CODE |
string (6 chars) | Login code that grants admin access |
Server Entry Point
server.ts (compiled to dist/server.js for production):
- Registers
@/path alias viamodule-alias - Creates Next.js app instance
- Creates Node.js
http.Serverwith Next.js as the request handler - Dynamically imports and calls
initWebSocketServer(httpServer)(dynamic import avoids bundling issues with env validation) - Listens on
PORT(default 3000)
Development: pnpm dev runs this via nodemon.
Production: cross-env NODE_ENV=production node dist/server.js.
Dual TypeScript compilation
The build runs two separate tsc compilations:
tsconfig.json— used by Next.js for the app (ESM,noEmit: true)tsconfig.server.json— compiles onlyserver.tsandsrc/lib/ws.tstodist/as CommonJS (module: "commonjs",target: "es2019")
Only those two files are included in the server compilation. Other src/ files are not compiled to dist/ — they're bundled by Next.js instead. If you add server-only code that server.ts needs to import, you must add it to the include array in tsconfig.server.json.
Routes & Pages
All under src/app/ (Next.js App Router).
| Route | Component | Auth | Purpose |
|---|---|---|---|
/ |
LoginPage |
None | Team login form |
/board |
BoardPage |
Team or admin (admin redirected to /admin) |
Main 4×4 bingo board, team points |
/admin |
AdminPage |
Admin only | Game status, activity editor, reset progression |
/collection |
CodePage |
Team | View collected cards (completed activities) |
/leaderboard |
Leaderboard |
Team or admin | Team rankings |
/collect/[cardName] |
CollectPage |
Team | Individual card detail |
The root layout (src/app/layout.tsx) renders RevalidationComponent, which mounts the WebSocket listener for the entire app.
Leaderboard visibility rules
- Admin: sees all teams ranked 1+
- Non-admin: sees only teams ranked 6+ (top 5 are hidden); sees an indicator if their own team is in the top 5
SWR Hooks & Mutations
All in src/queries/. The useSWRWithZod wrapper in src/lib/swr.ts validates all responses against Zod schemas before returning.
Query Hooks
| Hook | SWR Key | Returns |
|---|---|---|
useAuth() |
"auth" |
string (teamId) |
useGetTeam(teamId) |
"getTeam/{teamId}" |
Team \| null |
useGetAllTeams() |
"getAllTeams" |
Team[] |
useGetGameStatus() |
"getGameStatus" |
GameStatus |
useGetAllActivities() |
"getAllActivities" |
Activity[] |
useGetTeam does not fetch if teamId is null (passes null as SWR key).
Mutation Helpers
| Function | Action called | Invalidates |
|---|---|---|
mutateTeam(team) |
updateTeamAction |
"getTeam/{id}", "getAllTeams" |
mutateGameStatus(status) |
updateGameStatusAction |
"getGameStatus" |
mutateActivityDescription(id, desc) |
updateActivityDescriptionAction |
"getActivity/{id}", "getAllActivities" |
Activity Completion
useCompleteActivityMutation() returns { completeActivity, isSubmitting }.
- Args:
{ activityId: string; answer: string } - Calls
completeActivityAction, which handles its own server-side invalidation broadcast
Real-time Invalidation
src/revalidation/
The architecture keeps the WebSocket layer minimal: it only carries short string keys, never data.
Flow
Server action completes
→ sendInvalidationCode("getTeam/abc123")
→ POST to WebSocket server
→ ws.ts broadcasts key to all open connections
→ useRevalidationSocket receives key
→ calls useSWRConfig().mutate("getTeam/abc123")
→ SWR re-fetches via the registered fetcher
Invalidation Keys
| Key pattern | Triggered by |
|---|---|
"getTeam/{teamId}" |
Activity completion, team rename, progress reset |
"getAllTeams" |
Activity completion, team rename, progress reset |
"getGameStatus" |
Game status update |
"getActivity/{activityId}" |
Activity description update |
"getAllActivities" |
Activity description update |
Key Files
ws.ts—initWebSocketServer(httpServer): stores activeWebSocketconnections in aSet, broadcasts incoming messages to all clientssendInvalidationCode.ts—sendInvalidationCode(code): opens a WS connection from the server side, sends the key, closes immediatelyuseRevalidationSocket.ts—useRevalidationSocket(): client hook, connects to/api/ws, callsswr.mutate(key)on each message; guards against duplicate connections
Cookie forwarding in sendInvalidationCode
When sendInvalidationCode opens a server-side WebSocket connection, it reads the bingo_session cookie from the current request and forwards it in the WebSocket handshake headers. The invalidation code itself is passed as a query parameter (?invalidate-code=<key>), not in the message body — the server reads it on connection open and immediately broadcasts it, which is why the client closes the connection in onopen.
Server Actions
All in src/actions/. These are Next.js Server Actions called directly from SWR fetchers or mutation functions.
Auth (authActions.ts)
signIn(formData: FormData): Promise<{ error?: string } | null>
Validates the code, sets session, redirects.
auth(): Promise<{ teamId: string; error?: string }>
Returns the current session's teamId. Used as SWR fetcher for "auth" key.
signOut(): Promise<void>
Destroys session.
Activities
completeActivityAction(activityId, activityCode): Promise<void>
Validates the 6-char answer code, calls completeTeamActivity, sends invalidation codes for "getTeam/{teamId}" and "getAllTeams".
getAllActivitiesAction(): Promise<Activity[]>
Returns all activities.
updateActivityDescriptionAction(activityId, description): Promise<void>
Admin only. Updates the activity's description (rich text markdown).
Teams
getTeamAction(teamId): Promise<Team | null>
Self-or-admin auth check. Returns cleaned team (codes redacted via cleanTeam()).
getAllTeamsAction(): Promise<Team[]>
Returns all teams sorted by points descending, with codes redacted via cleanTeam().
Code redaction:
cleanTeam()(src/logic/cleanTeam.ts) replacesteam.codeand everyboard[n].activity.codewith"XXXXXX"before data reaches the client. Any new action returning team data must also call it.
updateTeamNameAction(team: Team): Promise<void>
Self-or-admin. Updates team name, invalidates "getTeam/{id}" and "getAllTeams".
resetTeamProgressAction(teamIds?: string[]): Promise<void>
Admin only. Resets isCompleted to false for the given teams, or all teams if no IDs provided.
Game Status
getGameStatusAction(): Promise<GameStatus>
Returns current game status.
updateGameStatusAction(newStatus: GameStatus): Promise<void>
Admin only. Validates against gameStatusSchema, updates status, invalidates "getGameStatus".
Services
All in src/services/. These contain the Drizzle ORM queries.
| Function | Description |
|---|---|
getTeamByCode(code) |
JOIN teams/team_activities/activities by login code → Team |
getTeamById(id) |
Same JOIN by team id |
getAllTeams() |
JOIN all three tables for all teams → Team[] |
assembleTeam(rawRows) |
Groups raw JOIN rows into a Team with a sorted board array |
completeTeamActivity(teamId, activityId) |
Sets isCompleted = true in teamActivitiesTable |
updateTeamName(teamId, name) |
Updates name, re-fetches full team |
resetTeamProgress(teamIds?) |
Sets isCompleted = false for given teams (or all) |
getAllActivitiesService() |
Returns all rows from activitiesTable |
getActivityById(activityId) |
Returns single activity, validated with ActivitySchema |
updateActivityDescription(activityId, description) |
Updates activities.description |
Scoring Logic
src/logic/points/
Activity points
Each team has one special square (stored as team.specialActivity, the board index). Completing that square gives +1 bonus point.
Combo bonuses
Each completed row, column, or diagonal adds +1 point.
| Combo | Board indices checked |
|---|---|
| Row N | [N*4, N*4+1, N*4+2, N*4+3] |
| Column N | [N, N+4, N+8, N+12] |
Diagonal \ |
[0, 5, 10, 15] |
Diagonal / |
[3, 6, 9, 12] |
Maximum possible score
| Source | Points |
|---|---|
| 16 activities (base 1 each) | 16 |
| Special activity bonus | 1 |
| 4 rows | 4 |
| 4 columns | 4 |
| 2 diagonals | 2 |
| Total | 27 |
Deployment
Database scripts
| Command | Script | Behaviour |
|---|---|---|
pnpm db:seed |
src/db/seed/ |
Seeds all teams and activities from data files |
pnpm db:reset |
src/db/seed/resetProgression.ts |
Resets isCompleted to false; prompts for confirmation. Accepts optional space-separated team IDs to reset only specific teams (pnpm db:reset <id> <id>); unknown IDs are warned and skipped |
Deployment
CI (.github/workflows/build.yml): runs on all non-main branches and PRs.
CD (.github/workflows/fly-deploy.yml): runs on push to main. Builds Docker image with build-time secrets and deploys to Fly.io.
Docker secret mounting: All five env vars (DB_URL, COOKIE_SECRET, APP_URL, ADMIN_ID, ADMIN_CODE) are injected at build time via --mount=type=secret rather than baked into the image. This is necessary because src/lib/env.ts validates env vars at startup, which runs during next build. Adding a new required env var means adding it to both the Dockerfile RUN block and the GitHub Actions secrets.
Fly.io config (fly.toml):
- App:
yoyos-amazing-bingo, region: Sydney (syd) - VM: 256MB RAM, 1 shared CPU, 512MB swap
- Machines scale to zero when idle (
min_machines_running = 0) - Force HTTPS enabled