Skip to content

Enhancement: tiered computer opponent with turn-level planning + 1P/2P setup UI #1

Description

@jimerb

Summary

The current computer opponent (cfg.ai, see maybeAI() / aiStep() / scoreMove() in twiddle.html) picks each of its sub-moves with a single fixed heuristic: take a free capture if available, otherwise weight down squares that hang a piece and up squares that threaten one, plus a little random jitter. It has no difficulty levels, no lookahead beyond the immediate move, and no awareness of the mechanic that makes Twiddle interesting — that the square you leave rotates, which reshapes the board for whoever visits it next.

This issue proposes (1) a tiered computer opponent that actually plans a turn instead of picking moves one at a time in isolation, and (2) the UI to select and configure it.


1. Computer strategy

Why "one move at a time" isn't enough

A Twiddle turn is 3–6 individual sub-moves, and only the board state at the end of the turn is visible to the opponent — everything in between is invisible bookkeeping. A greedy per-sub-move AI can walk itself into a bad final position (e.g. spend its last move capturing a piece but land its own piece on a now-exposed square) because it never evaluates the turn as a whole. The fix is to search over short sequences of sub-moves and score the resulting end-of-turn board, not each sub-move independently.

Proposed structure: three difficulty tiers

Easy — reactive (mostly what exists today)

  • Keep the current one-ply greedy scorer: prioritize free captures, penalize moves that hang a piece to an immediate recapture, small randomness for variety.
  • No lookahead past the current sub-move. This tier exists to be genuinely beatable — good for learning the rules.

Normal — turn-level beam search

  • At the start of the turn, generate the top-K candidate moves (K ≈ 5–6) by the Easy heuristic, then extend each into a full turn via a bounded beam search (keep the best 3–4 partial turns after each sub-move, discard the rest) rather than exhaustively enumerating every combination (branching factor is too large to search fully: up to ~4 legal directions × up to NM pieces-worth of moves per turn).
  • Score only the final board state at the end of the simulated turn, using the evaluation function below — this is what fixes the "captured a piece but left itself exposed" problem.
  • Depth: 1 full own-turn lookahead. Does not simulate the opponent's reply.

Hard — shallow minimax over turns

  • Same beam-search turn generation as Normal, but now also simulates the opponent's best-response turn (via a cheaper Easy/Normal-level beam search) and picks the move that maximizes (own end-of-turn score − opponent's best reply score), i.e. a 2-ply minimax where each "ply" is a full turn, not a single move.
  • Iterative deepening with a time budget (e.g. 400–600ms per decision) so it degrades gracefully instead of freezing the UI — start with Normal-depth search, extend if time remains.
  • Move ordering: try captures and escape-from-threat moves first, so alpha-beta pruning cuts the tree effectively within the time budget.

Evaluation function (shared by Normal/Hard)

Score a board state from the AI's perspective as a weighted sum:

Factor What it measures Why it matters
Material (my pieces − their pieces), heavily weighted The actual win condition
Threat exposure count of my pieces reachable by an enemy piece next turn, minus the same for the opponent Avoid walking into a capture; look for forcing the opponent into one
Mobility number of legal destinations available across my pieces vs. theirs A piece boxed into a single-arrow square is nearly dead weight; more exits = more options
Wraparound tempo how close my home row is to fully "unlocking" (see the 🔒 badge mechanic already in the game) vs. theirs Unlocking wraparound early is a known strategic lever — the eval should notice and pursue it, not stumble into it by accident
Vacated-square awareness after a candidate move, check whether the square just vacated now points an arrow straight at one of my own remaining pieces This is the "you're handing the next visitor a rotated square" trap described in the in-game help — a decent opponent should actively avoid twiddling a square into a shape that helps the opponent's next move
Endgame aggression when either side is down to ≤2 pieces, upweight material/threat and reduce exploration randomness Prevents a long, aimless drought-avoidance stalemate late game

Random jitter (small, e.g. ±3% of eval range) stays in at every tier so the AI isn't perfectly deterministic and repeat games don't play out identically.

Non-goals for this issue

  • No full game-tree solver / perfect play — Twiddle's branching factor (dynamic arrows + variable turn length + wraparound) makes that impractical and unnecessary for a fun opponent.
  • No persistent learning between games. Difficulty is a fixed configuration per game, not adaptive.

2. UI changes

Game mode selection (Setup screen)

Replace the current buried "Player 2: Human / Computer" dropdown with an explicit, first-choice mode selector at the top of Setup, before the other parameters:

[ ● 1 Player (vs Computer) ]   [ ○ 2 Player (hot seat) ]
  • 1 Player: reveals two follow-up controls —
    • Difficulty: Easy / Normal / Hard (maps to the tiers above)
    • Play as: Player 1 (moves first) / Player 2 (moves second) — today the human is hard-coded to Player 1; this lets the human choose either seat
  • 2 Player: hides the computer-specific controls entirely (current hot-seat behavior, unchanged)

In-game affordances

  • Thinking indicator: already shows "Computer is thinking…" in the message line — extend this with a small animated dot/spinner near the turn indicator so it's visible even if the user isn't looking at the message line, and to make it obvious why input is briefly disabled between the AI's own sub-moves.
  • Difficulty badge: show the active difficulty next to "Player 2 (CPU)" in the turn panel (e.g. "Player 2 (CPU · Hard)") so it's always visible mid-game, not just something you configured once at setup and forgot.
  • Mid-game difficulty change: not required for this issue, but flag as a natural follow-up (a settings-gear icon that reopens a mini version of the difficulty picker without resetting the board).
  • New Game dialog: preserve the last-used mode/difficulty/seat choice as the new defaults when reopening Setup, so starting a rematch doesn't require re-selecting everything.

Accessibility / clarity

  • Label the mode selector and difficulty controls with <label>/aria-label pairing consistent with the rest of the Setup screen's existing <select> pattern.
  • Keep Easy as the default difficulty (least likely to frustrate a first-time player who just wants to see how the game works).

Acceptance criteria

  • Setup screen has a clear 1-Player/2-Player mode choice, shown before other parameters
  • 1-Player mode exposes Difficulty (Easy/Normal/Hard) and Play-as (P1/P2) controls
  • Easy behaves like today's heuristic (documented baseline, not a regression)
  • Normal demonstrably avoids "capture now, get captured back immediately" blunders that Easy is prone to (spot-check with a few constructed positions)
  • Hard measurably outperforms Normal in self-play over a batch of automated games (e.g. Hard should win the large majority of Hard-vs-Normal games)
  • AI decision time stays under ~1s at Hard on a 5×5 board so the UI doesn't feel frozen
  • Turn panel shows which difficulty is active when playing against the computer
  • Existing hot-seat 2-player mode is unaffected

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions