Boardgame BOT Concept
Board game bot concept for web app version
Principles for Building AI Bots for Web-Based Board Games
Oct 2, 2026 · @Poom
Overview
A good bot for a web-based board game should offer three difficulty levels that are meaningfully different in skill, measurable in performance, fair to players, and fast enough to respond within an acceptable waiting time.
This document summarizes principles that can be applied to general turn-based board games, including games with hidden information such as cards in hand or draw piles, as well as games involving randomness such as dice rolls and card draws.
Four core principles:
- No cheating: The bot sees exactly the same information as a single player. Anything hidden must be inferred or guessed.
- Always follow the rules: The bot may only choose actions from the engine's list of legal actions, with tests verifying this behavior.
- Deterministic and reproducible: Given the same seed and the same state, the bot must make the same decision so games can be debugged and replayed.
- Measurable difficulty: Each difficulty level should have a measurable win-rate target against the level below it, evaluated through thousands of simulated games.
| Level | Intended Experience | Target Players |
|---|---|---|
| Easy | Plays legally, but forgiving and makes visible mistakes | Children, first-time players |
| Medium | Good at short-term scoring and avoids obvious damage | General players |
| Hard | Looks ahead, estimates opponents' hands, pressures the leader, and plays to win | Board game enthusiasts |
Engine Requirements for Bots
A bot can only be as strong as the engine's ability to simulate the game efficiently.
The engine should therefore be designed from the beginning as pure game logic independent of the UI. Separate it into engine, bots, and sim packages that can run in the browser, Web Workers, and Node.js.
| Engine Requirement | Why the Bot Needs It |
|---|---|
GameState is pure JSON with no classes or circular references | Fast cloning, transferable to Workers, saveable and replayable |
pending indicates who must make which decision | Every decision point, including sub-decisions during a turn, goes through the same bot interface |
getLegalActions(state) | The bot can only select from this list, guaranteeing that it never makes an illegal move |
applyAction, both pure and in-place versions | Pure version for UI; in-place version for simulations to avoid copying state every turn |
advance automatically progresses phases with stop points such as end-of-round | Allows rollouts to reach a defined horizon |
getPlayerView(state, player) | Removes hidden information before sending state to the bot, acting as the anti-cheating boundary |
previewAction(state, action) | Provides immediate consequences such as score or resource changes; useful for both UI and Easy bots |
Seedable RNG with fork() | Reproducible simulations while keeping simulated randomness independent from the real game |
Game-rule constants stored in RulesConfig rather than hard-coded | Bots read scoring and penalty values from one source, so rule changes do not require bot rewrites |
Never use Math.random() or real-world time directly inside the engine or bots.
All randomness must come from an injected RNG. Time should also be injected through now, allowing tests to replace wall-clock time with deterministic iteration limits.
The bot interface should remain as small as possible:
interface Bot {
level: 'random' | 'easy' | 'medium' | 'hard';
decide(
view: PlayerView,
ctx: {
rng: Rng;
now?: () => number;
timeBudgetMs?: number;
maxIterations?: number;
}
): Action;
}
Determinization: Guessing Hidden Information
Every bot level starts from the same function:
determinize(view, rng)
This function generates a complete GameState consistent with everything the player currently knows, while randomly filling in everything else.
The bot can therefore use the full engine without ever accessing the game's real hidden information.
| Use Actual Information | Randomize Each Time |
|---|---|
| The player's own hand | Opponents' hands, preserving visible hand sizes |
| Face-up cards or pieces on the board | Face-down cards |
| Scores, resources, phases, and turn order | Order of all draw piles |
| Revealed and discarded cards | Future random outcomes such as dice rolls and events, using rng.fork() |
A consistent randomization process:
- Build the full deck for each player according to the rules, then remove all cards whose identities are already known, such as cards in our hand or cards revealed on the board.
- Shuffle the remaining cards and distribute them in order: face-down cards on the board → hands based on visible hand sizes → draw piles.
- Shuffle shared random decks such as event decks using only cards that remain. The player's view should expose aggregate information such as how many cards remain without revealing their order.
- Create a new RNG for the generated state using
rng.fork()so the simulation never uses the real game's RNG seed.
Important considerations:
- Uniform randomization is a good enough starting point. Do not add behavioral inference about opponents until measurements show that it provides meaningful value.
- Bots that repeatedly use determinization, especially Hard bots, must generate a new determinization for every iteration. Otherwise, the bot becomes overly committed to a single imagined hidden state.
- Required test: given two states with different hidden information but identical player views, the bot must make the same decision when given the same seed.
Difficulty Ladder
Each difficulty level adds one additional layer of reasoning.
Higher levels should reuse components from lower levels. For example, Hard may use Medium's evaluation function to prune candidate actions, while every level should be able to fall back to Easy when necessary.
| Level | Algorithm | Lookahead | Opponent Modeling | Time per Decision |
|---|---|---|---|---|
| Random | Random legal action | None | No | Immediate |
| Easy | Weighted random + remove obviously bad moves | Immediate effects | No | < 5 ms |
| Medium | Greedy: test every move and simulate to the next scoring point | End of current round/phase | Slightly, assuming opponents pass | 10–100 ms |
| Hard | Determinized flat Monte Carlo + UCB1 | 1–2 rounds or game end | Simulates realistic opponent play | 0.5–1 second budget |
Random should not be selectable by players.
It exists as a baseline for performance measurements and as a fuzzer for finding bugs in the game engine.
Easy: Random, but with Guardrails
decide(view):
s = determinize(view)
if sub-question has an obviously good answer:
return that answer
if rng < 0.25:
return random(legal) ← intentionally makes visible mistakes
good = legal.filter(
immediate result > 0
and does not cause immediate disaster
)
return good is empty
? random(legal)
: weightedRandom(good, w = 1 + score)
- Mistakes should come from short-sightedness and randomness, not from repeatedly making self-destructive moves. Otherwise, players will feel that the bot is broken rather than easy.
- Minimum requirement: Easy should clearly outperform Random.
Medium: Greedy Lookahead to the Next Scoring Point
value(s, a) =
t = clone(s)
apply(t, a)
everyone plays a "neutral" action
(pass / default choice)
until the next scoring point
return ΔourScore
− α·ΔhighestOpponentScore
+ β·outlook(ourState)
decide = argmax value
(tie-break using rng · 1e-6)
- Good initial values:
α = 0.2,β = 0.5, then tune through simulation. outlookrepresents the expected value of our position after the next random event. Calculate it by averaging all possible outcomes rather than randomly sampling them, because this is both accurate and fast.- Intentional weakness: Medium assumes opponents do nothing. Human players can therefore beat it by actively responding and interfering with its plans.
- Use the same function at every decision point, including sub-decisions. Avoid writing special-case rules where possible.
Hard: Simulate Hundreds of Possible Futures
decide(view):
C = legal(determinize(view))
if |C| = 1:
return C[0]
if |C| > K:
C = top-K by Medium value ← K ≈ 24
for i in 0..maxIter while time remains
(check time every 16 iterations):
arm =
i < |C|
? i
: argmax_j(
w[j] / n[j]
+ c·√(ln(i) / n[j])
) ← UCB1, c ≈ 0.4
t = determinize(view) ← generate a new hidden state every time
apply(t, C[arm])
rollout all players using
ε-greedy quick policy
until horizon or game end
n[arm]++
w[arm] += reward(t) ← reward ∈ [0, 1]
return C with highest n
(tie → higher w)
- Flat means that the bandit algorithm runs only at the root instead of constructing a deep tree. In games with hidden information, deep trees branch heavily across different determinizations and quickly become expensive. Start with flat Monte Carlo. Only move toward ISMCTS if measurements show that flat search cannot meet the target.
- Select the action with the highest visit count, not simply the highest average reward. An action tried only a few times may appear unusually strong due to lucky random outcomes.
- Use both a time budget and an iteration limit. The real game uses the time budget, while simulations use iteration counts to guarantee reproducibility.
- Short horizons, such as the end of the next round followed by heuristic evaluation, often outperform full-game rollouts because they allow more iterations with less noise.
Evaluation Functions and Rollout
Most of the bot's actual strength comes from three functions.
Everything else is largely reusable infrastructure across different games.
1. evaluate(state, me) — Evaluate a State at the Search Horizon
V = (\text{score}_{me} - \max_{opp} \text{score}_{opp})
+ w \cdot \text{outlook}_{me}
\cdot \min(1, \text{remainingRounds})
- Measure the score difference against the current leader, not just our own score. This naturally encourages the bot to pressure the leading player without requiring explicit special-case rules.
outlookrepresents the expected value of assets or resources that will reach us in the next round, averaged across all possible random outcomes. It must become0when the game has ended.- Start with
w = 0.5–1.0and tune from there.
2. reward(state, me) — Convert the Result to [0, 1] for UCB1
-
During the game:
0.5 + 0.5 · tanh(V / scale)where
scale ≈the typical score gained in one round. -
At game end:
0.8 · winShare + 0.2 · marginA shared win between
nplayers gives each player1/n.
This encourages the bot to play for winning, rather than merely maximizing score.
The margin component helps distinguish actions when victory or defeat is already nearly guaranteed.
3. Quick Policy for Rollouts
During rollouts, the bot must make decisions for every player tens of thousands of times per turn.
These decisions therefore need to operate on the microsecond scale.
-
Evaluate actions without cloning state. Calculate deltas directly using the formulas associated with cards or actions.
-
Reduce the action space:
- Count identical card types only once.
- Merge actions with equivalent outcomes.
- For parameter-heavy actions, such as moving objects in many directions, use 6–12 predefined patterns instead of exploring every combination.
-
Always include a
passaction with value0, providing a safe exit when every available action is harmful. -
Use ε-greedy:
- 90% choose the highest-valued action.
- 10% choose randomly.
This prevents simulated opponents from behaving too predictably.
-
Sub-decisions during rollout should use reasonable default answers rather than performing expensive search.
Suggested heuristic components:
| Component | Starting Weight |
|---|---|
| Immediate score gained | 1.0 |
| Improvement or deterioration of our assets/resources | 1.0 |
| Damage to opponents' assets/resources | 0.4 |
| Value expected to reach us next round | 0.35 |
| Risk of penalties affecting something we just invested in | − full penalty value |
All scoring and penalty values must always come from RulesConfig.
Never hard-code rule values inside the bot.
Running Bots on the Web
Bots should always calculate their moves inside a Web Worker.
This prevents the UI from freezing even when the Hard bot uses its entire computation budget.
There must also be an immediate fallback path if the Worker fails or takes too long.
[embedded content: Requesting a move from the bot · Normal path and fallback path]
The normal execution path runs across the top.
Any abnormal condition falls back to the Easy bot, ensuring that the game can always continue.
-
Send only
PlayerViewto the Worker, never the full state. This both prevents cheating and reduces message size. -
Assign an ID to every request and discard late responses. For example, the player may press Undo or leave the game while the bot is still thinking.
-
Use one seed per decision, generated from the game seed plus the decision sequence number. This allows the entire game to be replayed deterministically.
-
Use difficulty-specific computation budgets. Hard should receive roughly 900 ms, ideally configurable from server-side settings. Other levels can use approximately 200 ms. The UI timeout can be calculated as:
budget × 2 + 1.5 seconds -
Fallback: if there is a timeout, error, or Worker creation failure, execute the Easy bot on the main thread so the game never gets stuck.
-
Player experience: minimum thinking time, such as 0.6 seconds, should be separate from computation time and configurable in settings. Show a "Thinking..." state and animate the bot's move using the same animation system used for human moves.
-
Slow devices: the time budget acts as a soft limit. The Hard bot naturally completes fewer iterations on slower hardware, making it weaker but preventing the game from freezing.
Testing and Tuning
A bot that looks smart may still lose to a simpler bot.
A simulation harness is therefore required so bots can play hundreds or thousands of games against one another.
Performance decisions should be based on numbers rather than intuition.
Harness
- CLI accepts:
- Bot type for each seat
- Number of games
- Seed
- JSON output file
- Rotate seats every game to eliminate first-player or positional advantages.
- During simulation, Hard bots should use
maxIterationsinstead of time budgets so results remain reproducible across machines. - Distribute games across multiple workers and report progress.
Metrics
- Win share: a win counts as
1; a shared win betweennplayers counts as1/n. Compare this against the baseline of1 / numberOfSeats. - Pairwise wins: in a 2-vs-2 test, compare every
(Bot A, Bot B)pair and record which bot scores higher. - Hard bot p95 decision time: measured separately through performance benchmarks.
Recommended Acceptance Criteria for Four-Player Games
| Experiment | Target |
|---|---|
| Easy ×1 vs Random ×3 | Easy scores higher than Random in most pairwise comparisons |
| Medium vs Easy (2v2) | Medium pairwise win rate ≥ 70% |
| Hard vs Medium (2v2) | Hard pairwise win rate ≥ 65% |
| Hard ×1 vs Medium ×3 | Hard win share ≥ 40% (baseline 25%) |
| Hard decision time | p95 ≤ 1,000 ms on desktop |
These thresholds may be adjusted depending on the game.
Games with a large amount of randomness will naturally produce smaller performance gaps between bot levels.
Define the thresholds before implementing the bots, and rerun the measurements whenever the rules change.
Required Tests
- Every bot level always returns a legal action across a large number of randomized states using property tests.
- Hidden information remains invisible: different hidden states with identical views → identical decisions.
- Same seed → same decision.
- "Must-see" situations: create states with one obviously correct move; Medium and Hard must select it.
- Small acceptance test of approximately 200 games in CI, plus full acceptance tests of at least 2,000 games nightly or before release.
Tuning
- Change one parameter at a time and measure against the same seed set, or use grid search when tuning several parameters.
- Recommended starting values:
- top-K = 24
- UCB
c = 0.4 ε = 0.1- 1,500 iterations
α = 0.2β = 0.5
- If Hard does not beat Medium convincingly, inspect the rollout policy first. A rollout policy that behaves very differently from real players can push estimated values in the wrong direction.
Explaining the Bots to Players and Final Checklist
Players are more likely to trust the bots when they understand that the bots do not cheat.
There should be a "How the Bot Thinks" page accessible from both the opponent-selection screen and the rules/help section.
It should include:
- How the bot avoids cheating, explaining what information is used directly and what information is randomly inferred.
- Each difficulty level:
- One-sentence summary
- Step-by-step process
- Medium evaluation formula
- Hard bot decision-flow diagram
- Comparison table covering:
- Lookahead depth
- Opponent modeling
- Randomness awareness
- Mistake frequency
- Guidance on which difficulty players should choose, including notes about fallback behavior on slower devices.
Values configurable by the system, such as the Hard bot's time budget, should be pulled from the actual configuration.
Constants hard-coded inside bot logic should include notes in the explanation page indicating that both locations must be updated together.
Checklist for a New Game
- Engine provides legal actions, in-place apply, advance with stop points, player views, and seeded RNG.
- Implement
determinizeand tests proving hidden information is inaccessible. - Implement Random bot and simulation harness; use Random to find engine bugs first.
- Define acceptance targets before implementing production bots.
- Implement Easy → measure → Medium → measure → Hard → measure, one level at a time.
- Add Worker execution, time budgets, fallback behavior, and minimum UI thinking time.
- Create a player-facing explanation page for the bots.
- Rerun acceptance tests whenever game rules change and record the results in the design documentation.