mancala/docs/specs/tutorial/design.md
Halit Aksoy acd60816f7 docs: describe the tutorial
AGENTS.md and TODO.md notes for the tutorial, and the end-to-end results. A
move that ends the game is no longer explained as giving an extra turn.
2026-10-16 15:00:44 +03:00

9.8 KiB
Raw Permalink Blame History

Tutorial: design

See requirements.md; requirement ids (R1.1, …) are referenced below. Builds on web-redesign (board, pages, themes) and game-bot (the Easy bot for the practice game).

Decisions

Decision Reason
Rules, lessons and hints are defined once in packages/core The web and mobile apps show the same rules (R5.1) and the texts stay in one place.
The "after" board of every example and the outcome of every lesson move come from running the engine (mancala.js), never written by hand The texts and pictures cannot drift from what the game does (R1.3). A rule change shows up in the tutorial at once, and a test fails if a lesson no longer works.
Lessons run entirely in the client on the TypeScript engine No server, no login, no waiting (R2.5). The engine is already in every client.
A lesson's task is an outcome ("end in your store"), not a pit Players who find another move with the same effect are right too; the check is the engine's game steps, the same ones the move list uses.
The practice game is an ordinary Easy bot game with hints on; hints are a client feature of any bot game No new server state (R3.1). Hints only show the immediate effect of a move, which the board's preview mostly shows already, so they give little away; still, they are limited to bot games (R3.5).
Hints look one move ahead only They explain the rules, not strategy. Looking further would need the bot's search on the client or a new server message (out of scope).
Progress, "not now" and the hints setting are kept in the device's storage Enough for learning the rules (R2.6, R3.4, R4.1); accounts are not needed for any of it.
Mobile gets the rules screen only for now The mobile app still has the old board and game screen; lessons and hints there would be built twice (R5.2).
The end-of-game lesson teaches the current rule TODO.md (Game rules) asks whether the remaining stones should always go to the player whose side is empty. The tutorial follows the engine; because its boards come from the engine, a rule change only needs the texts updated.

The rules as taught (R1.3)

Checked against packages/mancala.js (Board.move, the four GR… rules):

  1. Board and goal: two rows of 6 pits with 4 stones each, a store on each side. Stones in your store are yours; at the end, more stones in your store wins (48 stones: 25 always wins). A draw is possible.
  2. Sowing: take all stones from one of your pits; put the first back into the same pit and one into each pit after it. A single stone moves to the next pit. Your own store gets a stone on the way, the opponent's is skipped.
  3. Extra turn: if the last stone lands in your store, you play again.
  4. Even capture: if the last stone lands in an opponent's pit and makes its count even, you take all stones in it.
  5. Empty pit capture: if the last stone lands in an empty pit of yours and the opponent's pit opposite has stones, you take both.
  6. End of the game: the game ends when one side's pits are empty; the player whose side is empty gets the stones left on the other side. Then the stores are counted.
  7. Clock: 5 minutes per player for the whole game; the clock of the player to move runs; when it reaches zero, that player loses.

Shared code (packages/core/src/tutorial/)

type RuleId = "board" | "sowing" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endOfGame" | "clock";

interface RuleExample {
    board: number[];          // 14 stone counts, player 1 (bottom) to move
    pit: number;              // board index played
}

type LessonTask = "anyMove" | "extraTurn" | "evenCapture" | "emptyPitCapture" | "endGame";

interface Lesson {
    board: number[];          // player 1 (bottom) to move
    task: LessonTask;
    taskKey: string;          // e.g. "LessonTaskExtraTurn": "Find the pit whose last stone lands in your store"
}

interface Rule {
    id: RuleId;
    titleKey: string;
    textKeys: string[];       // at most three sentences (R1.2)
    example?: RuleExample;
    lesson?: Lesson;          // every rule except the clock (R2.1)
}

export const RULES: Rule[];

// The immediate effect of playing a pit, by running the engine on a copy of the board.
interface MoveOutcome {
    path: number[];               // where the stones went (getSowingPath)
    extraTurn: boolean;
    capture?: "even" | "emptyPit";
    captured: number;             // stones taken from the opponent's side
    storeGain: number;
    endsGame: boolean;
    board: number[];              // after the move
}
export function getMoveOutcome(board: number[], pitIndex: number): MoveOutcome;   // the mover follows from the pit
export function fulfilsTask(task: LessonTask, outcome: MoveOutcome): boolean;

// "Why did that happen" for a wrong lesson move and for the practice game (R2.3, R3.3).
export function explainMove(outcome: MoveOutcome, mover: "you" | "bot", t): string;

