mancala/docs/specs/game-bot/design.md
Halit Aksoy e66c4a3a41 feat(frontend): offer games against the bot
The lobby offers the bot levels after 10 seconds without an opponent, the
home page has a Play with bot menu, and a game against the bot ends with
Play again at the same level.
2026-10-16 14:22:04 +03:00

17 KiB
Raw Blame History

Game bot: design

See requirements.md for what is built; requirement ids (R1.1, …) are referenced below. Builds on game-clock (the clock), game-history (saved games, live previews) and quarkus-backend (the Java backend and its engine).

Decisions

Decision Reason
The bot runs in the backend, in Java The rules are already in Java (app.playmancala.engine) and kept equal to the TypeScript engine by EngineParityTest. A bot game is then an ordinary server game: the clock, saving, spectators, previews and restarts work without changes in the clients (R4). A bot on the device would only add offline play, at the cost of a second bot in TypeScript (out of scope).
A compact board, Position (int[14] and whose turn it is), in the engine package Searching means playing millions of moves; MancalaGame builds Pit objects, lists and animation steps for each, which is far too slow. Position plays a move into an existing array without allocating. It is in engine, not bot, so other code can use it too, e.g. checking a move before running it (see TODO.md, Security). It is tested against MancalaGame with the recorded parity games and random games.
MancalaGame is not rebuilt on Position now MancalaGame also produces the game steps clients animate, the history and the JSON the Node backend wrote, and it must stay equal to the TypeScript engine. Rebuilding it is a separate refactor once Position has proven itself (out of scope).
Negamax with alpha-beta pruning, limited by depth and a node budget, not by time Mangala is small (6 moves at most per turn), so alpha-beta is strong enough: at depth 12 the bot plays close to perfectly. Limits that do not depend on time make a move depend only on the position, the strength and the seed, so a game can be reproduced for debugging; a slower machine only takes longer.
Strength (0–100) sets three things: search depth, how much the bot prefers the best move (softmax temperature) and how often it plays a random move Depth alone gives few, coarse steps and a predictable bot. With softmax, a weaker bot more often plays a move that is nearly as good, which looks like a human mistake; the random moves make Easy beatable by beginners (R2.3). Interpolating a table gives smooth steps between levels.
Each level is a range of strengths; the strength is drawn per game and varies by ±5 per move R2.2. The ranges have gaps between them so neighbouring levels stay clearly apart (R2.4).
Randomness comes from a per-game seed and the move number The bot's choice of a move is reproducible from the saved game, and a restart does not change how it plays (R4.6). The seed and strength are kept with the live game and the saved game.
Searching runs on its own small thread pool; the result goes back to the game loop, which checks that the game is still waiting for that move The game loop must never wait (R3.5, AGENTS.md). The pool has a fixed number of threads, so many bot games share the CPU instead of starving the loop and the WebSocket threads. A result is dropped if the game ended or moved on meanwhile.
The bot's waiting time is computed on the server: the previous move's animation (300 ms per step, as PitAnimator) plus a random 1–3 seconds R3.2. Clients do not need to know about bots for this. Searching happens during the wait, so the time it takes is not added to it.
Bot ids are bot:<level>, e.g. bot:medium One stable id per level reads well in saved games and lets later statistics group games by level. It is public and cannot belong to a person: Keycloak ids start with kc: and legacy ids must be UUIDs (RtmtServer). Because one id plays many games at once, GameStore does not map it to a game. Clients recognise bots by the id, so PlayerSummary keeps its shape.
The offer to play the bot (R1.1) is a timer in the client The server does not need to know how long someone has waited. Matchmaking stays as it is; choosing a level sends a new message that also ends the search on the server.
Bot games are ordinary games everywhere: clock, saving, spectators, live previews, popular games R4. Fewer special cases. The bot's moves go through the same code as a player's moves (R3.1).

Shared types (packages/core)

// rtmt/channel_names.ts
export const channel_new_bot_game = "new_bot_game";   // client -> server: { level: BotLevel }

