Skip to content

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

{
  activity: Activity
  isCompleted: boolean
}

Board

An array of exactly 16 TeamActivity items, sorted by boardOrder. Zod will reject any board that doesn't have exactly 16 entries.

GameStatus

"running" | "yoyover" | "finished"

TeamCollection

{
  name: string
  imageKey: string
  order: number
  basePoints: number
  isCompleted: boolean
}

Commons (src/models/common.ts)

  • IdSchema — non-empty string
  • CodeSchema — 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:

{ teamId?: string }

Cookie config (src/lib/auth.ts):

  • Name: bingo_session
  • Max age: 604800s (7 days)
  • httpOnly: true, sameSite: "lax", secure: true (production only)
  • Password: COOKIE_SECRET env var (min 32 chars)

Login flow (signIn action):

  1. Looks up team by the 6-char code entered
  2. If code matches ADMIN_CODE, sets teamId = ADMIN_ID in session → redirects to /admin
  3. 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):

  1. Registers @/ path alias via module-alias
  2. Creates Next.js app instance
  3. Creates Node.js http.Server with Next.js as the request handler
  4. Dynamically imports and calls initWebSocketServer(httpServer) (dynamic import avoids bundling issues with env validation)
  5. 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 only server.ts and src/lib/ws.ts to dist/ 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.tsinitWebSocketServer(httpServer): stores active WebSocket connections in a Set, broadcasts incoming messages to all clients
  • sendInvalidationCode.tssendInvalidationCode(code): opens a WS connection from the server side, sends the key, closes immediately
  • useRevalidationSocket.tsuseRevalidationSocket(): client hook, connects to /api/ws, calls swr.mutate(key) on each message; guards against duplicate connections

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) replaces team.code and every board[n].activity.code with "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

points = activity.basePoints + (isSpecialActivity ? 1 : 0)

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