IAIScanner interface, the AI state machine, the four difficulty tiers, and the optional rubber-band assist feature that keeps matches competitive.
Pre-production notice. SET: 3D Edition is in pre-production. The AI scanner design is specified but not yet implemented. All difficulty values, interface signatures, and config formats described here reflect the current design. The rubber-band assist toggle and false-start animation hint are planned features.
Scope: Single Player Only
AI opponents are active only in Single Player game modes — Solo vs AI, Campaign, and Practice (where no opponent is needed, so AI is disabled). Online Multiplayer uses human players only; the server never instantiatesIAIScanner. If you find yourself referencing IAIScanner from any multiplayer code path, that is an architecture error.
The IAIScanner Interface
GameSession calls StartScan once after the board stabilises, calls Tick every frame with its own deltaTime, and calls Cancel whenever the board changes mid-scan. The scanner is not responsible for watching the board for changes — that is GameSession’s job.
AI State Machine
The scanner moves through four internal states: Idle — No scan in progress.Tick is a no-op.
Scanning — Timer counting down. The scanner has already called FindAllSets internally and stored the results (or it calls FindAllSets at expiry — see the performance note below). Every Tick(deltaTime) decrements the remaining delay. Cancel() transitions to Cancelled without firing the callback.
Found — Timer expired and a Set was selected (subject to miss rate). The callback fires synchronously in the same Tick call. GameSession receives the result and processes it as a ClaimSelectedCommand.
Completed / Cancelled — Transition back to Idle on the next Tick or after Cancel returns, ready for a new StartScan.
Difficulty Configuration
Four tiers control three behavioural parameters:
Reaction delay — When
StartScan is called, the scanner picks a uniformly random delay from [minDelaySec, maxDelaySec]. The AI does nothing visible until this timer expires.
Miss rate — When the timer expires, if System.Random.NextDouble() < missRate, the AI “misses” its chance. In practice, this means it either submits a deliberately invalid Set (to simulate a wrong guess and trigger penalty feedback) or simply skips and waits for the next board change. The MVP implementation skips rather than submitting an invalid Set, unless the false-start path is active.
False start rate (Planned) — A brief animation hint that the AI is “considering” a card (the AI player token moves slightly toward a card) before pulling back. This is a purely visual effect controlled by the Presentation layer. The domain scanner fires a FalseStartEvent before resetting its timer; the view reacts to that event. This is a planned feature and not required for the first playable build.
Configuration File
Difficulty parameters are loaded from a JSON config file, not hard-coded. This allows designers to tune balance without recompiling.AIScanner loads this file via the ILocalSaveService (or a direct Resources.Load for the initial build). If a tier key is missing from the file, the scanner falls back to Medium parameters and logs a warning. Hard-coded fallback values are acceptable for this fallback only — the config file must exist for shipped builds.
Implementation Details
Timer and Random
AIScanner uses System.Random with a non-deterministic seed (default constructor). AI decisions do not need to be reproducible across runs — only the Daily Challenge deck shuffle requires a fixed seed, and that is handled by Deck, not by the scanner.How GameSession Drives the Scanner
GameSession.Update(float deltaTime) is called each frame by the Unity MonoBehaviour wrapper:
GameSession owns deltaTime — the scanner never calls Time.deltaTime directly. This is intentional: in unit tests, you can manually advance the scanner by calling Update(4.5f) without waiting real time.
Board Stability: When to StartScan
The scanner starts only when the board has stabilised — that is, whenGameSession enters BoardIdle after completing all refills and expansions. The sequence:
- Valid Set claimed →
ValidSetAnim→RefillAnim→ board stabilised →BoardIdle→StartScan. - Expansion needed →
ExpandBoardAnim→ board stabilised →BoardIdle→StartScan. - Board changes while AI is scanning (player claims a Set) →
GameSessioncallsCancel()before the board mutation, then starts a new scan after the board re-stabilises.
Rubber-Band Difficulty Assist (Planned)
If the human player’s Set count falls 3 or more behind the AI,GameSession detects the gap and temporarily passes a softer AIDifficulty to StartScan. For example, if the match is configured at Hard, the session might pass Easy until the gap closes to 1.
AIDifficulty is passed to StartScan. All the logic for detecting the gap and choosing the adjusted tier lives in GameSession.
Performance Notes
FindAllSets(21 cards)completes in under 1 ms on the main thread — no threading is needed. The scanner runs entirely on the Unity main thread.FindAllSetsis called once perStartScancall (not everyTick). Between calls toStartScan, the scanner simply decrements a float counter — zero CPU overhead.- For MVP, there is no need for
IJob,Task,async/await, or any background execution. If future board sizes or AI behaviour (e.g., multi-step lookahead) require it, theTick-based interface already supports offloading: callFindAllSetson a background thread inStartScanand store the result when it arrives. The interface does not need to change.
Implementation Checklist
1
Timer uses injected deltaTime, not Time.deltaTime
AIScanner.Tick(float deltaTime) must use the deltaTime parameter, not UnityEngine.Time.deltaTime. This decouples the scanner from Unity’s frame loop and allows deterministic unit testing.2
Cancel() stops the scan without firing onSetFound
After
Cancel() returns, _onSetFound must be null and _state must be Idle. Write a unit test: call StartScan, then Cancel, then Tick(100f). Assert that onSetFound was never called.3
Miss rate applied correctly
When
_rng.NextDouble() < cfg.MissRate, the scan ends without a claim. The callback is not called. The scanner returns to Idle. The AI will get another opportunity on the next StartScan (after the next board change).4
Re-validate before calling onSetFound
Between
StartScan and timer expiry, the board might change (player claimed a Set). Always call ISetValidator.Validate(chosen) immediately before invoking onSetFound. If the chosen Set is no longer valid, abort the claim and return to Idle.5
Config loaded from ai_difficulty.json
Difficulty parameters must be loaded from the JSON config file at startup. Do not hard-code values in
AIScanner except as a fallback for missing tiers.6
Missing config tier falls back to Medium
If the config file exists but the requested tier key is missing, log a warning and use the
Medium parameters. Do not throw or leave _timerRemaining at its default 0.7
No UnityEngine references in AIScanner
Place
AIScanner in the same assembly definition as SetValidator — the one that excludes UnityEngine. The only Unity coupling is in GameSession.Update, which passes Time.deltaTime to AIScanner.Tick.Common Mistakes
Related Pages
Card Model
The Card type and CardAttributes struct that AIScanner passes to SetValidator.
Set Validation
FindAllSets and Validate — the two ISetValidator methods AIScanner depends on.
Board & Dealing
How the Board stabilises after refill and expansion, triggering a new AI scan.
Session Lifecycle
How GameSession drives AIScanner via Tick, StartScan, and Cancel across the full match lifecycle.