Compare commits

...
2 Commits
Author SHA1 Message Date
Eric WagonerandClaude Opus 5.5 bc5f93c2fa The kit sets games up on the shared hall host
setup-droplet.sh prepares a host several games share and adds this game
to it: its own Caddy site file, port, user, trees and access log, with
/etc/caddy/Caddyfile only importing sites/*.caddy. setup-server.sh is
folded in. A slug that is already a system group stops the setup with a
clear word. new-game.sh now stamps .py files too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-25 16:49:03 -04:00
Eric WagonerandClaude Opus 5.5 cd9d14ec35 The voice: every game speaks in its own world
Conventions gain a section on in-game language: an image with every
fact, the fact exact, unfolding effects told a turn at a time and naming
the turn they end on, our own words rather than an older version's, and
chronicle wording shipped without a rules revision. new-game.sh points
at it, and the demo game's lines follow it. Born in Waving Hands'
atmospheric chronicle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-09-24 23:57:59 -04:00
10 changed files with 119 additions and 81 deletions

No files matched your search

+23
View File
@@ -158,6 +158,29 @@ emoji in the interface; the words do the work. Phone width first: a sticky
bar for the move if the board runs long, lists that shorten, gutters of bar for the move if the board runs long, lists that shorten, gutters of
16px, no horizontal scroll. 16px, no horizontal scroll.
## The voice
Every game speaks in its own world. Wherever the page tells a player what
happened, it gives an image as well as the fact: not "Corwin takes 5 from
a fireball" but "A fireball bursts over Corwin, for 5". The fact stays
exact and whole (the number, the duration, who is affected, the turn a
thing will happen), and the image carries it.
- **The chronicle** is where the voice matters most. Each move, blow,
enchantment, arrival and death gets its own picture. Something that
unfolds over turns (an illness, a siege, a fuse) is told a turn at a
time, names the turn it ends on when it begins, and warns on the last
turn anything can be done about it.
- **The rest of the surround** speaks the same way: the lobby, the guide,
the status lines, the verdict. Plain words for controls and buttons;
the images belong to what happens, not to what you press.
- **Our own words.** An older implementation's prose is a guide to the
tone, never a source to copy.
- **Changing text is not changing rules.** Chronicle wording is not part
of the ledger, so it ships without a rules revision; the deploy gate
compares outcomes, not sentences. A test that pins a phrase should pin
the fact inside it.
## Things learned the hard way ## Things learned the hard way
- Caddy's `try_files` rewrites `/api` before `reverse_proxy` unless the - Caddy's `try_files` rewrites `/api` before `reverse_proxy` unless the
+8 -6
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Stamp out a new game from the template. # Stamp out a new game from the template.
# ./new-game.sh <slug> "<Name>" [port] [domain] # ./new-game.sh <slug> "<Name>" [port] [domain]
# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8789 tumbleweed.kestrelsnest.social # e.g. ./new-game.sh tumbleweed "Tumbleweed" 8791 tumbleweed.kestrelsnest.social
# Ports so far: wizwar 8787, waving-hands 8788. Each game is its own droplet, # Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790. The
# so the port only has to be unique on your machine for running two locally. # games share one host (deploy/README.md), so a new game takes the next free port.
set -euo pipefail set -euo pipefail
SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}" SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
NAME="${2:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}" NAME="${2:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
@@ -20,7 +20,7 @@ find "$DEST" -depth -name '*__SLUG__*' | while read -r f; do
mv "$f" "$(dirname "$f")/$(basename "$f" | sed "s/__SLUG__/$SLUG/g")" mv "$f" "$(dirname "$f")/$(basename "$f" | sed "s/__SLUG__/$SLUG/g")"
done done
# Then the placeholders inside. __IP__ and the Sentry ids stay until they exist. # Then the placeholders inside. __IP__ and the Sentry ids stay until they exist.
find "$DEST" -type f \( -name '*.ts' -o -name '*.svelte' -o -name '*.json' -o -name '*.md' -o -name '*.sh' -o -name '*.tmpl' -o -name '*.service' -o -name '*.cron' -o -name '*.html' -o -name '*.css' -o -name '.gitignore' \) -print0 \ find "$DEST" -type f \( -name '*.ts' -o -name '*.svelte' -o -name '*.json' -o -name '*.md' -o -name '*.sh' -o -name '*.py' -o -name '*.tmpl' -o -name '*.service' -o -name '*.cron' -o -name '*.html' -o -name '*.css' -o -name '.gitignore' \) -print0 \
| xargs -0 sed -i '' -e "s/__SLUG__/$SLUG/g" -e "s/__NAME__/$NAME/g" -e "s/__PORT__/$PORT/g" -e "s/__DOMAIN__/$DOMAIN/g" | xargs -0 sed -i '' -e "s/__SLUG__/$SLUG/g" -e "s/__NAME__/$NAME/g" -e "s/__PORT__/$PORT/g" -e "s/__DOMAIN__/$DOMAIN/g"
cp "$KIT/CONVENTIONS.md" "$DEST/docs-conventions.md" 2>/dev/null || true cp "$KIT/CONVENTIONS.md" "$DEST/docs-conventions.md" 2>/dev/null || true
mkdir -p "$DEST/docs" && mv "$DEST/docs-conventions.md" "$DEST/docs/conventions.md" mkdir -p "$DEST/docs" && mv "$DEST/docs-conventions.md" "$DEST/docs/conventions.md"
@@ -37,11 +37,13 @@ Next:
and src/lib/components/Board.svelte with its board. Keep the tests green. and src/lib/components/Board.svelte with its board. Keep the tests green.
2. Fill in the hall's pitch, tag and about panel (src/lib/components/Hall.svelte), 2. Fill in the hall's pitch, tag and about panel (src/lib/components/Hall.svelte),
the rules page, the guide, src/app.html's description, and the palette in src/app.css. the rules page, the guide, src/app.html's description, and the palette in src/app.css.
Write every line a player reads in the game's own voice: an image with each
fact (docs/conventions.md, "The voice").
Add static/og.png (1200 by 630) and static/apple-touch-icon.png (180 by 180): Add static/og.png (1200 by 630) and static/apple-touch-icon.png (180 by 180):
app.html already points at them, and the static fallback serves the hall's app.html already points at them, and the static fallback serves the hall's
HTML for a missing file, so a share card without the image breaks quietly. HTML for a missing file, so a share card without the image breaks quietly.
3. Create the droplet (deploy/README.md), then replace __IP__ everywhere: 3. Add the game to the shared host (deploy/README.md), after replacing __IP__ with its address:
grep -rl __IP__ . --exclude-dir=node_modules | xargs sed -i '' 's/__IP__/<ip>/g' grep -rl __IP__ . --exclude-dir=node_modules | xargs sed -i '' 's/__IP__/159.203.115.252/g'
4. Create a Sentry project (the Sentry MCP can) and put its DSN and project id 4. Create a Sentry project (the Sentry MCP can) and put its DSN and project id
in deploy/$SLUG.service and deploy/sentry-slack-alert.sh. in deploy/$SLUG.service and deploy/sentry-slack-alert.sh.
5. deploy/deploy.sh <ip> 5. deploy/deploy.sh <ip>
+3 -3
View File
@@ -1,5 +1,4 @@
__HOST__ __HOST__ {
root * /opt/__SLUG__/build root * /opt/__SLUG__/build
encode gzip zstd encode gzip zstd
@@ -11,7 +10,7 @@ header {
} }
log { log {
output file /var/lib/caddy/access.log { output file /var/lib/caddy/__SLUG__-access.log {
roll_size 10MiB roll_size 10MiB
roll_keep 30 roll_keep 30
} }
@@ -40,3 +39,4 @@ handle {
try_files {path} {path}.html /index.html try_files {path} {path}.html /index.html
file_server file_server
} }
}
+28 -24
View File
@@ -1,22 +1,28 @@
# Deploying __NAME__ # Deploying __NAME__
Every game, against a housecarl or a friend, is a room on a small Node Every game is a room on a small Node process that keeps it as an
process that keeps it as an append-only ledger of moves in append-only ledger of moves in /var/lib/__SLUG__/rooms. Production is the
/var/lib/__SLUG__/rooms. Production is one DigitalOcean droplet: Caddy shared Kestrel's Hall host: Caddy terminates TLS with automatic
terminates TLS with automatic certificates, serves the static build from certificates, serves the static build from /opt/__SLUG__/build, and proxies
/opt/__SLUG__/build, and proxies /api and /ws to the game server on port /api and /ws to the game server on port __PORT__, which runs as the
__PORT__, which runs as the `__SLUG__` user under systemd from `__SLUG__` user under systemd from /opt/__SLUG__/app.
/opt/__SLUG__/app.
## Sizing ## The shared host
The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy The games of Kestrel's Hall share one droplet, `hall` (nyc3, s-1vcpu-1gb,
and the game server comfortably: the server is one small Node process capped $6 a month, tag `kestrels-hall`, IP 159.203.115.252). Each game keeps its
at 300 MB by its unit file, and a room is a few kilobytes of ledger. own Caddy site file in /etc/caddy/sites/<slug>.caddy, its own port,
service user, /opt/<slug> and /var/lib/<slug> trees, access log
(/var/lib/caddy/<slug>-access.log), cron file and ops scripts;
/etc/caddy/Caddyfile only says `import sites/*.caddy`. A game server is one
small Node process capped at 300 MB by its unit file, and a room is a few
kilobytes of ledger, so the host carries several. Each game needs a port
of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789,
dip 8790.
## Current production ## Current production
- Droplet: `__SLUG__` (nyc3, s-1vcpu-512mb-10gb, tag `__SLUG__`), IP __IP__ - Host: __IP__
- URLs: https://__DOMAIN__ (A record at Hover, where kestrelsnest.social's - URLs: https://__DOMAIN__ (A record at Hover, where kestrelsnest.social's
DNS lives) and https://__SLUG__.__IP__.sslip.io (always works, zero DNS). DNS lives) and https://__SLUG__.__IP__.sslip.io (always works, zero DNS).
- Everyday deploy: `deploy/deploy.sh __IP__` - Everyday deploy: `deploy/deploy.sh __IP__`
@@ -24,21 +30,19 @@ at 300 MB by its unit file, and a room is a few kilobytes of ledger.
and the screenshots to ~/Desktop/__SLUG__-reports with a digest; and the screenshots to ~/Desktop/__SLUG__-reports with a digest;
`deploy/report-reply.sh __IP__ <id> <status> "text"` answers one. `deploy/report-reply.sh __IP__ <id> <status> "text"` answers one.
## New droplet from scratch ## Adding this game to the host
1. `doctl compute droplet create __SLUG__ --region nyc3 \ 1. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> \
--size s-1vcpu-512mb-10gb --image ubuntu-24-04-x64 \
--ssh-keys <your-key-ids> --tag-name __SLUG__ --wait`
2. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> \
"bash /root/setup-droplet.sh '__DOMAIN__, __SLUG__.<ip>.sslip.io'"` "bash /root/setup-droplet.sh '__DOMAIN__, __SLUG__.<ip>.sslip.io'"`
(point the A record at the new IP first, or leave the real name out until it is). It installs Caddy and Node if the host lacks them, writes this game's
3. `scp deploy/setup-server.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> "bash /root/setup-server.sh"` site file, and touches nothing of another game's. Point the A record at
4. `deploy/deploy.sh <ip>` the host first, or leave the real name out until it is; running it again
with the new line is how a name is added later.
2. `deploy/deploy.sh <ip>`
The sslip.io hostname works with no DNS at all. To add a real name, point an A fresh host, when one is needed: `doctl compute droplet create hall --region nyc3 \
A record at the droplet and add the name to the first line of --size s-1vcpu-1gb --image ubuntu-24-04-x64 --ssh-keys <your-key-ids> \
/etc/caddy/Caddyfile (space separated), then `systemctl reload caddy`; Caddy --tag-name kestrels-hall --wait`, then step 1.
fetches the certificate on first request.
## Operations scripts on the droplet ## Operations scripts on the droplet
+1 -1
View File
@@ -33,4 +33,4 @@ else
fi fi
echo "--- box" echo "--- box"
echo "disk: $(df -h / | awk 'NR==2{print $5" used of "$2}'); memory: $(free -m | awk 'NR==2{print $7" MB available of "$2}'); load: $(cut -d' ' -f1-3 /proc/loadavg)" echo "disk: $(df -h / | awk 'NR==2{print $5" used of "$2}'); memory: $(free -m | awk 'NR==2{print $7" MB available of "$2}'); load: $(cut -d' ' -f1-3 /proc/loadavg)"
echo "caddy access log: $(du -sh /var/lib/caddy/access.log 2>/dev/null | cut -f1) live, $(ls /var/lib/caddy/access*.log* 2>/dev/null | wc -l) files" echo "caddy access log: $(du -sh /var/lib/caddy/__SLUG__-access.log 2>/dev/null | cut -f1) live, $(ls /var/lib/caddy/__SLUG__-access*.log* 2>/dev/null | wc -l) files"
+51 -18
View File
@@ -1,36 +1,69 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# One-time droplet setup. Copy this script and Caddyfile.tmpl to the droplet # Prepare a host for __NAME__. The host may be shared with other games of
# and run ON the droplet as root: # Kestrel's Hall: each keeps its own Caddy site file in /etc/caddy/sites, its
# bash setup-droplet.sh '__DOMAIN__, __SLUG__.<droplet-ip>.sslip.io' # own port, service user, data directory and access log, and this script
# The argument is the Caddy site address line: one name, or several # touches nothing that belongs to another game. Copy it and Caddyfile.tmpl
# separated by commas. Every name must already resolve to this droplet. # to the host and run ON it as root; safe to run again.
# Caddy serves the built files and terminates TLS; setup-server.sh adds the # bash setup-droplet.sh '__DOMAIN__, __SLUG__.<host-ip>.sslip.io'
# game server behind it. # The argument is this game's Caddy site address line: one name, or several
# separated by commas. Every name must already resolve to this host.
set -euo pipefail set -euo pipefail
HOST="${1:?usage: setup-droplet.sh <hostname>}" HOST="${1:?usage: setup-droplet.sh <site address line>}"
apt-get update -q apt-get update -q
apt-get install -qy curl rsync apt-get install -qy curl rsync
# Caddy (auto-HTTPS) # Caddy (auto-HTTPS), once per host.
if ! command -v caddy >/dev/null; then
apt-get install -qy debian-keyring debian-archive-keyring apt-transport-https apt-get install -qy debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| tee /etc/apt/sources.list.d/caddy-stable.list | tee /etc/apt/sources.list.d/caddy-stable.list
apt-get update -q && apt-get install -qy caddy apt-get update -q && apt-get install -qy caddy
fi
# Site directory, readable by Caddy. # Node 22, once per host; every game runs its server on it.
mkdir -p /opt/__SLUG__/build if ! command -v node >/dev/null || [[ "$(node -v)" != v22* ]]; then
chown -R root:caddy /opt/__SLUG__ curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
chmod -R g+rX /opt/__SLUG__ apt-get install -qy nodejs
fi
# The host's Caddyfile only gathers the games' site files. A Caddyfile that
# is neither Caddy's stock one nor this is a single-game host from before the
# shared layout: stop rather than overwrite it.
ROOT_CADDY='# Kestrel'"'"'s Hall: every game on this host brings its own site file.
import sites/*.caddy'
mkdir -p /etc/caddy/sites
if ! grep -q '^import sites/\*.caddy' /etc/caddy/Caddyfile 2>/dev/null; then
if [ -s /etc/caddy/Caddyfile ] && ! grep -q 'root \* /usr/share/caddy' /etc/caddy/Caddyfile; then
echo "/etc/caddy/Caddyfile holds another site; move it into /etc/caddy/sites/ first" >&2
exit 1
fi
printf '%s\n' "$ROOT_CADDY" > /etc/caddy/Caddyfile
fi
# This game: its user, its directories, its site.
# The service user shares the slug. A slug that is already a system group
# (Ubuntu ships "dip", "games", "audio" and others) cannot also be a user:
# stop and say so, and choose another user name in this script and the unit.
if ! id -u __SLUG__ &>/dev/null; then
if getent group __SLUG__ >/dev/null; then
echo "a system group is already called __SLUG__; pick another service user name" >&2
exit 1
fi
useradd -r -m -d /opt/__SLUG__-home __SLUG__
fi
mkdir -p /opt/__SLUG__/build /opt/__SLUG__/app /var/lib/__SLUG__/rooms /var/log/__SLUG__
chown -R root:caddy /opt/__SLUG__/build
chmod -R g+rX /opt/__SLUG__/build
chown -R __SLUG__:__SLUG__ /var/lib/__SLUG__
sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/sites/__SLUG__.caddy
# Caddy: the site and the game server's paths, from the template beside this script.
sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile caddy validate --config /etc/caddy/Caddyfile
# Validation as root touches the access log; Caddy runs as caddy and must own it. # Validation as root touches the access logs; Caddy runs as caddy and must own them.
chown -R caddy:caddy /var/lib/caddy chown -R caddy:caddy /var/lib/caddy
systemctl restart caddy systemctl reload-or-restart caddy
# Firewall: ssh + web only. # Firewall: ssh + web only.
ufw allow OpenSSH ufw allow OpenSSH
@@ -38,4 +71,4 @@ ufw allow 80/tcp
ufw allow 443/tcp ufw allow 443/tcp
ufw --force enable ufw --force enable
echo "droplet ready: now run deploy/deploy.sh <ip> from your machine" echo "host ready for __SLUG__: now run deploy/deploy.sh <ip> from your machine"
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
# Adds the game server to a droplet that setup-droplet.sh already prepared.
# Copy this script and Caddyfile.tmpl to the droplet and run ON the droplet
# as root; safe to run again. Installs Node 22, creates the service user and
# data directory, and teaches Caddy to hand /api and /ws to the server while
# it keeps serving the static site itself.
set -euo pipefail
if ! command -v node >/dev/null || [[ "$(node -v)" != v22* ]]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -qy nodejs
fi
id -u __SLUG__ &>/dev/null || useradd -r -m -d /opt/__SLUG__-home __SLUG__
mkdir -p /opt/__SLUG__/app /var/lib/__SLUG__/rooms
chown -R __SLUG__:__SLUG__ /var/lib/__SLUG__
# Caddy: rewrite the site's configuration from the template, keeping its
# address line, so the game server's paths are routed before the files.
HOST_LINE=$(head -1 /etc/caddy/Caddyfile)
sed "s|__HOST__|$HOST_LINE|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
echo "server prerequisites ready: now run deploy/deploy.sh <ip> from your machine"
+2 -1
View File
@@ -12,7 +12,8 @@ from collections import Counter
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I) BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"} PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"}
SITE = "__DOMAIN__" SITE = "__DOMAIN__"
LOGS = "/var/lib/caddy/access*.log*" # Each game on a shared host keeps its own access log.
LOGS = "/var/lib/caddy/__SLUG__-access*.log*"
def scan(day, logs=LOGS): def scan(day, logs=LOGS):
+2 -2
View File
@@ -56,12 +56,12 @@
{:else if room.spectating} {:else if room.spectating}
<section class="move"> <section class="move">
<h2>You watch from the Peanut Gallery</h2> <h2>You watch from the Peanut Gallery</h2>
<p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'is' : 'are'} still choosing.` : 'The round is being written.'}</p> <p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'turns a card over and over, still choosing.' : 'turn their cards over and over, still choosing.'}` : 'Cards are being chosen, face down.'}</p>
</section> </section>
{:else} {:else}
<section class="move"> <section class="move">
<h2>Round {g.rounds.length + 1}: name a number</h2> <h2>Round {g.rounds.length + 1}: name a number</h2>
<p class="muted">The highest number named by exactly one player scores. First to {TARGET}.</p> <p class="muted">Every player lays a card face down; the highest card nobody else laid takes the point. First to {TARGET}.</p>
<div class="picks"> <div class="picks">
{#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)} {#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)}
<button type="button" class="pick" class:chosen={pick === n} disabled={room.moved} onclick={() => (pick = n)}>{n}</button> <button type="button" class="pick" class:chosen={pick === n} disabled={room.moved} onclick={() => (pick = n)}>{n}</button>
+1 -1
View File
@@ -68,7 +68,7 @@ export function resolveRound(previous: State, inputs: Record<SeatId, Input>): St
if (scorer) state.players[scorer].points += 1; if (scorer) state.players[scorer].points += 1;
state.rounds.push({ picks, scorer }); state.rounds.push({ picks, scorer });
if (scorer && state.players[scorer].points >= TARGET) { if (scorer && state.players[scorer].points >= TARGET) {
state.over = { winner: scorer, reason: `${state.players[scorer].name} reached ${TARGET} points.` }; state.over = { winner: scorer, reason: `${state.players[scorer].name} lays down the last card and sweeps the table: ${TARGET} points, and the game.` };
} }
return state; return state;
} }