diff --git a/apps/game/src/components/manual/related-guides.tsx b/apps/game/src/components/manual/related-guides.tsx
new file mode 100644
index 00000000..0d935a8a
--- /dev/null
+++ b/apps/game/src/components/manual/related-guides.tsx
@@ -0,0 +1,52 @@
+import { Link } from "@tanstack/react-router"
+import { useTranslation } from "react-i18next"
+
+/** The guides, in reading order, each named by its page's own heading. */
+const GUIDES = [
+ ["/rules", "game:rules.heading"],
+ ["/strategy", "game:strategy.heading"],
+ ["/opening-moves", "game:openings.heading"],
+ ["/starting-positions", "game:positions.heading"],
+] as const
+
+export type GuidePath = (typeof GUIDES)[number][0]
+
+/**
+ * The other guides, at the end of a guide: a reader who finished one finds the
+ * next, and a crawler finds every guide from each of them. Plain links, which
+ * the router gives the page's language prefix (/de/strategy). After the last
+ * chapter and outside it, so the way to the board above stays where it was.
+ */
+export function RelatedGuides({ current }: { current: GuidePath }) {
+ const { t } = useTranslation()
+
+ return (
+
+ )
+}
diff --git a/apps/game/src/i18n/locales/de/game.json b/apps/game/src/i18n/locales/de/game.json
index 5cd66c40..daa0b0ca 100644
--- a/apps/game/src/i18n/locales/de/game.json
+++ b/apps/game/src/i18n/locales/de/game.json
@@ -332,5 +332,6 @@
"next": "Weiter",
"done": "Verstanden",
"skip": "Überspringen"
- }
+ },
+ "related_guides": "Weitere Anleitungen"
}
diff --git a/apps/game/src/i18n/locales/en/game.json b/apps/game/src/i18n/locales/en/game.json
index 158ae144..e5577aec 100644
--- a/apps/game/src/i18n/locales/en/game.json
+++ b/apps/game/src/i18n/locales/en/game.json
@@ -515,5 +515,6 @@
"next": "Next",
"done": "Got it",
"skip": "Skip"
- }
+ },
+ "related_guides": "Related guides"
}
diff --git a/apps/game/src/i18n/locales/es/game.json b/apps/game/src/i18n/locales/es/game.json
index 761a6bc1..aaa6d9fa 100644
--- a/apps/game/src/i18n/locales/es/game.json
+++ b/apps/game/src/i18n/locales/es/game.json
@@ -332,5 +332,6 @@
"next": "Siguiente",
"done": "Entendido",
"skip": "Omitir"
- }
+ },
+ "related_guides": "Guías relacionadas"
}
diff --git a/apps/game/src/i18n/locales/fr/game.json b/apps/game/src/i18n/locales/fr/game.json
index 71ff6fd1..64855d64 100644
--- a/apps/game/src/i18n/locales/fr/game.json
+++ b/apps/game/src/i18n/locales/fr/game.json
@@ -332,5 +332,6 @@
"next": "Suivant",
"done": "Compris",
"skip": "Passer"
- }
+ },
+ "related_guides": "Guides associés"
}
diff --git a/apps/game/src/i18n/locales/hi/game.json b/apps/game/src/i18n/locales/hi/game.json
index 34824396..3f250e44 100644
--- a/apps/game/src/i18n/locales/hi/game.json
+++ b/apps/game/src/i18n/locales/hi/game.json
@@ -332,5 +332,6 @@
"next": "आगे",
"done": "समझ गया",
"skip": "छोड़ें"
- }
+ },
+ "related_guides": "संबंधित गाइड"
}
diff --git a/apps/game/src/i18n/locales/it/game.json b/apps/game/src/i18n/locales/it/game.json
index 2ec00e1d..c4124ad5 100644
--- a/apps/game/src/i18n/locales/it/game.json
+++ b/apps/game/src/i18n/locales/it/game.json
@@ -332,5 +332,6 @@
"next": "Avanti",
"done": "Ho capito",
"skip": "Salta"
- }
+ },
+ "related_guides": "Guide correlate"
}
diff --git a/apps/game/src/i18n/locales/ja/game.json b/apps/game/src/i18n/locales/ja/game.json
index 4633c3d8..0da24ae3 100644
--- a/apps/game/src/i18n/locales/ja/game.json
+++ b/apps/game/src/i18n/locales/ja/game.json
@@ -331,5 +331,6 @@
"next": "次へ",
"done": "わかった",
"skip": "スキップ"
- }
+ },
+ "related_guides": "関連ガイド"
}
diff --git a/apps/game/src/i18n/locales/ko/game.json b/apps/game/src/i18n/locales/ko/game.json
index 8cb4326c..c9ce1ab4 100644
--- a/apps/game/src/i18n/locales/ko/game.json
+++ b/apps/game/src/i18n/locales/ko/game.json
@@ -331,5 +331,6 @@
"next": "다음",
"done": "알겠어요",
"skip": "건너뛰기"
- }
+ },
+ "related_guides": "관련 가이드"
}
diff --git a/apps/game/src/i18n/locales/nl/game.json b/apps/game/src/i18n/locales/nl/game.json
index 89614840..e6ff5395 100644
--- a/apps/game/src/i18n/locales/nl/game.json
+++ b/apps/game/src/i18n/locales/nl/game.json
@@ -332,5 +332,6 @@
"next": "Volgende",
"done": "Begrepen",
"skip": "Overslaan"
- }
+ },
+ "related_guides": "Gerelateerde gidsen"
}
diff --git a/apps/game/src/i18n/locales/pl/game.json b/apps/game/src/i18n/locales/pl/game.json
index 7845a006..62a7f5c2 100644
--- a/apps/game/src/i18n/locales/pl/game.json
+++ b/apps/game/src/i18n/locales/pl/game.json
@@ -333,5 +333,6 @@
"next": "Dalej",
"done": "Rozumiem",
"skip": "Pomiń"
- }
+ },
+ "related_guides": "Powiązane poradniki"
}
diff --git a/apps/game/src/i18n/locales/pt/game.json b/apps/game/src/i18n/locales/pt/game.json
index 8c4bc977..45996b47 100644
--- a/apps/game/src/i18n/locales/pt/game.json
+++ b/apps/game/src/i18n/locales/pt/game.json
@@ -332,5 +332,6 @@
"next": "Seguinte",
"done": "Percebi",
"skip": "Saltar"
- }
+ },
+ "related_guides": "Guias relacionados"
}
diff --git a/apps/game/src/i18n/locales/tr/game.json b/apps/game/src/i18n/locales/tr/game.json
index acaff69c..87ed20fc 100644
--- a/apps/game/src/i18n/locales/tr/game.json
+++ b/apps/game/src/i18n/locales/tr/game.json
@@ -331,5 +331,6 @@
"next": "İleri",
"done": "Anladım",
"skip": "Geç"
- }
+ },
+ "related_guides": "İlgili rehberler"
}
diff --git a/apps/game/src/i18n/locales/zh/game.json b/apps/game/src/i18n/locales/zh/game.json
index 9c0e24d0..86ed3405 100644
--- a/apps/game/src/i18n/locales/zh/game.json
+++ b/apps/game/src/i18n/locales/zh/game.json
@@ -331,5 +331,6 @@
"next": "下一步",
"done": "知道了",
"skip": "跳过"
- }
+ },
+ "related_guides": "相关指南"
}
diff --git a/apps/game/src/routing/pages/opening-moves.page.tsx b/apps/game/src/routing/pages/opening-moves.page.tsx
index e35958e4..bc29d6c4 100644
--- a/apps/game/src/routing/pages/opening-moves.page.tsx
+++ b/apps/game/src/routing/pages/opening-moves.page.tsx
@@ -3,6 +3,7 @@ import { Trans, useTranslation } from "react-i18next"
import type { ManualChapter } from "@/components/manual/chapter"
import { Chapter } from "@/components/manual/chapter"
import { ManualHero, ManualPage } from "@/components/manual/manual-page"
+import { RelatedGuides } from "@/components/manual/related-guides"
import { PlayBlock } from "@/components/play-block"
import { SubpageHeader } from "@/components/ui/subpage-header"
import { COMPUTER_GAME } from "@/hooks/use-play-actions"
@@ -118,6 +119,8 @@ function OpeningMovesPage() {
{playBlock}
+
+
>
)
diff --git a/apps/game/src/routing/pages/rules.page.tsx b/apps/game/src/routing/pages/rules.page.tsx
index 2f284777..f3fb45fe 100644
--- a/apps/game/src/routing/pages/rules.page.tsx
+++ b/apps/game/src/routing/pages/rules.page.tsx
@@ -9,6 +9,7 @@ import { Chapter, Plate } from "@/components/manual/chapter"
import { formatChapterNumber } from "@/components/manual/chapter-number"
import { FaqList } from "@/components/manual/faq-list"
import { ManualHero, ManualPage } from "@/components/manual/manual-page"
+import { RelatedGuides } from "@/components/manual/related-guides"
import { MarbleGlyph } from "@/components/marble-glyph"
import { PlayBlock } from "@/components/play-block"
import { RuleDiagram } from "@/components/rule-diagram"
@@ -498,6 +499,8 @@ function RulesPage() {
{playBlock}
+
+
>
)
diff --git a/apps/game/src/routing/pages/starting-positions.page.tsx b/apps/game/src/routing/pages/starting-positions.page.tsx
index 5737e7d3..2a70df17 100644
--- a/apps/game/src/routing/pages/starting-positions.page.tsx
+++ b/apps/game/src/routing/pages/starting-positions.page.tsx
@@ -4,6 +4,7 @@ import { Trans, useTranslation } from "react-i18next"
import type { ManualChapter } from "@/components/manual/chapter"
import { Chapter, Plate } from "@/components/manual/chapter"
import { ManualHero, ManualPage } from "@/components/manual/manual-page"
+import { RelatedGuides } from "@/components/manual/related-guides"
import { PlayBlock } from "@/components/play-block"
import { RuleDiagram } from "@/components/rule-diagram"
import { buttonClassName } from "@/components/ui/button"
@@ -266,6 +267,8 @@ function StartingPositionsPage() {
/>
+
+
>
)
diff --git a/apps/game/src/routing/pages/strategy.page.tsx b/apps/game/src/routing/pages/strategy.page.tsx
index 0f772341..cc57c1d2 100644
--- a/apps/game/src/routing/pages/strategy.page.tsx
+++ b/apps/game/src/routing/pages/strategy.page.tsx
@@ -8,6 +8,7 @@ import {
} from "@/components/manual/chapter-number"
import { FaqList } from "@/components/manual/faq-list"
import { ManualHero, ManualPage } from "@/components/manual/manual-page"
+import { RelatedGuides } from "@/components/manual/related-guides"
import { PlayBlock } from "@/components/play-block"
import { SubpageHeader } from "@/components/ui/subpage-header"
import { COMPUTER_GAME } from "@/hooks/use-play-actions"
@@ -182,6 +183,8 @@ function StrategyPage() {
{playBlock}
+
+
>
)
diff --git a/apps/game/src/routing/seo-pages.test.ts b/apps/game/src/routing/seo-pages.test.ts
index 8d539d51..9a29ab79 100644
--- a/apps/game/src/routing/seo-pages.test.ts
+++ b/apps/game/src/routing/seo-pages.test.ts
@@ -101,11 +101,11 @@ describe("SITEMAP", () => {
it("dates each page by the day its text last changed", () => {
expect(SITEMAP).toEqual({
"/": "2026-11-01",
- "/rules": "2026-10-19",
- "/strategy": "2026-10-19",
+ "/rules": "2026-11-02",
+ "/strategy": "2026-11-02",
"/play-online": "2026-11-01",
- "/opening-moves": "2026-10-18",
- "/starting-positions": "2026-10-18",
+ "/opening-moves": "2026-11-02",
+ "/starting-positions": "2026-11-02",
})
})
})
diff --git a/apps/game/src/routing/seo-pages.ts b/apps/game/src/routing/seo-pages.ts
index f1c99557..e025643e 100644
--- a/apps/game/src/routing/seo-pages.ts
+++ b/apps/game/src/routing/seo-pages.ts
@@ -96,11 +96,11 @@ export const SITEMAP: Record<
string
> = {
"/": "2026-11-01",
- "/rules": "2026-10-19",
- "/strategy": "2026-10-19",
+ "/rules": "2026-11-02",
+ "/strategy": "2026-11-02",
"/play-online": "2026-11-01",
- "/opening-moves": "2026-10-18",
- "/starting-positions": "2026-10-18",
+ "/opening-moves": "2026-11-02",
+ "/starting-positions": "2026-11-02",
}
/**
diff --git a/e2e/related-guides.spec.ts b/e2e/related-guides.spec.ts
new file mode 100644
index 00000000..0b5a67c1
--- /dev/null
+++ b/e2e/related-guides.spec.ts
@@ -0,0 +1,85 @@
+import { expect, test } from "@playwright/test"
+import { openApp } from "./support/app"
+
+// Each guide ends with links to the other three, so a reader who finished one
+// finds the next and a crawler reaches every guide from each of them. The links
+// are in the served HTML, in the page's language (/de/rules links /de/strategy),
+// and sit below the way to the board, which stays on the first screen.
+
+const GUIDES = [
+ "/rules",
+ "/strategy",
+ "/opening-moves",
+ "/starting-positions",
+] as const
+
+const CTA = "Play against the computer"
+
+/** The hrefs inside the served related-guides block. */
+function relatedLinks(html: string): string[] {
+ const start = html.indexOf("data-related-guides")
+ if (start === -1) return []
+ const block = html.slice(start, html.indexOf("", start))
+ return [...block.matchAll(/]*href="([^"]*)"/g)].map(
+ (match) => match[1],
+ )
+}
+
+test.describe("related guides in the served HTML", () => {
+ test.skip(
+ ({ browserName }) => browserName !== "chromium",
+ "one HTTP pass is enough",
+ )
+
+ for (const prefix of ["", "/de"]) {
+ for (const guide of GUIDES) {
+ const path = `${prefix}${guide}`
+
+ test(`${path} links the other three guides, each a 200 page`, async ({
+ request,
+ }) => {
+ const response = await request.get(path, { maxRedirects: 0 })
+ expect(response.status()).toBe(200)
+
+ const links = relatedLinks(await response.text())
+ expect(links.sort()).toEqual(
+ GUIDES.filter((other) => other !== guide)
+ .map((other) => `${prefix}${other}`)
+ .sort(),
+ )
+
+ for (const link of links) {
+ const target = await request.get(link, { maxRedirects: 0 })
+ expect(target.status(), link).toBe(200)
+ }
+ })
+ }
+ }
+})
+
+test.describe("related guides at 375×667", () => {
+ test.use({ viewport: { width: 375, height: 667 } })
+
+ for (const guide of GUIDES) {
+ test(`${guide}: the play button is on the first screen, the guides below it`, async ({
+ page,
+ }) => {
+ await openApp(page, guide)
+
+ const play = page
+ .locator("main")
+ .getByRole("link", { name: CTA, exact: true })
+ .first()
+ const playBox = await play.boundingBox()
+ expect(playBox).not.toBeNull()
+ expect(
+ (playBox?.y ?? 0) + (playBox?.height ?? 0),
+ ).toBeLessThanOrEqual(667)
+
+ const related = page.locator("[data-related-guides]")
+ await expect(related.getByRole("link")).toHaveCount(3)
+ const relatedBox = await related.boundingBox()
+ expect(relatedBox?.y ?? 0).toBeGreaterThan(667)
+ })
+ }
+})
diff --git a/e2e/seo.spec.ts b/e2e/seo.spec.ts
index 4c411dce..c34590fe 100644
--- a/e2e/seo.spec.ts
+++ b/e2e/seo.spec.ts
@@ -26,11 +26,11 @@ const INDEXABLE = {
// page that answers "play abalone".
const SITEMAP = {
"/": "2026-11-01",
- "/rules": "2026-10-19",
- "/strategy": "2026-10-19",
+ "/rules": "2026-11-02",
+ "/strategy": "2026-11-02",
"/play-online": "2026-11-01",
- "/opening-moves": "2026-10-18",
- "/starting-positions": "2026-10-18",
+ "/opening-moves": "2026-11-02",
+ "/starting-positions": "2026-11-02",
} as const
// A language's own URL: /pt, /pt/rules
diff --git a/e2e/strategy-page.spec.ts b/e2e/strategy-page.spec.ts
index ea598fdf..078a6231 100644
--- a/e2e/strategy-page.spec.ts
+++ b/e2e/strategy-page.spec.ts
@@ -24,6 +24,8 @@ const SECTIONS = [
["computer", "How strong is the computer?"],
["faq", "Frequently asked"],
["practise", "Practise"],
+ //the links to the other guides close the page (related-guides.spec.ts)
+ ["related-guides", "Related guides"],
] as const
/** The served HTML as the text a reader sees: tags out, entities decoded. */