mancala/docs/specs/game-bot/design.md
Halit Aksoy f7a76c861e docs: describe the game bot
The bot in the backend README with the calibration command, AGENTS.md notes on
bot ids and test tags, and the open work in TODO.md.
2026-10-16 14:27:49 +03:00

240 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`)
```ts
// 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`
```java
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`)
```java
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`
```java
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 · <level>"; "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.
Checked end to end in Chrome on 2026-10-16 with the default clock: the home menu and the lobby offer (shown after
10.3 s) start games at the chosen level; "Bot · Easy" / "Bot · Hard" on the game page, in recent games and in a
second browser's live previews; an Easy game played to the end (the bot waited 1.45–4 s per move, longer after long
animations); "Play again" starts a new game at the same level; the backend stopped right after a Master game started
and the bot made its first move after the restart.