Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# The gate in front of Railway. Railway's build runs `tsc --noEmit`, but nothing
# else: a red test suite or a broken level bank reaches production and only
# surfaces after the deploy. These five steps run first, on every push to main
# and every pull request.
#
# No Postgres service here on purpose: no test file imports `src/db/index.ts` or
# reads DATABASE_URL, so the suite is stateless and stays fast. The day a test
# needs a database, add the service then — not in advance.
name: CI

on:
push:
branches: [main]
pull_request:

# A new push to the same ref makes the run in flight pointless.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

# Same literal as `packageManager` in package.json — CI and Railway build
# on one version of bun, or the pin means nothing.
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.0

- run: bun install --frozen-lockfile

# --max-warnings=12 is the invariant from AGENTS.md made executable: the
# dozen react(only-export-components) warnings are inherent to TanStack
# route modules, a thirteenth is a regression.
- run: bun run lint

# `test` and `build` each compile paraglide first; src/paraglide is
# gitignored, so no separate generation step is needed.
- run: bun run test

- run: bun run verify

- run: bun run build
4 changes: 3 additions & 1 deletion .railway/railway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ export default defineRailway(() => {
build: "bun run build",
start: "bun run start",
preDeployCommand: ["bun run db:migrate"],
healthcheckPath: "/",
// `/` is the SPA shell: it answers 200 with the database on the floor.
// /api/health runs `select 1` first.
healthcheckPath: "/api/health",
env: {
DATABASE_URL: Postgres.env.DATABASE_URL,
// secrets set out-of-band (dashboard / CLI) — keep their remote values
Expand Down
142 changes: 85 additions & 57 deletions AGENTS.md

Large diffs are not rendered by default.

49 changes: 27 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ same way.
<p align="center"><sub>The last level, <em>Tectonique</em>, played out by the solver: moves, world drift (<code>decalage</code>), fusion (the pawns overlap to white), scission, then the amber lock — "ready to print".</sub></p>

A React web game running on TanStack Start, fully playable in the browser. The
core game is client-only; the optional **daily mode** (accounts, shared board,
leaderboard) is the one part that talks to a server.
core game is client-only. Everything that talks to a server is optional: the
**daily mode** (accounts, shared board, leaderboard), the per-level
leaderboards, and the public player pages.

## The idea

Expand Down Expand Up @@ -49,28 +50,31 @@ hashable**: no randomness during play, no real time, no hidden information. The
direct consequence is that the game and the solver consume exactly the same API
(`successors` / `isWin` / `hashState`), so they cannot drift apart.

| Path | Purpose |
| --------------------------------------- | ------------------------------------------------------------------------------------ |
| `src/engine/types.ts` | The contract: the game state and the mechanic protocol |
| `src/engine/{grid,state,successors}.ts` | Geometry, lifecycle, move enumeration |
| `src/engine/mechanics/` | One mechanic = one file + `registry.ts` |
| `src/engine/levels.ts` | The level bank (pure data, 22 boards) |
| `src/solver/` | Rule-agnostic BFS + the `verify` / `gen` CLIs |
| `src/ui/screens/` | The title / select / play screens |
| `src/ui/components/` | Board, InkLayer, RegMark, Wordmark, Hud, Controls… |
| `src/ui/hooks/` | `useGame`, `useSound`, `useKeyboard`, `useSwipe`, `useBestScores` |
| `src/routes/` | TanStack Start file-based routes (single `/` mounts the game; `api/` for daily mode) |
| `src/db/` | Postgres + Drizzle: schema, client, `drizzle.config.ts`, `migrations/` |
| `src/lib/` | Better Auth setup (email + password) |
| `project.inlang/messages/{fr,en}.json` | i18n catalogue (translation source, inlang format) |
| Path | Purpose |
| --------------------------------------- | ------------------------------------------------------------------------------------------- |
| `src/engine/types.ts` | The contract: the game state and the mechanic protocol |
| `src/engine/{grid,state,successors}.ts` | Geometry, lifecycle, move enumeration |
| `src/engine/mechanics/` | One mechanic = one file + `registry.ts` |
| `src/engine/levels.ts` | The level bank (pure data, 22 boards) |
| `src/solver/` | Rule-agnostic BFS + the `verify` / `gen` CLIs |
| `src/ui/screens/` | The title / select / play / profile screens, plus the 404-and-error fallback |
| `src/ui/components/` | Board, InkLayer, RegMark, Wordmark, Hud, Controls… |
| `src/ui/hooks/` | `useGame`, `useSound`, `useKeyboard`, `useSwipe`, `useBestScores` |
| `src/routes/` | TanStack Start file-based routes — one per screen, plus `api/`, `sitemap.xml`, `robots.txt` |
| `src/server/` | Server-only: daily puzzle, leaderboards, trace replay, OG cards, crawler documents |
| `src/db/` | Postgres + Drizzle: schema, client, `drizzle.config.ts`, `migrations/` |
| `src/lib/` | Better Auth setup (email + password), streaks, distinctions |
| `project.inlang/messages/{fr,en}.json` | i18n catalogue (translation source, inlang format) |

Data flow: input (keyboard / swipe / buttons) → `useGame.play` →
`engine.applyInput` → new state → render.

This repo is a TanStack Start (React 19 + Vite + Nitro) shell around the
original game. SSR is disabled app-wide — the game needs `AudioContext`,
`localStorage`, and keyboard/swipe — so the core loop is client-only. See
`AGENTS.md` for the full port history and toolchain notes.
original game. Components render client-side by default — the game needs
`AudioContext`, `localStorage`, and keyboard/swipe — so the core loop is
client-only; loaders and `<head>` still run on the server, and the public
profile page renders there in full so crawlers see it. See `AGENTS.md` for the
full port history and toolchain notes.

## Getting started

Expand All @@ -95,7 +99,7 @@ bun run db:migrate # apply migrations to the database
```sh
bun run dev # dev server
bun run build # paraglide + tsc typecheck + vite build
bun run test # Vitest suite (engine only)
bun run test # Vitest: pure (engine, solver, rules) + dom (components)
bun run lint # oxlint
bun run verify # certify every board in the bank is solvable (via the solver)
bun run gen # hunt for new boards
Expand Down Expand Up @@ -126,8 +130,9 @@ bun run gen -- --mods fusion,scission --size 5 --min 18 --ms 30000

Every displayed string goes through a key in `project.inlang/messages/{fr,en}.json`
and is read as `m.key()` (Paraglide). `src/paraglide/` is generated (git-ignored)
and regenerated on build, or by hand with `bun run paraglide`. The default
language follows the browser, with a French fallback.
and regenerated on build, or by hand with `bun run paraglide`. The locale is
resolved from a cookie first, then the browser's preference, with a French
fallback.

## Adding content

Expand Down
Loading
Loading