// models/Bot.ts
export type BotLevel = "easy" | "medium" | "hard" | "master";
export const BOT_LEVELS: BotLevel[] = ["easy", "medium", "hard", "master"];
export const BOT_OFFER_DELAY_MS = 10_000;              // R1.1
export function isBotId(userId: string | undefined): boolean;        // starts with "bot:"
export function getBotLevel(userId: string | undefined): BotLevel | undefined;
export function isBotLevel(value: unknown): value is BotLevel;
export function getBotLevelTextKey(level: BotLevel): string;            // e.g. BotLevelMedium
export function getLastBotLevel(storage): BotLevel;                     // Medium until one is chosen (R1.4)
export function setLastBotLevel(storage, level: BotLevel): void;
  • getPlayerDisplayName(player, t) takes userId too (every caller has it) and returns e.g. "Bot · Orta" for bots (R4.1).
  • Texts (en, tr): PlayWithBot "Play with bot" / "Bota karşı oyna", NoOpponentYet "No opponent yet. Play with the bot?" / "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", ChooseBotLevel "Choose how strong the bot is" / "Botun ne kadar güçlü olacağını seç", Bot "Bot" / "Bot", BotLevelEasy, BotLevelMedium, BotLevelHard, BotLevelMaster "Easy", "Medium", "Hard", "Master" / "Kolay", "Orta", "Zor", "Usta", PlayAgain "Play again" / "Tekrar oyna".

Backend

Modules

engine/Position.java          compact board and the rules on it
bot/BotLevel.java             the levels, their wire names and strength ranges
bot/BotSettings.java          record (level, strength, seed); kept with a bot game
bot/BotStrength.java          strength -> depth, temperature, random move chance
bot/BotSearch.java            negamax with alpha-beta; scores of the root moves
bot/BotPolicy.java            chooses a move: strength, search, softmax, random moves
bot/BotPlayer.java            plays the bot's turns in live games
db/migrations/003_bot.sql     bot column

engine and bot (except BotPlayer) have no Quarkus or game loop code, so they can be tested and run on their own.

Position

public final class Position {
    // CommonBoard layout: player 1's pits 0-5, bank 6; player 2's pits 7-12, bank 13.
    public static Position initial(boolean player1ToMove);
    public static Position of(int[] stones, boolean player1ToMove);
    public static Position of(MancalaGame game);
    public int[] stones();               // a copy
    public boolean isPlayer1ToMove();
    public int legalPits(int[] into);    // pit numbers 0-5 of the mover's non-empty pits; returns the count
    // Plays pit 0-5 of the mover into `into` (may be this). Returns true if the mover plays again.
    public boolean play(int pit, Position into);
    public boolean isOver();             // one side is empty
    public int bankDifference();         // mover's bank minus the opponent's
}

play applies the rules in the engine's order: sowing (one stone goes to the next pit, otherwise the first stone goes back into the played pit; the opponent's bank is skipped), last stone in an own empty pit, even count in an opponent's pit, last stone in the own bank (extra turn) or the turn passes, and clearing the board when a side is empty.

Search (BotSearch)

  • Negamax with alpha-beta. A move that gives an extra turn keeps the side to move, so its child's score is not negated.
  • Scores are from the mover's view, in hundredths of a stone. An ended game scores ±100_000 + 100 × final bank difference; a game where a bank holds more than 24 stones is decided and scores as ended. Other leaves: 100 × bank difference. A term for the stones on each side (PIT_STONE_WEIGHT) is there but set to 0: the levels met R2.4 without it, and stones on a side may still go to either player.
  • Move order: extra turns first, then by the stones a move brings to the bank. Each node plays its moves once to order them, into arrays kept per ply, so searching allocates nothing.
  • Iterative deepening up to the depth; each root move is searched with a full window so every root move gets an exact score (the softmax needs them). If the node budget (3 million) runs out, the scores of the last completed depth are used; depth 1 always completes.

Strength (BotLevel, BotStrength)

