A competitive line-sprint Tetris-style with accounts, server-verified times, and full replays.
Race the clock to clear your target lines, then watch any run back frame-for-frame. Every leaderboard time is recomputed on the server from the recorded inputs โ so the ranking is honest by construction.
Built on Remix 3 (beta) ยท powered by Node's built-in SQLite in dev and Turso (libSQL) in production.
- ๐ฎ Modern guideline gameplay โ 7-bag randomizer, SRS rotation with wall kicks, ghost piece, hold, a 3-piece next queue, soft/hard drop, and DAS/ARR auto-shift.
- ๐ Line-sprint modes โ clear 40 Lines or 20 Lines as fast as you can. Modes live in a registry and are trivial to extend.
- ๐ Accounts โ username + password auth, hashed with scrypt (
node:crypto). No third-party services, no secrets to provision in dev. - ๐๏ธ Replays โ every finished run is stored and replayable with play / pause / scrub.
- ๐ฅ Honest leaderboards โ the server re-simulates each submission to derive the canonical time and line count. Client-reported times are never trusted.
- ๐งฉ One engine, three jobs โ the same deterministic core powers live play, server verification, and replay.
- ๐จ Themes โ switch between Midnight, Aesthetic, Futuristic, and Retro from the header; your choice is remembered.
The whole game is reproducible from a single 32-bit seed plus the list of inputs, each tagged with the tick it happened on. The simulation is a pure, DOM-free, fixed-timestep (60 Hz) state machine: given the same seed and inputs, it always produces the same board, the same line count, and the same finishing time.
That one property is what makes everything else simple:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
seed + your keys โ โ app/game/engine.ts (pure) โ โ deterministic, 60 Hz
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ โ
โโโโโโโโโโโโ โ โโโโโโโโโโโโ
โผ โผ โผ
๐น๏ธ PLAY โ
VERIFY ๐๏ธ REPLAY
browser runs it live, server re-runs the same viewer re-runs it in
recording {tick,action} inputs to compute the playback, scrubbable
for each input authoritative time
- Time is derived, not measured โ
duration = finishingTick ร (1000 / 60) ms. It can't drift between machines. - A replay is tiny โ just
seed + actions[], not a stream of board states. - Cheat-resistance is free โ to fake a fast time you'd have to submit inputs that actually clear the lines that fast. The server replays them and rejects anything that doesn't reach the goal.
| Layer | Choice |
|---|---|
| Framework | Remix 3 (beta) โ server-first, Web-API based, its own UI runtime (not React) |
| Runtime | Node โฅ 24.3 with the experimental built-in node:sqlite |
| Database | SQLite via remix/data-table + hand-written SQL migrations โ a local node:sqlite file in dev, Turso (libSQL embedded replica via libsql) in production |
| Auth | Session cookies + node:crypto scrypt password hashing |
| Styling | css() mixins (CSS-in-JS, server-rendered, adopted as a stylesheet) |
| Package mgr | pnpm |
The only runtime dependencies are remix itself and libsql (the Turso/libSQL client, used only when a Turso URL is configured) โ in plain local dev the database driver and password hashing both come from the Node standard library.
- Node โฅ 24.3 (for built-in
node:sqlite) - pnpm (
npm i -g pnpm)
pnpm install # install dependencies
pnpm dev # start the dev server (auto-restart on change)Then open http://localhost:44100.
Migrations run automatically on startup, creating db/retris.sqlite on first boot. Sign up, hit Play, and clear some lines.
| Variable | Default | Notes |
|---|---|---|
SESSION_SECRET |
insecure dev secret | Used to sign session cookies. Required in production (the app refuses to boot without it). |
ADMIN_USERNAME |
admin@example.test (dev/test) |
Username of the single admin account. Required in production (the app refuses to boot without it); dev/test fall back to admin@example.test. |
PORT |
44100 |
HTTP port. |
NODE_ENV |
development |
production enables secure cookies + minification; test uses in-memory DB & sessions. |
TURSO_DATABASE_URL |
(unset) | When set, the app reads/writes through a Turso libSQL embedded replica synced from this primary instead of the local SQLite file. Leave unset for a plain local db/retris.sqlite. |
TURSO_AUTH_TOKEN |
(unset) | Auth token for the Turso primary referenced by TURSO_DATABASE_URL. |
| Command | What it does |
|---|---|
pnpm dev |
Run the server with --watch (restarts on file changes). |
pnpm start |
Run the server once (production-style). |
pnpm migrate |
Apply pending database migrations explicitly. |
pnpm test |
Run the test suite (node:test) โ engine, password hashing, HTTP flows. |
pnpm typecheck |
Type-check the whole project with tsc --noEmit. |
| Action | Keys |
|---|---|
| Move left/right | โ โ |
| Soft drop | โ |
| Hard drop | Space |
| Rotate CW | โ / X |
| Rotate CCW | Z / Ctrl |
| Hold | C / Shift |
Pick a theme from the swatches in the header โ the choice is saved to a cookie and applied on every page (including the game board), with no page-specific work.
| Theme | Vibe |
|---|---|
| Midnight | Calm slate-blue dark mode (default) |
| Aesthetic | Soft pastel lavender, light |
| Futuristic | Deep-space black with neon teal |
| Retro | Green-phosphor / Game Boy CRT |
A theme is just a set of CSS custom properties (--bg, --panel, --accent, โฆ) in app/ui/themes.ts; the shell sets them on its root so they cascade everywhere. Adding one is a single entry there โ the switcher and every page pick it up automatically.
app/
โโ routes.ts # Typed URL contract (source of truth for hrefs)
โโ router.ts # Middleware stack + controller wiring; exports AppContext
โโ assets.ts # Source-asset server (compiles browser modules)
โ
โโ actions/ # Controllers โ return Response objects
โ โโ controller.tsx # home (leaderboards) + assets
โ โโ auth/ # login ยท signup ยท logout
โ โโ play/ # the play page (requires auth)
โ โโ games/ # submit (verify + store) ยท show (replay viewer)
โ
โโ game/ # ๐ง Pure, deterministic, DOM-free engine (shared client+server)
โ โโ engine.ts # tick-based state machine + simulate()
โ โโ pieces.ts # tetromino shapes, SRS rotations & kick tables
โ โโ rng.ts # seeded PRNG + 7-bag
โ โโ modes.ts # mode registry (40 Lines, 20 Lines, โฆ)
โ
โโ assets/ # Browser client entries & presentational UI
โ โโ entry.ts # boots the client runtime (run())
โ โโ game-board.tsx # live game: loop, input/DAS-ARR, submit-on-win
โ โโ replay-player.tsx # playback with scrubber
โ โโ board.tsx, game-view.tsx
โ
โโ data/ # schema.ts + queries (users, games) + db.ts (sqlite + migrations)
โโ middleware/ # database ยท session ยท auth ยท render
โโ ui/ # shared cross-route UI (layout, home, auth form, document)
โโ utils/ # pure helpers (passwords, time formatting, redirects)
db/migrations/ # hand-written SQL migrations (immutable, checksum-tracked)
public/ # static files served as-is
server.ts # Node HTTP adapter
- In local dev the database lives at
db/retris.sqlite(git-ignored) and is opened with Node's built-innode:sqlite. - In production, set
TURSO_DATABASE_URL(+TURSO_AUTH_TOKEN) to back the app with Turso. The app opens a libSQL embedded replica (db/replica.sqlite) via thelibsqldriver: reads hit the local replica instead of a ~200 ms network round-trip per query, writes are delegated to the Turso primary, and the replica re-syncs every 10 s. Everything still flows through the same synchronous sqlite adapter, so the rest of the code is unchanged. - Migrations are plain SQL files under
db/migrations/<timestamp>_<name>/{up,down}.sql. They are applied automatically on startup and tracked by checksum, so they run exactly once. - Two tables:
users(id, username, password hash) andgames(the stored replays โ seed, mode, recorded actions JSON, plus the server-verified duration and line count).
To add a migration, create a new timestamped folder with an up.sql (and optional down.sql); it'll apply on the next boot or via pnpm migrate.
Modes are data, not code paths. To add one, register it in app/game/modes.ts:
export const MODES: Record<string, GameMode> = {
sprint40: { id: "sprint40", label: "40 Lines", goal: { type: "lines", count: 40 } },
sprint20: { id: "sprint20", label: "20 Lines", goal: { type: "lines", count: 20 } },
// sprint100: { id: 'sprint100', label: '100 Lines', goal: { type: 'lines', count: 100 } },
};The engine checks the goal generically, the play page exposes it via /play?mode=<id>, and the home page automatically renders a play button and a leaderboard for every registered mode. The Goal union is ready for new goal kinds (e.g. a timed mode) when you want them.
pnpm testThe suite (Node's built-in test runner) covers the parts that matter most:
- Engine โ determinism (same seed + inputs โ identical result), 7-bag fairness, line clears, and goal completion across modes.
- Passwords โ scrypt hash/verify round-trips and rejection of bad input.
- HTTP flows โ driving the router with real
Requests: signup/login, and submitting a replay so the server re-simulation path (accept a real run, reject an incomplete/tampered one) is exercised end-to-end.
SESSION_SECRET="$(openssl rand -hex 32)" NODE_ENV=production PORT=8080 pnpm startIn production the app:
- requires
SESSION_SECRETand fails fast without it, - marks session cookies
Secureand minifies browser assets, - persists sessions to
tmp/sessions/, - uses Turso (libSQL embedded replica) when
TURSO_DATABASE_URL/TURSO_AUTH_TOKENare set, falling back to the localdb/retris.sqlitefile otherwise.