9.1 KiB
Architecture
How Lila is structured, how data flows, and why the boundaries are where they are.
Monorepo Layout
lila/
├── apps/
│ ├── api/ — Express backend (HTTP + WebSocket)
│ └── web/ — React frontend (Vite, TanStack Router)
├── packages/
│ ├── shared/ — Zod schemas + constants (API/web contract)
│ └── db/ — Drizzle schema, migrations, models, seeding
├── data-pipeline/ — Kaikki extraction → enrichment → PostgreSQL sync
├── documentation/ — Project docs
├── Caddyfile — Reverse proxy routing
├── docker-compose.yml — Local dev stack
└── pnpm-workspace.yaml — Workspace definition
Package boundaries:
| Package | Owns | Consumed by |
|---|---|---|
packages/shared |
Zod schemas, constants, derived TypeScript types | apps/api, apps/web, packages/db |
packages/db |
Drizzle schema, DB connection, all model/query functions | apps/api |
apps/api |
Router, controllers, services, error handling, WebSocket handlers | — |
apps/web |
React components, routes, client-side state | — |
Rule: apps/api never imports drizzle-orm for queries. It only calls functions exported from packages/db.
Layered Architecture (HTTP)
HTTP Request
↓
Router — maps URL + HTTP method to a controller
↓
Controller — handles HTTP only: validates input (Zod safeParse),
calls service, sends response or next(error)
↓
Service — business logic only: no HTTP, no direct DB access
↓
Model — database queries only: no business logic
↓
Database — PostgreSQL via Drizzle ORM
The rule: each layer only talks to the layer directly below it.
- Controller never touches the database.
- Service never reads
req.body. - Model never knows what a quiz is.
Error Flow
Controller throws ValidationError (400) or calls next(error)
↓
Central errorHandler middleware in app.ts
↓
Maps AppError subclasses to HTTP status codes
↓
Unknown errors → 500
WebSocket Architecture
The WebSocket server is attached to the same Express HTTP server. It upgrades connections on the /ws path.
WS Connection Upgrade
↓
Auth middleware — validates Better Auth session from cookie
↓
Message Router — dispatches by `type` field (Zod discriminated union)
↓
Handler (lobby or game) — business logic, broadcasts state
↓
In-memory stores (lobby game state, game session state)
Message protocol: All WebSocket messages are validated against Zod schemas defined in packages/shared/src/schemas/lobby.ts and packages/shared/src/schemas/game.ts. The type field is a discriminated union — the router switches on it and validates the payload against the corresponding schema.
State storage:
- Lobby membership — stored in PostgreSQL (
lobbies,lobby_playerstables) for durability - Game/room state — stored in-memory (
InMemoryLobbyGameStore,InMemoryGameSessionStore). Valkey migration is planned.
Database Schema (Core)
Concept: Words are language-neutral concepts (terms) with per-language translations. Adding a new language requires no schema changes — only new rows.
Core Tables
| Table | Purpose |
|---|---|
terms |
Language-neutral concept: id, pos (noun/verb/adj/adv), source, source_id |
translations |
Per-language word: term_id (FK), language_code, text, cefr_level (A1–C2) |
term_glosses |
Per-language definition: term_id (FK), language_code, text |
decks |
Curated wordlists: source_language, validated_languages, frequency tier |
deck_terms |
Junction: which terms belong to which deck |
Auth Tables (managed by Better Auth)
| Table | Purpose |
|---|---|
user |
Account: id, name, email, image |
session |
Active sessions: id, user_id, token, expires_at |
account |
Social provider links: user_id, provider (google/github), providerAccountId |
verification |
Email verification tokens (unused for social-only auth) |
Key constraints:
language_codeis CHECK-constrained againstSUPPORTED_LANGUAGE_CODES(en,it,de,es,fr)posis CHECK-constrained againstSUPPORTED_POS(noun,verb,adjective,adverb)cefr_levelis nullablevarchar(2)with CHECKA1–C2translationshas UNIQUE(term_id, language_code, text)— allows synonyms, prevents exact duplicates
Data Flow: Quiz Session
Singleplayer
User clicks "Start Quiz"
↓
POST /api/v1/game/start (GameRequestSchema: source_lang, target_lang, pos, difficulty, rounds)
↓
gameController.validate → gameService.createGameSession
↓
termModel.getGameTerms(filters) + termModel.getDistractors(filters)
↓
Service shuffles options, stores session in GameSessionStore
↓
Returns GameSession { sessionId, questions[] } — correct answer NEVER sent to frontend
↓
User answers → POST /api/v1/game/answer (AnswerSubmissionSchema)
↓
Service evaluates server-side, returns AnswerResult { isCorrect, correctOptionId, selectedOptionId }
Multiplayer
Host creates lobby → POST /api/v1/lobbies → returns room code
↓
Players join via code → POST /api/v1/lobbies/:code/join
↓
All players connect WebSocket → send lobby:join with room code
↓
Server broadcasts lobby:state (player list) to all connections in room
↓
Host clicks "Start" → WS lobby:start
↓
Server generates questions via MultiplayerGameService, broadcasts game:question
↓
Players submit answers via WS game:answer within 15s server timer
↓
On all-answered or timeout → evaluate, broadcast game:answer_result
↓
After N rounds → broadcast game:finished with final scores
The packages/shared Contract
packages/shared is the single source of truth for all data shapes crossing the API boundary.
What lives here:
constants.ts—SUPPORTED_LANGUAGE_CODES,SUPPORTED_POS,DIFFICULTY_LEVELS,CEFR_LEVELS,GAME_ROUNDSschemas/game.ts—GameRequestSchema,GameSessionSchema,GameQuestionSchema,AnswerOptionSchema,AnswerSubmissionSchema,AnswerResultSchemaschemas/lobby.ts—LobbyCreateSchema,LobbyJoinSchema,LobbyStateSchema,WebSocketMessageSchema(discriminated union)schemas/auth.ts— Auth-related shared types
Why this matters: If the shape changes, TypeScript compilation fails in both apps/api and apps/web simultaneously. Silent drift is impossible.
GameSessionStore Abstraction
The service layer stores session state through an interface, not a concrete implementation:
interface GameSessionStore {
createSession(session: GameSession): Promise<void>;
getSession(sessionId: string): Promise<GameSession | null>;
// ...
}
Current: InMemoryGameSessionStore — Map-based, lives in apps/api process memory. Lost on restart.
Planned: ValkeyGameSessionStore — Redis-compatible, persists across restarts, enables horizontal scaling.
The same pattern applies to LobbyGameStore (lobby state).
Key Design Decisions (Quick Reference)
| Decision | Where it's explained |
|---|---|
| Why Drizzle over Prisma | DECISIONS.md → ORM |
Why ws over Socket.io |
DECISIONS.md → WebSocket |
| Why server-side answer evaluation | DECISIONS.md → Architecture |
| Why Better Auth over Keycloak | DECISIONS.md → Auth |
| Why terms/translations schema | DECISIONS.md → Data Model |
| Why Caddy over Nginx/Traefik | DECISIONS.md → Deployment |
Further Reading
- DATA_PIPELINE.md — How vocabulary data gets from Kaikki into PostgreSQL
- DEPLOYMENT.md — Production infrastructure and ops
- MODEL_STRATEGY.md — LLM voter architecture for CEFR assignment
- design/GAME_MODES.md — Planned multiplayer modes