Aetherbond // dev log
A browser-based creature-collection RPG featuring exploration, turn-based battles, progression systems, and procedural audio generation. An independent project built to explore game architecture, procedural systems, and large-scale content management — 100 original creatures, 520 moves and 14 handcrafted maps.
overview & stack
Aetherbond is Next.js 16 + React 19 + TypeScript (strict), styled entirely with Tailwind v4 and state-managed by a single Zustand 5 store. There's no audio library and no sprite-loading runtime cost beyond pre-built tiles — even the music is generated on the fly with the Web Audio API. Playwright covers end-to-end smoke tests for the core loop (new game → battle → catch → save).
The codebase splits cleanly into five layers: components/ (28 files — screens, battle UI, overworld, dialogue, shared widgets), engine/ (battle math, AI, experience, evolution, catching — 712 lines total), data/ (17 files defining every creature, move, item, map and story beat), store/ (the one Zustand hub), and audio/ (the procedural music/SFX engine).
the game store — one hub, 39 connections
Almost every component in the app reads from or writes to a single useGame Zustand store. It holds the current screen, the player's grid position and facing direction, the active map, the full party and box of creatures, the bag, the Aetherex (Pokédex-equivalent), money, badges, a Set of story flags, and — when a fight is active — the entire BattleState.
Treating this as *the* cross-cutting piece of the codebase was a deliberate simplification: rather than prop-drilling player position into the overworld renderer, the collision system, the encounter roller, and the minimap all at once, every one of those reads playerX/playerY straight from the store. The trade-off is that the store interface is huge (30+ action methods) — but every screen only needs to know about the slice it cares about.
One subtle but important rule lives in move(): trainer levels only ever scale down, never up. Wild encounters and trainer rosters are capped relative to your strongest party member, so an over-leveled player doesn't make the game artificially harder, and an under-leveled player doesn't get steamrolled — the difficulty curve stays roughly flat across a long campaign.
export interface GameStore {
screen: GameScreen;
previousScreen: GameScreen;
playerName: string;
playerX: number; playerY: number;
playerDir: 'up' | 'down' | 'left' | 'right';
currentMapId: number;
party: AetherInstance[];
box: AetherInstance[];
bag: Record<number, number>;
aetherex: Record<number, 'seen' | 'caught'>;
money: number;
badges: string[];
flags: Set<string>; // story gates: 'PROLOGUE_DONE', 'WARDEN_ONE_JOINED', ...
battle?: BattleState; // undefined when not fighting
move: (dir: Direction) => void;
interact: () => void;
battleUseMove: (idx: number) => void;
startWildBattle: () => void;
startTrainerBattle: (trainerId: number) => void;
saveGame: () => void;
// ...30+ actions total
}the screen system — one switch, thirteen worlds
Game.tsx is the root component, and its entire job is renderScreen(): a switch over the store's screen field that mounts exactly one of thirteen screens — Title, Name Entry, Intro, Starter Select, Overworld, Battle, Party, Aether Detail, Aetherex, Bag, Notebook, Pause Menu, Credits.
Two screens are special-cased: battle falls back to the Overworld if hasBattle is false (protects against a stale screen state with no battle data), and dialogue doesn't get its own screen at all — it renders the previous screen underneath and overlays a DialogueBox on top. That overlay pattern means a conversation can interrupt the overworld, a shop, or even the post-battle results screen without ever unmounting it.
What happens *after* a dialogue finishes is driven by a dialogueThen union type — values like 'after_tutorial', 'start_trainer', 'mother_menu' or 'epilogue_end' tell the store exactly what to do next (give an item, start a specific trainer battle, open a choice menu, roll credits). It's a clean alternative to threading callbacks through every dialogue trigger in the game.
function renderScreen(screen: GameScreen, hasBattle: boolean, setScreen: (s: GameScreen) => void) {
switch (screen) {
case 'title': return <TitleScreen />;
case 'name_entry': return <NameEntry />;
case 'intro': return <IntroScreen />;
case 'starter_select': return <StarterSelect />;
case 'overworld': return <Overworld />;
case 'battle': return hasBattle ? <BattleScreen /> : <Overworld />;
case 'party': return <PartyScreen />;
case 'aether_detail': return <AetherDetail />;
case 'aetherex': return <AetherexScreen />;
case 'bag': return <BagScreen />;
case 'notebook': return <NotebookScreen />;
case 'credits': return <Credits onReturn={() => setScreen('title')} />;
case 'dialogue': return <Overworld />; // DialogueBox overlays on top
default: return <TitleScreen />;
}
}the overworld — tiles, collision & roaming creatures
Movement is grid-based: every step checks the target tile against walls, water, decor (trees/rocks drawn directly on the tilemap), NPCs and warp gates before the player actually moves. The collision predicates — isPathTile() and isBlockedByDecor() — live in data/mapMeta.ts and are shared between the renderer and the store's move logic, so the art and the collision can never silently drift apart.
Roads-style maps (towns, cities, the summit) additionally restrict movement to explicit path tiles or warps — you can't cut across someone's lawn — while grass and cave maps are freely walkable except for decor.
Wild creatures roam the grass via a useRoamers hook: 3–6 are spawned per map weighted by the local encounter table, and every 480ms tick each one either wanders randomly, or — if the player comes within 4 tiles — gives chase. A roamer will never stray more than 4 tiles from the patch of grass it spawned on (its 'leash'), and gives up the chase entirely past 7 tiles, drifting back home. Walking into a roamer (or it walking into you) starts a wild battle. Trainers use the same line-of-sight idea but in the four cardinal directions only, and trigger dialogue the instant they spot you — no input required.
the battle engine — type chart, damage, AI
Combat is turn-based with a Gen-V-style damage formula: ((((2·level/5 + 2) · power · atk/def) / 50) + 2), then multiplied in sequence by type effectiveness, STAB (×1.5 if the move's type matches the attacker's own type), weather (Scorchfield boosts Fire moves and weakens Water, Downpour does the reverse), a critical hit roll (1/16 normally, 1/8 for high-crit moves, ×1.5 damage), an 0.85–1.0 random variance, and finally a ×0.5 penalty if the attacker is burned and using a physical move.
Type effectiveness itself is precomputed once into a 15×15 TYPE_CHART, and getTypeEffectiveness() multiplies both of a dual-typed defender's type matchups together — so a move that's strong against one of a creature's two types but weak against the other can net out anywhere from 0.25× to 4×.
Enemy AI scales by difficulty tag (easy / medium / hard / boss): easy trainers pick a random usable move; medium scores every move by power × typeEffectiveness × STAB and picks randomly between the top two (80/20 split); hard and boss trainers always take the highest-scoring move — deterministic, optimal play.
Every turn starts from a deep-cloned BattleState (via JSON.parse(JSON.stringify(...))), so a bug in move resolution can never leak partial state into the next turn — and the active creature's reference is re-linked back to its party slot afterward so HP changes stay in sync.
export function getTypeEffectiveness(attack: AetherType, base: AetherBase): number {
let mult = TYPE_CHART[attack][base.type1];
if (base.type2) mult *= TYPE_CHART[attack][base.type2];
return mult; // e.g. 2 * 0.5 = 1, or 2 * 2 = 4
}
export function calcDamage(attacker, defender, move, weather, aVol, dVol) {
const isPhys = move.category === 'Physical';
const atk = (isPhys ? attacker.stats.attack : attacker.stats.spAttack) * stageMult(...);
const def = Math.max(1, (isPhys ? defender.stats.defense : defender.stats.spDefense) * stageMult(...));
let base = Math.floor((((2 * attacker.level / 5 + 2) * move.power * (atk / def)) / 50) + 2);
base *= getTypeEffectiveness(move.type, getAetherBase(defender.baseId));
const atkBase = getAetherBase(attacker.baseId);
if (atkBase.type1 === move.type || atkBase.type2 === move.type) base *= 1.5; // STAB
const crit = Math.random() < (move.highCrit ? 1/8 : 1/16);
if (crit) base *= 1.5;
base *= 0.85 + Math.random() * 0.15; // variance
if (attacker.status === 'burn' && isPhys) base *= 0.5;
return { dmg: Math.max(1, Math.floor(base)), crit };
}the content layer — 100 creatures, 520 moves
All of the game's content is data, not code. data/raw/aethers.json (6,224 lines) defines 100 original creatures — base stats, dual typing across 15 elemental types, learnsets, evolution chains (by level or stone), catch rates and flavor abilities. data/raw/moves.json (5,721 lines) defines 520 moves with power, accuracy, PP, priority, and a free-text effect description.
Those free-text descriptions get run through moves.ts, which parses natural-language effects into structured MoveEffect objects — status conditions, stat-stage changes (-3 to +3), weather, recoil/drain/heal, multi-hit, flinch chance, and so on. That decouples *writing* a move ('raises Attack 2 stages') from the battle engine *executing* it, so new moves can be authored as data without touching battle.ts at all.
On top of the creatures and moves sit 46 items across five categories (healing, capture spheres with 1–4× catch multipliers, battle boosters, evolution stones, key items), 14 hand-built maps each with their own encounter tables and NPC rosters, 8 gym leaders with scaled teams, and 50+ story flags that gate dialogue, warps and events without a single giant if chain.
the audio engine — music with no audio files
Every track — 13 in total, one per area plus battle and victory themes — is synthesized live with the Web Audio API. Each track config picks a tempo, an oscillator waveform for the lead and bass (sine/square/triangle/sawtooth), and a musical scale (pentatonic major/minor, diatonic major, natural minor).
composeMelody() walks a seeded random number generator across the scale: at each step there's an 18% chance of a rest, otherwise the melody steps up or down by 1–2 scale degrees from wherever it currently is. Because the RNG is seeded per track, the same track always generates the same melody — so it's deterministic and repeatable without storing a single note in a file. Playback schedules bass notes on alternating steps, the lead melody at low gain, and an optional harmony a third above the lead, with exponential gain ramps standing in for an ADSR envelope.
function composeMelody(scale: string[], steps: number, rng: Rng): (string | null)[] {
const out: (string | null)[] = [];
let idx = Math.floor(scale.length / 2); // start in the middle of the scale
for (let i = 0; i < steps; i++) {
if (rng.chance(0.18)) { out.push(null); continue; } // 18% rest
idx = Math.max(0, Math.min(scale.length - 1, idx + rng.int(-2, 2)));
out.push(scale[idx]);
}
return out;
}small details worth knowing about
A few patterns I'd carry forward into the next project:
- Shared collision predicates
- mapMeta.ts is the single source of truth for what's solid, used by both the renderer and the movement logic, so visuals and collisions can never desync.
- Story flags over state machines
- 50+ string flags in a Set (PROLOGUE_DONE, POSTGAME, etc.) gate content without a tangle of nested conditionals.
- Deep-clone-per-turn
- In the battle engine, every turn mutates a fresh snapshot, eliminating an entire class of 'stale state from last turn' bugs.
- Seeded procedural music
- Deterministic per-track RNG means 13 unique looping BGMs with zero audio assets and zero file size cost.