A pixel-art, culture-driven RPG across the Indonesian archipelago.
You sail a pinisi between eight islands of the Indonesian archipelago. Each island is a distinct regional culture with its own architecture, food, folklore and people — and, crucially, its own verb. One island you mend. One you navigate. One you walk a funeral through with your blade locked. One you carve.
The through-line is Bhinneka Tunggal Ika — unity in diversity — and the antagonist is a travelling puppeteer who has been helpful on every shore and would very much like the archipelago to become one story, told in one voice. His. The only way to answer him is with the specific things you bothered to remember.
Everything in the game is authored in code: every sprite is a grid of characters, every
building is drawn with fill rects, and every sound is synthesized live with the Web
Audio API. There is not a single .png or .wav in the project.
Roughly 8,600 lines of TypeScript plus 700 of tests, no game-engine editor, no asset pipeline.
- Overview
- Key features
- Architecture
- Tech stack
- Prerequisites
- Getting started
- Controls
- The eight islands
- Screenshots
- Project structure
- Testing
- Design notes
- Roadmap
- Credits and licence
Eight regions, eight different verbs. Not eight reskins of the same fight. A courtroom of contradictions, a river with the UI taken away, a funeral you escort without drawing a weapon, a loom where the order of the motifs is the puzzle.
Data-driven regions. Half the islands are one config object each — ground tile, flora, border, set dressing, costumes, foe skin, terrain layout and quest structure — rendered by generic hub / wilderness / dungeon scenes with no new scene code.
A moral choice you can actually make wrong. Bali's balance meter lets you smother Rangda entirely and "win". The island goes grey and silent, and you have to undo it.
No assets. Sprites are character grids turned into textures at runtime; buildings are procedural; audio is synthesized to an approximate slendro, the five-tone gamelan scale, so the archipelago colours the feedback itself.
Plays everywhere. Keyboard, gamepad and touch, all routed through one synthetic-key layer so no scene knows the difference. Runs from a link on a phone.
Save that survives a reload. Versioned localStorage snapshots with schema tolerance, autosaved on every quest transition, resumable from the title screen.
Engine-free core. The state, save, quest, audio, input and data layers import nothing from Phaser, so the rules of the game are unit-tested without a browser.
The load-bearing decision is the dashed line. Everything below it is plain TypeScript — no Phaser import, no canvas, no DOM — which means the quest state machine, the save schema, the finale's unlock conditions and the archipelago's geometry can all be tested in milliseconds. Everything above it draws.
The second decision is that regions come in two shapes, deliberately:
- Hub-shaped (Padang, Parahyangan, Bali, Maluku) are pure data. A new one is a config object plus a dialogue resolver plus a port on the map.
- Verb-shaped (Yogyakarta, Sulawesi, Kalimantan, Papua) get their own scene, because their verb needs a mechanic the generic scenes don't have. A courtroom of contradictions isn't a village with different tiles, and pretending otherwise would have meant a config format with a hole shaped like every possible game.
Systems shared between them became their own modules: the pattern grid (arrange motifs so their order carries meaning), the notebook (collect claims, pair two, find the lie), the balance meter (a tension with a centre band you must not win), and observe (look before you choose, when nothing on screen will tell you).
| Layer | Choice | Why |
|---|---|---|
| Language | TypeScript 5.6, strict + noUnusedLocals |
The compiler catches the dead code before a reviewer does |
| Engine | Phaser 3.80 | Mature 2D web renderer with arcade physics and scene management |
| Build | Vite 5 | Instant HMR while iterating on game feel; single-command production bundle |
| Tests | Vitest 4 + jsdom | Same transform pipeline as the build, no separate config |
| Lint | ESLint 9 (flat config) + Prettier | Zero warnings enforced in CI |
| CI/CD | GitHub Actions → GitHub Pages | Lint, typecheck, test, build on every push; deploys on main |
| Art | None — procedural | Character grids and fill rects, authored in src/art/ |
| Audio | None — Web Audio API | Oscillators and filtered noise, tuned to a slendro scale |
- Node.js 22.22.2 or newer — check with
node --version. The repo ships an.nvmrc, sonvm usepicks the right version - npm 10 or newer — ships with Node 22
- A modern browser (Chrome, Firefox, Safari or Edge)
Nothing else. No game engine, no asset tooling, no native build step.
1. Clone the repository
git clone https://github.com/lumixed/pusaka.git2. Enter the project
cd pusaka3. Install dependencies
npm install4. Start the dev server
npm run dev5. Open the game at http://localhost:5173 — it opens on the title screen. Pick New Game and you begin in Padang.
| Command | What it does |
|---|---|
npm run dev |
Dev server with hot reload on port 5173 |
npm run build |
Typecheck, then a production bundle into dist/ |
npm run preview |
Serve the production build locally |
npm test |
Run the unit test suite once |
npm run test:watch |
Re-run tests on change |
npm run typecheck |
tsc --noEmit |
npm run lint |
ESLint over the whole project |
npm run format |
Prettier write |
The included workflow publishes to GitHub Pages on every push to main. Fork the repo,
then set Settings → Pages → Source to GitHub Actions. The bundle uses a relative
base path, so it works from any sub-path without configuration.
| Keyboard | Gamepad | Touch | |
|---|---|---|---|
| Move | WASD / arrows | Left stick or D-pad | On-screen D-pad |
| Attack · confirm | J / Space / Enter | A | J |
| Dash | K / Shift | LB | K |
| Talk · interact | E | RB | E |
| Salawaku Spin | L | LT | L |
| Barong's Ward | U | RT | U |
| Notebook | N | — | N |
| Choices 1–4 | 1 2 3 4 | A B X Y | 1 2 3 4 |
| Pause · volume | Esc | Start | ❚❚ |
| Mute | M | — | — |
Touch controls appear automatically on touch devices; landscape is the comfortable orientation. Continue on the title screen resumes your autosaved voyage on the open water with every companion, ability, heart and quest flag intact, and New Game is guarded so it never silently overwrites a saved voyage.
| Region | Verb | What you actually do |
|---|---|---|
| Padang (Minangkabau) | MEND | Free the stone-cursed on the shore, finish a dead weaver's songket, and release Malin Kundang instead of fighting him |
| Parahyangan (Sundanese) | GATHER | Recover Ki Dalang's scattered angklung from the paddies while restless spirits prowl the banks |
| Bali (Balinese) | BALANCE | Hold Barong and Rangda in tension. You can smother Rangda entirely — and then Bali goes grey and silent, and you have to undo it |
| Yogyakarta (Javanese) | DECEIVE | No combat. Interview the court, log their claims, expose the mole by the contradiction in his testimony. One accusation |
| Sulawesi (Toraja · Bugis) | ESCORT | Walk a funeral procession to the cliff graves with your blade locked. Turn the grief-shades aside; never cut them |
| Kalimantan (Dayak) | NAVIGATE | The UI is taken away. Read the river — hornbill roosts, black water, thinning mist — to reach a longhouse that's on no chart |
| Maluku (Moluccan) | CLEAR | The one island where the answer really is a fight: drive the shades out of a burned nutmeg grove and put its warden down |
| Papua (Dani · Asmat) | CARVE | Cut one notch per shore you've walked, each demanding the true detail of that place. Skim a region and its notch stays blank |
Maluku exists so the other seven mean something. If every island is a fight, refusing to fight in Sulawesi is just a gimmick; if no island is, the refusals cost nothing.
All eight feed the finale on Pulau Tunggal, which only appears on the map once you hold all four gates.
| The archipelago | Banda Neira, Maluku |
|---|---|
![]() |
![]() |
| The Pura Dalem, Bali | Mande's loom, Padang |
|---|---|
![]() |
![]() |
index.html Phaser mount point + touch control styling
src/
main.ts game config + scene registration
core/
event-bus.ts engine-free typed signal hub
game-state.ts single source of truth for run progress (serializable)
save-system.ts localStorage save/load, 3 slots, versioned
quest-manager.ts quest state machine + autosave
audio.ts synthesized Web Audio SFX, ambience and volume
virtual-input.ts synthetic key layer shared by gamepad and touch
gamepad.ts navigator.getGamepads polling
transitions.ts edge triggers + fade-guarded scene swaps
juice.ts slash arcs, hit sparks, shake, hit-stop
art/
pixel.ts char-grid -> texture helpers
sprites.ts character, creature and item grids
buildings.ts procedural architecture
tiles.ts procedural ground and effects
assets.ts registerArt / registerAnims
data/
archipelago.ts islands, ports, land rasterisation, the finale gate
region-hubs.ts a hub-shaped region as one config object
companions.ts recruitable crew and their passives
testimony.ts claims and contradiction matching
regions/ per-region quest defs and constants
dialogue/ branching, quest-reactive dialogue
quests/ quest stage definitions
entities/ player, enemy, boss, npc, petrified, pusaka-pickup
ui/ hud, dialogue box, quest log, notebook, pattern grid,
balance meter, touch controls
scenes/ title, pause, overworld, ship deck, the generic region
scenes, and one bespoke scene per verb-shaped region
legacy-godot/ the original Godot 4 prototype, kept for reference
docs/ README screenshots, demo and architecture diagram
Because the rules live below the engine line, they're tested without a canvas:
- Save system — round-trip through localStorage, three independent slots, corrupt slots reported rather than thrown, old saves tolerated as the schema grows
- Quest manager — transitions, idempotent starts, reward application, autosave
- Finale reachability — the gate opens from every legal path, including a wrong accusation in Yogyakarta, and stays shut while any single requirement is outstanding
- Event bus — context binding, targeted removal, unsubscribing mid-dispatch
- Testimony — contradiction matching in either order
- Companions — bonuses stack and only the heart benefit touches max health
- Archipelago — point-in-polygon, land rasterisation, gated islands
- Virtual input — synthetic keys carry the character a real keyboard sends, so the number-driven puzzles work identically on keyboard, pad and touch
npm testThat finale suite exists because of a real bug it now guards: a wrong accusation in Yogyakarta used to complete the quest without granting The Count, which meant the last island never appeared and the run was quietly unfinishable.
Every region is a different verb. The temptation with a "tour of cultures" game is to build one loop and reskin it, which flattens the very thing the game is about. So the mechanic changes with the island, and Ki Tunggal — who wants everything to be the same story — is the argument against reskinning made flesh.
Losing should cost something, not everything. Accusing the wrong courtier costs you the companion and the clean ending. It used to cost you the game, silently. That's the distinction: consequences yes, dead ends no.
Culture is in the details, not the label. The gonjong horns of a rumah gadang, the tiered meru of a pura, the carved ridge of a baileo, pela gandong as a reason a crewman joins you, the food in the warung. Sacred practice is handled as living culture, never as a monster to defeat — Bali's arc is explicitly about restoring balance rather than defeating a faith.
Constraints are load-bearing. Authoring art in code kept the whole game in one language with no pipeline, and 320×180 with a 16×16 grid made "draw a building" a tractable afternoon rather than a commission.
- Full playthrough polish pass — the individual regions are verified, the whole run needs the kind of pacing tuning only playing it start to finish reveals
- Reduce the ~390 KB gzipped bundle with a custom Phaser build
- Key rebinding
- More regions — Lombok, Sumbawa, Flores, Sumba and Timor are already drawn on the map but have no port yet
Written by lumixed. Built with Phaser and Vite.
The cultures, folklore and architecture referenced here are real and belong to the people of the Indonesian archipelago; the mistakes are mine. Malin Kundang, Barong and Rangda, Roro Jonggrang and the rambu solo are drawn from living tradition and treated as such.
Released under the MIT licence.
This started as a Godot 4 prototype, archived under legacy-godot/.
Moving to a web-native stack meant the game runs from a link and iterates in-browser, and
the data-driven design carried over unchanged.




