Split the site out, and offer the source the licence requires

The hub page and the games.jcoffey.dev deployment move to their own
private repository; this one keeps only the game, its leaderboard and a
generic compose that runs both anywhere. No deployment details remain
here, and no site references.

A SOURCE link now sits on every screen. AGPL section 13 entitles anyone
playing this over a network to the source of what they are playing, and
a link in the footer is how that offer gets made.
This commit is contained in:
2026-09-08 17:34:51 -07:00
parent 6db47b338e
commit f2e084ef7a
11 changed files with 93 additions and 337 deletions
+17 -17
View File
@@ -144,30 +144,30 @@ season is shared or synchronised.
The only thing they have in common is the leaderboard they both post to at the
end. The skin preference is the one thing still kept in the browser.
## Deploying
Three containers behind the host's nginx, which does TLS and routing and serves
nothing off disk:
| Path | Container | What it is |
| ----------- | ----------------- | -------------------------- |
| `/` | `games-hub` | the list of games |
| `/lemonade/`| `lemonade-web` | this game, a static bundle |
| `/api/` | `lemonade-scores` | the shared leaderboard |
## Running it anywhere
```bash
docker compose -f deploy/compose.yml up -d --build
docker compose up -d --build
# game http://localhost:8080
# scores http://localhost:5184/api/scores
```
`deploy/nginx-host.conf.example` is the host-side server block. The game's
asset paths are baked in at build time, so `BASE_PATH` in the compose file has
to match the path nginx proxies it to. Only the scores container writes
anything: a named volume holding the SQLite database, which is the one piece of
state that must survive a redeploy.
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).
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
+52
View File
@@ -0,0 +1,52 @@
# Run the game and its leaderboard locally, or on any host you like.
#
# docker compose up -d --build
# -> game http://localhost:8080
# scores http://localhost:5184/api/scores
#
# The game talks to /api, so put both behind one origin in production (see
# README.md). Nothing here is specific to any particular deployment.
services:
lemonade-web:
build:
context: .
dockerfile: web/Dockerfile
args:
# Where the game is served from. "/" unless it sits under a path.
BASE_PATH: /
image: lemonade-web:latest
restart: unless-stopped
ports:
- '8080:8080'
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /var/run
security_opt:
- no-new-privileges:true
lemonade-scores:
build:
context: ./server
image: lemonade-scores:latest
restart: unless-stopped
ports:
- '5184:5184'
environment:
# Set to 1 when something else terminates TLS in front of this, so the
# rate limit counts players rather than counting the proxy.
TRUST_PROXY: '0'
DB_PATH: /data/scores.db
# The only writable path, and the only state worth keeping.
volumes:
- lemonade-scores-data:/data
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
volumes:
lemonade-scores-data:
-73
View File
@@ -1,73 +0,0 @@
# games.jcoffey.dev
#
# Three containers, one domain. The host's nginx does TLS and routes paths to
# them; nothing is served off the host's disk.
#
# / -> games-hub the list of games
# /lemonade/ -> lemonade-web the game, a static bundle
# /api/ -> lemonade-scores the shared leaderboard
#
# Run from the repository root:
# docker compose -f deploy/compose.yml up -d --build
services:
games-hub:
build:
context: ..
dockerfile: hub/Dockerfile
image: games-hub:latest
container_name: games-hub
restart: unless-stopped
ports:
- '127.0.0.1:5186:8080'
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /var/run
security_opt:
- no-new-privileges:true
lemonade-web:
build:
context: ..
dockerfile: web/Dockerfile
args:
# Must match the path the host proxies, or asset URLs will be wrong.
BASE_PATH: /lemonade/
image: lemonade-web:latest
container_name: lemonade-web
restart: unless-stopped
ports:
- '127.0.0.1:5185:8080'
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /var/run
security_opt:
- no-new-privileges:true
lemonade-scores:
build:
context: ../server
dockerfile: Dockerfile
image: lemonade-scores:latest
container_name: lemonade-scores
restart: unless-stopped
ports:
- '127.0.0.1:5184:5184'
environment:
TRUST_PROXY: '1'
DB_PATH: /data/scores.db
# The only writable path, and the only state that must survive a redeploy.
volumes:
- lemonade-scores-data:/data
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
volumes:
lemonade-scores-data:
-50
View File
@@ -1,50 +0,0 @@
# Host-side nginx for games.jcoffey.dev. TLS, logging and proxying only -
# nothing is read from disk here, every path goes to a container.
#
# Copy into the host's sites-available, adjust the certificate paths, then
# `nginx -t && systemctl reload nginx`.
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name games.jcoffey.dev;
ssl_certificate /etc/letsencrypt/live/games.jcoffey.dev/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/games.jcoffey.dev/privkey.pem;
access_log /var/log/nginx/games.jcoffey.dev.access.log;
error_log /var/log/nginx/games.jcoffey.dev.error.log;
# The leaderboard. TRUST_PROXY=1 in the container means it reads the
# forwarded address, so the rate limit applies per player rather than
# counting every submission as coming from nginx.
location /api/ {
proxy_pass http://127.0.0.1:5184;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /lemonade/ {
proxy_pass http://127.0.0.1:5185/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Everything else is the list of games.
location / {
proxy_pass http://127.0.0.1:5186;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
listen [::]:80;
server_name games.jcoffey.dev;
return 301 https://$host$request_uri;
}
-8
View File
@@ -1,8 +0,0 @@
# The front door for games.jcoffey.dev. Static, no build step: adding a game is
# one more card in index.html.
FROM nginx:alpine
COPY hub/nginx.conf /etc/nginx/conf.d/default.conf
COPY hub/public /usr/share/nginx/html
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
-15
View File
@@ -1,15 +0,0 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
location = /healthz {
access_log off;
return 200 "ok\n";
}
}
-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" shape-rendering="crispEdges">
<rect width="32" height="32" fill="#2b45b8"/>
<rect x="6" y="4" width="20" height="3" fill="#d24b3e"/>
<rect x="6" y="7" width="20" height="3" fill="#f2f6ff"/>
<rect x="10" y="12" width="12" height="12" fill="#f7de5a"/>
<rect x="12" y="10" width="8" height="2" fill="#e0a92b"/>
<rect x="22" y="15" width="3" height="5" fill="#e0a92b"/>
<rect x="6" y="25" width="20" height="3" fill="#8a5a2b"/>
</svg>

