diff --git a/website/.gitignore b/website/.gitignore
new file mode 100644
index 000000000..14a497406
--- /dev/null
+++ b/website/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+shots/
diff --git a/website/Caddyfile b/website/Caddyfile
new file mode 100644
index 000000000..ebda45386
--- /dev/null
+++ b/website/Caddyfile
@@ -0,0 +1,42 @@
+# Serves dist/ on Railway's $PORT. Fingerprinted CSS and JS are cached for a year,
+# media for a week, and the page itself is always revalidated. The site lives at
+# https://playhorizon.dev: Railway's own *.up.railway.app address redirects there, so the
+# page is indexed once. Railway's healthcheck (Host: healthcheck.railway.app) is still served.
+{
+ admin off
+ auto_https off
+ persist_config off
+}
+
+:{$PORT:8080} {
+ root * /srv
+
+ @railway header_regexp Host ^[^.]+\.up\.railway\.app(:[0-9]+)?$
+ redir @railway https://playhorizon.dev{uri} permanent
+ encode zstd gzip
+
+ header {
+ X-Content-Type-Options nosniff
+ Referrer-Policy strict-origin-when-cross-origin
+ X-Frame-Options DENY
+ Permissions-Policy "interest-cohort=(), browsing-topics=(), camera=(), microphone=(), geolocation=()"
+ Content-Security-Policy "default-src 'self'; script-src 'self' {$CSP_SCRIPT}; style-src 'self' {$CSP_STYLE}; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.github.com https://raw.githubusercontent.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
+ -Server
+ }
+ @assets path /assets/*
+ header @assets Cache-Control "public, max-age=31536000, immutable"
+ @media path /media/* /fonts/* /og.png /favicon* /apple-touch-icon.png
+ header @media Cache-Control "public, max-age=604800"
+ @pages path / /index.html /404.html
+ header @pages Cache-Control "no-cache"
+ @legal path /LEGAL.md /fonts/*.txt
+ header @legal Content-Type "text/plain; charset=utf-8"
+
+ file_server {
+ hide .csp-script .csp-style og.html
+ }
+ handle_errors {
+ rewrite * /404.html
+ file_server
+ }
+}
diff --git a/website/Dockerfile b/website/Dockerfile
new file mode 100644
index 000000000..a5ae2f744
--- /dev/null
+++ b/website/Dockerfile
@@ -0,0 +1,18 @@
+# Railway builds this with the service's root directory set to `website`.
+# Stage 1 renders dist/ from src/ and the README on main (no npm dependencies needed).
+FROM node:22-alpine AS build
+WORKDIR /site
+COPY build.mjs ./
+COPY src ./src
+COPY public ./public
+COPY LEGAL.md ./
+ARG GITHUB_TOKEN
+RUN node build.mjs
+
+# Stage 2 serves it with Caddy on $PORT.
+FROM caddy:2-alpine
+COPY Caddyfile /etc/caddy/Caddyfile
+COPY --from=build /site/dist /srv
+ENV PORT=8080
+# The Content-Security-Policy allows the page's inline script and style by the hashes the build wrote.
+CMD ["sh", "-c", "CSP_SCRIPT=\"$(cat /srv/.csp-script)\" CSP_STYLE=\"$(cat /srv/.csp-style)\" exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile"]
diff --git a/website/LEGAL.md b/website/LEGAL.md
new file mode 100644
index 000000000..28c3dc606
--- /dev/null
+++ b/website/LEGAL.md
@@ -0,0 +1,76 @@
+# The website's assets and their licences
+
+Every file the site serves, where it comes from and the terms it is used under. The build
+copies this file to `/LEGAL.md` on the site.
+
+Horizon is not an official Minecraft product. It is not approved by or associated with
+Mojang or Microsoft. Minecraft is a trademark of Mojang Synergies AB.
+
+## Code
+
+| File | Source | Licence |
+| --- | --- | --- |
+| `src/*.html`, `src/*.css`, `src/*.js`, `build.mjs`, `serve.mjs`, `scripts/*`, `Caddyfile`, `Dockerfile` | Written for this site | MIT, as the rest of Horizon ([LICENSE](https://github.com/ludovic111/horizon/blob/main/LICENSE)) |
+
+The site loads no third-party code at runtime: no frameworks, CDNs, analytics, trackers or
+cookies. The only requests to other hosts are the browser's calls to `api.github.com`
+(releases and patch notes) and `raw.githubusercontent.com` (the README's roadmap and
+`launcher/links.json`), both on Horizon's own repository.
+
+## Fonts
+
+| File | Source | Licence |
+| --- | --- | --- |
+| `public/fonts/Geist-Regular.woff2`, `Geist-Medium.woff2`, `Geist-SemiBold.woff2`, `GeistMono-Regular.woff2` | Geist and Geist Mono, from `crates/horizon-theme/fonts` (converted from TTF to WOFF2) | SIL Open Font License 1.1, shipped as `public/fonts/OFL-Geist.txt` |
+| `public/fonts/InstrumentSerif-Regular.woff2` | Instrument Serif, from `crates/horizon-theme/fonts` (converted from TTF to WOFF2) | SIL Open Font License 1.1, shipped as `public/fonts/OFL-InstrumentSerif.txt` |
+
+The fonts are self-hosted; nothing is fetched from a font service.
+
+## Icons and the social preview
+
+| File | Source | Licence |
+| --- | --- | --- |
+| `public/favicon.svg`, `public/favicon-32.png`, `public/apple-touch-icon.png` | Horizon's logo, `docs/media/logo.svg` (an original drawing of a sunset over blocks) | MIT, Horizon's original work |
+| `public/og.png` | Rendered by `scripts/shots.mjs` from `public/og.html`: the Instrument Serif wordmark over `public/media/hero-1200.webp` | MIT for the layout; the fonts and the hero as listed here |
+
+No Minecraft logo or wordmark appears anywhere on the site, in images or as text styling.
+GitHub and Discord appear only as plain text links, without their logos.
+
+## The hero
+
+| File | Source | Licence |
+| --- | --- | --- |
+| `public/media/hero.webp`, `hero-1200.webp`, `hero-portrait.webp` | `docs/media/sunset.jpg`, darkened slightly and, for phones, cropped by `scripts/assets.mjs` | A screenshot of Horizon's client, as listed under Screenshots |
+
+The grain over the hero is drawn by the stylesheet (an SVG noise filter inlined as a data URI),
+not an image file. The launcher's own background (`launcher/assets/background.jpg`, from
+backgrounds.supply) is **not** used: its licence is unconfirmed. For the same reason the
+launcher's screenshots, which show it, are not on the site; the launcher section shows
+Horizon's own menus instead. No image from backgrounds.supply or prismal is used anywhere.
+
+## Screenshots
+
+All captured in Horizon's Rust client, with vanilla resources prepared from the maintainer's
+own copy of Minecraft: Java Edition 26.3, and no mods or shaders. Converted to WebP at 1600
+and 800 pixels wide by `scripts/assets.mjs`.
+
+| Files in `public/media/` | Source | What it shows |
+| --- | --- | --- |
+| `menu-title*.webp` | `docs/media/menu-title.png` | Horizon's own title screen (no Minecraft logo or splash text) |
+| `menu-worlds*.webp` | `docs/media/menu-worlds.png` | Horizon's own world list |
+| `menu-options*.webp` | `docs/media/menu-options.png` | Horizon's own options screen |
+| `menu-pause*.webp` | `docs/media/menu-pause.png` | Horizon's own pause screen |
+| `settings*.webp` | `docs/media/settings-simple.png` | Horizon's own Video settings, Simple view |
+| `hero*.webp`, `sunset*.webp`, `village*.webp`, `survival*.webp`, `nether*.webp`, `night*.webp`, `underwater*.webp` | `docs/media/*.jpg` | In-game views; none shows a Mojang logo or splash text |
+
+Horizon's interface in these images is Horizon's original work (MIT). The block, mob and
+item textures and the HUD sprites drawn in them belong to Mojang and Microsoft; they are
+shown as screenshots of the game, as the
+[Minecraft Usage Guidelines](https://www.minecraft.net/en-us/usage-guidelines) allow for
+fan websites about the game, with the disclaimer they require. The site offers no game
+files: players need their own legally obtained copy of Minecraft: Java Edition.
+
+## Text
+
+The parity table and the roadmap are generated from Horizon's README, and the patch notes
+come from Horizon's GitHub releases: Horizon's own text, MIT.
diff --git a/website/README.md b/website/README.md
new file mode 100644
index 000000000..7a2e6d319
--- /dev/null
+++ b/website/README.md
@@ -0,0 +1,95 @@
+# Horizon's website
+
+Live at **https://playhorizon.dev**.
+
+One static page in the launcher's design language rather than its layout: the palette and
+violet accent of `crates/horizon-theme`, Instrument Serif for display type and Geist for the
+rest, 1px dividers and short, eased motion. A full-viewport hero over Horizon's own sunset,
+then a scroll through what it is, the screenshots, the parity grid, the launcher, the patch
+notes, the roadmap and the community links. Plain HTML, CSS and JavaScript; no framework and
+no runtime dependencies.
+
+## Layout
+
+```
+build.mjs renders dist/ (no dependencies)
+serve.mjs a tiny static server for local checks
+src/index.html the page, with {{placeholders}} build.mjs fills
+src/404.html the not-found page
+src/style.css the design tokens and every style
+src/main.js download buttons, patch notes, roadmap, links, nav and menu, tabs, lightbox, motion
+src/markdown.js a small Markdown renderer (escapes everything), shared by build and page
+src/releases.js the patch notes' markup, shared by build and page
+public/ fonts, screenshots, the hero, icons, og.png, robots.txt, sitemap.xml: copied as is
+scripts/assets.mjs rebuilds public/ from the repo (fonts to WOFF2, images to WebP)
+scripts/shots.mjs Playwright screenshots at every width, and og.png
+Dockerfile, Caddyfile, railway.json the Railway service
+LEGAL.md every asset, its source and licence
+shots/ (gitignored) full-page screenshots at 360–1920 px, a 1250×700 window, the open phone menu
+```
+
+## What comes from where
+
+- **Built in** (`build.mjs`): the parity table, its score, harness count and audit date,
+ and the roadmap, all from `README.md`; the Discord and Sponsor links from
+ `launcher/links.json`; the latest releases, when GitHub answers. Locally these come from
+ the checkout; on Railway, which builds only this folder, from `main` on GitHub. The build
+ fails if the README has no parity table or roadmap, so a broken README never deploys.
+- **Fetched by the page**: the releases (the download button picks the newest
+ `launcher-v*` release's asset for the visitor's OS, and the three latest game releases'
+ notes are rendered), the roadmap from the raw README on `main`, and `links.json`. Each
+ falls back to what was built in, and the download button to the releases page.
+
+## Run it locally
+
+```sh
+cd website
+node build.mjs && node serve.mjs # http://localhost:4173
+```
+
+Asset and screenshot scripts need the dev dependencies (sharp, wawoff2, Playwright):
+
+```sh
+npm install
+GITHUB_TOKEN=$(gh auth token) node build.mjs # optional token: bakes the releases past the API rate limit
+npm run assets # after a font, screenshot or logo changes in the repo
+npm run shots # shots/*.png (gitignored; SHOTS=dir to write elsewhere) and public/og.png
+```
+
+The screenshots are not committed: full-page PNGs at a dozen widths weigh about 17 MB.
+Running text stays under 80 characters a line; on 360–430 px phones the narrowest paragraphs
+fall to about 26–35, which is what that width allows. `npm run shots` also reports horizontal overflow, tap targets under 44 px on touch widths
+and characters per line, for 360 to 1920 px wide and a 1250×700 window.
+
+## Railway
+
+The service deploys the `website` branch and answers on https://playhorizon.dev. Its
+`*.up.railway.app` address redirects there with a 301 (the Caddyfile), so search engines index
+one URL; the page also names it as canonical, in its Open Graph tags and in `sitemap.xml`.
+Railway's healthcheck reaches `$PORT` under its own host name and is served normally.
+
+Create a service from the GitHub repo with **root directory `website`**. `railway.json`
+selects the Dockerfile: a Node stage runs `build.mjs` (reading the README from `main`), and
+a Caddy stage serves `dist/` on `$PORT` with compression, cache headers, a strict
+Content-Security-Policy and the 404 page. Its watch patterns also redeploy when the README
+or `launcher/links.json` change on main. No variables are needed; an optional
+`GITHUB_TOKEN` (read-only, public repos) lets the build bake the releases even when
+GitHub's anonymous rate limit is hit.
+
+## The hero
+
+`public/media/hero*.webp` is Horizon's sunset over a savanna river valley
+(`docs/media/sunset.jpg`), darkened a little by `scripts/assets.mjs`, with a portrait crop for
+phones. The launcher's prismal background is not used, and the launcher's screenshots are left
+out because they show it: its licence (backgrounds.supply) is unconfirmed. The launcher section
+shows Horizon's own menus and settings instead; see LEGAL.md.
+
+## Rules the site keeps
+
+- The disclaimer, "Not an official Minecraft product. Not approved by or associated with
+ Mojang or Microsoft.", is in the hero, the phone menu, the footer and the 404 page.
+- No Minecraft logo or wordmark; "Minecraft" appears only descriptively ("for Minecraft:
+ Java Edition 26.3"). No game files are offered or linked.
+- Parity is worded as the README words it: 100% of what the harnesses compare.
+- No cookies, analytics, trackers or third-party fonts and scripts.
+- Motion stops under `prefers-reduced-motion`; nothing hijacks the scroll.
diff --git a/website/build.mjs b/website/build.mjs
new file mode 100644
index 000000000..0edf6d53c
--- /dev/null
+++ b/website/build.mjs
@@ -0,0 +1,186 @@
+// Builds the site into dist/: src/index.html with the README's parity table, roadmap and
+// the community links baked in, plus public/ and fingerprinted CSS and JS. No dependencies.
+//
+// Railway builds only website/, so the README and links.json come from the repo when they
+// are next to it (a local checkout) and from main on GitHub otherwise. The latest releases
+// are baked in when GitHub answers, so the page reads well before (or without) JavaScript.
+import { readFile, writeFile, mkdir, rm, cp } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { createHash } from "node:crypto";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { markdown, inline, section, escape } from "./src/markdown.js";
+import { releasesHtml } from "./src/releases.js";
+
+const site = dirname(fileURLToPath(import.meta.url));
+const dist = join(site, "dist");
+const REPO = "ludovic111/horizon";
+const RAW = `https://raw.githubusercontent.com/${REPO}/main/`;
+const DEFAULT_LINKS = { discord: "https://discord.gg/HcTeMZxe5E", sponsor: "https://github.com/sponsors/ludovic111" };
+
+async function repoFile(path) {
+ const local = join(site, "..", path);
+ if (existsSync(local)) return [await readFile(local, "utf8"), "the checkout"];
+ const res = await fetch(RAW + path, { signal: AbortSignal.timeout(15000) });
+ if (!res.ok) throw new Error(`${path}: ${res.status} from GitHub`);
+ return [await res.text(), "main on GitHub"];
+}
+
+// The README: required, a site with a stale or missing parity table is worse than no deploy.
+const [readme, readmeFrom] = await repoFile("README.md");
+
+let links = { ...DEFAULT_LINKS };
+try {
+ const parsed = JSON.parse((await repoFile("launcher/links.json"))[0]);
+ for (const key of Object.keys(links)) if (typeof parsed[key] === "string") links[key] = parsed[key].trim();
+} catch (e) {
+ console.warn(`links.json: ${e.message}; using the built-in links`);
+}
+
+let releases = [];
+try {
+ const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=30`, {
+ // An optional token (a Railway variable, or `gh auth token` locally) lifts the rate limit.
+ headers: { Accept: "application/vnd.github+json", ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}) },
+ signal: AbortSignal.timeout(15000),
+ });
+ if (res.ok) releases = (await res.json()).filter((r) => !r.draft);
+ else console.warn(`releases: ${res.status} from GitHub; the page will load them itself`);
+} catch (e) {
+ console.warn(`releases: ${e.message}; the page will load them itself`);
+}
+
+// --- The parity table ------------------------------------------------------------------
+
+const status = section(readme, "Project status") ?? "";
+const parityHeading = (status.match(/^###\s+(Java parity:\s*[\d.]+%)/m) || [])[1];
+if (!parityHeading) throw new Error("README: no '### Java parity: N%' heading under 'Project status'");
+const percent = parityHeading.match(/[\d.]+%/)[0];
+const parityBody = section(readme, parityHeading) ?? "";
+const rows = parityBody
+ .split("\n")
+ .filter((l) => /^\|/.test(l) && !/^\|\s*-/.test(l))
+ .map((l) =>
+ l
+ .replace(/^\|/, "")
+ .replace(/\|\s*$/, "")
+ .split("|")
+ .map((c) => c.trim()),
+ );
+const [, ...body] = rows; // the first row is the header
+if (body.length < 5) throw new Error("README: the parity table has fewer than five rows");
+const harnesses = (readme.match(/all (\d[\d,]*) (?:parity )?harnesses/) || [])[1];
+if (!harnesses) throw new Error("README: no 'all N harnesses' count");
+const audited = (parityBody.match(/Last audited\s+(\d{4}-\d{2}-\d{2})/) || [])[1] ?? "";
+const intro = status.split(/^###/m)[0].trim();
+
+const scoreLabel = (s) => ({ 0: "Absent", 0.25: "Started", 0.5: "Playable", 0.75: "Mostly complete", 1: "Complete" })[Number(s)] ?? s;
+const check = ``;
+const parityCells = body
+ .map(([name, score, gaps], i) => {
+ // "None: what matches" reads as the evidence; anything else is a gap and says so.
+ const none = gaps.match(/^None:\s*(.*)$/);
+ const full = Number(score) === 1;
+ const detail = none ? inline(none[1].charAt(0).toUpperCase() + none[1].slice(1)) : `Gaps: ${inline(gaps)}`;
+ const state = full ? `${check}Matches vanilla` : `${escape(score)} · ${escape(scoreLabel(score))}`;
+ return `
Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
HorizonHorizon
+
The Minecraft: Java Edition 26.3 client and its game logic, rewritten in Rust and checked against the vanilla game by {{HARNESSES}} differential harnesses.
Horizon rebuilds Minecraft: Java Edition 26.3 in Rust, one subsystem at a time, and holds every piece to the same inputs as the vanilla game until the outputs are identical.
+
+
+
Your own copy
+
Install Java Edition with the official launcher and play 26.3 once. Horizon builds its resources from those files on your machine, and never downloads or shares game files.
+
+
+
Native on three platforms
+
Linux, macOS on Apple Silicon and Windows. Worlds come from a Rust port of the vanilla generators, drawn with vanilla's block models, lighting, sky and fog.
+
+
+
Open source
+
Horizon's code is MIT. It contains no Minecraft code, assets or data; those belong to Mojang and Microsoft under the Minecraft EULA.
+
+
+
+
+
+
+
+
+
02In game
+
Drawn by Horizon
+
Captured in the Rust client itself, with vanilla assets from the player's own copy and no mods or shaders.
+
+
+ 01A plains village
+ 02Moonrise
+ 03A crimson forest in the Nether
+ 04Salmon in a river
+ 05Survival at dusk
+
+
+
+
+
+
+
03Parity
+
+
+
{{HARNESSES}}
+
{{HARNESSES}} differential harnesses drive the same inputs through Horizon and through the vanilla client and server, and compare what comes out.
+
+
+
{{PARITY_HEADLINE}}
+
{{PARITY_INTRO}}
+
Last audited {{AUDITED}} · {{PERCENT}} of what the harnesses compare · from the README on main
+
+
+
+ {{PARITY_CELLS}}
+
+
Each subsystem scores 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified against vanilla by differential tests).
+
+
+
+
+
+
+
04The launcher
+
One small app, then play
+
The launcher installs Horizon, prepares it from your copy of the game and keeps both up to date.
+
+
One-click install
Pick a release and it installs the build for your platform into its own folder, then prepares the resources from your Java Edition 26.3 install.
+
Updates on its own
It looks for a new Horizon release when it starts and every four hours, and updates itself from the launcher's own releases.
+
Client or server
Start the client, or a dedicated server in a folder you choose, with a live console.
+
Your choice of menus
Play with Horizon's own menus, shown here, or vanilla's classic screens.