diff --git a/PLAN.md b/PLAN.md
index 5ae207e..b4d4af6 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -298,3 +298,12 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game
- Credits-Fenster im Post geprüft: passt auch bei 320 px Breite (Knopf endet bei 413 von 512 px).
- Pixelify-Ligatur „fi“ (sieht aus wie „A“) jetzt für die ganze App aus (`font-variant-ligatures: none`).
- **Fallstrick:** `devvit.json` geändert → laufenden Playtest stoppen und neu starten.
+
+### 2026-11-13 – Bilder für die Community-Posts
+
+- `npm run capture:community` (`scripts/capture/community.{mjs,ts,html,css}`): zwei Bilder 1080 × 1350 (4:5, im Feed
+ am Handy groß) – „How to play“ (vier Figuren mit beanspruchten Feldern, Gold je Gelände, Sterne) und „Choose your
+ realm“ (Völker, Bretter, Banner mit Freischalt-Bedingung). Muster kommen aus `UNIT_DEFS` (Strahlen/Ring, Budget),
+ Bedingungen aus `look.ts` – stimmen also nach Regeländerungen. Seite 540 × 675 mit Faktor 2 aufgenommen, Pixel scharf.
+- Das Skript meldet, wenn der Inhalt höher als das Bild ist („… px zu hoch“).
+- Texte der drei Posts: `docs/community-posts.md`.
diff --git a/docs/community-posts.md b/docs/community-posts.md
new file mode 100644
index 0000000..8bd109f
--- /dev/null
+++ b/docs/community-posts.md
@@ -0,0 +1,69 @@
+# Community-Posts in r/Ninelands (Entwürfe)
+
+Drei eigene Posts zum Go-live, kurz nacheinander: How to play und Go-live anheften. Am selben Tag die App
+installieren – das legt sofort den Tagespost #1 an. Bilder erzeugen: `npm run capture:community` →
+`dist/marketing/community/` (1080 × 1350, Werte aus Engine und `src/shared/look.ts`). Video für Post 3:
+`npm run capture:trailer` → `dist/marketing/trailer.mp4`.
+
+## 1 – How to play (Bildpost: `how-to-play.png`)
+
+Titel: `How to play Ninelands`
+
+```text
+Every day at 00:00 UTC a new 9×9 map appears as a post here. Everyone plays the same map.
+
+**Goal:** earn gold and rule the nine lands.
+
+- **Castle** (free, once): claims up to 5 cells in straight lines.
+- **Farmer** (free): claims the 8 cells around it, inside its own land. Must border your domain.
+- **Knight** (10 gold): 2 cells straight, stays in its land – pays double.
+- **Bishop** (15 gold): 2 cells diagonally, also across borders.
+
+Claimed cells pay gold: ore and herbs 20, pasture 5 (7 next to a lake). Nothing can be built on claimed land, so
+plan where you build. You rule a land when you claim at least half of it.
+
+**Stars:** ★ reach the gold goal, ★ rule the lands goal, ★ both in one game.
+**Law of the day:** each map has one special rule, e.g. "Costly knights".
+**End game:** when no move earns gold anymore, end the game to keep your gold.
+
+Only your **first game** counts for the leaderboard – after that you can practice. Play daily for a 🔥 streak;
+yesterday's winner wears the 👑 flair.
+```
+
+## 2 – Peoples & realms (Bildpost: `realms.png`)
+
+Titel: `Choose your people: Kingdom, Orcs and Pirates`
+
+```text
+Your realm, your look. Tap 🎨 Realm on the daily post:
+
+- **Colours** – red, cyan, green, purple – free for everyone
+- **Peoples** – Kingdom, Orcs (20 ★), Pirates (40 ★)
+- **Boards** – Autumn (3-day streak), Winter (7), Night (14)
+- **Banners** – a new one every 10 stars
+- **Winterfest** (Dec 1 – Jan 6): snowy board and snowflake banner for everyone
+
+Same rules for all – it's just about looking good while you win. Which people are you playing?
+```
+
+## 3 – Go-live (Videopost: `trailer.mp4`, sonst `vimeo-cover.png`)
+
+Titel: `Ninelands is live – a new 9×9 map every day`
+
+```text
+Ninelands is a small strategy game I've been building – and now there's a daily version right here on Reddit.
+
+Every day: one new map, one leaderboard, one first try. Play it in the daily post, compare your realm in the
+comments, keep your streak going.
+
+Today's map is #1 – good luck, rulers!
+
+New here? Read "How to play" (pinned). Bugs, ideas or feedback: comment below.
+
+Original game: ninelands.stackteam.at
+Music: "Dreamscape (part I)" by Vee Ray (CC BY-SA 4.0) · Sprites: "Mini World Sprites" by Shade & octoshrimpy (CC0)
+```
+
+Hinweise: Kosten in Post 1 sind die normalen (einzelne Gesetze ändern sie). „I've been building“ ist persönlich
+gemeint – als StackTeam „we“ schreiben. Ändern sich Kosten oder Freischaltungen, Bilder neu erzeugen und Texte
+anpassen.
diff --git a/package.json b/package.json
index 44d87f9..8177ba8 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,8 @@
"publish": "npm run clean && npm run build && devvit publish",
"watch": "sh -c 'trap \"kill 0\" exit; npm run build:client -- --watch=forever& npm run build:server -- --watch=forever& wait' --",
"capture": "node scripts/capture/record.mjs",
- "capture:trailer": "node scripts/capture/trailer.mjs"
+ "capture:trailer": "node scripts/capture/trailer.mjs",
+ "capture:community": "node scripts/capture/community.mjs"
},
"devDependencies": {
"@biomejs/biome": "2.6.6",
diff --git a/scripts/capture/community.css b/scripts/capture/community.css
new file mode 100644
index 0000000..39c133c
--- /dev/null
+++ b/scripts/capture/community.css
@@ -0,0 +1,234 @@
+/* Bilder für die Community-Posts in r/Ninelands: 540 × 675 CSS-Pixel, aufgenommen mit Faktor 2 (1080 × 1350). */
+html,
+body {
+ margin: 0;
+ width: 540px;
+ height: 675px;
+ overflow: hidden;
+}
+
+#sheet {
+ box-sizing: border-box;
+ width: 540px;
+ height: 675px;
+ padding: 14px 22px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ font-family: var(--font-pixel);
+ color: var(--px-parchment);
+ font-variant-ligatures: none;
+}
+
+img {
+ image-rendering: pixelated;
+}
+
+.title {
+ text-align: center;
+}
+
+.title h1 {
+ margin: 0;
+ font-size: 31px;
+ color: var(--px-gold);
+ text-shadow: 3px 3px 0 var(--px-ink);
+}
+
+.title p {
+ margin: 2px 0 0;
+ font-size: 14px;
+ color: var(--px-parchment-dim);
+}
+
+.card {
+ background: var(--px-wood);
+ border: 3px solid var(--px-ink);
+ box-shadow: var(--px-shadow);
+ padding: 6px 10px;
+}
+
+.card h2 {
+ margin: 0;
+ font-size: 17px;
+ color: var(--px-gold);
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+.card small {
+ font-size: 13px;
+ color: var(--px-parchment);
+}
+
+.card p {
+ margin: 2px 0 6px;
+ font-family: var(--font-text);
+ font-size: 11.5px;
+ line-height: 1.3;
+ color: var(--px-parchment-dim);
+}
+
+/* Regeltafel: 2 × 2 Figuren */
+.units {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.pattern {
+ display: grid;
+ width: fit-content;
+ margin: 0 auto;
+ border: 2px solid var(--px-ink);
+}
+
+.pattern .c {
+ position: relative;
+ background-size: cover;
+ image-rendering: pixelated;
+}
+
+.pattern .c.claim::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: rgba(150, 70, 255, 0.55);
+ outline: 1px dashed rgba(255, 255, 255, 0.7);
+ outline-offset: -2px;
+}
+
+.c > img {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+}
+
+.row {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ justify-content: space-around;
+}
+
+.gold-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 16px;
+}
+
+.gold-item img {
+ width: 30px;
+ height: 30px;
+}
+
+.gold-item b {
+ color: var(--px-gold);
+}
+
+.stars {
+ color: var(--px-gold);
+}
+
+.foot {
+ margin: auto 0 0;
+ text-align: center;
+ font-size: 13px;
+ color: var(--px-parchment-dim);
+}
+
+/* Realm-Übersicht */
+.peoples {
+ display: grid;
+ grid-template-columns: auto repeat(4, 1fr);
+ align-items: center;
+ gap: 2px 6px;
+}
+
+.peoples img {
+ width: 40px;
+ height: 40px;
+ justify-self: center;
+}
+
+.peoples .name {
+ font-size: 15px;
+}
+
+.lock {
+ display: block;
+ font-size: 11px;
+ color: var(--px-gold);
+}
+
+.tiles {
+ display: flex;
+ justify-content: space-between;
+ gap: 6px;
+}
+
+.tile {
+ flex: 1;
+ text-align: center;
+ font-size: 13px;
+}
+
+.patch {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ width: 66px;
+ height: 66px;
+ margin: 0 auto 3px;
+ position: relative;
+ border: 2px solid var(--px-ink);
+}
+
+.patch span {
+ background-size: cover;
+ image-rendering: pixelated;
+}
+
+.patch img {
+ position: absolute;
+ left: 22px;
+ top: 22px;
+ width: 22px;
+ height: 22px;
+}
+
+.banners {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 4px;
+ text-align: center;
+ font-size: 12px;
+}
+
+.banners svg {
+ display: block;
+ width: 38px;
+ height: 38px;
+ margin: 0 auto;
+}
+
+.stars-card {
+ text-align: center;
+ font-size: 16px;
+ white-space: pre;
+}
+
+.c > img.big {
+ inset: -100%;
+ width: 300%;
+ height: 300%;
+ z-index: 2;
+}
+
+.gold-item small {
+ font-size: 12px;
+ color: var(--px-parchment-dim);
+}
diff --git a/scripts/capture/community.html b/scripts/capture/community.html
new file mode 100644
index 0000000..118eefb
--- /dev/null
+++ b/scripts/capture/community.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Ninelands community images
+
+
+
+
+
+
+
diff --git a/scripts/capture/community.mjs b/scripts/capture/community.mjs
new file mode 100644
index 0000000..fe53ddc
--- /dev/null
+++ b/scripts/capture/community.mjs
@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+// Bilder für die Community-Posts in r/Ninelands (1080 × 1350, hochkant für den Feed am Handy).
+// Aufruf: npm run capture:community → dist/marketing/community/how-to-play.png, realms.png
+import {copyFileSync, mkdirSync, readFileSync, statSync} from 'node:fs'
+import {createServer} from 'node:http'
+import {dirname, extname, join, resolve} from 'node:path'
+import {fileURLToPath} from 'node:url'
+import {build} from 'esbuild'
+import {chromium} from 'playwright'
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
+const site = join(root, 'dist/community')
+const out = join(root, 'dist/marketing/community')
+mkdirSync(site, {recursive: true})
+mkdirSync(out, {recursive: true})
+
+await build({
+ entryPoints: [join(root, 'scripts/capture/community.ts')],
+ bundle: true,
+ format: 'esm',
+ outdir: site,
+ logLevel: 'warning',
+ define: {
+ 'import.meta.env.MODE': '"reddit"',
+ 'import.meta.env.BASE_URL': '"/"',
+ __API_ORIGIN__: '""',
+ },
+ loader: {'.woff2': 'file', '.woff': 'file'},
+ assetNames: 'fonts/[name]-[hash]',
+})
+copyFileSync(
+ join(root, 'scripts/capture/community.html'),
+ join(site, 'index.html'),
+)
+
+const types = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.css': 'text/css',
+ '.png': 'image/png',
+ '.woff2': 'font/woff2',
+ '.woff': 'font/woff',
+}
+const server = createServer((req, res) => {
+ const path = decodeURIComponent((req.url ?? '/').split('?')[0])
+ for (const dir of [site, join(root, 'public')]) {
+ const file = join(dir, path === '/' ? 'index.html' : path)
+ try {
+ if (file.startsWith(dir) && statSync(file).isFile()) {
+ res.writeHead(200, {
+ 'Content-Type': types[extname(file)] ?? 'application/octet-stream',
+ })
+ res.end(readFileSync(file))
+ return
+ }
+ } catch {}
+ }
+ res.writeHead(404)
+ res.end()
+})
+await new Promise(r => server.listen(0, '127.0.0.1', r))
+const base = `http://127.0.0.1:${server.address().port}/`
+
+const browser = await chromium.launch()
+try {
+ for (const page of ['howto', 'realms']) {
+ const p = await browser.newPage({
+ viewport: {width: 540, height: 675},
+ deviceScaleFactor: 2,
+ })
+ p.on('pageerror', e => console.error(`Seitenfehler: ${e.message}`))
+ await p.goto(`${base}?page=${page}`)
+ await p.evaluate(() => document.fonts.ready)
+ await p.waitForLoadState('networkidle')
+ // Passt alles aufs Bild?
+ const over = await p.evaluate(() => {
+ const s = document.getElementById('sheet')
+ return s ? s.scrollHeight - s.clientHeight : 0
+ })
+ if (over > 0) console.error(`${page}: ${over} px zu hoch`)
+ const name = page === 'howto' ? 'how-to-play.png' : 'realms.png'
+ await p.screenshot({path: join(out, name)})
+ console.log(
+ `${name} ${Math.round(statSync(join(out, name)).size / 1024)} KB`,
+ )
+ await p.close()
+ }
+} finally {
+ await browser.close()
+ server.close()
+}
diff --git a/scripts/capture/community.ts b/scripts/capture/community.ts
new file mode 100644
index 0000000..fe61086
--- /dev/null
+++ b/scripts/capture/community.ts
@@ -0,0 +1,220 @@
+// Bilder für die Community-Posts (How to play, Realms). Werte kommen aus Engine und look.ts, damit die Bilder
+// stimmen, wenn sich Regeln oder Freischaltungen ändern. ?page=howto | realms
+import '../../src/client/style.css'
+import './community.css'
+import {h} from '../../src/client/dom.ts'
+import {UNIT_NAME} from '../../src/client/texts.ts'
+import {spriteUrl} from '../../src/engine/app/board/atlas.ts'
+import {
+ bannerPreview,
+ pieceSpriteName,
+} from '../../src/engine/app/board/BoardView.ts'
+import {TEAM_HEX} from '../../src/engine/core/avatars.ts'
+import {
+ UNIT_DEFS,
+ UNIT_TYPES,
+ type UnitType,
+} from '../../src/engine/core/units.ts'
+import {
+ BANNER_OPTIONS,
+ COLORS,
+ FACTION_OPTIONS,
+ THEME_OPTIONS,
+ type Unlock,
+ unlockText,
+} from '../../src/shared/look.ts'
+
+const sheet = document.getElementById('sheet') as HTMLElement
+const COLOR = 'purple'
+
+/** Normale Kosten (ohne Gesetz des Tages) */
+const COST: Record = {
+ HIVEMOTHER: 'free',
+ HIVEWORKER: 'free',
+ DRONE: '10 gold',
+ HIVESCOUT: '15 gold',
+}
+const RULE: Record = {
+ HIVEMOTHER: 'Only once. Up to 5 cells per line.',
+ HIVEWORKER: 'The 8 cells around it, own land.',
+ DRONE: '2 cells per line, own land. Pays 2×.',
+ HIVESCOUT: '2 cells per diagonal, any land.',
+}
+
+/** Beanspruchte Felder auf freiem Grasland – direkt aus den Engine-Daten (Strahlen bzw. Ring) */
+function claimed(unit: UnitType, n: number): Set {
+ const def = UNIT_DEFS[unit]
+ const mid = (n - 1) / 2
+ const out = new Set()
+ for (const [dx, dy] of def.directions) {
+ const steps = def.shape === 'ring' ? 1 : def.budget
+ for (let s = 1; s <= steps; s++) {
+ const x = mid + dx * s
+ const y = mid + dy * s
+ if (x >= 0 && y >= 0 && x < n && y < n) out.add(y * n + x)
+ }
+ }
+ return out
+}
+
+function pattern(unit: UnitType): HTMLElement {
+ const n = unit === 'HIVEMOTHER' ? 11 : 5
+ const size = unit === 'HIVEMOTHER' ? 12 : 26
+ const grid = h('div.pattern')
+ grid.style.gridTemplateColumns = `repeat(${n}, ${size}px)`
+ grid.style.gridAutoRows = `${size}px`
+ const claim = claimed(unit, n)
+ for (let i = 0; i < n * n; i++) {
+ const c = h('span.c')
+ c.style.backgroundImage = `url(${spriteUrl(`grass${(i * 7) % 6}` as 'grass0')})`
+ if (claim.has(i)) c.classList.add('claim')
+ if (i === (n * n - 1) / 2) {
+ const piece = img(pieceSpriteName(unit, COLOR, 'kingdom'))
+ // Burg auf dem großen Raster dreifach groß, sonst kaum zu erkennen
+ if (n > 5) piece.classList.add('big')
+ c.append(piece)
+ }
+ grid.append(c)
+ }
+ return grid
+}
+
+function img(name: string): HTMLImageElement {
+ const el = h('img')
+ el.src = name.startsWith('/') ? name : spriteUrl(name as 'grass0')
+ el.alt = ''
+ return el
+}
+
+function howto(): void {
+ // Reihenfolge wie man spielt: Burg, Bauer, Ritter, Läufer
+ const order: UnitType[] = ['HIVEMOTHER', 'HIVEWORKER', 'DRONE', 'HIVESCOUT']
+ if (order.some(u => !UNIT_TYPES.includes(u))) throw new Error('Einheiten')
+ sheet.append(
+ h(
+ 'header.title',
+ h('h1', 'How to play'),
+ h('p', 'Ninelands · a new 9×9 map every day'),
+ ),
+ h(
+ 'section.units',
+ ...order.map(u =>
+ h(
+ 'div.card',
+ h('h2', UNIT_NAME[u], h('small', COST[u])),
+ h('p', RULE[u]),
+ pattern(u),
+ ),
+ ),
+ ),
+ h(
+ 'section.card',
+ h('h2', 'Claimed cells pay gold'),
+ h(
+ 'div.row',
+ h('span.gold-item', img('ore'), 'Ore ', h('b', '20')),
+ h('span.gold-item', img('herbs'), 'Herbs ', h('b', '20')),
+ h(
+ 'span.gold-item',
+ img('grass0'),
+ 'Pasture ',
+ h('b', '5'),
+ h('small', ' (7 by a lake)'),
+ ),
+ ),
+ h(
+ 'p',
+ 'Claimed land can’t be built on – plan ahead. Claim half of a land to rule it.',
+ ),
+ ),
+ h(
+ 'section.card.stars-card',
+ h('span.stars', '★'),
+ ' gold goal ',
+ h('span.stars', '★'),
+ ' lands goal ',
+ h('span.stars', '★'),
+ ' both in one game',
+ ),
+ h(
+ 'p.foot',
+ 'Only your first game counts · each map has its own law · new map 00:00 UTC',
+ ),
+ )
+}
+
+const NAMES: Record = {
+ kingdom: 'Kingdom',
+ orc: 'Orcs',
+ pirate: 'Pirates',
+ classic: 'Classic',
+ autumn: 'Autumn',
+ winter: 'Winter',
+ night: 'Night',
+ winterfest: 'Winterfest',
+}
+
+const lock = (u: Unlock) =>
+ u.kind === 'free' ? h('span.lock', 'free') : h('span.lock', unlockText(u))
+
+function realms(): void {
+ const peoples = h('div.peoples')
+ FACTION_OPTIONS.forEach((f, row) => {
+ peoples.append(h('span.name', NAMES[f.id] ?? f.id, lock(f.unlock)))
+ for (const u of UNIT_TYPES) {
+ // Jede Zeile in einer anderen Farbe – zeigt Völker und Farben zugleich
+ const color = COLORS[(row + 1) % COLORS.length]?.id ?? 'red'
+ peoples.append(img(pieceSpriteName(u, color, f.id)))
+ }
+ })
+ const tiles = h(
+ 'div.tiles',
+ ...THEME_OPTIONS.map(t => {
+ const patch = h('div.patch')
+ for (let i = 0; i < 9; i++) {
+ const s = h('span')
+ const file = `grass${(i * 5) % 6}.png`
+ s.style.backgroundImage = `url(/sprites/themes/${t.id}/${file})`
+ patch.append(s)
+ }
+ patch.append(img(pieceSpriteName('HIVEMOTHER', COLOR, 'kingdom')))
+ return h('div.tile', patch, NAMES[t.id] ?? t.id, lock(t.unlock))
+ }),
+ )
+ const banners = h(
+ 'div.banners',
+ ...BANNER_OPTIONS.map(b =>
+ h(
+ 'div',
+ bannerPreview(b.id, TEAM_HEX[COLOR]),
+ `${b.id[0]?.toUpperCase()}${b.id.slice(1)}`,
+ lock(b.unlock),
+ ),
+ ),
+ )
+ sheet.append(
+ h(
+ 'header.title',
+ h('h1', 'Choose your realm'),
+ h(
+ 'p',
+ 'Tap 🎨 Realm on the daily post · 4 colours free · same rules for all',
+ ),
+ ),
+ h('section.card', h('h2', 'Peoples', h('small', 'collect stars')), peoples),
+ h('section.card', h('h2', 'Boards', h('small', 'keep your streak')), tiles),
+ h(
+ 'section.card',
+ h('h2', 'Banners', h('small', 'a new one every 10 ★')),
+ banners,
+ ),
+ h(
+ 'p.foot',
+ 'It’s all about looking good while you win – which people are you playing?',
+ ),
+ )
+}
+
+const page = new URLSearchParams(location.search).get('page')
+if (page === 'realms') realms()
+else howto()