getMoveOutcome sets the stones on a CommonMancalaGame, plays the pit and reads the game steps, as describeMove does for the move list. None of the six lessons needs the opponent to move, so lessons have no scripted replies (R2.4 has nothing to do yet). describeMove gains capture kind and captured, so the move list, hints and explanations share one reading of the steps.

Lesson positions (player 1, bottom, to move; pits 0–5, store 6), checked with the rules above. Task 2 tests that each has a move that fulfils its task and, where there is more than one legal move, one that does not:

Lesson Board Task Moves that fulfil it
Board and goal start position anyMove all
Sowing [4,4,1,4,4,4, 1, 4,4,4,4,4,4, 2] anyMove; the text points at pit 3's single stone, which moves to pit 4 all
Extra turn start position extraTurn pit 4
Even capture [4,4,4,4,4,4, 0, 4,3,4,4,4,4, 1] evenCapture pit 6: its last stone makes the opponent's 3 a 4
Empty pit capture [2,0,4,4,4,4, 3, 4,4,4,4,5,4, 2] emptyPitCapture pit 1: its last stone lands in the empty pit 2, opposite 5 stones
End of the game [0,0,0,0,0,1, 20, 2,3,1,0,2,1, 18] endGame pit 6 (the only move): your side is empty and the 9 stones opposite are yours, 30–18

Texts: Rule<Id>Title, Rule<Id>Text1..3, LessonTask…, MoveExplain…, HowToPlay, Learn, Hints, NewToMangala, StartTutorial, NotNow, PracticeGame, in en.ts and tr.ts.

Storage helpers next to the rules: getCompletedLessons / completeLesson, isTutorialBannerHidden / hideTutorialBanner, getHintsEnabled / setHintsEnabled, markGamePlayed.

Web app

Routes:

  • /rules: RulesPage with RulesView.
  • /learn: the lesson list with progress, then the practice game button.
  • /learn/:ruleId: LessonPage.

Components:

  • RulesView: the rules in order; each with its texts, a RuleBoard and "Try it" linking to the lesson (R1.5).
  • RuleBoard: two MiniBoards, before and after the example move, the played pit and the landing pit marked.
  • RulesPanel: RulesView in a side panel (full screen below 600 px) over the game page; opened from a "How to play" button in the game page's header; the game keeps running behind it (R1.4).
  • AppHeader: a help icon button "How to play" on every page, going to /rules (or opening RulesPanel on the game page).
  • LessonPage: the rule's text and task above a BoardView on a local game (createGameFromBoard), animated with PitAnimator like a live game. On a move: getMoveOutcome, animate, then praise and "Next lesson" or explainMove and "Try again", which resets the board.
  • TutorialBanner on the home page for newcomers (R4).
  • Hints (R3): in GamePage, when the opponent is a bot and hints are on, BoardView gets a hints map (pit → "Extra turn" / "Takes 5"), drawn as a chip above the pit; pits whose move does neither get no chip. Below the board a line with explainMove of the last move. A "Hints" switch next to the turn indicator, only in bot games; hidden while looking back at earlier moves.
  • Practice game: the lesson list's last button sets hints on and goes to /loby?bot=easy.

Mobile app

RulesScreen: the rules' texts and a simple stone-count drawing of each example (before/after), reachable from HelpScreen and from the game screen's header (R5.1).

Testing

  • Core: every lesson position fulfils its task with some move and (except anyMove) fails it with another; getMoveOutcome against the engine on random positions; every rule example produces the effect its rule is about (the even capture example really captures, etc.); explainMove for each kind of move in both languages; every text key exists in en and tr.
  • Web: build and the temporary type check.
  • End to end in Chrome: a newcomer sees the banner and "Not now" hides it; all six lessons, each first with a wrong move (explanation, reset) and then a right one; the list shows them done; the rules page and the rules panel during a game (the clock goes on); a practice game shows hints and explanations, and a game between two people shows none.
  • Mobile: type check and bundle; the rules screen on a device when one is available.

Checked end to end in Chrome on 2026-10-16: a newcomer's banner, "Not now" hiding it, and the banner gone after the lessons; all six lessons from the banner, the four with a wrong move first (explained, then reset) and each done; the list showing all six done and the last lesson offering the practice game; the rules page and, during a game, the rules panel with the clock running behind it (5:00 → 4:57); a practice game with hint chips that match the board and an explanation of each move, hints turned off with the switch; a game between two people with hints turned on on both devices showing no switch and no hints; the rules page and the lesson list at phone width.