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:
@@ -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
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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–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>
|
||||
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user