GitHub took the organization's repos and GHCR offline on 2026-09-20. Repo, release, raw-file and clone links now go to Gitea at git.coffeylabs.org, container images to registry.coffeylabs.org, and GitLab-style /-/blob paths to Gitea's /src/branch form. Go module paths are identifiers and stay as they are; links to GitHub issues and pull requests are left as history.
199 lines
9.0 KiB
Markdown
199 lines
9.0 KiB
Markdown
# Lemonade Stand
|
||
|
||
A browser recreation of the Atari 8-bit BASIC classic — the one you typed in,
|
||
ran on a TV, and lost two dollars to on a cloudy day.
|
||
|
||
It ships in two skins over one game. **1979** is the machine as it was: Atari
|
||
blue, block letters, scanlines, chiptune. **Remaster** is the same rules with
|
||
cel-shaded art, a crowd that turns up in proportion to trade, and a funk band.
|
||
The rules never differ between them — only the paint. Switch any time with the
|
||
button under the set.
|
||
|
||
React + TypeScript + Vite, and not one binary asset: every pixel is SVG drawn
|
||
in code and every note is generated by a small synth on the Web Audio API.
|
||
|
||
## Playing
|
||
|
||
```
|
||
npm install
|
||
npm run dev
|
||
```
|
||
|
||
It loads from cassette first, because that is how it arrived. Pressing PLAY on
|
||
the recorder is also what unlocks the audio — browsers want a gesture before
|
||
they will make a sound, and that happens to be exactly the gesture the game
|
||
already wanted.
|
||
|
||
One to four players share the keyboard, hot-seat style. Each morning you see
|
||
the weather, then decide three things: how many glasses to make, how many
|
||
15-cent signs to put up, and what to charge. Then the town votes with its
|
||
pocket money.
|
||
|
||
- Lemonade costs 2 cents a glass on days 1–2, 4 cents through day 7, and
|
||
5 cents after that.
|
||
- Everything you make is made *today*. Unsold glasses are poured away.
|
||
- A hot, dry day brings a bigger crowd that will pay more. A heat wave is
|
||
better still.
|
||
- A cloudy day can turn into a thunderstorm, and a thunderstorm ruins every
|
||
glass you made.
|
||
- Street crews close the road now and then, and they tend to stay for a
|
||
second day.
|
||
- The summer fair brings half the town past your stand, in a spending mood.
|
||
- A rival sometimes sets up on the next corner and takes a share of the
|
||
street until they get bored and move on.
|
||
- Signs work, but with sharp diminishing returns — the fourth one barely
|
||
earns its 15 cents.
|
||
|
||
Once the prices are in, the stand trades for a few seconds before the books
|
||
open: the crowd arrives at the rate you actually sold, the tally climbs, and
|
||
the till fills. It can be skipped, but the day is worth watching.
|
||
|
||
You are broke when you cannot afford a single glass. Otherwise the summer runs
|
||
as long as you like; **RETIRE** closes the books and shows the standings.
|
||
|
||
## High scores
|
||
|
||
One board, shared by everybody. Retiring posts every player's closing balance
|
||
to the scores service, and the top 50 comes back ranked best first — ties break
|
||
on the shorter season, then the earlier date. Runs from the summer you just
|
||
finished are picked out in yellow.
|
||
|
||
There is no way to clear it. A stand leaves the list only by being pushed off
|
||
the bottom by a better one, so a good summer stands until somebody beats it.
|
||
|
||
The board is no longer in this repository and is no longer ours alone: every
|
||
game on the site shares one service,
|
||
[games-scores](https://git.coffeylabs.org/jcoffey-dev/games-scores), which is one
|
||
container and one volume however many games there are. It started here, because
|
||
when it was written there was one game; a second game would have meant a second
|
||
container and a second database to back up for every game after that. All this
|
||
repository holds now is the client in `src/game/highscores.ts`, which names the
|
||
game on every call and is otherwise what it always was — rows come back in this
|
||
game's own field names.
|
||
|
||
It is still the one part of the game that needs a line out. With the service
|
||
unreachable the game plays exactly as normal and the board says so plainly
|
||
rather than breaking; posting happens behind the closing standings, so a slow
|
||
network never holds up the end of a season. That repository's README has the
|
||
API, the validation and what a board with no accounts can and cannot promise.
|
||
|
||
## How it is put together
|
||
|
||
```
|
||
src/
|
||
game/ pure simulation - no React, no DOM
|
||
constants.ts costs, weather profiles, the demand curve
|
||
engine.ts rolls each day and settles the takings
|
||
reducer.ts the day/turn state machine
|
||
rng.ts seeded mulberry32, so a run can be replayed
|
||
highscores.ts the persisted table, with validation on load
|
||
audio/
|
||
synth.ts pulse voices, a drum kit, filters, look-ahead sequencer
|
||
tunes.ts the 1979 chiptunes
|
||
funk.ts the remaster's band
|
||
skin.ts which of the two skins is on, and where it is kept
|
||
components/
|
||
PixelScene the 1979 artwork, in chunky rectangles
|
||
CelScene the remaster's artwork, and the people on the street
|
||
Scene picks between them, so no screen needs to care
|
||
```
|
||
|
||
The simulation is deliberately kept out of React: `engine.ts` takes a player, a
|
||
decision and a day and hands back a `DayResult`. That is what makes it possible
|
||
to run a few hundred seasons in a script and check that a careful player grows
|
||
their two dollars while a reckless one goes broke about seven times in ten.
|
||
|
||
### The demand curve
|
||
|
||
The original BASIC listing is not reproduced line for line. `constants.ts`
|
||
holds a reconstruction tuned to behave the way the game plays: cheap lemonade
|
||
sells out, sales fall to nothing once the price stops being a bargain, heat
|
||
lifts both the crowd and the price people will tolerate, and signs help a lot
|
||
at first and hardly at all later.
|
||
|
||
### The television
|
||
|
||
An Atari 800 fed a 4:3 television, and a television has no scrollbar. The tube
|
||
is locked to 4:3 and everything inside it is sized in container units, so the
|
||
picture stays in proportion at any size. If a page ever did come out taller
|
||
than the tube, `Fit` scales it down rather than clipping or scrolling it.
|
||
|
||
### The people on the street
|
||
|
||
How many figures walk on is driven by trade, not decoration. Before you have
|
||
priced anything it comes from the forecast and the day's conditions — a hot day
|
||
or the fair fills the pavement, road works and a rival empty it, a downpour
|
||
leaves one soul hurrying past under an umbrella.
|
||
Once the day has traded it comes from the glasses that actually crossed the
|
||
counter, so the report shows you the crowd you earned. In a heat wave they fan
|
||
themselves.
|
||
|
||
## Sound
|
||
|
||
Audio starts on the first gesture, as browsers require. The 1979 skin gets the
|
||
chiptune: two pulse voices, a triangle bass and filtered noise, with real
|
||
Fourier-built duty cycles rather than a plain square.
|
||
|
||
The remaster gets a band. Same engine, more of it — a sine kick with a pitch
|
||
envelope, a snare with body under the crack, sixteenth hats, and a sawtooth
|
||
bass through a sweeping resonant lowpass, which is where the funk actually
|
||
lives. Patterns are written in sixteenths with a light shuffle, and chords are
|
||
just slash-separated notes in a step (`F4/A4/C5`).
|
||
|
||
Both run off one scheduler that queues notes 200 ms ahead, so the groove does
|
||
not stutter when React re-renders. `MUSIC` and `SOUND` toggle independently.
|
||
|
||
## How many people can play
|
||
|
||
As many as you like. There is no limit, and no meaningful sense in which
|
||
players share anything while they are playing.
|
||
|
||
The game holds no state on the server — it is a static bundle, and a season
|
||
lives entirely in the page. Two people, or two hundred, on any mix of
|
||
machines, browsers and profiles get completely independent games: different
|
||
seeds, different weather, different books. The server never learns a game is
|
||
happening; it only ever sees a finished score being posted at the end.
|
||
|
||
The one real cap is **four players to a game**, and that is a keyboard
|
||
limitation rather than a technical one — they are taking turns at the same
|
||
stand, hot-seat style. Nothing stops four separate people playing four
|
||
separate games at the same moment.
|
||
|
||
Everyone posts to the same leaderboard, and everyone reads the same one. That
|
||
is the only thing players have in common. The skin preference is the one thing
|
||
still kept in the browser.
|
||
|
||
## Running it anywhere
|
||
|
||
```bash
|
||
docker compose up -d --build
|
||
# game http://localhost:8080
|
||
# scores http://localhost:5184/api/scores
|
||
```
|
||
|
||
Two containers: a static bundle behind nginx, and the leaderboard. Only the
|
||
scores container writes anything — a named volume holding the SQLite database,
|
||
which is the one piece of state worth keeping.
|
||
|
||
The game asks for `/api`, so in production put both behind one origin and
|
||
proxy `/api/` to the scores container. Serving the game from a sub-path means
|
||
rebuilding it for that path, because asset URLs are baked in: set `BASE_PATH`
|
||
(the `web/Dockerfile` build arg) to match. Set `TRUST_PROXY=1` on the scores
|
||
container when something else terminates TLS in front of it, so the rate limit
|
||
counts players rather than counting the proxy.
|
||
|
||
## Licence
|
||
|
||
AGPL-3.0-or-later — see [LICENSE](LICENSE). Source:
|
||
<https://git.coffeylabs.org/jcoffey-dev/lemonade>, also linked from every screen in the
|
||
game, which is what section 13 asks for.
|
||
|
||
Affero rather than plain GPL because the leaderboard is a network service:
|
||
anyone running a modified copy of it for other people has to offer them the
|
||
source. Running the game on its own imposes nothing extra.
|
||
|
||
The simulation, the artwork, the music and every word on screen are original
|
||
work — the game's *rules* are nobody's property, and nothing here is quoted
|
||
from the original. [NOTICE.md](NOTICE.md) sets out exactly what came from
|
||
where, and credits the people who wrote it first.
|