Skip to content
Felucca Online

Docs

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

PathWhat
web/Next.js App Router site (static pages + revalidateTag).
web/velite.config.tsContent schemas; compiles the markdown below into typed data at build.
changes/Changelog fragments, one per PR.
blog/Blog posts.
docs/*.mdDesign docs, published as /docs/<name>.
CLAUDE.mdThe 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.jsonMedia 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, or dev (shown as Internal) for changes players do not see: tooling, CI, infrastructure, the site's internals.
  • area: one or more area ids from roadmap.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, draft are 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 has type: feature (CI checks it).
  • design-decision: required for every PR that adds a game-design decision to CLAUDE.md, i.e. a fragment with a decision field. 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 (![alt](media:<key>)), 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 _felucca extension (type: post | change, slug, kind or change type, areas, decision). This is the bot's input; the types are in packages/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 when KV_REST_API_URL + KV_REST_API_TOKEN (or UPSTASH_REDIS_REST_*) are set, else the JSON file DISCORD_THREADS_FILE (default web/.data/discord-threads.json). The lookup is a fetch cached forever with tag discord:slug:<slug>.
  • The thread's messages are fetched from the Discord API with cache: 'force-cache' and tag discord: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/revalidate takes {"tags": ["discord:thread:<id>", ...]} with header x-felucca-signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>. It rejects bad or stale (> 5 min) signatures and any tag outside discord:*, then calls revalidateTag(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-comments daily (Hobby allows one run a day; raise it on Pro). It revalidates every known thread, in case the bot missed events. Protected by CRON_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:

  1. Listens for the Vercel deployment.succeeded webhook (POST /webhooks/vercel, verified with x-vercel-signature, HMAC-SHA1 of the raw body with the webhook secret). Only production deployments count.
  2. Reads <SITE_URL>/feed.json and 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 than ANNOUNCE_SINCE is marked as announced without posting, so a fresh bot does not flood the channels.
  3. On Gateway MESSAGE_CREATE, MESSAGE_UPDATE, MESSAGE_DELETE (and bulk delete, thread delete) in a known thread, revalidates discord: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.

  1. Put originals in web/.media/originals/ (or point MEDIA_ORIGINALS at them). Screenshots already in git under docs/screenshots/ are a second source. A generated image keeps its provenance in a sidecar <file>.json ({"prompt", "createdAt"}), copied into the manifest.
  2. npm run media:variants -w web builds variants into web/public/media/ (the local bucket) and writes web/media-manifest.json (committed): per key the class, size, variants (width, height, file, bytes), blurDataURL and provenance. Unchanged sources are skipped. An entry whose original is missing is kept with a warning; -- --prune removes it (unpublishing it).
  3. npm run media:upload -w web uploads every file the bucket does not have yet (--dry-run to list them). Env: R2_ACCOUNT_ID, R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY; S3_ENDPOINT for another S3 endpoint, MEDIA_BUCKET_DIR for a directory instead of a bucket.

Classes (web/media.config.json, first matching rule wins):

ClassForVariants
pixel (default)pixel art, client captureswhole-number downscales 1/1, 1/2 … 1/8 (≥ 160 px), nearest-neighbour, WebP
logopixel-art logos shipped as 1:1 masters (logo*-1x.png)whole-number upscales ×1 … ×8, nearest-neighbour, PNG
artpainted art, photos (hero*, night*, other logos)480 … 3840 px, Lanczos, WebP
filevideo, PDFcopied 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: pixelated every 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, ![alt](media:<key>) 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 (in web/). It skips the build unless the push changes web/, packages/, changes/, blog/, docs/, tasks/, roadmap.yaml, CLAUDE.md or the root package*.json. A redeploy of the deployed commit (e.g. after changing env vars, which NEXT_PUBLIC_* bakes into the build) always builds. Production deploys come from main only (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 edits changes/, blog/, tasks/ or roadmap.yaml;
    • a new fragment with type: feature or a decision requires a new blog/*.md.
  • .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:

  1. Install the Claude GitHub app on the repository (claude CLI: /install-github-app, or https://github.com/apps/claude).
  2. Add the secret ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN from claude setup-token).
  3. Set the repository variable DIGEST_ENABLED=true.
  4. 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} to POST /api/waitlist. website is a honeypot; ref comes from ?ref= or utm_source on 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 form KIT_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 of id:email with WAITLIST_SECRET, valid one hour); the optional questions post them with uo_played and interests to POST /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). Set KIT_API_KEY, KIT_FORM_ID, WAITLIST_SECRET in web/.env.local and 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, main pushed.
  • Vercel project felucca (root web/, files outside the root included, Node 24, linked to main), env vars set; domains felucca.online (production) and www.felucca.online (308 to it). *.vercel.app deployments sit behind Vercel Authentication; the custom domain is public. DNS in Cloudflare: apex A to Vercel's address and www CNAME to Vercel, DNS only (grey cloud), so Vercel issues the certificate.
  • Cloudflare R2 bucket felucca-media on media.felucca.online, first upload (612 files, 45 MB). Upload credentials stay in web/.env.local, not on Vercel.

Left: Discord application, bot, channels and forum guidelines; bot on the VPS; Vercel webhook to the bot.