Public development pipeline
How Felucca Online is developed in the open (decision #78, built in #81): the website, the changelog, the blog with Discord comments, the docs, the roadmap board, the Discord bot and the media pipeline. The repository stays private; the site publishes its content.
repo (private) Vercel (felucca.online) Discord
────────────── ────────────────────── ───────
changes/*.md ─┐ /changelog (weekly, no comments)
blog/*.md ────┤ merge to main ─────► /blog, /blog/<slug> ◄── comments ──── forum thread per post
docs/*.md ────┤ (Vercel build) /docs, /decisions ▲
tasks/*.md ───┤ /roadmap │ replies
roadmap.yaml ─┘ /rss.xml, /feed.json ──┐ │
/api/revalidate ◄──────┼──── signed ───── bot (VPS)
media originals ── sharp ──► R2 ◄───── next/image loader │ │
(outside git) variants (media-manifest.json) └──── read ───────► posts #changes,
creates threads
Where things live
| Path | What |
|---|---|
web/ | Next.js App Router site (static pages + revalidateTag). |
web/velite.config.ts | Content schemas; compiles the markdown below into typed data at build. |
changes/ | Changelog fragments, one per PR. |
blog/ | Blog posts. |
docs/*.md | Design docs, published as /docs/<name>. |
CLAUDE.md | The decision log table, published as /decisions (until docs/decisions/ exists). |
roadmap.yaml, tasks/ | The kanban: columns, milestones, areas; one file per task. |
bot/ | Discord Gateway bot for the VPS. |
packages/devlog-core/ | Code shared by site and bot: feed types, request signing, slug → thread store. |
web/media.config.json, web/media-manifest.json | Media sources and the generated manifest. |
web/scripts/media/ | Variant generator and R2 uploader. |
.github/workflows/ | CI: content checks, site build, fragment rule, digest draft. |
The JS side is one npm workspace at the repo root (package.json): npm install once, then
npm run dev (site), npm test (all packages), npm run build.
Changelog fragments
Every change gets one (#82): each PR, and each direct commit to main, in any area, adds
one file changes/<yyyy-mm-dd>-<slug>.md (date = when it merges, roughly), written with the
change. The only exception is an edit of the devlog content itself (changes/, blog/,
tasks/, roadmap.yaml). CI fails any other PR without a fragment. Template and fields:
changes/README.md.
type:feature,fix,perf,art,docs, ordev(shown as Internal) for changes players do not see: tooling, CI, infrastructure, the site's internals.area: one or more area ids fromroadmap.yaml.decision: the CLAUDE.md decision this PR introduces or implements, if any.summary: the player-facing line. Written for someone who plays, not for us: what they will see or feel. No crate names, no jargon. ≤ 280 characters. Quote it if it contains:.- Body: the developer detail (what, why, numbers, caveats).
tasks,media,pr,draftare optional.
/changelog groups fragments by ISO week (Monday first), newest first, with the detail folded
under each entry. There are no comments on the changelog. After each production deploy the bot
posts every new fragment's summary to #changes as a plain message.
Blog posts
blog/<yyyy-mm-dd>-<slug>.md, URL /blog/<slug>. Template: blog/README.md. Kinds:
feature: required for every PR whose fragment hastype: feature(CI checks it).design-decision: required for every PR that adds a game-design decision to CLAUDE.md, i.e. a fragment with adecisionfield. One post may cover both.digest: compiled from recent fragments by the scheduled workflow (see Digest drafts).release: playtests and builds, later.
The LLM writes the post in the same PR as the change. Review happens in the PR; merging
publishes it. draft: true holds a post back after merge (it still renders in next dev).
Write for players: short paragraphs, real captures (), no invented dates
or promises. Link fragments (changes:), decisions (decisions:) and tasks (tasks:) so the
page can show what the post covers.
Docs and decisions
docs/*.md are published as they are: the first # Heading is the title, relative links to
other docs become /docs/..., links to source files become plain text (the repo is private),
and images under docs/screenshots/ come from the media pipeline. #NN in prose links to
/decisions#dNN, which renders the CLAUDE.md decision log table.
Roadmap board
roadmap.yaml defines columns, milestones and areas; each task is tasks/<id>.md
(template in tasks/README.md). One file per task means parallel sessions never conflict on
the board. A PR that starts, finishes or blocks a task updates its status (and updated) in
the same PR. The build fails on a status that is not a column, an unknown milestone or area, or
links to fragments, posts, docs or decisions that do not exist.
/roadmap renders the columns with their cards, filterable by milestone (?milestone=<id>),
and /roadmap/<id> shows one task.
Feeds
/rss.xml: RSS 2.0 of blog posts./feed.json: JSON Feed 1.1 of blog posts and changelog fragments, newest first. Each item has a_feluccaextension (type: post | change, slug, kind or change type, areas, decision). This is the bot's input; the types are inpackages/devlog-core/src/feed.ts.
Drafts never appear in production feeds or listings.
Comments from Discord
Comments exist only on blog posts. Each post is mirrored as a forum post in the Discord forum channel; the replies in that thread are rendered under the post.
- The page looks up the thread id for its slug in the thread store
(
packages/devlog-core/src/threads.ts): Upstash Redis REST / Vercel KV whenKV_REST_API_URL+KV_REST_API_TOKEN(orUPSTASH_REDIS_REST_*) are set, else the JSON fileDISCORD_THREADS_FILE(defaultweb/.data/discord-threads.json). The lookup is afetchcached forever with tagdiscord:slug:<slug>. - The thread's messages are fetched from the Discord API with
cache: 'force-cache'and tagdiscord:thread:<threadId>, so the page stays static until that tag is revalidated. The starter message (the bot's mirror post) is skipped; bots and system messages are hidden. - Message text is rendered by our own Discord-markdown parser to React elements
(
web/src/lib/discord/markdown.ts): bold, italics, underline, strike, spoilers, inline and block code, quotes, headings, lists, masked links (http/https only), mentions, channels, custom emoji and timestamps. No raw HTML is ever injected. Avatars use Discord's CDN with?size=. POST /api/revalidatetakes{"tags": ["discord:thread:<id>", ...]}with headerx-felucca-signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>. It rejects bad or stale (> 5 min) signatures and any tag outsidediscord:*, then callsrevalidateTag(tag, { expire: 0 })so the very next view is fresh. Secret:REVALIDATE_SECRET, shared with the bot.- Cron fallback: Vercel cron calls
GET /api/cron/revalidate-commentsdaily (Hobby allows one run a day; raise it on Pro). It revalidates every known thread, in case the bot missed events. Protected byCRON_SECRET. - Channel notice: every forum post the bot creates ends with a line saying that replies appear on the website; set the same text as the forum channel's guidelines.
Locally without Discord, DISCORD_API_BASE can point to a mock (see Local testing).
The Discord bot
bot/, TypeScript on Node 22 with discord.js. Why TypeScript and not Rust like the game: it
shares the feed types, request signing and thread store with the site
(packages/devlog-core), discord.js is the most complete Gateway library, and the bot is
I/O glue that never touches game code. It runs in ≈ 60–80 MB of RAM on any small VPS.
What it does:
- Listens for the Vercel
deployment.succeededwebhook (POST /webhooks/vercel, verified withx-vercel-signature, HMAC-SHA1 of the raw body with the webhook secret). Only production deployments count. - Reads
<SITE_URL>/feed.jsonand compares it with its state file (announced ids):- each new change → a plain message in
#changes(summary + link, embeds suppressed); - each new post → a forum post in the forum channel (title, summary, link, notice), then
stores slug → thread id in the thread store and revalidates
discord:slug:<slug>. On the first run (empty state) everything older thanANNOUNCE_SINCEis marked as announced without posting, so a fresh bot does not flood the channels.
- each new change → a plain message in
- On Gateway
MESSAGE_CREATE,MESSAGE_UPDATE,MESSAGE_DELETE(and bulk delete, thread delete) in a known thread, revalidatesdiscord:thread:<id>through/api/revalidate, debounced per thread (1.5 s).
Intents: Guilds, Guild Messages and Message Content. Message Content is privileged (switch
it on in the Developer Portal → Bot). The bot itself only needs to know that a thread changed,
but the site reads the comments through the REST API with the same bot token, and Discord
leaves content empty, on REST as on the Gateway, for apps without the intent. Permissions in
the forum and #changes: View Channel, Send Messages, Send Messages in Threads, Create Posts
(Create Public Threads), Read Message History.
Configuration: bot/.env.example. Deploy: bot/deploy/README.md (Docker or systemd).
Media
Images and video are served from Cloudflare R2 under content-hash names with
Cache-Control: public, max-age=31536000, immutable; originals stay outside git.
- Put originals in
web/.media/originals/(or pointMEDIA_ORIGINALSat them). Screenshots already in git underdocs/screenshots/are a second source. A generated image keeps its provenance in a sidecar<file>.json({"prompt", "createdAt"}), copied into the manifest. npm run media:variants -w webbuilds variants intoweb/public/media/(the local bucket) and writesweb/media-manifest.json(committed): per key the class, size, variants (width, height, file, bytes),blurDataURLand provenance. Unchanged sources are skipped. An entry whose original is missing is kept with a warning;-- --pruneremoves it (unpublishing it).npm run media:upload -w webuploads every file the bucket does not have yet (--dry-runto list them). Env:R2_ACCOUNT_ID,R2_BUCKET,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY;S3_ENDPOINTfor another S3 endpoint,MEDIA_BUCKET_DIRfor a directory instead of a bucket.
Classes (web/media.config.json, first matching rule wins):
| Class | For | Variants |
|---|---|---|
pixel (default) | pixel art, client captures | whole-number downscales 1/1, 1/2 … 1/8 (≥ 160 px), nearest-neighbour, WebP |
logo | pixel-art logos shipped as 1:1 masters (logo*-1x.png) | whole-number upscales ×1 … ×8, nearest-neighbour, PNG |
art | painted art, photos (hero*, night*, other logos) | 480 … 3840 px, Lanczos, WebP |
file | video, PDF | copied as is |
A web-ready original (WebP, or a logo PNG) is published byte for byte as its own 1/1 variant, so pages show exactly the master.
next/image uses a custom loader (web/src/lib/media/loader.ts, no Vercel Image
Optimization):
- a manifest key → the nearest pre-built variant: the smallest at least as wide as requested
(the browser only shrinks it); for logos the largest whole-number upscale not wider than
requested, so with
image-rendering: pixelatedevery scale on screen stays an integer; https://cdn.discordapp.com/...→ Discord's own resizing with?size=(power of two);- any other absolute URL → Cloudflare Image Transformations
(
https://<NEXT_PUBLIC_CF_IMAGE_ZONE>/cdn-cgi/image/width=…,quality=…,format=auto/<url>) when a zone is configured, else unchanged.
Pages use the server component Img (web/src/components/img.tsx), which renders a plain
<img> from getImageProps, so no image code ships to the browser. In markdown,
 is resolved at build into a responsive <img> (or <video> for files);
an unknown key fails the build. NEXT_PUBLIC_MEDIA_BASE is the public URL of the bucket
(default /media, i.e. web/public/media locally).
Builds and deploys
- Vercel project with Root Directory
web/; install and build run from the workspace. - Ignored build step:
bash scripts/vercel-ignore-build.sh(inweb/). It skips the build unless the push changesweb/,packages/,changes/,blog/,docs/,tasks/,roadmap.yaml,CLAUDE.mdor the rootpackage*.json. A redeploy of the deployed commit (e.g. after changing env vars, whichNEXT_PUBLIC_*bakes into the build) always builds. Production deploys come frommainonly (vercel.json); PR previews build when site files change. - Env vars:
web/.env.example. - Nothing is deployed, uploaded or posted by any script in this repo without someone running it.
CI
.github/workflows/site.yml: on PRs and pushes touching the site or content: install, unit tests (site, bot, core), content validation,next build..github/workflows/pr-rules.yml: on PRs,.github/scripts/check-pr-content.mjs:- every PR requires a new
changes/*.md, unless it only editschanges/,blog/,tasks/orroadmap.yaml; - a new fragment with
type: featureor adecisionrequires a newblog/*.md.
- every PR requires a new
.github/workflows/digest.yml: see below.
Digest drafts
.github/workflows/digest.yml runs weekly (Monday 07:00 UTC) and on demand. It uses
anthropics/claude-code-action to read the
fragments since the last kind: digest post and open a PR adding a digest post draft. It is a
stub until set up:
- Install the Claude GitHub app on the repository (
claudeCLI:/install-github-app, or https://github.com/apps/claude). - Add the secret
ANTHROPIC_API_KEY(orCLAUDE_CODE_OAUTH_TOKENfromclaude setup-token). - Set the repository variable
DIGEST_ENABLED=true. - Allow Actions to create PRs (Settings → Actions → General → Workflow permissions).
The digest PR is reviewed and merged like any other; merging publishes the post.
Local testing
npm install # once, at the repo root
npm run media:variants -w web # needs the originals; see Media
npm run dev # http://localhost:3000, drafts visible; content edits reload
# (web/scripts/velite-watch.mjs watches only the content paths;
# `velite --watch` watches the whole repo root and hits EMFILE)
npm test # site, bot and shared package
npm run e2e:comments -w web # builds the site, serves a mock Discord thread, checks
# comments render and refresh through /api/revalidate
node --experimental-strip-types web/scripts/compare-home.ts <old-url> <new-url>
# screenshot diff of two versions of the landing page
Waitlist
The playtest waitlist (#99) lives in Kit (kit.com); the site keeps no copy.
- Both forms on the landing page (
web/src/app/(home)/waitlist-form.tsx; a plain form post before the page script loads is accepted too and redirected to/?waitlist=joined|invalid|failed#join, never with the email in a URL) post{email, ref, website}toPOST /api/waitlist.websiteis a honeypot;refcomes from?ref=orutm_sourceon the landing URL and is kept for the visit (sessionStorage). Give every outreach channel its own?ref=(e.g.?ref=reddit-uo) to compare them in Kit. - Consent: the line under the button says joining gets you playtest invites, development
news and offers; submitting is the consent, there is no checkbox (#99: a deliberate choice;
in the EU this is formally "bundling" offers with playtest access, accepted as a small risk).
If that wording changes, change
OPT_IN_TEXT: every subscriber keeps the text they saw. - The route (
web/src/lib/waitlist/handler.ts) creates the Kit subscriber inactive with custom fields, then adds them to the formKIT_FORM_ID; the form's double opt-in sends the confirmation email. Fields:opt_in_at,opt_in_text(the line they saw),source. - The answer carries
{id, token}(HMAC ofid:emailwithWAITLIST_SECRET, valid one hour); the optional questions post them withuo_playedandintereststoPOST /api/waitlist/profile, which updates the subscriber's fields. Then the Discord invite (NEXT_PUBLIC_DISCORD_INVITE) is shown. - Limits: 5 joins per IP per 10 minutes, per serverless instance (best effort; double opt-in is the real guard against signing up strangers).
- In the Kit form's incentive (confirmation) settings, redirect confirmed subscribers to
https://felucca.online/confirmed: a page saying they are on the list, what comes next, the Discord invite and the devlog (not indexed; it drops any query Kit appends). The landing page still understands?waitlist=confirmed. - Setup: create a form in Kit with double opt-in, then
npm run kit:setup -w web(lists form ids, shows missing custom fields) and-- --apply(creates them). SetKIT_API_KEY,KIT_FORM_ID,WAITLIST_SECRETinweb/.env.localand on Vercel. The API key is server-only. What we keep is explained to players on/privacy.
Deploy
Tracked as the task deploy-devlog. Each step needs the owner's accounts and is done by hand or
with explicit approval.
Done (2026-10-20):
- GitHub: private
alerdenisov/felucca,mainpushed. - Vercel project
felucca(rootweb/, files outside the root included, Node 24, linked tomain), env vars set; domainsfelucca.online(production) andwww.felucca.online(308 to it).*.vercel.appdeployments sit behind Vercel Authentication; the custom domain is public. DNS in Cloudflare: apexAto Vercel's address andwwwCNAMEto Vercel, DNS only (grey cloud), so Vercel issues the certificate. - Cloudflare R2 bucket
felucca-mediaonmedia.felucca.online, first upload (612 files, 45 MB). Upload credentials stay inweb/.env.local, not on Vercel.
Left: Discord application, bot, channels and forum guidelines; bot on the VPS; Vercel webhook to the bot.