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
|
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.
|
end. The skin preference is the one thing still kept in the browser.
|
||||||
|
|
||||||
## Deploying
|
## Running it anywhere
|
||||||
|
|
||||||
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 |
|
|
||||||
|
|
||||||
```bash
|
```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
|
Two containers: a static bundle behind nginx, and the leaderboard. Only the
|
||||||
asset paths are baked in at build time, so `BASE_PATH` in the compose file has
|
scores container writes anything — a named volume holding the SQLite database,
|
||||||
to match the path nginx proxies it to. Only the scores container writes
|
which is the one piece of state worth keeping.
|
||||||
anything: a named volume holding the SQLite database, which is the one piece of
|
|
||||||
state that must survive a redeploy.
|
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
|
## 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:
|
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
|
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;
|
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 {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
+15
@@ -22,6 +22,12 @@ import { SetupScreen } from './components/SetupScreen'
|
|||||||
import { TitleScreen } from './components/TitleScreen'
|
import { TitleScreen } from './components/TitleScreen'
|
||||||
import './App.css'
|
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() {
|
export default function App() {
|
||||||
const [seed] = useState(randomSeed)
|
const [seed] = useState(randomSeed)
|
||||||
const [state, dispatch] = useReducer(reducer, seed, initialState)
|
const [state, dispatch] = useReducer(reducer, seed, initialState)
|
||||||
@@ -241,6 +247,15 @@ export default function App() {
|
|||||||
<Btn kind="ghost" onClick={restart} title="Abandon this run">
|
<Btn kind="ghost" onClick={restart} title="Abandon this run">
|
||||||
NEW GAME
|
NEW GAME
|
||||||
</Btn>
|
</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>
|
<span className="seed">SEED {state.seed}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@ import { defineConfig } from 'vite'
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// The game is served under a path on games.jcoffey.dev, so asset URLs have
|
// Asset URLs are baked in at build time, so this has to match the path the
|
||||||
// to be built for it. Local development stays at the root.
|
// game is served from. Local development stays at the root.
|
||||||
base: process.env.BASE_PATH ?? '/',
|
base: process.env.BASE_PATH ?? '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user