diff --git a/apps/game/src/components/home/play-poster.tsx b/apps/game/src/components/home/play-poster.tsx
index d14705e..19b93cc 100644
--- a/apps/game/src/components/home/play-poster.tsx
+++ b/apps/game/src/components/home/play-poster.tsx
@@ -9,6 +9,7 @@ import { Reveal } from "@/components/ui/reveal"
import { useIsDesktop } from "@/hooks/use-is-desktop"
import { COMPUTER_GAME, usePlayActions } from "@/hooks/use-play-actions"
import { BOT_LEVELS } from "@/i18n/bots"
+import { DEFAULT_LANGUAGE } from "@/i18n/locale-path"
/**
* The front door for anyone without an account: what the game is on one side,
@@ -40,7 +41,7 @@ const TEXT_LINK_CLASS =
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm px-1.5 lg:px-2.5 text-brand-lighter underline underline-offset-[3px] transition-colors duration-200 ease-out hover:text-brand-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
export function PlayPoster() {
- const { t } = useTranslation()
+ const { t, i18n } = useTranslation()
const { playOnline, playOffline } = usePlayActions()
//the eight faces are eight pictures, and a phone never shows them: mounted
//behind `hidden` they are eight requests spent on nothing
@@ -109,6 +110,14 @@ export function PlayPoster() {
two. One label per link rather than a short and a long one swapped
by width: both would be in the served text. */}
+ {i18n.language === DEFAULT_LANGUAGE && (
+ <>
+
+ {t("common:home.how_to_play_link")}
+
+ ·
+ >
+ )}
{t("common:nav.rules")}
diff --git a/apps/game/src/components/manual/related-guides.tsx b/apps/game/src/components/manual/related-guides.tsx
index 0d935a8..095e241 100644
--- a/apps/game/src/components/manual/related-guides.tsx
+++ b/apps/game/src/components/manual/related-guides.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"
/** The guides, in reading order, each named by its page's own heading. */
const GUIDES = [
["/rules", "game:rules.heading"],
+ ["/how-to-play", "game:howto.heading"],
["/strategy", "game:strategy.heading"],
["/opening-moves", "game:openings.heading"],
["/starting-positions", "game:positions.heading"],
diff --git a/apps/game/src/i18n/locales/en/common.json b/apps/game/src/i18n/locales/en/common.json
index 54011aa..67f9584 100644
--- a/apps/game/src/i18n/locales/en/common.json
+++ b/apps/game/src/i18n/locales/en/common.json
@@ -57,7 +57,8 @@
}
}
},
- "pause_demo": "Pause demo"
+ "pause_demo": "Pause demo",
+ "how_to_play_link": "How to play"
},
"auth": {
"sign_in": "Sign in",
diff --git a/apps/game/src/i18n/locales/en/game.json b/apps/game/src/i18n/locales/en/game.json
index e5577ae..fa3f3e7 100644
--- a/apps/game/src/i18n/locales/en/game.json
+++ b/apps/game/src/i18n/locales/en/game.json
@@ -516,5 +516,89 @@
"done": "Got it",
"skip": "Skip"
},
- "related_guides": "Related guides"
+ "related_guides": "Related guides",
+ "howto": {
+ "title": "How to play",
+ "heading": "How to play Abalone",
+ "intro": "To play Abalone, take turns with your opponent and move one, two or three of your marbles one space. A longer line of your marbles can push a shorter line of theirs, and the first player to push six of their marbles off the board wins. Below are the rules in five steps and a first game you can play now, free against the computer. For the full detail, see the Abalone rules.",
+ "steps": {
+ "title": "The rules in five steps",
+ "setup": {
+ "title": "Start with 14 marbles each",
+ "body": "Each player has 14 marbles on a hexagon board. Black moves first, then you take turns. You cannot pass."
+ },
+ "move": {
+ "title": "Move up to three marbles",
+ "body": "On your turn, move one, two or three of your own marbles one space to a neighbouring space. Two or three marbles must sit in a straight line and move together."
+ },
+ "kinds": {
+ "title": "Choose an in-line or a side-step move",
+ "body": "An in-line move sends the group along the line it points. A side step slides the group sideways, and every space it moves into must be empty."
+ },
+ "push": {
+ "title": "Push with more marbles",
+ "body": "Move in line into your opponent's marbles to push them, if you have more marbles in the line than they do: 2 push 1, 3 push 1 and 3 push 2. The space behind them must be empty or off the board. Equal numbers never push."
+ },
+ "win": {
+ "title": "Push six marbles off to win",
+ "body": "A marble pushed past the edge is out of the game. The first player to push six of the opponent's marbles off wins."
+ }
+ },
+ "first_game": {
+ "title": "Your first game, move by move",
+ "intro": "Press the button below to open a game against the computer. This is how the first minutes go.",
+ "steps": {
+ "open": {
+ "title": "Open the game",
+ "body": "Press Play against the computer. The board opens ready to play, with no account and nothing to download."
+ },
+ "pick": {
+ "title": "Pick your marbles",
+ "body": "On your turn, tap up to three of your marbles that sit in a line."
+ },
+ "direction": {
+ "title": "Choose where they go",
+ "body": "Tap one of the highlighted spaces. Your marbles move there and the computer replies."
+ },
+ "stuck": {
+ "title": "Ask for help when you are stuck",
+ "body": "The Hint button shows a suggested move, and Undo takes back your last move."
+ },
+ "push": {
+ "title": "Look for your first push",
+ "body": "Once the armies touch, line up more marbles than your opponent has in a row and push. Keep your own marbles together so they cannot do the same to you."
+ }
+ }
+ },
+ "faq": {
+ "title": "Questions beginners ask",
+ "items": {
+ "start": {
+ "question": "How do you start a game of Abalone?",
+ "answer": "Each player puts 14 marbles on their side of the board. Black goes first, then you take turns, one move each."
+ },
+ "first_move": {
+ "question": "What is a good first move in Abalone?",
+ "answer": "Move a short row from the front toward the center and keep your marbles touching. Do not send one marble alone ahead of the rest."
+ },
+ "win": {
+ "question": "How do you win at Abalone?",
+ "answer": "Push six of your opponent's marbles off the edge of the board. Trapping marbles does not win the game."
+ },
+ "free": {
+ "question": "Can I learn to play Abalone online for free?",
+ "answer": "Yes. Play against the computer or share one screen with a friend in your browser, with no account and no download."
+ }
+ }
+ },
+ "try": {
+ "title": "Play your first game",
+ "more_reading": "Done with the basics? Read the opening moves for beginners and the strategy tips."
+ },
+ "play": {
+ "cta": "Play against the computer",
+ "more": "Other ways to play"
+ },
+ "rules_link": "New to the game? Follow how to play Abalone step by step."
+ }
}
diff --git a/apps/game/src/routing/config.ts b/apps/game/src/routing/config.ts
index c0943f2..794cd01 100644
--- a/apps/game/src/routing/config.ts
+++ b/apps/game/src/routing/config.ts
@@ -26,6 +26,7 @@ export const routes = rootRoute([
layout("subpage", "layouts/subpage.layout.tsx", [
route("/rules", "pages/rules.page.tsx"),
route("/strategy", "pages/strategy.page.tsx"),
+ route("/how-to-play", "pages/how-to-play.page.tsx"),
route("/opening-moves", "pages/opening-moves.page.tsx"),
route("/play-online", "pages/play-online.page.tsx"),
route("/starting-positions", "pages/starting-positions.page.tsx"),
diff --git a/apps/game/src/routing/pages/how-to-play.page.tsx b/apps/game/src/routing/pages/how-to-play.page.tsx
new file mode 100644
index 0000000..37f4f89
--- /dev/null
+++ b/apps/game/src/routing/pages/how-to-play.page.tsx
@@ -0,0 +1,142 @@
+import { createFileRoute, Link } from "@tanstack/react-router"
+import { Trans, useTranslation } from "react-i18next"
+import type { ManualChapter } from "@/components/manual/chapter"
+import { Chapter } from "@/components/manual/chapter"
+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"
+import { routeLanguage } from "@/routing/route-language"
+import { seoPageHead } from "@/routing/seo-pages"
+
+export const Route = createFileRoute("/_subpage/how-to-play")({
+ head: ({ match }) =>
+ seoPageHead("/how-to-play", routeLanguage(match.context)),
+ component: HowToPlayPage,
+})
+
+/**
+ * How to play, for somebody who has never played: the rules in five steps, a
+ * first game against the computer, and a few beginner questions. Built like the
+ * other guides, with link targets for each step (/how-to-play#push) that the
+ * HowTo markup points at (`structured-data.ts`).
+ */
+
+/** The rule steps, in the order the HowTo markup lists them. */
+const RULE_STEPS = ["setup", "move", "kinds", "push", "win"] as const
+
+const GAME_STEPS = ["open", "pick", "direction", "stuck", "push"] as const
+
+const FAQ_ITEMS = ["start", "first_move", "win", "free"] as const
+
+const TEXT_LINK_CLASS =
+ "font-semibold text-brand-light underline underline-offset-2 hover:text-white"
+
+const STEP_LIST_CLASS =
+ "flex list-decimal flex-col gap-y-3 pl-6 marker:font-semibold marker:text-faint"
+
+function HowToPlayPage() {
+ const { t } = useTranslation()
+
+ const chapters: ManualChapter[] = [
+ { id: "rules-in-five-steps", title: t("game:howto.steps.title") },
+ { id: "first-game", title: t("game:howto.first_game.title") },
+ { id: "faq", title: t("game:howto.faq.title") },
+ { id: "play", title: t("game:howto.try.title") },
+ ]
+
+ const playBlock = (
+
+ )
+
+ return (
+ <>
+ {/* The page's own heading is on a phone too, so the bar's title is a
+ label: one `