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
+76
View File
@@ -0,0 +1,76 @@
import type { Weather } from './types'
/** Everything is held in whole cents so the books never drift. */
export const STARTING_ASSETS = 200 // $2.00
export const SIGN_COST = 15 // 15 cents per advertising sign
export const MAX_PLAYERS = 4
export const MAX_SIGNS = 50
/**
* The cost of a glass climbs as the season goes on, exactly as in the
* original: 2 cents for days 1-2, 4 cents through day 7, 5 cents after that.
*/
export function costPerGlass(day: number): number {
if (day <= 2) return 2
if (day <= 7) return 4
return 5
}
export function costMessage(day: number): string[] | null {
if (day === 1)
return [
'ON DAY 1 THE COST OF LEMONADE IS',
'2 CENTS PER GLASS.',
]
if (day === 3)
return [
'YOUR COST OF LEMONADE HAS GONE UP',
'TO 4 CENTS PER GLASS.',
]
if (day === 8)
return [
'YOUR COST OF LEMONADE HAS GONE UP',
'TO 5 CENTS PER GLASS.',
]
return null
}
/**
* Demand model.
*
* The original BASIC listing is not reproduced here line for line - this is a
* reconstruction tuned to behave the way the game plays: cheap lemonade sells
* out, nobody buys at any price once it stops being a bargain, heat pushes both the crowd and the
* price people will tolerate upwards, and signs help a lot at first and
* hardly at all after the fourth one.
*/
export interface WeatherProfile {
label: string
/** Passers-by on an average day. */
traffic: number
/** Price (in cents) at which sales fall to nothing. */
priceCeiling: number
}
export const WEATHER: Record<Weather, WeatherProfile> = {
sunny: { label: 'SUNNY', traffic: 90, priceCeiling: 20 },
cloudy: { label: 'CLOUDY', traffic: 60, priceCeiling: 15 },
hot: { label: 'HOT AND DRY', traffic: 120, priceCeiling: 30 },
}
/** A heat wave on a hot day turns the street into a desert full of buyers. */
export const HEAT_WAVE_TRAFFIC = 1.5
export const HEAT_WAVE_CEILING = 1.25
/** Street crews close the road; a handful of regulars still find you. */
export const STREET_CREW_TRAFFIC = 0.2
/** Signs: +20% for the first, tailing off to a hard ceiling near +50%. */
export function signFactor(signs: number): number {
return 1 + 0.5 * (1 - Math.pow(0.6, Math.max(0, signs)))
}
export function priceFactor(price: number, ceiling: number): number {
if (price >= ceiling) return 0
if (price <= 0) return 1
return Math.pow((ceiling - price) / ceiling, 1.6)
}
+100
View File
@@ -0,0 +1,100 @@
import {
HEAT_WAVE_CEILING,
HEAT_WAVE_TRAFFIC,
STREET_CREW_TRAFFIC,
SIGN_COST,
WEATHER,
costPerGlass,
priceFactor,
signFactor,
} from './constants'
import { chance, type Rng } from './rng'
import type { DayConditions, DayResult, Decision, Player, Weather } from './types'
/**
* Roll the weather and the day's events. The storm is decided here but must
* not be shown to the player until their money is committed - that is the
* whole cruelty of a cloudy day.
*/
export function rollDay(day: number, rng: Rng, streetCrewYesterday: boolean): DayConditions {
const roll = rng()
let weather: Weather
if (roll < 0.5) weather = 'sunny'
else if (roll < 0.8) weather = 'cloudy'
else weather = 'hot'
// Day 1 is always sunny so nobody loses their stake before they have played.
if (day === 1) weather = 'sunny'
const heatWave = weather === 'hot' && chance(rng, 0.35)
const storm = weather === 'cloudy' && chance(rng, 0.25)
// Road works run for two days once they start, and never on day 1 or 2.
const streetCrew = day > 2 && (streetCrewYesterday ? chance(rng, 0.5) : chance(rng, 0.12))
return { day, weather, heatWave, streetCrew, storm, costPerGlass: costPerGlass(day) }
}
/** The most glasses a player can pay for today, given signs already planned. */
export function affordableGlasses(assets: number, day: number, signs = 0): number {
return Math.max(0, Math.floor((assets - signs * SIGN_COST) / costPerGlass(day)))
}
export function simulate(
player: Player,
decision: Decision,
cond: DayConditions,
rng: Rng,
): DayResult {
const lemonadeCost = decision.glasses * cond.costPerGlass
const signCost = decision.signs * SIGN_COST
const expenses = lemonadeCost + signCost
let glassesSold = 0
if (!cond.storm) {
const profile = WEATHER[cond.weather]
let traffic = profile.traffic
let ceiling = profile.priceCeiling
if (cond.heatWave) {
traffic *= HEAT_WAVE_TRAFFIC
ceiling *= HEAT_WAVE_CEILING
}
if (cond.streetCrew) traffic *= STREET_CREW_TRAFFIC
const demand =
traffic *
priceFactor(decision.price, ceiling) *
signFactor(decision.signs) *
// A little daily luck, +/- 10%, so identical days are never identical.
(0.9 + rng() * 0.2)
glassesSold = Math.min(decision.glasses, Math.round(demand))
}
const income = glassesSold * decision.price
const profit = income - expenses
return {
playerId: player.id,
decision,
glassesSold,
income,
lemonadeCost,
signCost,
expenses,
profit,
assetsAfter: player.assets + profit,
}
}
/** Broke means you cannot even make a single glass tomorrow. */
export function isBankrupt(assets: number, nextDay: number): boolean {
return assets < costPerGlass(nextDay)
}
export const dollars = (cents: number): string => {
const sign = cents < 0 ? '-' : ''
const abs = Math.abs(cents)
return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`
}
export const randomSeed = () => Math.floor(Math.random() * 1_000_000) + 1
+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
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* mulberry32 — small, fast, seedable PRNG.
* The original ran on RND(0); we keep a seed so a run can be replayed or shared.
*/
export function makeRng(seed: number) {
let a = seed >>> 0
return function rng(): number {
a = (a + 0x6d2b79f5) >>> 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export type Rng = ReturnType<typeof makeRng>
export const randInt = (rng: Rng, min: number, max: number) =>
min + Math.floor(rng() * (max - min + 1))
export const chance = (rng: Rng, p: number) => rng() < p
+49
View File
@@ -0,0 +1,49 @@
export type Weather = 'sunny' | 'cloudy' | 'hot'
export interface Player {
id: number
name: string
assets: number // in cents, always integer
bankrupt: boolean
bankruptDay: number | null
}
/** What a player typed in for a single day. */
export interface Decision {
glasses: number
signs: number
price: number // cents per glass
}
/** Everything the world decided for a given day, before anyone buys anything. */
export interface DayConditions {
day: number
weather: Weather
heatWave: boolean
streetCrew: boolean
/** Only ever true on a cloudy day, and only revealed after decisions are locked in. */
storm: boolean
costPerGlass: number
}
export interface DayResult {
playerId: number
decision: Decision
glassesSold: number
income: number
lemonadeCost: number
signCost: number
expenses: number
profit: number
assetsAfter: number
}
export type Phase =
| 'title'
| 'intro'
| 'setup'
| 'briefing'
| 'decide'
| 'resolve'
| 'report'
| 'gameover'