A cross-platform 3D chess app: a React Native shell with all game logic in Kotlin (compiled to JavaScript via Kotlin/JS), and a clean two-renderer split for the 3D board — React Native Filament (Metal/Vulkan) on native, three.js (WebGL) on web/Electron — both driven by a single Kotlin scene model.
Rebuilt from compose-multiplatform-chess:
the Compose UI is replaced by React Native components, the four original 3D renderers collapse to
two, and the chess engine/rules/scene-math stay Kotlin (no logic reimplemented in JS/TS). The chess
logic is published as a Maven artifact (io.github.ber4444:chess-core) from the source repo and
consumed here — there is no duplicated Kotlin between the two repos.
- One shell, five platforms. iOS + Android run React Native natively (New Architecture / Fabric, via Expo); Web uses React Native Web; Desktop (Linux/macOS/Windows) is Electron hosting the RN Web build.
- Two renderers, one scene model. Native goes to Filament — the same engine the original
Android backend used, so the PBR/IBL/material tuning and the
chess.glbasset transfer directly, and iOS lands on a real Metal renderer (not deprecatedexpo-gl/GL ES). Web reuses the existing three.js renderer. Both consume the same KotlinBoard3DScene, so chess logic never knows which engine is underneath. - Logic stays Kotlin — single source of truth, including the TypeScript types. The Compose-free
chess engine core is published from compose-multiplatform-chess
as
io.github.ber4444:chess-core(a Kotlin Multiplatform Maven artifact on GitHub Packages) and consumed by this repo's thinchess-core/wrapper, which adds only the@JsExportJS interop (ChessSession,JsChessEngineAdapter). There is no duplicated Kotlin between the two repos — bumpchessCoreVersioninchess-core/gradle.propertiesto pick up core changes. The wrapper'sgenerateTypeScriptDefinitions()emitschess-core.d.tsstraight from the Kotlin@JsExportdeclarations, so the RN app's TypeScript bindings are generated, not hand-written — change the Kotlin API, rerunnpm run build:core, and the TS types update (no gateway file to drift).
A single Kotlin core drives every platform. It compiles to a JS bundle that the React Native app
consumes; the same renderer-agnostic Board3DScene feeds two renderers; and a per-platform Stockfish
(or the Kotlin CPU fallback) closes the opponent-move loop back into the session.
flowchart TB
subgraph CORE["chess-core — Kotlin KMP · commonMain (all game logic)"]
direction TB
LOGIC["Chess engine<br/>rules · FEN · UCI · draw detection"]
SCENE["board3d/ — renderer-agnostic scene<br/>Board3DScene · OrbitCameraController · BoardRayPicker"]
CPU["pickMoveCPU<br/>(fallback opponent)"]
FACADE["ChessSession.kt — @JsExport facade<br/>subscribe · playerMove · currentScene"]
LOGIC --> FACADE
SCENE --> FACADE
CPU --> FACADE
end
FACADE ==>|"compiled to Kotlin/JS (IR) bundle"| GATEWAY
subgraph APP["my-app — React Native · Expo SDK 56 · Fabric (JS runtime)"]
direction TB
GATEWAY["src/chess-core/<br/>TS gateway to Kotlin/JS"]
HOOK["hooks/useChessSession<br/>subscribe · attach platform engine"]
UI2D["components/chess/<br/>2D board (react-native-svg) · dialogs · 2D⇄3D toggle"]
GATEWAY --> HOOK
HOOK --> UI2D
end
HOOK --> B3D["Board3D.tsx<br/>RN Filament (native)"]
HOOK --> B3DW["Board3D.web.tsx<br/>three.js · window.chess3d"]
B3D --> IOS["iOS<br/>Metal"]
B3D --> AND["Android<br/>Vulkan / GLES"]
B3DW --> WEB["Web<br/>WebGL"]
B3DW --> ELE["Desktop · Electron<br/>Chromium WebGL"]
IOS -. "ChessKit *" .-> ENG
AND -. "libstockfish.so" .-> ENG
WEB -. "stockfish.wasm *" .-> ENG
ELE -. "system stockfish (child_process)" .-> ENG
ENG["Stockfish / CPU opponent"] -. "best move (UCI)" .-> FACADE
classDef core fill:#ede7f6,stroke:#5e35b1,color:#311b92;
classDef app fill:#e3f2fd,stroke:#1976d2,color:#0d47a1;
classDef rend fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20;
classDef plat fill:#eceff1,stroke:#546e7a,color:#263238;
classDef eng fill:#fff8e1,stroke:#f9a825,color:#e65100;
class LOGIC,SCENE,CPU,FACADE core;
class GATEWAY,HOOK,UI2D app;
class B3D,B3DW rend;
class IOS,AND,WEB,ELE plat;
class ENG eng;
*= planned native engine; CPU fallback runs today. See the Stockfish per platform table below for details.
.
├── chess-core/ # Thin Kotlin/JS (IR) wrapper → Kotlin/JS bundle + generated .d.ts
│ ├── build.gradle.kts # Depends on io.github.ber4444:chess-core (Maven artifact);
│ │ # generateTypeScriptDefinitions() emits chess-core.d.ts
│ └── src/commonMain/ # Only the RN-specific @JsExport interop:
│ ├── ChessSession.kt # facade (subscribe, playerMove, currentScene, …)
│ └── JsChessEngineAdapter.kt# adapts a JS-Promise engine → core's ChessEngine
│ # ↑ all rules/FEN/UCI/scene-math come from the artifact
├── on-device-ai/ # Smoke consumer of io.github.ber4444:onDeviceAi + coachApi
│ └── src/ # Proves the published artifacts resolve + compile on JS.
│ # (No @JsExport facade or app integration yet — follow-up.)
├── my-app/ # React Native app (Expo SDK 56, RN 0.85, Fabric)
│ ├── src/
│ │ ├── chess-core/ # TS gateway to the Kotlin/JS bundle
│ │ ├── components/chess/ # 2D board (react-native-svg) + 3D board + dialogs
│ │ │ ├── Board3D.tsx # Native: RN Filament (Metal/Vulkan)
│ │ │ ├── Board3D.web.tsx # Web: three.js (window.chess3d, WebGL)
│ │ │ └── board-renderer/ # BoardRenderer interface + three.js impl
│ │ ├── hooks/ # useChessSession (subscribe + platform engine attach)
│ │ └── constants/ # strings + theme (ported from the source app)
│ ├── electron/ # Electron main process + Stockfish child_process bridge
│ ├── android/ # Android native project (Stockfish module + libstockfish.so)
│ ├── ios/ # iOS native project
│ ├── assets/3d/ # chess.glb, papermill IBL/skybox (source; split glbs generated)
│ ├── public/ # Web assets (chess.glb, HDRs, stockfish.wasm)
│ └── scripts/ # Asset generation (split glb, convert pieces, prepare renderer)
└── .github/workflows/ci.yml # CI for all five platforms
| Platform | 3D Renderer | Backend |
|---|---|---|
| iOS | React Native Filament | Metal |
| Android | React Native Filament | Vulkan / OpenGL ES |
| Web | three.js (window.chess3d) |
WebGL |
| Desktop (Electron) | three.js | WebGL (Chromium) |
Camera orbit/zoom and tap-to-square picking are both routed through the Kotlin core
(OrbitCameraController / BoardRayPicker), so 2D and both 3D backends stay in sync — neither
renderer raycasts or animates on its own.
Note on native piece transforms: the Filament backend writes each piece's transform absolutely via
TransformManager.setTransformrather than the declarative<ModelInstance>transform props. Those props default tomultiplyWithCurrentTransform = trueand accumulate on every value change, which made animated pieces drift off the board. See the header comment inBoard3D.tsxfor the full explanation.
| Platform | Engine |
|---|---|
| Electron | System stockfish via Node child_process (UCI) |
| Android | Vendored libstockfish.so via StockfishModule.kt (UCI child process) |
| Web | stockfish-18-lite-single.wasm in a Web Worker (planned; CPU fallback today) |
| iOS | ChessKit StockfishChessEngine (planned native module; CPU fallback today) |
On any platform without a wired-up Stockfish, the Kotlin core's pickMoveCPU fallback plays Black —
so the opponent always moves.
Full parity with the source app: legal-move highlighting, castling, en passant, promotion dialog, draw detection (threefold repetition / fifty-move / insufficient material), draw offers and agreements, a 2D⇄3D toggle, a game-over popup, and a Stockfish-or-CPU opponent.
- Node.js 20+
- JDK 21+ (for the chess-core Gradle build)
- A GitHub Personal Access Token with
read:packages— the chess-core Maven artifact lives on GitHub Packages, which requires auth even for public packages. Provide it as:- CI: the
CHESS_PACKAGES_TOKENrepo secret (already wired in.github/workflows/ci.yml). - Local:
gpr.user+gpr.keyin~/.gradle/gradle.properties, orGITHUB_ACTOR+GITHUB_PACKAGES_TOKENenv vars.
- CI: the
- Xcode (for iOS) / Android Studio + SDK (for Android)
cd my-app
npm install
npm run build:core # Gradle: compile chess-core → JS, copy into src/generated/
npm run prepare:assets # Split chess.glb + convert piece SVGs + prepare the three.js rendererbuild:core and prepare:assets produce generated files that are intentionally not committed
(they're reproducible) — run them once after cloning.
npm run web # Web (browser)
npm run build:desktop # Desktop (Electron — builds web first, then launches)
npm run ios # iOS (simulator)
npm run android # Android (emulator/device)npm run typecheck # TypeScript
npm run lint # ESLint
cd ../chess-core && ./gradlew jsNodeTest # Kotlin core unit tests.github/workflows/ci.yml builds all five platforms on every push/PR:
| Job | Runner | What it checks |
|---|---|---|
chess-core |
ubuntu | Pulls io.github.ber4444:chess-core from GitHub Packages (PAT auth), Kotlin/JS build + jsNodeTest |
app-web |
ubuntu | TypeScript + ESLint + expo export web bundle |
electron |
ubuntu | Syntax-checks the Electron main/preload + the electron binary |
ios |
macOS | pod install + xcodebuild (no code signing) |
android |
ubuntu | ./gradlew assembleDebug |
If chess.glb or the piece drawables change upstream, re-run npm run prepare:assets. Individual
generators:
scripts/split-chess-glb.js— splitsassets/3d/chess.glb→assets/3d/split/*.glbscripts/convert-chess-pieces.js— converts the vector XML drawables →piece-paths.generated.tsscripts/prepare-3d-renderer.js— derives the Metro-compatiblechess3d-renderer.generated.js
This project is licensed GPL-3.0 — see LICENSE. It is a port of
compose-multiplatform-chess, which is GPL-3.0 too.