Papan permainan satir tentang politik dan ekonomi Indonesia.
Oligarki is a satirical Monopoly-style board game set in Indonesian politics and economics. Buy up warteg and nickel smelters, dodge the anti-corruption commission, and outlast opponents like Jenderal (Purn.) and Crazy Rich Menantu.
It runs fully offline as an installable PWA and ships as an Android app via Capacitor. All in-game text is in Bahasa Indonesia.
- 40-tile board with a fake-3D tilted view and a follow camera, rendered in Phaser
- 32 event cards across two decks — Kartu Nasib (16) and Kartu Kebijakan (16)
- 7 CPU personas across three difficulty tiers (
santai/normal/licik) - 8 optional house rules — free-parking jackpot, mortgages, inflation, auctions, trading, and more
- Two modes — Klasik (last one standing) and Kilat (lap target), plus a fully custom setup
- Fully offline — no network at runtime; audio is WebAudio-synthesised, so no audio files ship
- Deterministic engine — seeded RNG makes every game reproducible and testable
Requires Node.js 22+.
npm install
npm run dev # Vite dev server with HMR on :5173| Command | What it does |
|---|---|
npm run dev |
Dev server with hot reload |
npm run test |
Full Vitest suite, once |
npm run test -- tests/engine/rent.test.ts |
A single test file |
npm run test:watch |
Watch mode |
npm run build |
svelte-check typecheck + production build to dist/ |
npm run preview |
Serve the built dist/ (use this to test the offline/PWA build) |
npm run icons |
Regenerate public/icons/* from source art |
Definition of done for any change: npm run test && npm run build both clean.
The game is a strict one-way loop. Every state change goes through the orchestrator — there is no other mutation path.
UI / AI → Orchestrator.dispatch(Action) → engine reducer
│
(new GameState, GameEvent[])
│
animator plays events → stores update → UI reacts
| Directory | Responsibility |
|---|---|
src/engine/ |
The entire rules state machine — pure, deterministic, seeded RNG. Touches no Phaser, Svelte, or DOM. |
src/orchestrator.ts |
The only mutation path. Owns autosave, store updates, animation queueing, and CPU scheduling. |
src/scene/ |
Phaser presentation. animator.ts drains the GameEvent queue; iso.ts handles ring→grid→screen math. |
src/ai/ |
decide(state, playerId) → Action, parameterised by persona and difficulty. |
src/content/ |
All game data — board, card decks, personas, and strings.ts. Every UI string lives here. |
src/ui/ |
Svelte 5 overlay mounted above the Phaser canvas. Screens route off screenStore. |
Two invariants worth knowing before you touch the orchestrator:
dispatchuses a queue plus a re-entrancy guard, because CPU auto-play can trigger synchronous recursive dispatches.gameStoreis only published after the animator drains, so dialogs and the HUD never spoil a result before the token visually lands.
All art and audio is indirected through public/assets/asset-manifest.json (59 keys). Code
references manifest keys such as tile.go or token.helm — never filenames. To swap an asset,
drop the file into public/assets/ and repoint the manifest entry; no code change needed.
tests/ mirrors src/. Vitest runs in a Node environment with no DOM or Phaser — the engine's
purity is what makes that possible.
npm run test # 184 tests across 32 files
npm run test -- tests/sim # the balance regression gatetests/sim/ is a balance regression gate, not a smoke test: it runs roughly 1,600 simulated
games and asserts there are no deadlocks, that turn counts stay in band, and that difficulty win
rates hold. Re-run it and check the printed median and win-rate stats whenever you change rent
tables (src/engine/rent.ts), persona parameters, or level multipliers.
Gotcha:
newGame(config)stores the config by reference. In tests, never mutatest.config— spread a fresh config object instead.
The web build is bundled into the APK and served from the WebView's https://localhost/ root, so
the game runs entirely offline. App ID com.oligarki.game, minSdk 24, targetSdk 36.
npm run build # vite -> dist/
npx cap sync android # copy dist/ into the Android project
cd android
export ANDROID_HOME=$HOME/Android/Sdk ANDROID_SDK_ROOT=$HOME/Android/Sdk
export JAVA_HOME=/path/to/jdk-21
./gradlew assembleRelease
# -> android/app/build/outputs/apk/release/app-release.apkJDK 21 is required. Capacitor 8's Android project compiles with source release 21; building
under JDK 17 fails with invalid source release: 21. The JBR bundled with Android Studio works
(<android-studio>/jbr).
Release signing is read from android/keystore.properties, which is gitignored and not included
in this repository — you need to supply your own. Create it with:
storeFile=keystore/your-release.jks
storePassword=…
keyAlias=…
keyPassword=…storeFile is resolved via rootProject.file(...), so the path is relative to android/, not to
the app module. Getting that wrong produces a "keystore not found" error.
Keep your keystore safe — losing it means the app can no longer be updated under the same identity.
ISC.
