A mots fléchés (French crossword variant) puzzle game for web, tablet, and mobile, with future Discord-Activity support. Brand identity is recorded in ADR-0005; "Bliss" is the working codename used throughout the repo.
Live: https://wordsparrow.io
Sandbox / pre-alpha. Daily puzzles generate and play end-to-end; the multiplayer game context and player identity (OIDC) are in active development. Operated by a single maintainer with a fleet of AI agents working in parallel (ADR-0001).
Bounded contexts, each hexagonally layered
(domain/ → application/ → infrastructure/ → api/):
grid/— Kotlin/JVM. Puzzle generation, validation, word corpus. Includes a daily pre-generation worker (ADR-0042) and the bitmask-CSP grid generator (ADR-0039).game/— Kotlin/JVM. Multiplayer lobbies and realtime play over REST + WebSocket (ADR-0018).identity/— Kotlin/JVM. Player OIDC and session tokens (ADR-0044, ADR-0047).billing/— Kotlin/JVM. Subscription entitlement and checkout via Mollie as the payment provider (ADR-0078).frontend/— Vite + React 19 + TypeScript + Panda CSS + TanStack Router. Player UI, deployed as a static bundle to Cloudflare Workers static assets (ADR-0002, ADR-0090).
JVM is Kotlin 2.x on JDK 21 with Ktor for HTTP/WS (ADR-0006) and Postgres via CNPG + Flyway. Cross-context imports are forbidden; communication is schema-first via OpenAPI for HTTP and AsyncAPI 2.6 for WebSocket (ADR-0003, ADR-0019), with cross-context events flowing over NATS JetStream (ADR-0049). Generated TypeScript types are checked in and gated by drift CI.
All infrastructure is declarative and version-controlled. Nothing is clicked in a console.
flowchart LR
subgraph Edge
ingress["ingress-nginx"]
certmanager["cert-manager"]
end
subgraph Messaging
nats["NATS JetStream"]
end
subgraph ctx_grid["grid"]
grid["grid-api"]
gridDB[("grid pg")]
grid --> gridDB
end
subgraph ctx_game["game"]
game["game-api"]
gameDB[("game pg")]
game --> gameDB
end
subgraph ctx_identity["identity"]
identity["identity-api"]
identityDB[("identity pg")]
identity --> identityDB
end
subgraph ctx_survey["survey"]
survey["survey-api"]
surveyDB[("survey pg")]
survey --> surveyDB
end
subgraph ctx_billing["billing"]
billing["billing-api"]
billingDB[("billing pg")]
billing --> billingDB
end
cluepipeline["clue AI pipeline (Modal)"]
mollie["Mollie (payment provider)"]
certmanager -->|TLS certs| ingress
ingress --> grid
ingress --> game
ingress --> identity
ingress --> survey
ingress --> billing
grid -->|publishes| nats
identity -->|publishes| nats
billing -->|publishes| nats
nats -->|consumed by| game
survey -. manual export .-> cluepipeline
billing -->|hosted checkout / webhooks| mollie
classDef data fill:#c8945633,stroke:#a87538;
classDef external fill:#b8554022,stroke:#b85540;
class gridDB,gameDB,identityDB,surveyDB,billingDB data;
class cluepipeline,mollie external;
style Edge fill:#5a655a1f,stroke:#8b9488;
style Messaging fill:#a875381f,stroke:#c89456;
style ctx_grid fill:#6a93581f,stroke:#6a9358;
style ctx_game fill:#6a93581f,stroke:#6a9358;
style ctx_identity fill:#6a93581f,stroke:#6a9358;
style ctx_survey fill:#6a93581f,stroke:#6a9358;
style ctx_billing fill:#6a93581f,stroke:#6a9358;
classDef default stroke:#6b7fd7,stroke-width:1.5px;
Figure 1. In-cluster topology grouped by bounded context — each box is one context's API and database. Dashed edges are manual or leave the cluster.
flowchart LR
subgraph CI
deploy_api_k8s["deploy-api-k8s.yml"]
deploy_frontend["deploy-frontend.yml"]
end
subgraph Cloud
workers["Cloudflare Worker (frontend)"]
pages["Cloudflare Pages (legacy 301)"]
dns["Cloudflare DNS"]
cacherules["Cloudflare cache rules"]
httpsredirect["Cloudflare Always Use HTTPS"]
k3s["Hetzner k3s"]
end
deploy_frontend -->|wrangler deploy| workers
deploy_api_k8s -->|helm upgrade| k3s
pages -. 301 redirect .-> workers
dns -->|service subdomains via external-dns| k3s
dns -->|301 upgrade| httpsredirect
dns -->|edge cache| cacherules
cacherules -->|cache miss / bypass| k3s
style CI fill:#5a655a1f,stroke:#8b9488;
style Cloud fill:#b8554020,stroke:#b85540;
classDef default stroke:#6b7fd7,stroke-width:1.5px;
Figure 2. Where the frontend and cluster are hosted, and the CI workflows that deploy them.
flowchart LR
browser["Browser"]
ingress["ingress-nginx"]
grid["grid-api"]
game["game-api"]
nats["NATS JetStream"]
browser -->|HTTPS| ingress
browser -->|WSS| ingress
ingress --> grid
ingress --> game
grid -->|PuzzleReady event| nats
nats -->|consumed by| game
classDef context fill:#6a935826,stroke:#6a9358;
classDef messaging fill:#a8753826,stroke:#c89456;
class grid,game context;
class nats messaging;
classDef default stroke:#6b7fd7,stroke-width:1.5px;
Figure 3. How a request and the daily-puzzle event move through the system at runtime.
- Cloud + DNS — OpenTofu manages a self-hosted Hetzner k3s cluster
(ADR-0009,
ADR-0011), Cloudflare
DNS records, and the Cloudflare Workers static-assets project for the
frontend (ADR-0004,
ADR-0090).
The retired Cloudflare Pages project survives as a 301 grace stub
until its 2026-08-04 deletion. Roots in
terraform/(Cloudflare) andterraform/k8s/(provider-agnostic cluster module). State is remote (ADR-0010); versions are pinned viaversions.tfand.terraform.lock.hcl. - Cluster apps — every in-cluster app ships as a Helm chart under
infra/:infra/platform/(ingress-nginx, cert-manager, ClusterIssuers),infra/observability/(SigNoz + alerts + oauth2-proxy),infra/nats/(JetStream streams bootstrapped via in-cluster Job),infra/matomo/(RGPD-compliant product analytics, ADR-0025). App charts and Postgres CNPG clusters live alongside each bounded context. - Configure-in-cluster, not push-from-CI — when an app needs config
bootstrapped (alert rules, JetStream streams, feature-flag seeds), it
ships as a Helm
post-install,post-upgradeJob inside the chart rather thankubectl port-forwardfrom a GitHub Action. - Deploy pipelines — frontend via
.github/workflows/deploy-frontend.ymlto Cloudflare Workers static assets; APIs + workers via.github/workflows/deploy-api-k8s.ymlusinghelm upgrade --install. Container images are pinned by digest.
Operational guides: docs/local-development.md,
docs/deploy.md, docs/secrets.md.
OpenTelemetry from day 1, both ends of the stack. The diagram below is the target topology — telemetry and symptom alerts on every module (plus the RGPD-compliant Matomo analytics path). A module without a source edge here is a gap to address, not a documented exception.
flowchart LR
subgraph Sources
frontend["frontend (browser SDK)"]
grid["grid-api"]
game["game-api"]
identity["identity-api"]
survey["survey-api"]
billing["billing-api"]
nats["NATS JetStream"]
k8smetrics["k8s pod / node metrics"]
end
subgraph Ingest
otlpingress["otlp.wordsparrow.io"]
collector["OTel collector"]
end
subgraph Backend
signoz["SigNoz"]
clickhouse["ClickHouse"]
end
subgraph Analytics
matomo["Matomo"]
end
subgraph Alerting
alerts["symptom alert rules"]
gmail["Gmail SMTP"]
oauth2["oauth2-proxy"]
end
frontend -->|analytics| matomo
frontend -->|OTLP traces| otlpingress
otlpingress -->|forward| collector
grid -->|otel| collector
game -->|otel| collector
identity -->|otel| collector
survey -->|otel| collector
billing -->|otel| collector
nats -->|metrics| collector
k8smetrics -->|metrics| collector
collector -->|ingest| signoz
signoz -->|store| clickhouse
signoz -->|evaluate| alerts
alerts -->|5xx / errors / staleness| gmail
oauth2 -->|gates admin UI| signoz
classDef data fill:#c8945633,stroke:#a87538;
class clickhouse data;
style Sources fill:#6a93581f,stroke:#6a9358;
style Ingest fill:#5a655a1f,stroke:#8b9488;
style Backend fill:#5a655a1f,stroke:#8b9488;
style Analytics fill:#a875381f,stroke:#c89456;
style Alerting fill:#b8554020,stroke:#b85540;
classDef default stroke:#6b7fd7,stroke-width:1.5px;
Figure 4. Target telemetry, alerting and analytics topology — a module without a source edge here is a gap to address, not an exception.
- Frontend traces ship to a public OTLP ingest fronted by ingress (ADR-0033).
- Backend telemetry lands in SigNoz on ClickHouse (ADR-0027, ADR-0041), with a dedicated worker topology to isolate the observability data plane (ADR-0040). Cluster + node metrics flow via the k8s infra collector (ADR-0038).
- Logs are structured JSON with correlation IDs. No
println, noconsole.log, no string concatenation in log messages. - Alerts target symptoms, not causes — API 5xx rate, frontend error
rate, daily-puzzle staleness. Alert rules are markdown files in
infra/observability/alerts/and applied via an in-cluster Helm Job; routing is Gmail SMTP (ADR-0032). The admin UI is gated by oauth2-proxy (ADR-0030).
French crossword clues need a French model that respects domain rules
(no stem leak, right register, exact length, valid morphology).
Off-the-shelf APIs don't clear that bar, so the project trains its own
French clue model on Modal GPU
(ADR-0057) through
human-in-the-loop rounds. The lane lives in
scripts/clue_generation/pipeline_v2/.
flowchart LR
gen["Modal GPU generate (model n-1)"]
judge["learned judge — pre-filter"]
human["human rates · /contribuer"]
winners["winners (qualité=5)"]
sft["SFT → model n"]
grid["grid corpus"]
gen --> judge
judge --> human
human --> winners
winners --> sft
sft -. next round .-> gen
human -. not yet wired .-> grid
classDef context fill:#6a935826,stroke:#6a9358;
classDef data fill:#c8945633,stroke:#a87538;
classDef messaging fill:#a8753826,stroke:#c89456;
class gen,sft context;
class grid data;
class human messaging;
classDef default stroke:#6b7fd7,stroke-width:1.5px;
Figure 5. The Modal clue-generation training loop. Dashed edges are the round restart and the not-yet-wired grid corpus.
- Generator — successive fine-tuned model iterations on Modal GPU; each round's model generates candidate clues for the sampled lemmas.
- Structural filters — a deterministic chain in
pipeline_v2gates candidates before human review: typography, length, French-language detection (lingua), self-reference, tautology, stem-leak, and pleonasm. - Judge — a learned judge (
filter_8) scores semantic quality as a pre-filter ahead of human rating (currently shadow-scored). The human rater on/contribueris the reward signal — never the judge — so the generator cannot reward-hack it. - Rounds — maintainer-rated winners (
qualité=5) become the SFT training set for the next model iteration; the loop stays human-anchored. - Grid corpus — not yet fed from this lane. The in-cluster
words-clues-workeringestion (ADR-0013) hasn't been rewired from the old CSV path to the Modal pipeline.
The human stays the reward by design: the judge only triages obvious-bad
to cut rating load, never grading the data that becomes training winners.
Data-licence posture (e.g. DBnary CC BY-SA) is governed by
ADR-0058; evaluation
logbooks live in docs/eval/.
The repo is built to be worked on by many Claude Code agents in
parallel, with the maintainer as the human-in-the-loop reviewer and
arbiter (ADR-0001).
The operational rules are in CLAUDE.md; the
rationale is in MANIFESTO.md. Mechanics:
- One workstream per PR, hard-capped at 400 lines of diff (excluding
generated code). Branches follow
<type>/<short-description>and are enforced bybranch-name.yml. Implementer ≠ reviewer (§6a). - Skill library in
.claude/skills/—dispatch(orchestrator playbook),reviewer(§6a reviewer agent),clue-ai,jvm-backend,frontend,schemas. Skills load via the Claude Code Skill tool and encode repo conventions so each agent starts with the same context. - Worktree isolation — agents run in
.claude/worktrees/agent-<id>/via theAgenttool withisolation: "worktree", so parallel work never collides on the working tree. - Wave-based rollouts — large features (multiplayer, custom
mobile keyboard) are decomposed into a plan under
docs/superpowers/plans/, then dispatched in waves of disjoint PRs. The dispatcher orchestrates implementer + reviewer + fixer loops. - Autonomous cron mode (
/orchestrate) — a 2-minute cron tick picks up the plan, dispatches the next phase, runs the auto-fixer loop, and merges PRs when CI is green and the §6a reviewer LGTMs. Maintainer remains the escalation backstop via the log file. - CI gates that keep the fleet honest — Spotless, Konsist
architecture tests,
openapi-lint,openapi-typescript-drift,helm-lint, CodeQL, dependency-review, gitleaks, DCO sign-off, conventional commits viacommitlint, andclaude-code-reviewfor the §6a review/fix cycle. No--no-verify, no force-push to shared branches.
Local development runs against a k3d cluster that mirrors the prod k3s
topology. See docs/local-development.md
for the full walkthrough; the short version:
make cluster-up # create k3d cluster (idempotent)
make cluster-bootstrap # ingress-nginx, cert-manager, CNPG
make deploy-local # build images, helm install
make dev # API hot reload + Vite HMRJVM build:
./gradlew build --parallel --build-cache # what CI runs
./gradlew spotlessApply # fix formatting in placeFrontend (from frontend/):
pnpm dev # Vite + Panda codegen
pnpm test # vitest
pnpm e2e # Playwright
pnpm a11y # axe-core via Playwright (WCAG AA baseline)
pnpm api:check # regenerate OpenAPI types; fails on driftSee CONTRIBUTING.md for branch naming, commit
conventions, DCO sign-off, and local hook setup. Every non-trivial
change starts with an ADR in docs/adr/.
FSL-1.1-MIT — Functional Source License 1.1, MIT Future License.
In plain English:
- Free for any non-competing use — personal, internal-business, educational, research, professional services around the Software.
- Commercial competition is restricted — you may not host or sell a product or service that substitutes for, or substantially duplicates, WordSparrow.
- Becomes MIT after two years — every release auto-converts to a full MIT license on the second anniversary of its publication. The Software is genuinely open in the long run; the restriction applies only to the current frontier.
The full text and edge cases are in LICENSE. For
commercial-use licensing inquiries that fall outside the Permitted
Purpose, contact ISHO IT EURL.