Level Wire name Strength
Easy easy 5–25
Medium medium 30–55
Hard hard 60–80
Master master 85–100

Interpolated linearly between rows (depth rounded):

Strength Depth Temperature (stones) Random move chance
0 1 4 35%
25 2 2 15%
50 4 1 5%
75 7 0.4 1%
100 16 0.05 0%

The first table had depth 12 at strength 100 and already met R2.4. Searching deeper still helped: with best moves only, depth 16 scored 64% against depth 12 (depth 14: 54%), at about 60 ms per move on average and 1 s at most without a budget. So the top row is 16, and the node budget keeps the slowest moves near 0.2 s.

BotTournamentTest with these values, 200 games per pair (October 2026, 24 cores, one thread):

Pair Won Lost Drawn Score Stronger level's time per move
Medium vs Easy 155 34 11 0.80 < 0.1 ms
Hard vs Medium 158 29 13 0.82 0.2 ms
Master vs Hard 177 16 7 0.90 20 ms
Master vs Easy 200 0 0 1.00 22 ms

Choosing a move (BotPolicy)

static int chooseMove(Position position, BotSettings bot, int moveNumber);   // pit 0-5
  1. A random source from bot.seed() and moveNumber (number of moves played in the game).
  2. This move's strength: bot.strength() ±5, kept within 0–100.
  3. With the random move chance: a random legal pit.
  4. Otherwise search to the depth and pick a root move with probability ∝ exp(score / (temperature × 100)).
  5. With a single legal pit, play it without searching.

