Every "why?" an interviewer might ask, answered once so you never have to think about it again.
This is the document you hand someone who asks "why did you choose X?" — or open yourself 10 minutes before an interview. Each question is something a Staff+/Principal engineer at a top-500 company would reasonably ask while reviewing this project.
- Deployment & Infrastructure
- Database
- Frontend Architecture
- Chess Engine
- Server Architecture
- Testing & Quality
- Rust & WASM
- Trade-offs I'd Change
Short answer: Kubernetes solves problems I don't have yet, and introduces problems I'd have immediately.
| Fly.io | Kubernetes (EKS/GKE) | |
|---|---|---|
| Time to deploy | fly deploy — 90 seconds |
Cluster setup + Helm charts + ingress controller — days |
| Cost at my scale | $0–6/month (free tier) | $70+/month (control plane alone on EKS) |
| WebSocket support | Native TCP services in fly.toml |
Requires sticky sessions, ingress annotations, pod disruption budgets |
| Persistent volume | fly volumes create — one command |
PersistentVolumeClaim + StorageClass + CSI driver |
| Operational burden | Zero — managed platform | Upgrades, node pools, RBAC, network policies, monitoring stack |
When I'd switch: At 10K+ concurrent players, when I need multi-region, horizontal scaling, and a dedicated DevOps team. The architecture is already containerized (Docker), so migration is a packaging change, not a rewrite.
The real answer for interviews: "I chose Fly.io because the infrastructure decision should match the current scale, not the aspirational scale. Kubernetes at 100 users is resume-driven development. I documented the exact inflection points where I'd migrate — see the scaling roadmap in the README."
Three concrete requirements eliminated the alternatives:
-
Persistent volumes — SQLite needs a filesystem that survives deploys. Railway has no volume support. Render's free tier has no persistent disk. Fly.io has
fly volumeswith 1 GB free. -
WebSocket configuration — Fly.io's
fly.tomlhas explicit TCP service blocks with configurable concurrency limits (hard_limit = 250). Railway and Render treat WebSockets as a side-effect of HTTP, with no tuning surface. -
Auto-stop/start —
min_machines_running = 0means the VM sleeps when unused and wakes on request. This keeps a portfolio project at $0 while still being "deployed." Render's free tier also sleeps but with a 30-second cold start vs. Fly's ~2-3 seconds.
Full decision record: ADR-001: Fly.io over Railway.
Vercel is already hosting the frontend. The server can't go there because:
- Vercel is serverless — functions execute and die. Socket.io needs a long-lived process to hold WebSocket connections.
- No persistent state — no filesystem, no SQLite, no in-memory game rooms that persist between requests.
- Cold starts on every request — a chess game room needs to exist for 30+ minutes. Serverless functions time out at 10–60 seconds.
Vercel is perfect for the static frontend (CDN, instant deploys, zero config). The server needs a different host.
min_machines_running = 0 in fly.toml means:
- Cost: $0/month when nobody is playing (the VM literally stops)
- Trade-off: 2–3 second cold start on first request after idle
- Why it's acceptable: This is a portfolio project. Nobody is playing at 3 AM. The first visitor waits 2 seconds; subsequent visitors get sub-50ms responses.
- When I'd change: Set
min_machines_running = 1($6/month) when real users depend on instant availability.
Because the bottleneck isn't what people think it is.
The AI runs client-side in WASM. 10,000 single-player sessions produce zero server load. The server only handles multiplayer — typically 1–5% of registered users. A single shared-cpu-1x Fly VM can handle 250 concurrent WebSocket connections (hard_limit in fly.toml), which is roughly 125 simultaneous games.
Horizontal scaling would require:
- Redis for shared state (game rooms)
- Sticky sessions or pub/sub for WebSocket routing
- PostgreSQL instead of SQLite (no more single-file DB)
I've documented exactly when each change triggers. (See README Section F: Scaling Roadmap.)
| Factor | SQLite | Managed PostgreSQL |
|---|---|---|
| Cost | $0 (file on disk) | $7–15/month (Neon, Supabase, RDS) |
| Latency | 0ms network hop (same filesystem) | 1–5ms per query (even in same region) |
| Backup | cp chess.db chess.db.bak or Litestream |
Point-in-time recovery (managed) |
| Deployment | No connection pool, no credentials, no secrets | Connection string, pool size, SSL config |
| Scale limit | ~50 concurrent writes/sec | Thousands of concurrent writes |
SQLite is the right choice at current scale because:
- Single server = single writer (no write contention)
- Read-heavy workload (leaderboard queries >> game inserts)
- Zero operational cost
- In-process queries are faster than any network database
The docker-compose.yml actually does use PostgreSQL for local development. The schema is Prisma — it's database-agnostic. Switching production to PostgreSQL is a one-line change:
datasource db {
provider = "postgresql" // was "sqlite"
url = env("DATABASE_URL")
}I didn't do this because PostgreSQL adds $7–15/month and operational complexity (managed service credentials, connection pooling, SSL) for a server that currently has zero active users. That's premature optimization.
Specific failure mode: SQLite uses a single writer lock. Under concurrent multiplayer load:
| Concurrent Games | What Happens | Detection |
|---|---|---|
| 1–50 | No issues | — |
| 50–100 | Occasional SQLITE_BUSY on game-end writes |
chess_db_query_seconds P95 > 100ms |
| 100–250 | Write queue backpressure, timeouts | P95 > 500ms, error rate > 1% |
| 250+ | Server capacity limit regardless | hard_limit in fly.toml |
Mitigation already in place: WAL mode (Write-Ahead Logging) gives SQLite ~10x better concurrent read performance and eliminates reader-blocks-writer.
Migration trigger: When chess_db_query_seconds P95 exceeds 200ms consistently, I migrate to PostgreSQL. The Prisma schema already supports it.
- Over raw SQL: Type-safe queries, auto-generated client, migration tracking. For a project with 2 models it's marginal, but it demonstrates I can use an ORM properly — schema-first, migration-based, not entity-sync.
- Over Drizzle: Prisma has better documentation, wider adoption (for interview signal), and the schema DSL is more readable. Drizzle's SQL-like API is better for complex queries that don't exist in this project yet.
- Over TypeORM/Sequelize: Dead or dying. Prisma is the industry direction.
Trade-off accepted: Prisma's generated client adds ~3MB to node_modules and startup time. For a server that auto-stops and cold-starts, this is 200ms of boot time that's dwarfed by the 2-second Fly machine start.
This is the question I get asked most. The answer has three parts:
-
Performance: React's virtual DOM reconciliation is designed for UI with frequent partial updates. A chess game renders via
requestAnimationFrameon a Three.js<canvas>. React would be a VDOM layer sitting above a WebGL render loop — two rendering systems fighting each other. The DOM elements (move list, capture panel, ELO display) change infrequently and are trivially managed with direct DOM manipulation. -
Bundle size: The project already ships Three.js (~600KB), chess.js, stockfish.js, and a WASM binary. Adding React + ReactDOM is 40KB more of code that does nothing the
<canvas>can't do. Vanilla TS + Vite's tree-shaking keeps the bundle tight. -
Portfolio differentiation: Every junior developer's portfolio has a React app. A production-quality project built without a framework demonstrates understanding of what frameworks abstract away — event delegation, state management, component lifecycle, DOM diffing — because I had to implement those concerns manually.
When I'd use React: If the UI had complex form state, nested routing, or server-side rendering needs. A settings page with 20 inputs and validation? React. A full-screen <canvas> game? Vanilla.
| Option | Pros | Cons for this project |
|---|---|---|
| Unity | Full game engine, C# | 10MB+ runtime, no source access, overkill for a board game |
| PlayCanvas | WebGL engine | Proprietary editor dependency, heavy runtime |
| Babylon.js | Full 3D engine | 2x bundle size of Three.js, fewer examples for chess |
| Three.js | Minimal, stable, huge ecosystem | No physics engine (don't need one), manual state management |
| Raw WebGL | Maximum control | Person-months of shader/math code for a chess game |
Three.js hits the sweet spot: enough abstraction to be productive (scene graph, materials, lighting), thin enough to not bloat the bundle, and ubiquitous enough that any 3D developer can read the code.
- HMR that actually works (sub-100ms hot reload vs. Webpack's multi-second rebuilds)
- WASM support via explicit loader configuration — Vite handles
.wasmfiles as assets with proper MIME types - Worker bundling —
new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })just works - Tree-shaking for Three.js (only ship used modules)
- No config for 90% of use cases — the
vite.config.tsis 20 lines
Over Webpack: faster, simpler, modern ESM-first. Over Parcel: more control, better plugin ecosystem. Over esbuild directly: Vite uses esbuild for dev and Rollup for prod, getting the best of both.
Trade-off I accept with eyes open.
The HTML is a single-page app with all DOM structure inline. In a framework project, this would be 30+ component files. Here it's one file because:
- No build step for HTML — the DOM structure is static. Components would add a build abstraction layer (JSX, templates) for markup that rarely changes.
- CSS is co-located — styles are in
<style>blocks near their markup. No CSS-in-JS runtime, no CSS module config. - Simple mental model — open one file, see everything the browser sees.
Why I wouldn't do this at scale: Beyond ~2K lines, the file becomes hard to navigate. If I added a settings panel, chat UI, or tournament bracket, I'd extract those into web components or lit-html templates.
This is the single highest-leverage portfolio decision:
-
Demonstrates systems programming — bitboard manipulation, alpha-beta search with transposition tables, null-move pruning, late-move reductions. This is algorithms + data structures at a level that separates senior from staff.
-
WASM compilation — shows I can bridge Rust ↔ JavaScript via
wasm-bindgen, handle async loading, and gracefully degrade when WASM isn't available. -
Measurable performance — the Rust engine searches ~2M nodes/sec vs. the TypeScript fallback at ~50K nodes/sec. That's a 40x speedup that I can quantify in an interview.
-
It's not a toy — magic bitboards, transposition tables with Zobrist hashing, and killer move heuristics are techniques used in Stockfish. Writing them proves I can read and implement academic computer science.
Economics. If the AI runs server-side:
| Users | Server CPU Cost | WASM Cost |
|---|---|---|
| 100 | $15/month (compute) | $0 |
| 10,000 | $500/month | $0 |
| 1,000,000 | $50K/month | $0 |
The user's browser does the computation. The server only handles multiplayer. This means single-player is infinitely scalable at zero marginal cost — every user brings their own CPU.
Trade-off: Users on slow devices get a slower AI. That's why the three-tier fallback exists — if WASM is too slow or unavailable, the engine degrades to lighter alternatives rather than failing.
Tier 1: Rust WASM engine → Fastest, not available in all browsers
Tier 2: Stockfish.js Worker → Strong, runs in background thread
Tier 3: TypeScript minimax → Weakest, but works everywhere
Why not just Stockfish? Stockfish.js is 1.2MB of JavaScript. It requires a Web Worker and takes 2–3 seconds to initialize. The Rust engine is 200KB of WASM and initializes in <100ms.
Why not just Rust WASM? Safari on iOS had inconsistent WASM support until recently. Older Android browsers may not support WASM at all. The fallback chain ensures every user gets the best AI their browser can run.
Why not just TS minimax? It's 40x slower than Rust. Searching to depth 5 takes ~2 seconds in TS vs. ~50ms in Rust. It exists only as a last-resort guarantee that the game is playable.
Each tier is tried in order. If a tier fails (import error, timeout, crash), the next tier is seamlessly activated. The user never sees an error.
A chess position has 64 squares. A 64-bit integer can represent which squares a piece attacks with a single bitmask. Magic bitboards use hash-based lookup tables to compute sliding piece attacks (bishops, rooks, queens) in O(1) instead of O(n) ray scanning.
Performance difference: Bishop attacks via ray scan is ~20 operations. Via magic bitboard lookup: 1 multiply + 1 shift + 1 array access = 3 operations. At 2M nodes/sec search speed, this matters.
Why not mailbox (array) representation? Mailbox is simpler to implement but 5–10x slower for move generation. Since move generation runs millions of times per AI decision, the O(1) lookup justifies the implementation complexity.
| Protocol | Best For | This Project |
|---|---|---|
| REST (Express) | CRUD resources, stateless ops | Auth endpoints, leaderboard queries ✓ |
| WebSocket (Socket.io) | Real-time bidirectional | Live gameplay, matchmaking ✓ |
| GraphQL | Complex nested queries | Overkill — 2 models, simple queries |
| tRPC | Full-stack type safety | Requires coupled frontend — my frontend is vanilla |
| gRPC | Service-to-service | No microservices, browser doesn't speak gRPC |
Express handles the REST surface (auth, leaderboard). Socket.io handles the real-time surface (game moves, matchmaking). Two protocols, each used where it fits. GraphQL would add a query language, schema definition, and resolver boilerplate for a data model that has 2 tables and 5 queries.
- Passport: Adds 15+ npm packages for a strategy pattern I can implement in 40 lines.
jwt.sign()andjwt.verify()are the entire auth surface for a game that doesn't have OAuth, SAML, or MFA. - Auth0/Clerk: $0 for <7K MAU, then $25–100/month. Adds a third-party dependency for a portfolio project. Also means auth is a black box I can't explain in an interview.
- Custom: 80 lines total between
generateToken,verifyToken, and the Express middleware. I can explain every line. In an interview, I can discuss token expiry, refresh tokens (not implemented — I would for production), and bcrypt password hashing (implemented).
When I'd use Auth0/Clerk: When I need OAuth providers (Google, GitHub login), MFA, or compliance certifications (SOC2) that I shouldn't build myself.
Every WebSocket message from a client is untrusted input. Without validation:
// Client sends: { type: "move", gameId: "rm -rf /", from: undefined }
// Server crashes with TypeError: Cannot read properties of undefinedWith Zod:
const ClientMessageSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('move'), gameId: z.string().uuid(), from: z.string(), to: z.string() }),
// ...
]);Invalid messages are rejected before touching any game logic. Zod gives runtime type checking with TypeScript inference — the validated object is automatically typed.
Over io-ts: Zod has a cleaner API and doesn't require fp-ts. Over joi/yup: Zod is TypeScript-first with better inference. Over manual validation: 50+ lines of if statements vs. one schema.
- Cost: Prometheus is free. Datadog is $15–23/host/month. New Relic is $0.30/GB ingested.
- Portability: Prometheus exposition format is an industry standard. Any monitoring system (Grafana, Fly.io metrics, AWS CloudWatch) can scrape it. Vendor lock-in is zero.
- Weight:
prom-clientis 150KB. Datadog'sdd-traceis 15MB+ and monkey-patcheshttp,express, andnet. - Control: I define exactly 16 metrics that answer specific operational questions (see README Section D12). Datadog would auto-generate 200 metrics, most useless, and bill for all of them.
When I'd use Datadog: When a team needs APM traces, inter-service correlation, and someone else is paying $15K/year.
Two layers of rate limiting exist:
- HTTP:
express-rate-limit— 100 requests/minute per IP, applied to REST endpoints - WebSocket: Custom
checkWsRateLimit— tracks messages per IP per time window
Why not Cloudflare? Cloudflare sits in front of DNS. It would protect the HTTP layer but has no visibility into WebSocket message frequency inside an established connection. The per-message rate limiter catches abuse that happens after the connection is already open.
Why not API Gateway (Kong, AWS API GW)? Cost and complexity for a single-origin server. Kong requires its own deployment. AWS API GW charges per request and doesn't handle WebSockets natively (WebSocket API is a separate product with different pricing).
Because the number of tests is the fastest proxy a reviewer has for code quality.
- 420 frontend tests cover game logic, era progression, save/load, AI integration, aggression slider
- 168 server tests cover auth, matchmaking, protocol validation, database operations, CORS config
- 218 Rust engine tests cover move generation, search correctness, perft (position enumeration), tournament runner
A reviewer opens the repo, sees the test count, and immediately knows this isn't a tutorial copy-paste. The actual testing showed real bugs — off-by-one errors in castling rights, en passant square corruption after null moves, ELO calculation edge cases with zero draws.
- Speed: Vitest runs in Vite's dev server — no separate compilation step. Tests start in <100ms vs. Jest's 2–5 second startup.
- ESM native: The project uses ES modules (
"type": "module"). Jest's ESM support is experimental and requires--experimental-vm-modules. Vitest handles ESM natively. - Config sharing: Vitest reads
vite.config.ts— path aliases, env variables, and plugins are inherited. Jest needs a separatebabel.config.jsor transform map. - API compatible:
describe,it,expect,vi.fn()are the same API as Jest. Migration cost is near-zero.
- Over Artillery: k6 scripts are real JavaScript with ES modules. Artillery uses YAML — harder to write conditional logic, custom metrics, or WebSocket game simulations.
- Over Locust: Locust is Python. The project is TypeScript/Rust. k6's JavaScript stays in the same language ecosystem.
- Over JMeter: XML configuration files. Nobody should have to explain JMeter XML in 2026.
k6 also produces results in Prometheus exposition format — consistent with the server's own metrics. Load test dashboards and server dashboards use the same data format.
[lib]
crate-type = ["cdylib", "rlib"]cdylib: Required forwasm-packto produce a.wasmbinary. This is the browser-facing chess engine.rlib: Required for thebin/tournament.rsbinary to link against the same library code. Withoutrlib, Cargo can't compile native binaries that import from the lib.
This lets one codebase produce two artifacts: a WASM module for browsers and a native binary for tournament simulation. The #[cfg(not(target_arch = "wasm32"))] gates keep WASM-incompatible dependencies (rayon, rusqlite) out of the browser build.
[profile.release]
opt-level = 3
lto = trueopt-level = 3: Maximum compiler optimization. The search function's tight loop (move generation → evaluation → alpha-beta cutoff) benefits from aggressive inlining and vectorization.lto = true: Link-Time Optimization across crate boundaries. Thewasm-bindgenglue code and the chess engine code get optimized together, eliminating cross-crate call overhead.
Trade-off: Release builds take ~30 seconds instead of ~5 seconds. This only matters during development (where debug profile is used anyway), not in CI or deployment.
The tournament binary (bin/tournament.rs, 866 lines) serves two purposes:
-
A/B testing engine changes — run 1,000 games between two configurations (e.g., different search depths, evaluation weights) and measure win-rate difference with statistical significance. This is how Stockfish development works.
-
Portfolio artifact — a Staff+ candidate should show data-driven engineering. "I ran 10,000 AI vs. AI games and this parameter change improved win rate by 3.2% with p < 0.05" is a different conversation than "I think this number should be higher."
The binary uses rayon for parallel game execution and rusqlite for result persistence. These are gated behind #[cfg(not(target_arch = "wasm32"))] so they don't bloat the WASM build.
Transparency about what I'd do differently is as important as defending what I did.
| Decision | What I'd change | Why |
|---|---|---|
| 1,641-line index.html | Extract into web components or lit-html templates | File navigation becomes painful beyond ~1K lines |
| CSP disabled in Helmet | Enable strict CSP with nonces for server responses | Currently contentSecurityPolicy: false — CSP is enforced via frontend <meta> tag + vercel.json headers, not at the server layer |
| Google Fonts via CDN | Self-host fonts | Eliminates third-party request, improves privacy, avoids FOUT on slow connections |
| No refresh tokens | Add JWT refresh token rotation (httpOnly cookie) + tokenVersion column for server-side revocation |
Current tokens are purely stateless — no logout, no revocation, no password-change invalidation. Leaked tokens are valid until expiry. Acceptable for a portfolio-scale game with 1-day token TTL; would be a hard blocker for any SaaS or financial app |
| SQLite in prod | Keep it, but add Litestream replication | SQLite on a single volume has no backup. A disk failure = data loss |
| No OpenTelemetry | Add OTel traces | Metrics tell you what is slow. Traces tell you why. At multi-service scale, traces are essential |
| No feature flags | Add a simple flag system | A/B testing engine changes requires redeployment. In-app flags would allow runtime experimentation |
Last updated: February 2026. Questions? Open an issue or see the main README for project context.