🇬🇧 English | 🇷🇺 Русский
Browser 3D racer in time-trial format: every run is a solo lap against the clock, and your finishing time lands on the track leaderboard. React 19 + React Three Fiber + Rapier (WASM) physics on the front, NestJS 11 + Prisma 7 on the back. pnpm monorepo, Feature-Sliced Design, steady ~60 fps in the browser.
Contents: Gameplay · Screenshots · Highlights · Features · Controls · Architecture · Stack & why · Auth & security · Performance · Testing · Quick start · Development · Trade-offs · Roadmap · Author
The whole game in six recordings — the app tour on desktop, all three track runs, and menus/touch on a real phone. Click any frame to play:
menu-desktop.mp4
| Coastal Oval — run | Night City — run | Storm Eight — run |
|---|---|---|
race-1-desktop.mp4 |
race-2-desktop.mp4 |
race-3-desktop.mp4 |
| Menus — phone | Race — phone, touch controls |
|---|---|
menu-mobile.mp4 |
race-1-mobile.mp4 |
Desktop screens (click to open full size):
| Home | Tracks | Garage | Leaderboard |
|---|---|---|---|
| Login | Profile | Finish — SEGA-style moment | |
Light and dark theme, one toggle:
| Light | Dark |
|---|---|
Tracks:
| Coastal Oval — packed grandstands, sun-aligned shadows | Night City — moon, headlights, street lamps, neon bloom | Storm Eight — rain, wet asphalt, elevated deck |
|---|---|---|
Cameras — chase / bumper / TV:
| Chase | Bumper | TV |
|---|---|---|
Mobile layouts:
| Home | Tracks | Garage | Leaderboard | Profile |
|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
- Race simulation does not live in Redux. Per-frame state lives in a small vanilla store; the HUD reads it via
useSyncExternalStorewith updates throttled to ~80 ms, and the timer/speed are written straight into the DOM. The reason: React re-rendering every physics frame measurably hitched Chrome. Redux/RTK Query is used only for auth/API and the one POST at the finish line. → Performance - A full token lifecycle, not a "login endpoint". The short-lived access JWT (15 min) never leaves memory. The refresh token lives in a path-scoped httpOnly cookie, and the database stores only its SHA-256 hash. Every refresh replaces the token; reusing an already-replaced token is treated as theft — it revokes all of the user's sessions. → Auth & security
- 572 unit/integration tests + 5 Playwright user flows. CI runs
lint → typecheck → migrate → test → buildon every push; a pre-commit hook repeats lint/prettier/typecheck locally. → Testing - FSD boundaries are enforced, not documented. ESLint forbids cross-slice deep imports,
any/unknown, relative parent imports and default exports. - In-game performance HUD — FPS, main-thread busy %, JS heap: everything a browser can honestly measure about itself. → Perf HUD
- Patched upstream library ships with the repo via pnpm
patchedDependencies: the three-stdlib GLTF loader decodes textures straight intoImageBitmapinstead of blob URLs — faster preload, no memory leaks.
Three tracks, each with its own weather, palette and music
| Track | Setting | Character |
|---|---|---|
| Coastal Oval | Sunny day | Fast oval, sun-aligned shadows, packed grandstands |
| Night City | Night, clear | Moon + headlights + street lamps (LOD), neon bloom/vignette post-FX |
| Storm Eight | Rain | Figure-eight with an elevated deck over the crossing, wet surface, rain and wheel spray, storm sky and BGM |
Every track: multi-lap with ordered checkpoints, dynamic obstacles (tire stacks, fuel barrels you can bounce off), oil slicks that cut grip, trackside props with real colliders (trees, billboards, lamps, tribunes).
Garage — 5 GLB bodies fitted to real-world class scale (4.14–5.71 m): hatchback, sedan, pickup, Juke, sport coupe. Paint shifts with the selected track; the garage preview is live — wheels steer and spin there, driven from measured axle positions.
Physics (Rapier WASM) — arcade, but consistent: fixed 1/60 step, steering independent of framerate, handbrake drift, per-surface feel (asphalt / kerb / offroad), dynamic obstacle damping, and the car stays glued on descending viaduct decks and crest slopes.
Graphics — procedural canvas textures (asphalt, kerbs, bark, crowd caps and flags), sun/moon shadows that follow the car, skid marks kept for the whole race, tire smoke, brake/reverse light modes, headlight beams bound to the car's visual pose (no lag), F1-style start-light gantry.
Audio — 100% synthesized in JavaScript. Chip-rock BGM is generated at runtime by the Web Audio API (oscillators + filtered noise, ~150 BPM per-track themes, storm variations, victory theme on finish), with driving SFX and rain scene audio. Zero audio files shipped.
Race UX — three cameras (chase / bumper / TV) with FOV and shake, live minimap with a pose-projected vehicle marker, SEGA-style finish moment, pause menu, restart.
Around the race — per-track leaderboards (server-side, filterable by slug), guest mode with bests in localStorage, profile page with aggregate stats, per-track bests with rank and gap to the leader, and 6 achievements; i18n (EN/RU); light/dark theme; mobile support: touch controls (throttle / brake / steering / handbrake), compact HUD, safe-area insets and responsive layouts down to phone width. Menus and modals are keyboard-operable with focus trapping.
| Action | Keyboard | Touch |
|---|---|---|
| Throttle / brake+reverse | W / ↑ · S / ↓ | on-screen pedals |
| Steer | A / D or ← / → | on-screen wheel buttons |
| Handbrake (drift) | Space | handbrake button |
| Camera (chase/bumper/TV) | C | — |
| Restart / pause | R / Esc | HUD buttons |
flowchart LR
Web["apps/web<br/>SPA"] -->|"REST JSON<br/>+ JWT"| Api["apps/api<br/>NestJS"]
Api --> Db["SQLite<br/>via Prisma"]
Web --> Shared["packages/shared<br/>types"]
Api --> Shared
flowchart TB
subgraph fsd [FSD web]
App --> Pages --> Widgets --> Features --> Entities --> Shared
end
flowchart LR
Input["Keyboard<br/>/ touch"] --> Ctrl["Vehicle<br/>useFrame"]
Ctrl --> Rapier["Rapier<br/>RigidBody"]
Sensors["Checkpoints"] --> Sim["race-session<br/>vanilla store"]
Ctrl --> Sim
Sim --> Hud["HUD useSync<br/>ExternalStore"]
Sim -->|"only on<br/>finish"| Rtk["RTK Query POST<br/>race-results"]
apps/
web/ # Vite + React 19 SPA (game)
src/
app/ # entry, router, Redux store, i18n, theme CSS vars
pages/ # home, race, garage, tracks, leaderboard, profile, login, register
widgets/ # race-canvas, hud, minimap, countdown, pause menu, finish, leaderboard-table, vehicle-preview
features/ # auth, race-session (store/input/physics), race-audio, race-perf,
# backend-status, locale/theme switchers, vehicle-select
entities/ # track (layout, weather, obstacles), vehicle (GLB catalog),
# leaderboard, race-result (achievements)
shared/ # api base, config (palette, i18n messages), ui kit, lib
e2e/ # Playwright user flows
api/ # NestJS 11: auth, tracks, race-results, health; Prisma 7 + SQLite
packages/
shared/ # transport types & constants — single source of truth for both apps
| Choice | Why |
|---|---|
| Vite + React 19, not Next.js | It's an SPA game. SSR/hydration with a WebGL canvas and Rapier WASM would only get in the way. |
| Race sim outside Redux | Per-frame state in a global store means per-frame re-renders. Vanilla store + useSyncExternalStore keeps HUD updates out of the 3D frame. |
Rapier (@react-three/rapier) |
Deterministic WASM physics in the same component tree as R3F; fixed 1/60 step. Arcade goals, not an F1 simulator. |
| three.js primitives + procedural textures | A ~60 fps budget rules out scanning marketplace GLTFs. Only the 5 garage bodies are GLB; everything else is geometry and canvas textures — small payload, full control. |
| @react-three/drei | Ready-made scene helpers — Sky/Stars, Billboard, useGLTF — so we don't hand-roll skyboxes, billboarding and GLTF loading. |
| @react-three/postprocessing | Post-FX pipeline (Bloom + Vignette) for neon glow on Night City; applied only where the 60 fps budget allows. |
| Tailwind CSS 4 | Utility styling for all 2D chrome (HUD, menus, cards) via the Vite plugin; the 3D scene itself is styled in code, not CSS. |
| i18next + react-i18next | EN/RU localization from one messages.ts tree, switchable at runtime and shared by the UI and tests. |
| Redux Toolkit + RTK Query | Kept where it fits: auth state, API caching, single mutations. |
| NestJS 11 | Structured DI, guards, pipes and decorators; DTOs with class-validator give real input boundaries. |
| helmet + @nestjs/throttler | Security headers and rate-limiting on auth routes — cheap hardening that closes the obvious attack surface. |
| SQLite + Prisma 7 (better-sqlite3 driver adapter) | One file DB, no Docker — zero-friction clone & run; Prisma types shared with the frontend via @racing/shared. |
| JWT access + rotating httpOnly refresh | See Auth & security. |
| Web Audio synthesis, not audio files | Music/SFX are code: no binary assets, no licensing, per-track themes compose from note primitives, and the synth itself is a demo of the API. |
| pnpm workspaces, not Turborepo | Two apps and one shared package — an orchestrator would be ceremony. patchedDependencies upstream patch ships with the repo. |
Packages: @racing/web, @racing/api, @racing/shared. Node 22.12+, pnpm 10.15.
Token model
- Login/register returns a 15-min access JWT (HS256, issuer/audience pinned) that the client keeps in memory only — never in
localStorage, so an XSS payload can't exfiltrate a persisted token; a reload transparently restores the session via the refresh cookie. - The refresh token is an opaque random string in an httpOnly,
path=/authcookie (Secure/SameSiteconfigurable for production). The database stores only its SHA-256 hash — a DB leak yields nothing usable. - Every
/auth/refreshreplaces the token: the old session is revoked, and the database records which token replaced it (replacedById). Reusing an already-replaced token is the signature of token theft — it revokes every session of the user. Expired sessions are purged.
Hardening
- Passwords: bcrypt (cost 10); login against a nonexistent user compares against a dummy hash so response timing doesn't leak account existence.
- CSRF on cookie endpoints (
/auth/refresh,/auth/logout): a customX-Requested-Withheader is required when a refresh cookie is present — simple cross-origin form posts can't set it. - Helmet,
@nestjs/throttlerrate limit on auth routes, globalValidationPipewithwhitelist+forbidNonWhitelisted. - Fail-fast boot on invalid environment (placeholder
JWT_SECRET, missingCOOKIE_SECURE/CORS_ORIGINin production); Swagger disabled in production. - Server-side anti-cheat floor: results faster than 8 s (physically impossible) are rejected; checkpoint order is validated on the client.
Honest scope: this is portfolio-grade, not payment-grade — symmetric HS256, no device fingerprinting or server-side lap simulation. The model is chosen to demonstrate correct token hygiene at demo scale.
The budget is ~60 fps. No tricks — only standard techniques, each with a measured reason:
- Fixed 1/60 physics step; input and handling depend on
dt, never on frame count. - HUD notifications throttled to ~80 ms; time/speed written directly to the DOM in a rAF loop — React state updates inside
useFramewere the root cause of a real Chrome hitch (see1633782in history). - Renderer:
dprclamped to[1, 1.5], 1024 shadow maps, sun/moon shadows that follow the car, street-lamp spot LOD with distance cutoff, per-scene light/shadow budgets (Night City), Bloom+Vignette only on the night track. - Assets: everything procedural except 5 GLB bodies; a patched GLTF loader decodes texture bufferViews straight into
ImageBitmap; bodies are preloaded before the race mounts; routes are lazy-loaded. - Bundle shape (from
pnpm build): app shell + Redux/i18n/router ship first at ~93 kB gz (JS 84 + CSS 9); the 3D engine — three, Rapier WASM, post-FX, 1.29 MB gz — is a separate chunk fetched only when a race or the garage preview opens. - App level: the SPA gates on API readiness, retries idempotent GETs with backoff, and recovers from WebGL context loss.
Perf HUD (FPS / CPU / MEM). A browser cannot read the system CPU, so the HUD measures what it honestly can: FPS — rendered frames/s; CPU% — the share of wall-clock time the main thread spends inside frame work (measured in the render loop, sampled every 250 ms) — i.e. "how busy this tab makes the machine"; MEM — performance.memory.usedJSHeapSize (JS heap), a Chromium-only API, hidden where unavailable. It pairs race stats (lap, split, best) with the cost of drawing them.
Measured: 60 fps during MVP verification of Coastal Oval runs (dev preview, Stats.js overlay). Verify it live — the HUD is in-game.
572 tests (Vitest) — 536 web (unit + RTL/MSW integration, 101 files) · 28 API unit · 8 API e2e (supertest on a real Nest app, temp SQLite)
5 Playwright flows — auth/logout, guest profile, race flow, theme+locale, tracks+leaderboard
- MSW-backed integration tests instead of hand-rolled RTK Query mocks; unhandled requests fail the test.
- Playwright doesn't drive a car for 3 minutes: the race page exposes
window.__finishRaceForTest()and the suite finishes through the real API path. - Every logic change ships with its test in the same commit — physics, checkpoints, lap rules, obstacle placement, token rotation, anti-cheat floor.
pnpm install
cp apps/api/.env.example apps/api/.env && cp apps/web/.env.example apps/web/.env
pnpm --filter @racing/api prisma:generate # generated client is gitignored
pnpm ready-steady-go # builds shared → prisma migrate+seed → runs web + apiSeed accounts: test_user / 1234 (plus rival rival_racer / 1234). Requires Node 22.12+ and pnpm 10.15 — if anything refuses to start, the troubleshooting table and full command matrix are in docs/development.md.
| Command | What it does |
|---|---|
pnpm ready-steady-go |
the full dev cycle from Quick start |
pnpm lint / pnpm typecheck / pnpm format |
ESLint (--max-warnings=0), tsc -b, Prettier |
pnpm test / test:web / test:api / test:e2e |
Vitest (unit + RTL/MSW), API e2e, Playwright |
pnpm build |
production build of all packages |
A husky pre-commit hook reruns lint/format/typecheck locally; commits follow Conventional Commits (feat(web): …). CI (GitHub Actions, Node 22.12) enforces the same gates on every push: lint → typecheck → migrate → test → build. The full command matrix, troubleshooting and code rules live in docs/development.md.
- Arcade physics: yaw-only, walls slide the car instead of bouncing it, top speed ~108 km/h. A tire-model simulator is a different project.
- Anti-cheat is minimal: server-side floor is 8 s + checkpoint order validated in the browser. Server-authoritative lap simulation would be the next step.
- Guest bests live in
localStorage; only authenticated finishes reach the leaderboard. - Access JWT dies on reload by design (restored via refresh cookie).
performance.memoryis Chromium-only — MEM is hidden in Firefox/Safari.- SQLite + no ORM-level pooling: right for a demo, wrong at scale; Prisma makes the swap cheap.
- Gamepad support
- Per-wheel physics (independent suspension, per-tire grip)
- New tracks / marketplace GLTF environment
- Ghost replays from recorded telemetry
- Hosted demo (web on a static host, API on a small VPS/Fly)
Roman Kovalenko — solo author: product concept, architecture, frontend (React / R3F / Rapier / Web Audio), backend (NestJS / Prisma), tests and CI.




