Skip to content

Repository files navigation

Nusantara

A pixel-art, culture-driven RPG across the Indonesian archipelago.

ci typescript phaser tests license

Gameplay


Overview

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.

Table of contents

Key features

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.

Architecture

Architecture

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).

Tech stack

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

Prerequisites

  • Node.js 22.22.2 or newer — check with node --version. The repo ships an .nvmrc, so nvm use picks 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.

Getting started

1. Clone the repository

git clone https://github.com/lumixed/pusaka.git

2. Enter the project

cd pusaka

3. Install dependencies

npm install

4. Start the dev server

npm run dev

5. Open the game at http://localhost:5173 — it opens on the title screen. Pick New Game and you begin in Padang.

Other commands

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

Deploying your own copy

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.

Controls

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.

The eight islands

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.

Screenshots

The archipelago Banda Neira, Maluku
Overworld Maluku
The Pura Dalem, Bali Mande's loom, Padang
Bali Loom

Project structure

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

Testing

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 test

That 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.

Design notes

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.

Roadmap

  • 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

Credits and licence

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.

Engine note

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.

About

Pixel-art RPG across the Indonesian archipelago. Eight islands, eight different verbs. TypeScript + Phaser 3

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages