Compose Multiplatform chess app with full support for all standard chess rules and a 3D view mode, targeting:
- Android
- Desktop (JVM): Linux and macOS
- Web (Wasm)
- iOS
For the desktop target on Linux and macOS, JDK 26 is recommended. The desktop 3D renderer uses a native C++ Filament bridge; after a clean checkout run tools/fetch_filament_desktop.sh to fetch the gitignored Filament desktop payload before desktop builds.
For the desktop target on Linux, install stockfish first:
sudo apt install stockfish # For Ubuntu/Debian
sudo pacman -S stockfish # For Arch
sudo dnf install stockfish # For FedoraFor the desktop target on macOS:
brew install stockfishmacOS, Xcode 16+, and a working project JDK are required.
tools/fetch_filament_ios.shopen iosApp/iosApp.xcodeproj- Run the
iosAppscheme
The Stockfish engine is bundled automatically. The Filament iOS xcframeworks are fetched separately because they are large generated dependencies and are intentionally gitignored.
The project is split into focused Gradle modules:
:chess-core— the Compose-free, platform-agnostic chess engine core (all game rules, FEN/UCI/SAN/PGN converters,GameViewModel, and the pure-Kotlin 3D-board math/scene mapping). It has no Compose, no russhwolf/Settings, nojava.lang.Process. Targets: Android, JVM (desktop), iOS (arm64/simulator), JS (IR), and Wasm. Published to GitHub Packages asio.github.ber4444:chess-core(tag-driven: pushchess-core-v*→publish-chess-core.ymlpublishes that version). Consumed by:app(below) and by the React Native port, so there is no duplicated Kotlin across the two repos — bumpchessCoreVersionto pick up core changes. Also home to the perft verification rig — the move generator is proven correct against arithmetic ground truth (canonical perft counts + Stockfish cross-check).:app— the Compose Multiplatform app: all UI screens, platform glue, Stockfish bridges, and the 3D renderers. Depends on:chess-coreviaapi(project(":chess-core")).:androidApp— thin Android application wrapper (manifest, launcher icons) that depends on:app.:onDeviceAi— on-device AI orchestration (move coach, rules Q&A, opening explainer, route policy) incommonMain, with platform-specific LLM runtimes injected (Cactus on Android, Foundation Models on iOS, deterministic fallback on desktop/wasm/JS). Published to GitHub Packages asio.github.ber4444:onDeviceAi(tag-driven: pushon-device-ai-v*→publish-on-device-ai.ymlpublishes both:onDeviceAiand:coachApitogether). Consumed by:appand by the React Native port.:coachApi— serialization-only KMP wire models (OpeningExplainRequest/Response, etc.) shared by:onDeviceAiand the opening explainer service. Published asio.github.ber4444:coachApibecause:onDeviceAiexposes its types in public signatures (OpeningExplainer returnsOpeningExplainResponse), so consumers need it transitively.:server— JVM Ktor opening-explainer service with pgvector retrieval and deterministic composition; the optional provider composer always validates and falls back locally.:evals— rule-based grounding and length regression suite for AI coach routes.:perft-mcp— a thin stdio MCP server exposing the perft rig as three tools (run_perft_gate,stockfish_divide,read_divergence) for agent-driven verification loops. JVM-only; no dependency on:appor:chess-core(it shells out to gradle + stockfish as an adapter, not an engine).
The core↔app boundary is enforced by three seams (don't re-couple them):
Piecehas noassetfield;:appresolves drawables viaPieceAssets.asset().- Core types have no
@Immutable(Compose stability hints);:app's compiler re-infers stability. GameViewModeltakes aGameSnapshotSink(core interface), not the russhwolfCurrentGameStore;:appadapts viaCurrentGameStore.asSnapshotSink().
graph TD
subgraph commonMain ["commonMain (Shared)"]
GS["Game State (GameUiState)"] --> GameScreen["GameScreen (UI)"]
GS --> FC["FenConverter"]
FC --> FEN["FEN String"]
FEN --> BM["Board3DSceneMapper"]
BM --> B3S["Board3DScene (Renderer-Agnostic)"]
GameScreen -. "Toggles 3D view" .-> Renderer
Assets["Assets (chess.glb, IBL .ktx)"] --> Renderer
B3S --> Renderer
Renderer[["Chess3DBoardRenderer Contract"]]
end
subgraph Platforms ["Platform-Specific Renderers"]
Renderer --> Android["Android<br>AndroidSceneViewChessRenderer<br>(Filament / SceneView)"]
Renderer --> iOS["iOS<br>FilamentIosChessRenderer<br>(Filament / Metal)"]
Renderer --> Desktop["Desktop<br>DesktopFilamentChessRenderer<br>(Filament / native C++)"]
Renderer --> Web["Web (Wasm)<br>FilamentWasmChessRenderer<br>(Filament / WebGL)"]
end
%% Styling
classDef common fill:#e3f2fd,stroke:#1e88e5,stroke-width:2px,color:#000;
classDef state fill:#ffffff,stroke:#1e88e5,stroke-width:1px,color:#000;
classDef contract fill:#fff8e1,stroke:#ffb300,stroke-width:2px,color:#000;
classDef platform fill:#fafafa,stroke:#9e9e9e,stroke-width:2px,color:#000;
class commonMain common;
class GS,GameScreen,FC,FEN,BM,B3S,Assets state;
class Renderer contract;
class Android,iOS,Desktop,Web platform;
- Full Chess Rules: The application covers all standard chess rules and includes an explicit draw-by-agreement flow where the Stockfish engine evaluates whether to accept or decline draw offers.
- Game Lifecycle & Persistence: The in-progress game is auto-saved on every move and restored on next launch (board, turn, move list). On game end, the user can Save game (to a persisted Game History) and Share PGN (platform share sheet / file dialog / download). PGN export is full Standard Algebraic Notation with the Seven Tag Roster; paste a saved PGN into lichess.org "Import game" to validate. A History screen lists saved games with a detail view and delete.
- Engine Difficulty: A persisted Easy / Medium / Hard / Max setting (in Settings) weakens or strengthens Stockfish play via the UCI
Skill Leveloption and a per-move think-time budget. Applies to the Stockfish engine on every platform. - Settings & Navigation: A minimal multiplatform navigation host (
AppRoot) switches between the game, History, Settings, and Rules screens, with a persisted 3D-board toggle (default on) and the engine-difficulty selector in Settings. - On-device AI Move Coach: A debug-gated (
ApplicationInfo.FLAG_DEBUGGABLE) Compose panel (MoveCoachPanel) that surfaces a natural-language explanation of Black's move after it animates. Wired byGameViewModel.triggerCoach(...)— cancellable, never blocks the move, skipped if the move ends the game. Backed by a shared KMP module:onDeviceAiholding the routing policy (AiRoutePolicies.moveCoachOffline), prompt builder, and deterministic fallback incommonMain; platform runtimes are injected at the entry points. See On-Device AI Architecture for details on the prompt and routing policy.- Android backend: Cactus (
com.cactuscompute:cactus:1.4.1-beta) — llama.cpp CPU runtime. Thegemma3-270mmodel (~200 MB GGUF) is downloaded from Hugging Face by Cactus on first launch intofilesDir(debug APK ~258 MB; no model bundled in the APK).AndroidManifest.xmldeclaresINTERNETso Cactus can fetch the model. Cold start ~1–2 s. Replaced the earlier LiteRT-LM path (7–9 s cold init, streaming crash, no resolvable Maven coordinate) and the ML Kit Prompt API path (narrow AICore device support);MoveCoachModelAsset.ktandAndroidCoachWiringwere removed in the migration. Seedocs/benchmarks/on-device-ai/android-delivery-decision.md. - iOS backend: Foundation Models (Apple Intelligence) via
FoundationMoveCoachBridgeregistered intoFoundationModelsBridgeRegistryfromiOSApp.swift. Falls back to rule-based text when Apple Intelligence is unavailable. iOS has not been migrated to Cactus yet — it stays on Foundation Models, though the:onDeviceAiKMP module makes that swap feasible later. - Desktop backend: LiteRT-LM (
com.google.ai.edge.litertlm:litertlm-jvm, Google AI Edge) — the Kotlin/JVM API over LiteRT-LM, with native libs bundled inside the jar (linux-x86_64 / linux-aarch64 / darwin-aarch64 / win-x86_64; no Intel Mac — those hosts fall back). The Qwen3-0.6B-int4 model (~347 MB.litertlm) is downloaded from Hugging Face on first launch and cached under~/.chess-coach-models/. Gated behindCHESS_ENABLE_COACH=1(env var) — without it the coach panel stays hidden (the previous default). Seedocs/benchmarks/on-device-ai/desktop-wasm-litert-lm.md. - Wasm backend: LiteRT-LM for Web (
@litert-lm/core, loaded from the jsdelivr CDN at runtime) running in a module Web Worker so inference is off the main thread. Usesgemma-4-E2B-it-web.litertlm(~2 GB, the only model@litert-lm/coreofficially documents for web). Requires WebGPU (Chrome/Edge); on Firefox/Safari the generator reports unavailable and the orchestrator falls back to the deterministicMoveCoachFallback. Gated behind?coach=1on the page URL. - JS target (
js(IR){nodejs()}): stillUnsupportedTextGenerator— the React Native port has no WebGPU/workers.
- Android backend: Cactus (
- Opening Explainer (cloud route): The one AI feature that is allowed to leave the device. When a game ends, a post-game panel (
OpeningExplainerPanel) fetches a short, grounded explanation of the opening from a small Ktor + Postgres + pgvector service (:server). It is the only policy withallowCloud = true(AiRoutePolicies.openingExplainer,PUBLIC_OR_SYNTHETIC, 0.2¢ ceiling). The cloud client is injected from:app; if the base URL is unset, the network is down, or the service returns non-2xx, the panel shows a deterministic offline-guidance message instead. The move coach'sLOCAL_ONLYpolicy can never reach this route — an exhaustive 60-context decider test proves it. See Opening explainer service below for deployment. - Rules Q&A (on-device): A
LOCAL_ONLYfeature distinct from both the move coach (no retrieval) and the opening explainer (cloud retrieval). A Rules screen lets the player ask a natural-language chess-rules question; retrieval and generation stay entirely on-device. The corpus is a bundled 30-passage FIDE/Wikibooks adaptation (onDeviceAi/src/commonMain/resources/rulesCorpus/passages.tsv) looked up via BM25 (BundledRuleLookupTool). iOS uses a native Foundation ModelsToolconformance withNLEmbeddingquery-time ranking; Android uses structured-output prompting (the model emits a{"tool":"lookup_rule","query":"…"}envelope, Kotlin does the real lookup, then a second generation turn cites the passage). An answer that doesn't cite a retrieved passage ID is rejected and falls back to a static rules summary. Seedocs/benchmarks/on-device-ai/rules-qa-retrieval-decision.mdfor why BM25 was chosen over a bundled embedding model. - 3D Board View: The app features a playable 3D board with shared camera, tap-to-move, ray picking, and move animation logic. Desktop, iOS, and web share
FilamentEncodedChessRendererfor FEN-to-scene, camera, selection, and transition state; their platform peers only own the Filament surface. Android uses Filament through SceneView (the visual reference); iOS uses Metal-native Filament through a Swift/Obj-C++CAMetalLayerbridge; desktop uses native C++ Filament with a headless swap chain and RGBA readback into Compose; web uses Filament (Wasm) loading the samechess.glbAndroid uses. Seedocs/plans/web-graphics-spike-result.mdanddocs/plans/ios-filament-spike-result.mdfor the spike verdicts. - Stockfish Engine Integrations:
- Android: Pinned to Stockfish 17, as the Stockfish 18 binary exceeds GitHub's 100 MB file limit.
- Desktop: Relies on system-installed binaries (e.g., via
aptorbrew). - Web (Wasm): Uses a lightweight
stockfish-18-lite-single.jsrunning in a Web Worker. - iOS: Wraps
ChessKitEngineusing an async-sync bridge and utilizes NNUE viaEvalFileSmall.
- Perft Verification Rig: The move generator is proven correct against arithmetic ground truth — canonical perft counts for six standard positions, plus a Stockfish
go perftdivide-diff cross-check over a seeded random walk of arbitrary midgame/endgame positions. The rig runs in CI on every PR and nightly (deep tier). A pure top-levelapplyMove(extracted fromGameViewModel.deriveNewGameState) makes the generator testable to perft depth without ViewModel side effects. An optional MCP server (:perft-mcp) exposes the rig as tools for agent-driven verification loops. See docs/perft.md for the full walkthrough.
chess-core/src/commonMainthe Compose-free chess engine core (rules, FEN/UCI/SAN/PGN,GameViewModel, board3d math) — published asio.github.ber4444:chess-corechess-core/src/commonTest+chess-core/src/desktopTestthe perft verification rig (canonical gate + Stockfish oracle + deep tier)app/src/commonMainthe Compose UI, persistence, share, and 3D renderer glue (depends on:chess-core)app/src/androidMainAndroid-specific shared implementation and Stockfish integrationandroidApp/src/mainAndroid application manifest that depends on the shared KMP moduleapp/src/desktopMaindesktop launcherapp/src/wasmJsMainweb launcherapp/src/iosMainshared iOS implementationiosApp/Xcode project and Swift adaptercoachApi/src/commonMainserialization-only wire models — published asio.github.ber4444:coachApiserver/JVM Ktor opening-explainer service (Postgres + pgvector retrieval, ONNX MiniLM embeddings,:server:seedcorpus loader)evals/rule-based eval harness and golden-set scorecard for all AI coach routesonDeviceAi/src/commonMain/resources/rulesCorpus/the bundled offline FIDE/Wikibooks rules corpus (30 passages, BM25-lookupable)onDeviceAi/src/published asio.github.ber4444:onDeviceAi— the on-device AI orchestration consumed by:appand the RN portperft-mcp/the perft MCP server — a thin stdio adapter exposing the rig as agent tools
Third-party asset and dependency notices live in THIRD_PARTY_NOTICES.md.
:chess-core is the platform-agnostic chess engine core. It is consumed by :app for the UI/platform glue, and published as io.github.ber4444:chess-core for the React Native repository.
- Dependency split (
apivsimplementation):kotlinx-coroutines-coreis anapidependency becauseStateFlowis exposed onGameViewModel.kermitandkotlinx-serialization-jsonareimplementationdetails. - Public API surface: The core intentionally exposes engine entry points (
GameViewModel,ChessEngine), state types (GameUiState,WinState), the piece model, FEN/PGN converters, the persistence seam (GameSnapshotSink), andboard3dscene types. - Internal implementation: Raw move-generation rules, draw-condition internals, and 3D math helpers are deliberately marked
internaland are completely unreachable by consumers. If a symbol isn't listed above, assume it is internal.
:onDeviceAi is the on-device AI orchestration module (move coach, rules Q&A, opening explainer,
route policy). It is consumed by :app for the chess UI and published as
io.github.ber4444:onDeviceAi for the React Native repository. :coachApi is its serialization-only
wire-model dependency, published as io.github.ber4444:coachApi.
- Dependency split (
apivsimplementation)::coachApiis anapidependency becauseOpeningExplainer.ktexposesOpeningExplainRequest/OpeningExplainResponsein public signatures (constructor params, return types, sealed-result properties).kermitandkotlinx-coroutines-coreareimplementationdetails. Consumers of:onDeviceAitransitively get:coachApion their compile classpath. - Public API surface:
AiCoachOrchestrator/DefaultAiCoachOrchestrator,GameSummaryOrchestrator,RulesQaorchestrator +RulesQaResult/RulesQaModelOutput,OpeningExplainer+OpeningExplainerResult,RuleLookupTool/BundledRuleLookupTool,AiRoutePolicy/AiRoutePolicies,OnDeviceTextGenerator/OnDeviceTextGeneratorFactory, theMoveCoach*/GameSummary*model + result sealed types. - Platform actuals:
commonMaindeclaresexpectfactories for the on-device text generator,defaultNowMs(), and the rules-QA answerer. Each platform provides anactual: Android = Cactus (llama.cpp), iOS = Foundation Models, desktop = LiteRT-LM (litertlm-jvm, gated byCHESS_ENABLE_COACH=1), wasm = LiteRT-LM for Web (@litert-lm/core, gated by?coach=1, WebGPU-only), JS = deterministic no-op fallback (UnsupportedTextGenerator/null). The RN consumer's JS target gets the fallback — no on-device LLM on JS, so the orchestrators degrade to rule-based text. - Publishing: tag
on-device-ai-v*triggerspublish-on-device-ai.yml, which publishes both artifacts at the same version. Both target Android, JVM (desktop), iOS (arm64/simulator), JS (IR), and Wasm.
git tag -a on-device-ai-v0.1.0 -m "Publish io.github.ber4444:onDeviceAi:0.1.0 + coachApi"
git push origin on-device-ai-v0.1.0To measure performance metrics of the on-device AI integration (init times, tokens/sec, memory), the project includes a dedicated benchmarking harness for both Android and iOS targets.
- On-Device Benchmark Harness — Execution instructions and architecture details for the harness.
- Benchmark Schema — The schema and target latency thresholds for the metrics.
./gradlew :chess-core:checkruns the chess-core test suite across all targets (desktop + iOS sim + JS)./gradlew :chess-core:jsNodeTestruns just the Kotlin/JS tests (the target the React Native port consumes)./gradlew testruns shared unit tests./gradlew :androidApp:assembleDebug :androidApp:installDebugbuilds and installs the Android app./gradlew :app:runlaunches the desktop appCHESS_ENABLE_COACH=1 ./gradlew :app:runlaunches the desktop app with the on-device Move Coach enabled (downloads the Qwen3-0.6B model, ~347 MB, on first launch; cached at~/.chess-coach-models/). Without the env var the coach panel stays hidden — mirrors Android'sFLAG_DEBUGGABLEgate../gradlew :app:wasmJsBrowserDevelopmentRunstarts the web target./gradlew :app:wasmJsBrowserDevelopmentRunthen open the page with?coach=1appended to the URL to enable the on-device Move Coach (Chrome/Edge only — requires WebGPU; loadsgemma-4-E2B-it-web.litertlm~2 GB from Hugging Face). Without?coach=1the coach panel stays hidden../gradlew :app:wasmJsBrowserDevelopmentWebpackbuilds the web development bundle without starting the dev server./gradlew :app:connectedAndroidDeviceTestruns Android UI tests./gradlew :app:iosSimulatorArm64Testruns iOS Compose UI tests./gradlew :app:desktopTest --tests "*board3d*"runs the 3D desktop tests (DesktopRendererSmokeTest writesbuild/chess3d-*.pngto eyeball the render)./gradlew :chess-core:publishToMavenLocalpublishesio.github.ber4444:chess-coreto the local Maven cache (for local cross-repo iteration)./gradlew :coachApi:buildbuilds and tests the serialization-only wire-model module./gradlew :coachApi:publishToMavenLocal :onDeviceAi:publishToMavenLocalpublishes both AI artifacts to the local Maven cache (for local cross-repo iteration with the RN port)./gradlew :server:testruns the opening-explainer service tests (Testcontainers Postgres; skips without Docker)./gradlew :server:seedseeds the opening corpus into a Postgres database (needsDATABASE_URL+ embedding model paths)./gradlew :evals:runruns the rule-based eval harness and regeneratesevals/scorecard.md(fails on grounding regression)./gradlew :chess-core:desktopTest --tests "*Perft*"runs the perft gate (canonical counts + Stockfish cross-check)./gradlew :chess-core:desktopTest --tests "*PerftDeepTest*" -Dperft.deep=trueruns the deep perft tier (nightly-only depths; slow)./gradlew :perft-mcp:test :perft-mcp:installDistbuilds the perft MCP servertools/ios_3d_screenshot.shcaptures the real iOS 3D board from a booted simulator
The opening explainer is the one AI feature allowed to leave the device. A finished game sends the opening moves (FEN + first 20 SAN plies + ECO) to a small cloud service, which retrieves relevant opening passages from a vector corpus and composes a 2–3 sentence explanation. The app never sends anything that identifies a user — only public/synthetic chess position data.
The service contract is server/openapi.yaml — the source of truth for the two
endpoints (POST /v1/openings/explain and GET /health). A swagger-request-validator contract test
in :server:test validates real responses against it.
Architecture — two endpoints, one Postgres, no queues or caching tiers:
- Retrieval —
all-MiniLM-L6-v2(ONNX Runtime, 384-dim) embeds the query (ECO name + opening moves);ORDER BY embedding <=> $1 LIMIT 4pulls the four closest passages from apassagestable with a pgvectorvector(384)column. The embedder is behind anEmbedderinterface with a deterministic fake, so:server:testnever downloads a model. - Composition —
TemplateComposer(default, deterministic, zero model cost) stitches the retrieved passages into grounded sentences.LlmComposercalls an OpenAI-compatible provider API only whenCOACH_LLM_API_KEYis set; it validates the LLM output with the same grounding rules as the on-device coach (forbidden phrases, max length, citation + token overlap) and falls back toTemplateComposerif validation fails — unvalidated prose is never returned. - Corpus —
server/corpus/holds the five ECO openings TSVs fromlichess-org/chess-openings(CC0) plus curated concept notes. The:server:seedtask (SeedMain) chunks, embeds, and upserts them into Postgres.
Runtime configuration is read only from environment variables (no secrets are committed):
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL |
yes | Postgres connection string (Fly Postgres, Neon, etc.) with pgvector enabled |
COACH_EMBEDDING_MODEL |
yes | Path to the MiniLM ONNX model (baked into the Docker image at /opt/models/model.onnx) |
COACH_EMBEDDING_VOCAB |
yes | Path to the MiniLM vocab.txt (baked into the Docker image at /opt/models/vocab.txt) |
PORT |
no (default 8080) | HTTP listen port |
COACH_LLM_API_KEY |
no | Enables the paid LLM composer when set (with the two price vars below) |
COACH_LLM_API_URL |
no | OpenAI-compatible chat completions endpoint |
COACH_LLM_MODEL |
no | Model name (default gpt-4.1-mini) |
COACH_LLM_INPUT_USD_PER_MILLION |
no | Input token price — required to enforce the 0.2¢ ceiling |
COACH_LLM_OUTPUT_USD_PER_MILLION |
no | Output token price — required to enforce the 0.2¢ ceiling |
COACH_ALLOWED_ORIGINS |
no | Comma-separated hostnames for CORS (e.g. chess.example.com; schemes added by server) |
On Fly.io the runtime detects FLY_APP_NAME and uses Fly Proxy's Fly-Client-IP header for the
bounded, expiring in-process request limiter (30 req/min per client). Outside Fly, the direct peer
address is used.
App-side wiring — the cloud client is injected from :app (KtorOpeningExplainerClient), never
hardcoded to prod. The base URL comes from build config, resolved in this precedence:
CHESS_COACH_BASE_URLenvironment variable (CI / deploy builds)coach.baseUrlkey inlocal.properties(local development)- empty string (default — the client is
null, so the explainer shows offline guidance)
The generateOpeningExplainerConfig Gradle task generates an internal const val OPENING_EXPLAINER_BASE_URL from whichever source is set. When the URL is empty, offline, or the
service returns non-2xx, DefaultOpeningExplainer produces a deterministic fallback — surfaced as a
normal product state in the panel.
The service is packaged as a multi-stage Docker image (server/Dockerfile): a build stage runs
./gradlew :server:installDist, and the eclipse-temurin:21-jre-jammy runtime stage bakes in the
pinned MiniLM ONNX model + vocab. server/fly.toml configures the app
(compose-chess-opening-coach, region sjc, min_machines_running = 0 so cold starts are honest,
health check GET /health).
No production URL or credential is committed. Deployment is intentionally a human step:
# 1. Create the Fly app (does not deploy yet)
fly launch --no-deploy --config server/fly.toml
# 2. Provision Postgres with pgvector and attach it
fly pg create --name compose-chess-pg --region sjc --initial-cluster-size 1
fly pg attach --postgres-app compose-chess-pg --app compose-chess-opening-coach
# This sets DATABASE_URL as a Fly secret automatically. Verify:
fly secrets list --app compose-chess-opening-coach
# 3. Enable the pgvector extension (needed before the schema applies on startup)
fly ssh console --app compose-chess-opening-coach --command \
'psql "$DATABASE_URL" -c "CREATE EXTENSION IF NOT EXISTS vector;"'
# Alternatively, the app's applySchema() runs "CREATE EXTENSION IF NOT EXISTS vector" on boot,
# so the first deploy will create it if the connecting role has permission.
# 4. Deploy the service (the schema is applied idempotently on startup)
fly deploy --config server/fly.toml
# 5. Seed the opening corpus into the live database.
# `installDist` produces a single `bin/server` script (the app); there is no `bin/server-seed`.
# So the reliable way to seed the prod DB is the :server:seed Gradle task, run locally against
# the prod DATABASE_URL (pull it from `fly secrets`). The MiniLM model/vocab paths are the
# Docker image bake-in defaults; point them at your local copies for the seed run:
DATABASE_URL=… COACH_EMBEDDING_MODEL=model.onnx COACH_EMBEDDING_VOCAB=vocab.txt ./gradlew :server:seed
# Alternatively, run the seed JVM inside the Fly container directly (no separate seed script;
# invoke SeedMain on the runtime classpath the image ships):
fly ssh console --app compose-chess-opening-coach --command \
'DATABASE_URL="$DATABASE_URL" COACH_EMBEDDING_MODEL=/opt/models/model.onnx COACH_EMBEDDING_VOCAB=/opt/models/vocab.txt java -cp "/opt/coach-server/lib/*" com.example.coachserver.SeedMain'
# 6. Verify the service is live
curl https://compose-chess-opening-coach.fly.dev/health
# → ok
# 7. Point the app at it (local dev via local.properties, or CI/deploy via env var):
echo "coach.baseUrl=https://compose-chess-opening-coach.fly.dev" >> local.properties
# or: export CHESS_COACH_BASE_URL=https://compose-chess-opening-coach.fly.dev
# 8. (Optional) Enable the paid LLM composer for richer prose:
fly secrets set --app compose-chess-opening-coach \
COACH_LLM_API_KEY=… \
COACH_LLM_API_URL=https://api.openai.com/v1/chat/completions \
COACH_LLM_MODEL=gpt-4.1-mini \
COACH_LLM_INPUT_USD_PER_MILLION=0.40 \
COACH_LLM_OUTPUT_USD_PER_MILLION=1.60Why no
bin/server-seed? Theapplicationplugin'sinstallDistgenerates one launcher script (bin/server, wired toApplicationKtbyserver/build.gradle.kts:57); the seedJavaExectask (mainClass = SeedMain,server/build.gradle.kts:68) is a Gradle-side entry point, not a separate installed binary. That's why the in-Fly path invokesSeedMainon the shippedlib/*classpath directly, and the local path uses./gradlew :server:seed. Do not put database URLs or provider keys in this README or any.envfile.
After deployment, record the verified base URL here once you've confirmed /health responds:
The :evals module is a rule-based regression gate that scores every available generator against a
golden set. It has no judge model — v1 is rule-based only.
- Golden set —
evals/golden/candidates.jsonholds 100 semantically distinct opening positions generated from the checked-in Lichess opening lines. Each case hasfen,bestMoveUci,tags, and (for openings)eco+expectedConcepts. These are candidates: the repository owner must hand-check best-move and concept labels before treating the scorecard as article-grade evidence (seeevals/golden/README.md). - Scorer — move cases use the production
MoveCoachResponseValidator; opening cases require allexpectedConceptsto appear in the output (concept-coverage). Both check the 300-char length bound. - Routes —
:evals:runexecutes every case againstFakeTextGenerator, the deterministic fallback,TemplateComposervia a local in-process server instance, and (whenCOACH_DEPLOYED_URLis set and reachable) the deployed cloud service. It writesevals/scorecard.mdwith grounding-violation, retry, fallback, and length-violation rates per route. On-device numbers (Cactus, Foundation Models) are collected manually on hardware and marked as such in the scorecard. - CI gate —
.github/workflows/ai-coach-evals.ymlruns:evals:runon every PR touching:onDeviceAi,:coachApi,:server,:evals, or the opening-explainer app code. A grounding violation in any automated route fails the build.
./gradlew :evals:run # regenerate evals/scorecard.md; fails on grounding regression
COACH_DEPLOYED_URL=https://… ./gradlew :evals:run # also score the live deploymentThe core is published to GitHub Packages from CI (.github/workflows/publish-chess-core.yml), driven
by a tag. To publish a new version:
git tag -a chess-core-v0.2.0 -m "Publish io.github.ber4444:chess-core:0.2.0"
git push origin chess-core-v0.2.0The version is the tag with the chess-core-v prefix stripped. Consumers (the RN port, or any other
Kotlin project) add the GitHub Packages Maven repo (auth: PAT with read:packages) and depend on
io.github.ber4444:chess-core:<version>.