The README heading read 'Two people, two browsers', which sounds like a cap. It is not one: the server holds no game state at all, so any number of people play independent games and the server only ever sees a finished score. The only real cap is four to a game, and that is a keyboard sharing the same stand rather than anything technical. Checking that turned up a case where it would not have been true. A classroom, an office or a household all arrive from one address, and the submission limit of thirty an hour was low enough that a class finishing together would have started losing scores to a 429. Raised to 120 and verified with a burst of 130: the first 120 land, the rest are refused. What actually keeps rubbish off the board is the plausibility check, not this limit.
190 lines
8.4 KiB
Markdown
190 lines
8.4 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 lives in `server/`, not in the browser, so it is 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. See [server/README.md](server/README.md) for 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://github.com/Coffey-Labs/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.
|