Before

Width:  |  Height:  |  Size: 505 B

-163
View File
@@ -1,163 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#1d1136" />
<title>Games · jcoffey.dev</title>
<meta
name="description"
content="Small browser games. No accounts, no tracking, no installs — just open one and play."
/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<style>
:root {
--ink: #241640;
--cream: #fff6e6;
--lemon: #ffd93d;
--pink: #ff5d8f;
--teal: #2ec4b6;
--dim: #b9a9e0;
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100dvh;
padding: clamp(24px, 6vw, 72px) clamp(16px, 5vw, 40px);
background:
radial-gradient(90% 60% at 15% 0%, rgba(255, 93, 143, 0.28) 0%, transparent 60%),
radial-gradient(80% 60% at 90% 15%, rgba(46, 196, 182, 0.24) 0%, transparent 55%),
radial-gradient(120% 90% at 50% 100%, #3a2168 0%, #1d1136 55%, #0e0820 100%);
background-attachment: fixed;
color: var(--cream);
font-family: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, sans-serif;
display: flex;
justify-content: center;
}
main { width: min(760px, 100%); }
header { margin-bottom: clamp(28px, 6vw, 48px); }
h1 {
margin: 0;
font-size: clamp(2.4rem, 9vw, 4rem);
font-weight: 900;
letter-spacing: -0.03em;
line-height: 0.95;
color: var(--lemon);
-webkit-text-stroke: 3px var(--ink);
paint-order: stroke fill;
text-shadow: 6px 6px 0 var(--ink);
}
header p {
margin: 1.1rem 0 0;
max-width: 44ch;
color: var(--dim);
font-size: clamp(1rem, 2.6vw, 1.15rem);
line-height: 1.55;
}
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 20px; }
.game {
display: block;
padding: clamp(18px, 4vw, 28px);
border: 3px solid var(--ink);
border-radius: 22px;
background: linear-gradient(165deg, #5a3a9e 0%, #3c2470 100%);
box-shadow: 7px 7px 0 var(--ink);
color: inherit;
text-decoration: none;
transition: transform 120ms ease, box-shadow 120ms ease;
}
.game:hover, .game:focus-visible {
transform: translate(-3px, -3px);
box-shadow: 10px 10px 0 var(--ink);
outline: none;
}
.game:active { transform: translate(3px, 3px); box-shadow: 3px 3px 0 var(--ink); }
.game h2 {
margin: 0;
font-size: clamp(1.5rem, 4.5vw, 2rem);
font-weight: 900;
letter-spacing: -0.02em;
}
.game p { margin: 0.6rem 0 0; color: var(--dim); line-height: 1.55; max-width: 52ch; }
.tags { margin-top: 1rem; display: flex; flex-wrap: wrap; gap: 8px; }
.tags span {
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.28em 0.8em;
border-radius: 999px;
border: 2px solid var(--ink);
background: var(--lemon);
color: var(--ink);
}
.tags span.alt { background: var(--teal); }
.tags span.alt2 { background: var(--pink); color: var(--cream); }
footer {
margin-top: clamp(36px, 8vw, 64px);
color: var(--dim);
font-size: 0.9rem;
line-height: 1.7;
}
footer a { color: var(--lemon); }
@media (prefers-reduced-motion: reduce) {
.game { transition: none; }
}
</style>
</head>
<body>
<main>
<header>
<h1>Games</h1>
<p>
Small browser games. Nothing to install, no account to make, and no
tracking — open one and play.
</p>
</header>
<ul>
<li>
<a class="game" href="/lemonade/">
<h2>Lemonade Stand</h2>
<p>
The 1979 business sim, rebuilt. Read the weather, buy your glasses,
price the jug and hope it doesn't rain. Play it as the Atari left
it, or in a cel-shaded remaster with a funk band — same rules
either way.
</p>
<div class="tags">
<span>1&ndash;4 players</span>
<span class="alt">Leaderboard</span>
<span class="alt2">Remastered</span>
</div>
</a>
</li>
</ul>
<footer>
<p>
Free software, AGPL-3.0-or-later. Built by John Coffey.
</p>
</footer>
</main>
</body>
</html>
+7
View File
@@ -752,6 +752,13 @@ html[data-skin='modern'] .btn-ghost:active:not(:disabled) {
box-shadow: none;
}
/* The source link is an anchor, not a button, but wears the same clothes. */
a.btn {
text-decoration: none;
display: inline-flex;
align-items: center;
}
.controls {
display: flex;
flex-wrap: wrap;
+15
View File
@@ -22,6 +22,12 @@ import { SetupScreen } from './components/SetupScreen'
import { TitleScreen } from './components/TitleScreen'
import './App.css'
/**
* AGPL section 13: anyone playing this over a network is entitled to the
* source of the version they are playing, so the offer sits on every screen.
*/
const SOURCE_URL = 'https://github.com/Coffey-Labs/lemonade'
export default function App() {
const [seed] = useState(randomSeed)
const [state, dispatch] = useReducer(reducer, seed, initialState)
@@ -241,6 +247,15 @@ export default function App() {
<Btn kind="ghost" onClick={restart} title="Abandon this run">
NEW GAME
</Btn>
<a
className="btn btn-ghost"
href={SOURCE_URL}
target="_blank"
rel="noreferrer noopener"
title="Free software, AGPL-3.0-or-later"
>
SOURCE
</a>
<span className="seed">SEED {state.seed}</span>
</div>
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
// The game is served under a path on games.jcoffey.dev, so asset URLs have
// to be built for it. Local development stays at the root.
// Asset URLs are baked in at build time, so this has to match the path the
// game is served from. Local development stays at the root.
base: process.env.BASE_PATH ?? '/',
plugins: [react()],
server: {