Lemonade Stand: browser recreation of the Atari 8-bit BASIC game

Faithful day loop - weather report, cost of lemonade rising over the
summer, glasses/signs/price decisions, thunderstorms, heat waves and
street crews - with the $$ LEMONSVILLE DAILY FINANCIAL REPORT $$ laid
out the way it printed.

The simulation is kept out of React so a season can be run headlessly:
a careful player compounds $2.00 into roughly $22 over twelve days,
while an all-in player goes broke about seven times in ten.

Weather scenes are pixel rectangles in SVG and the music is a small
chiptune engine on the Web Audio API, so there are no binary assets.
The screen is locked to a 4:3 tube and never scrolls.
This commit is contained in:
2026-09-08 15:23:46 -07:00
commit 5f8ad511f0
32 changed files with 3913 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
import { STARTING_ASSETS } from './constants'
import { isBankrupt } from './engine'
import type { DayConditions, DayResult, Decision, Phase, Player } from './types'
export interface GameState {
phase: Phase
seed: number
day: number
players: Player[]
/** Index into players; only the ones still solvent take a turn. */
turn: number
conditions: DayConditions | null
decisions: Record<number, Decision>
results: DayResult[]
history: DayResult[]
/** True while road works were in progress yesterday, so they can run on. */
streetCrewYesterday: boolean
retired: boolean
}
export const initialState = (seed: number): GameState => ({
phase: 'title',
seed,
day: 0,
players: [],
turn: 0,
conditions: null,
decisions: {},
results: [],
history: [],
streetCrewYesterday: false,
retired: false,
})
export type Action =
| { type: 'SHOW_INTRO' }
| { type: 'SHOW_SETUP' }
| { type: 'START'; names: string[] }
| { type: 'BEGIN_DAY'; conditions: DayConditions }
| { type: 'OPEN_STAND' }
| { type: 'SUBMIT'; playerId: number; decision: Decision }
| { type: 'RESOLVE'; results: DayResult[] }
| { type: 'NEXT_DAY' }
| { type: 'RETIRE' }
| { type: 'RESTART'; seed: number }
/** Players who can still afford to open the stand, in seating order. */
export const activePlayers = (s: GameState) => s.players.filter((p) => !p.bankrupt)
export function reducer(state: GameState, action: Action): GameState {
switch (action.type) {
case 'SHOW_INTRO':
return { ...state, phase: 'intro' }
case 'SHOW_SETUP':
return { ...state, phase: 'setup' }
case 'START':
return {
...state,
phase: 'briefing',
day: 1,
players: action.names.map((name, i) => ({
id: i,
name: name.trim().toUpperCase() || `PLAYER ${i + 1}`,
assets: STARTING_ASSETS,
bankrupt: false,
bankruptDay: null,
})),
turn: 0,
decisions: {},
results: [],
history: [],
}
case 'BEGIN_DAY':
return {
...state,
phase: 'briefing',
conditions: action.conditions,
decisions: {},
results: [],
turn: 0,
}
case 'OPEN_STAND':
return { ...state, phase: 'decide', turn: 0 }
case 'SUBMIT': {
const decisions = { ...state.decisions, [action.playerId]: action.decision }
const active = activePlayers(state)
const done = active.every((p) => decisions[p.id] !== undefined)
return {
...state,
decisions,
turn: done ? state.turn : state.turn + 1,
phase: done ? 'resolve' : 'decide',
}
}
case 'RESOLVE': {
const byId = new Map(action.results.map((r) => [r.playerId, r]))
const nextDay = state.day + 1
return {
...state,
phase: 'report',
results: action.results,
history: [...state.history, ...action.results],
streetCrewYesterday: state.conditions?.streetCrew ?? false,
players: state.players.map((p) => {
const r = byId.get(p.id)
if (!r) return p
const assets = r.assetsAfter
const broke = isBankrupt(assets, nextDay)
return {
...p,
assets,
bankrupt: p.bankrupt || broke,
bankruptDay: p.bankrupt ? p.bankruptDay : broke ? state.day : null,
}
}),
}
}
case 'NEXT_DAY': {
if (activePlayers(state).length === 0) return { ...state, phase: 'gameover' }
return { ...state, phase: 'briefing', day: state.day + 1, turn: 0 }
}
case 'RETIRE':
return { ...state, phase: 'gameover', retired: true }
case 'RESTART':
return initialState(action.seed)
default:
return state
}
}