Bot games in the game logic (GameManager)

  • New channel new_bot_game with { level }. An unknown level is logged and ignored. A player with a game in progress gets on_new_game_rejected (R1.5). Otherwise matchMaker.cancel(userId), then a game with the player as player 1 and bot:<level> as player 2, a random first mover (R1.6), and bot = new BotSettings(level, random strength in the level's range, random seed); then startGame as usual.
  • BackendGame gains BotSettings bot (null for games between people) and botId().
  • The bot's PlayerSummary is (bot:<level>, "Bot", false).
  • onGameMove is split: the socket handler finds the game and calls playMove(game, playerId, pitIndex), which holds today's logic (clock check, engine, clock charge, updates, ending). The bot calls the same method (R3.1).
  • onTurnStarted(Consumer<BackendGame>): called after a game starts, after a move that does not end the game (including an extra turn) and for each restored game.
  • Connection info shows the bot online (R3.4); messages to the bot's id are not sent.
  • GameStore.setGameByUser and removeGameByPlayer ignore bot ids.
  • PublicIds.publicUserId returns bot ids as they are.

BotPlayer

public BotPlayer(GameManager gameManager, GameStore gameStore, Scheduler loop, Executor searchExecutor,
        RandomGenerator random)   // random: thinking times only; used on the loop

Listens to onTurnStarted. When it is the bot's turn and no move is being worked out for that game:

  1. On the loop: copy the position (Position.of), note the move number and compute readyAt = now + animationMs(last move) + thinkMs(level). animationMs is 300 ms per GAME_STEP_GAME_MOVE step of the last move plus 300 ms; before the first move it is 1.5 s, so the game page has opened. thinkMs is random: Easy 0.8–1.8 s, Medium 1.0–2.2 s, Hard 1.2–2.6 s, Master 1.4–3.0 s. With less than 30 s on the bot's clock it is 0.3 s (R3.3).
  2. On searchExecutor: BotPolicy.chooseMove. If it throws, the error is logged and the first legal pit is used, so the game never waits for a bot that failed.
  3. Back on the loop (loop.execute, then loop.schedule for what is left until readyAt): if the game is still live, it is the same BackendGame, the move number has not changed and it is the bot's turn, call gameManager.playMove(game, botId, pit); otherwise drop the result.

Games being worked on are kept in a set by identity, so a game restored with the same id is a new game for it.

searchExecutor is a fixed pool of BOT_SEARCH_THREADS platform threads (default: half the processors, at least 1), created and closed in Backend. Unit tests pass a direct executor and ManualScheduler.

Restore (R4.6)

  • LiveGameSnapshot gains bot (@JsonInclude(NON_NULL)); the snapshot stays version 2, since the field is optional. fromSnapshot restores it. A snapshot with a bot player but no bot field (not expected) gets the middle strength of the level and a new seed.
  • restoreLiveGames calls the turn listeners, so a bot whose turn it was moves.

Saving (R4.3)

  • GameRecord gains bot (@JsonInclude(NON_NULL)): { level, strength, seed }, sent with GET /game/:id; clients do not use it. It allows tuning the levels from real games later.
  • Migration 003_bot: ALTER TABLE games ADD COLUMN bot jsonb; (NULL for games between people). PostgresGameHistoryRepository writes and reads it.

Configuration

BOT_SEARCH_THREADS (default: half the processors, at least 1).

Web app

  • components/BotLevelPicker.tsx: the four levels as buttons; the last chosen level is highlighted. It is kept with getLastBotLevel / setLastBotLevel from core in the app's storage (R1.4).
  • Home page: a "Play with bot" button next to "New Game" opens BotLevelMenu, a menu of the levels like the account menu, with the last level checked; choosing a level goes to /loby?bot=<level> (R1.3).
  • LobyPage: with ?bot=<level>, it sends new_bot_game instead of new_game. Otherwise, after BOT_OFFER_DELAY_MS without a game, it shows NoOpponentYet and the picker below the search; choosing a level sends new_bot_game (R1.1, R1.2). The existing on_game_start and on_new_game_rejected handling stays.
  • GamePage / GameOverCard: in a bot game, the main button is "Play again" and goes to /loby?bot=<level> with the bot's level (R4.4).
  • Bot names: every place that shows a player uses getPlayerDisplayName with the player's userId (R4.1).

Mobile app

  • LobyScreen: the same offer after BOT_OFFER_DELAY_MS, and a botLevel route parameter that sends new_bot_game at once.
  • HomeScreen: a "Play with bot" button with the level choice; the last level is kept in MMKV.
  • Bot names through getPlayerDisplayName.

Testing

  • Position: replays the recorded parity games (/engine/recorded-games.json) and a few thousand random games move by move against MancalaGame, comparing stones, whose turn it is and the end.
  • BotSearch: finds an extra turn, a capture and a win in one or two moves; the same position, depth and budget give the same scores; the node budget is respected.
  • BotStrength and BotLevel: interpolation, the ranges, strengths drawn stay in range.
  • BotPolicy: the same seed and move number give the same move; random moves only at the expected rate; a single legal pit is played; R2.5 with a fixed opponent.
  • Calibration: BotTournamentTest, tagged calibration and left out of ./mvnw test (./mvnw test -Dgroups=calibration), plays 200 games for each pair of neighbouring levels and Master against Easy, alternating the first mover, and fails if R2.4 does not hold. It also reports the average search time per level. A quick version (20 games, Master against Easy) runs with the normal tests.
  • BotPlayer with FakeRtmt, ManualScheduler and a direct executor: the bot moves after the delay; extra turns; a game that ended or moved on while searching drops the result; low time shortens the wait; a restored game where it is the bot's turn gets a move. (The fallback for a failing search has no test: BotPolicy is static and cannot be made to fail from outside.)
  • GameManager: new_bot_game starts a game with the bot and ends the search; it is refused during a game; unknown levels are ignored; the bot shows as online and gets no messages; the saved record keeps the bot id and bot; leaving and running out of time end the game as usual.
  • Postgres repository: the bot column is saved and read. Snapshots with and without bot restore.
  • End to end in Chrome with GAME_CLOCK_MS=60000: the offer appears after 10 s in the lobby and starts a game; "Play with bot" on the home page; the bot moves after the animation; the game ends and shows in recent games and the replay as "Bot · "; "Play again" starts a new bot game; a backend restart during the bot's turn; a second browser watches a bot game from the live previews.