Part 3: Building a Production Browser Game with Next.js and Phaser 3
The Architecture Decision
After four failed prototypes (covered in Part 1), one lesson was clear: the game logic and the rendering engine must be completely separate. Investment calculations, board generation, and event processing should work the same whether rendered by Phaser, React, or a terminal. This led to a three-layer architecture:
┌─────────────────────────────────┐
│ Next.js 14 (App Router) │ UI shell, routing, auth
├─────────────────────────────────┤
│ Phaser 3 (BoardScene) │ Board rendering, animations
├─────────────────────────────────┤
│ game-core (Pure TS) │ Logic, rules, calculations
└─────────────────────────────────┘
The game-core directory contains five TypeScript files with zero framework dependencies. It can be imported by Phaser scenes, React components, unit tests, or a Node.js CLI — the logic does not care who calls it.
game-core: Framework-Agnostic Game Logic
Type System
The type definitions in types.ts establish the vocabulary of the entire game. Every concept — board spaces, stocks, players, events — has a precise TypeScript type:
// Nine distinct space types on the board
export type CellType =
| 'START' | 'GOAL'
| 'NEWS' // Historical events that shift the economy
| 'DIVIDEND' // Passive income from holdings
| 'BANKRUPTCY' // A stock goes to zero
| 'IPO' // New stocks unlock
| 'BONUS' // Windfall cash
| 'TAX' // Mandatory expenses
| 'CHANCE'; // Economy-linked random events
// Seven investable assets
export type StockType =
| 'INDEX' // Diversified index fund (dampened volatility)
| 'FOOD' // Consumer staples
| 'ENERGY' // Energy sector
| 'MANUFACTURING' // Manufacturing & AI
| 'GAME' // Tech & gaming (locked at start)
| 'FASHION' // Consumer discretionary (locked at start)
| 'GOLD'; // Safe haven (inverse correlation)
The stock system is designed to teach specific investment concepts. The INDEX fund has dampened volatility (60% of market moves) to demonstrate diversification benefits. GOLD moves inversely to stocks, teaching the flight-to-safety pattern. Two stocks start locked and are only available through IPO spaces, introducing the concept of market access.
// Index funds absorb only 60% of market-wide shocks
export const INDEX_DAMPENING_FACTOR = 0.6;
// Individual stock events ripple into the index at 15%
export const INDEX_LINK_COEFFICIENT = 0.15;
These two constants encode a key investing principle: diversified index funds are less volatile than individual stocks. When a CRASH event hits the FOOD sector, the food stock drops 30%, but the index only drops 4.5% (30% x 15%). Children learn this viscerally by watching their portfolio.
The Game Engine
The engine in engine.ts manages the entire game loop. The initGame function creates a fresh game state with configurable parameters:
export function initGame(config: Partial<GameConfig> = {}): GameState {
const gameLength = config.gameLength || DEFAULT_CONFIG.gameLength;
const lengthConfig = GAME_LENGTH_CONFIG[gameLength];
const mergedConfig: GameConfig = {
...DEFAULT_CONFIG,
...config,
startYear: lengthConfig.startYear,
endYear: lengthConfig.endYear,
totalTurns: lengthConfig.turns,
yearsPerTurn: 1,
boardSize: CELLS_PER_LOOP, // 12 spaces per loop
};
return {
turn: 0,
year: mergedConfig.startYear,
phase: 'NORMAL', // Always start neutral
player: {
position: 0,
cash: mergedConfig.initialCash, // 1,000,000 yen
portfolio: initialPortfolio,
properties: [],
},
board: createBalancedBoard(mergedConfig),
stocks: getInitialStocks(),
history: [],
usedNewsIds: [],
isFinished: false,
config: mergedConfig,
};
}
Three game lengths map to different eras of Japanese history:
| Mode | Turns | Era | Experience |
|---|---|---|---|
| Short | 10 | 2011-2020 | Abenomics to COVID |
| Medium | 20 | 2001-2020 | Dot-com bust to COVID |
| Long | 41 | 1980-2020 | Bubble to COVID |
The core turn processing function, processTurn, is where everything comes together. It handles movement, event resolution, year transitions, and game-over detection in a single pure function:
export function processTurn(
state: GameState,
diceRoll: DiceValue
): {
newState: GameState;
looped: boolean;
yearChanged: boolean;
rentCollected?: number;
propertiesSold?: Array<{ propertyName: string; sellPrice: number }>;
} {
// 1. Collect rent from owned properties
const { player: playerAfterRent, totalRent } = collectRent(state.player);
// 2. Process pending property sales
const { player: playerAfterSales, soldProperties } = processPendingSales(
playerAfterRent, state.turn + 1, state.phase
);
// 3. Move player (with loop detection)
const { newState: movedState, looped } = movePlayer(
stateWithRentAndSales, diceRoll
);
// 4. If looped, advance year and regenerate board
if (looped && state.year < state.config.endYear) {
newYear = state.year + 1;
newBoard = createBoardForYear(newYear);
newPhase = getEraPhase(newYear);
}
// 5. Resolve current space event
const event = getCurrentCellEvent(newState);
// 6. Apply event effects to player and stocks
if (event) {
const { player, stocks } = applyEventEffect(
newState.player, newState.stocks, event.effect
);
// Update news IDs and economy phase
}
// 7. Check game over: final year + completed loop
const finished = state.year >= state.config.endYear && looped;
return { newState, looped, yearChanged };
}
Notice that processTurn is a pure function — it takes a state and a dice roll, and returns a new state. No side effects, no DOM manipulation, no rendering. This is what makes game-core testable with 19 unit tests and usable from any rendering layer.
Dynamic Board Generation
The board is not static. Every time the player completes a loop (one in-game year), a new board is generated based on the current era. The board.ts module defines era-specific distributions:
const ERA_CONFIGS: EraConfig[] = [
{
eraKey: '1985-1989',
yearStart: 1985,
yearEnd: 1989,
phase: 'GOOD', // Bubble era
distribution: {
NEWS: 20,
DIVIDEND: 20, // More dividends in good times
BANKRUPTCY: 5, // Fewer bankruptcies
IPO: 15, // More IPOs during booms
BONUS: 20,
TAX: 5,
CHANCE: 15,
},
},
{
eraKey: '1990-1994',
yearStart: 1990,
yearEnd: 1994,
phase: 'BAD', // Lost decade begins
distribution: {
NEWS: 30, // More news during crises
DIVIDEND: 10, // Fewer dividends
BANKRUPTCY: 15, // More bankruptcies
IPO: 5, // Fewer IPOs
BONUS: 5,
TAX: 15, // Higher taxes
CHANCE: 20,
},
},
];
During the bubble era (1985-1989), the board is generous: more dividends, more bonuses, more IPOs. When the bubble bursts in 1990, the board turns hostile: more bankruptcies, more taxes, more crisis news. Players feel the economic cycle through the spaces they land on, not just through stock prices.
The board generator uses a constraint system to ensure each loop is playable:
const CELL_LIMITS_BY_PHASE: Record<EconomyPhase, CellLimitConfig> = {
GOOD: {
BANKRUPTCY: { min: 0, max: 0 }, // No bankruptcies in good times
DIVIDEND: { min: 2, max: 3 },
CHANCE: { min: 2, max: 4 },
},
BAD: {
BANKRUPTCY: { min: 1, max: 2 }, // Guaranteed bankruptcies
DIVIDEND: { min: 0, max: 1 },
CHANCE: { min: 2, max: 4 },
},
};
Event Resolution: The Chance System
Chance spaces are where the economy-phase system shines. When a player lands on a Chance space, the event type (BOOM, CRASH, or GOLD) is probabilistically determined by the current economy phase:
export const CHANCE_PROBABILITIES: Record<EconomyPhase, {
boom: number; crash: number
}> = {
GOOD: { boom: 0.70, crash: 0.30 }, // 70% boom in good times
NORMAL: { boom: 0.50, crash: 0.50 }, // 50/50
BAD: { boom: 0.30, crash: 0.70 }, // 70% crash in bad times
};
Additionally, 20% of Chance events are gold-specific, teaching players about safe-haven assets. The resolution chain flows through three layers:
- Determine type: BOOM / CRASH / GOLD based on economy phase
- Select event: Random pick from 10 BOOM events, 10 CRASH events, or 8 GOLD events
- Calculate effects: Apply stock changes + index ripple + gold inverse
export function convertChanceEventToGameEvent(
chanceEvent: ChanceEvent,
stocks: Stock[]
): GameEvent {
const stockChange: Array<{ type: StockType; percent: number }> = [];
// Direct impact on target stocks
for (const type of targetStockTypes) {
stockChange.push({ type, percent: chanceEvent.changePercent });
}
// Index ripple: 15% of individual stock impact
if (shouldImpactIndex) {
const indexChange = chanceEvent.changePercent * INDEX_LINK_COEFFICIENT;
stockChange.push({ type: 'INDEX', percent: indexChange });
}
// Gold inverse: stocks down → gold up
if (hasGoldImpact) {
stockChange.push({ type: 'GOLD', percent: chanceEvent.goldImpact! });
}
return { id, type: 'CHANCE', title, description, effect: { stockChange } };
}
Phaser 3: Bringing the Board to Life
With the game logic isolated in game-core, Phaser 3 handles everything visual: the board layout, dice rolling animations, token movement, cutscenes for era transitions, and event display panels.
The board uses a 12-cell loop layout. Each cell is color-coded by type and the player token animates smoothly between positions using Phaser’s tween system. When the player completes a loop, a cutscene announces the new year and era with appropriate visual theming — warm tones for boom periods, cold tones for recessions.
The Phaser BoardScene communicates with game-core through simple function calls. It calls processTurn() with the dice result, receives the new game state, and animates the transition. The scene never modifies game state directly.
Beyond the Board: Production Features
Authentication with Supabase
MarketQuest supports Google OAuth and email authentication through Supabase. Players can save their scores to a global leaderboard:
- Google OAuth for frictionless sign-in
- Display name customization with UNIQUE constraints
- Admin mode with
is_adminflag for debug panels - Demo user mode for trying the game without an account
Ranking System
Game results are stored in a Supabase scores table. The leaderboard shows the top 10 players with their final asset totals and ranks (S through D):
export const RANK_THRESHOLDS: Record<GameRank, { min: number }> = {
S: { min: 3000000 }, // Investment Genius
A: { min: 2000000 }, // Expert Trader
B: { min: 1500000 }, // Steady Investor
C: { min: 1000000 }, // Getting Started
D: { min: 0 }, // Still Learning
};
PWA Support
The game is a Progressive Web App with a service worker and manifest.json, supporting “Add to Home Screen” on both iOS and Android. This is particularly important for the target audience — parents can add the game to their child’s tablet home screen for easy access without app store distribution.
Internationalization (i18n)
The game supports three language modes:
- Japanese (ja) — Standard mode
- Kids Mode (ja-kids) — Hiragana-heavy for younger children who cannot read kanji
- English (en) — Full translation
The TextProvider interface in game-core (shown in Part 2) enables this cleanly. All game text flows through locale dictionaries, and the game-core engine itself contains zero natural language strings. Stock names like “Mogu-Mogu Foods” and “Piko-Piko Games” come from locale files, not from the type system.
Game Balance Tuning
Getting the difficulty right for seven-year-olds required extensive playtesting. Some key balance decisions:
- Start with index fund shares: Every player begins with 10 units of the INDEX fund, ensuring they immediately experience stock ownership without needing to understand buying
- Economy always starts NORMAL: Starting in a boom or bust makes it hard for new players to calibrate what “normal” looks like
- IPO stocks have +5% first-day bonus: This teaches the real-world concept of IPO pops while rewarding exploration
- Gold never goes bankrupt: The safe-haven asset is always available, teaching that diversification into uncorrelated assets provides protection
The evaluation system at game end provides educational feedback based on the player’s actual behavior:
function generateTips(state: GameState): string[] {
const tipKeys: string[] = [];
// Check diversification
const holdingTypes = Object.keys(state.player.portfolio).length;
if (holdingTypes <= 1) {
tipKeys.push('diversification'); // "Try holding different types!"
} else if (holdingTypes >= 4) {
tipKeys.push('diversificationGood'); // "Great diversification!"
}
// Check cash ratio
const cashRatio = state.player.cash / totalAssets;
if (cashRatio > 0.7) {
tipKeys.push('highCashRatio'); // "Your cash isn't working for you"
}
// Check index fund ownership
if (!state.player.portfolio['INDEX']) {
tipKeys.push('recommendIndex'); // "Index funds are great for beginners"
}
return tipKeys;
}
The tips are personalized. A player who held only cash learns about opportunity cost. A player who concentrated in one stock learns about diversification. A player who diversified across four or more assets gets congratulated.
Deployment and Mobile Optimization
MarketQuest is deployed on Vercel with automatic builds from the main branch. The biggest deployment challenge was mobile optimization. The Phaser canvas uses FIT + CENTER_BOTH scaling, which required custom coordinate transformation to handle touch events correctly across different screen sizes and orientations:
- Portrait mode shows a compact UI with scrollable panels
- Landscape mode maximizes the board area
- Touch targets are sized for children’s fingers (minimum 44px)
- All modals use
fixed inset-0positioning to prevent scroll issues
Play MarketQuest
The game is live and free to play. Roll the dice, experience Japan’s economic history, and see if you can beat the market.
Reflections
MarketQuest started as a paper board game idea in 2015. It went through four HTML5 prototypes that each failed in different ways. It required a Python data pipeline processing 70 years of economic history. It needed a three-layer architecture separating pure logic from rendering. And it demanded careful game balance tuning for an audience that cannot read kanji.
The result is a game where a seven-year-old can roll dice, encounter the 2008 Lehman shock, watch their stocks crash, see their gold rise, and intuitively understand something that many adults struggle with: markets go down, but they also go back up — and diversification protects you along the way.
That is the power of learning through play.
Previous: Part 2: Recreating Economic Cycles with Real Japanese News