AI Agents
Bots and humans are interchangeable seat occupants. Both implement
IPlayerAgent, so a room doesn't care which is which — that's what makes
mid-game AI substitution and bot-only rooms possible.
The agent hierarchy
IPlayerAgent(Agents/IPlayerAgent.cs) — the seat contract shared byUserand every bot: identity/status/seat,GetState,Transit,OnEvent,OnInquiry.AIAgent(Agents/AIAgent.cs) — abstract base for bots.OnEventis a no-op (bots are stateless and decide purely from the inquiry snapshot), andOnInquiryresponds on a background task so it never re-enters the engine's processing lock, falling back to the default choice on error. Concrete bots implementDecide.DefaultAI(AiType.Dummy) — always picks the default option. It's also the agent that gets substituted into a seat when a human leaves mid-game.RuleBasedAI(AiType.RuleBased) — builds aPublicGameViewand delegates toRuleBasedStrategy.Decide.LlmAI(AiType.Llm) — an LLM picks the move and talks at the table. See The LLM agent.
Fair information — PublicGameView
Agents/PublicGameView.cs is a per-seat, read-only façade over the authoritative
Game that exposes only public information: the bot's own hand, everyone's
discards and melds, revealed dora, wall count, riichi/points/winds/round. It also
offers analysis helpers built on the engine's PatternResolver:
EvaluateDiscard (shanten + ukeire), ShantenOf, and visible/unseen tile counts.
This deliberately prevents a bot from reading hidden state (opponents' concealed tiles, the wall order), so AI strategies play fair.
The rule-based strategy
Agents/RuleBasedStrategy.cs is the (static, testable) decision logic. Its
priority order:
- Win (ron/tsumo) when available.
- Abortive draw (nine terminals) — unless the same shape is a live thirteen orphans, in which case a yakuman beats a redeal.
- Declare riichi when the hand qualifies.
- Self-turn kan / nukidora when beneficial.
- Discard.
- Reactive calls (chii/pon/daiminkan) when they advance a hand that can still legally win.
- Acknowledge the next round; otherwise the default.
Everything is priced in points
Rather than juggling hand-tuned weights that fight each other, every option is scored as
(chance this hand still wins) x (what it pays) - (expected deal-in cost of this discard)
so efficiency, hand value, push/fold and safety all fall out of one comparison.
Win probability comes from a shanten/ukeire/turns-left curve; payout comes from
the engine's own per-wait scoring at tenpai (TenpaiInfo.points) and from the
yaku planner otherwise.
HandPlan — minimum-han awareness
Agents/HandPlan.cs answers "which yaku can this shape still reach, and how far
away is each one". It enumerates plausible routes (riichi, menzen tsumo, pinfu,
tanyao, each yakuhai, honitsu/chinitsu, toitoi, chiitoitsu, chanta/junchan,
ittsu, sanshoku, kokushi) with a han value and a structural distance, then stacks
the best mutually-compatible subset (tanyao excludes yakuhai, honitsu excludes
sanshoku, and so on).
This is what makes the agent respect the table's minimum-han rule (番縛り).
Under min-han, "advance the hand" and "win the hand" are different goals: a hand
with no route to minHan yaku han cannot win at all, no matter how efficient
or how much dora it holds. So the planner gates every decision that permanently
narrows the hand's options:
- Calling chii/pon is refused unless the resulting OPEN hand can reach min-han with an essentially locked-in yaku — an open hand has no riichi, so the yaku has to come from the tiles themselves.
- Kan is refused when the post-kan hand can no longer reach min-han. A concealed kan keeps the hand menzen but sets a triplet aside, which kills pinfu, seven pairs and thirteen orphans outright.
- Riichi is declined when even the riichi han leaves the hand two or more short, and is only taken on a one-han deficit (a tsumo-only hand) when the self-draw is genuinely likely.
- Discards that reopen a yaku route score above discards that do not, so a legally-dead hand reshapes instead of drifting.
Kan discipline
A kan is never free: it freezes four tiles and flips a brand new dora indicator
face up for every player. ScoreKan therefore checks, in order, that the shape
does not get worse and the wait does not shrink; that min-han is still reachable
afterwards (checked exactly against the engine's scoring when the result is
tenpai); that we are not folding; and finally that the new dora is worth more to
us than to the opponents most likely to be tenpai. Daiminkan additionally carries
a flat penalty and is refused outright out of a concealed tenpai that could
simply riichi instead.
DefenseModel — reading the table
Agents/DefenseModel.cs prices danger from public evidence only:
- Threats per opponent, from riichi, called melds, visible dora, turn count and late tedashi of live middle tiles, plus an estimated deal-in value.
- Genbutsu, including the strong read that anything discarded by anybody after a riichi declaration is safe against that riichi (a riichi hand cannot decline a ron).
- Suji, kabe / one-chance / no-chance, and honor counting.
RuleBasedStrategy.SafetyScore is the narrow public view of this (riichi
opponents only, higher = safer, 0 when nobody has declared).
Because the strategy is a pure function of a PublicGameView, it's unit-testable
without spinning up a full server.
The LLM agent
Agents/Llm/LlmAI.cs is a seat driven by a language model. Each turn it:
- builds the legal-action menu (
LlmActionMenu, stable numeric ids); - computes what
RuleBasedStrategywould do and presents it as the reference move, telling the model to follow it unless it has a concrete reason not to; - sends the persona system prompt plus the turn prompt (
LlmPromptBuilder); - plays the id the model returns.
The model's decision is the one that is played. Anything that cannot be honoured — a missing id, an id that is not on the menu, malformed JSON, a provider error, a timeout — falls back to the reference move, so a bad response degrades to solid play rather than to a random or defaulted action.
The response schema is {"action": <id>, "say": <chat or null>, "sticker": <mood or null>}. The end-of-game comment still uses the chat-only
{"say", "sticker"} form.
Token budget
How much history the agent has to pay for depends entirely on the provider, and the supported ones sit at opposite ends:
- OpenAI and Grok (
/v1/chat/completions) are stateless: every request carries the whole message list, so the client-side transcript is the cost. - Gemini (
/v1beta/interactions) is stateful: the provider stores each interaction and chains withprevious_interaction_id, sending only the newest user turn. The client-side transcript is dropped after the first turn, so it costs nothing there. What is re-sent every interaction is thesystem_instruction, which is stable and therefore implicit-cache friendly.
The agent is written so neither is pathological, and — importantly — so that neither fights the provider's prompt cache:
- The turn prompt is the only full snapshot. It restates the round, dora, our hand and waits, every river, the wall count and the legal action list — so the model never needs history to know where it stands.
- History is kept, but condensed. Once a turn is over, its prompt is replaced
in the transcript by a one-line stub (
BuildHistorySummary) carrying only the chat said at the time; the model's own replies are kept verbatim. A superseded turn therefore costs ~55 tokens instead of ~720, and — because the stub never changes again — every request stays a strict extension of the previous one. - The transcript grows; it does not slide. That is deliberate. Automatic
prompt caching only pays out on an exact longest-prefix match, so a small
window that drops one exchange per turn moves the prefix on every request and
forfeits the discount on everything but the system message. Measured over an
18-turn hand, a 4-exchange sliding window and a fully-kept condensed history
come out within 3% of each other on cache-adjusted cost — and keeping the
history is better for continuity. A bound still exists
(
LlmLimits.TranscriptMaxExchanges), but it is a rare block cut back toTranscriptTrimToExchanges, so the prefix stays stable for many turns between cuts instead of being invalidated every turn. - Standing rules are stated once, in the system prompt: how to read the
reference move, the invalid-id rule. It is never dropped when the transcript is
trimmed, and on Gemini it lands in the cacheable
system_instruction. The turn prompt carries only a terse marker and the JSON schema.
The full game configuration (wall composition, every rule option, the allowed yaku list) is deliberately left verbose — it rides in the system instruction, where it is cached on both providers.
Cache-adjusted cost over an 18-turn hand on OpenAI (50% discount on cached input): ~76k token-equivalents before, ~25k after. Gemini is stateful and was already flat at ~29k; the transcript work is a no-op there.
Providers
OpenAiProvider and GrokProvider both speak the OpenAI
/v1/chat/completions dialect and share their transport, auth and response
parsing through OpenAiCompatible. Grok is a separate provider rather than an
OpenAI base-URL override so its config stays isolated, and because xAI differs
in two ways that matter:
-
It sends
max_completion_tokensinstead of the deprecatedmax_tokens. The replacement excludes reasoning tokens, so a reasoning model cannot spend the whole budget thinking and return no JSON. -
It always sends
reasoning_effort. On Grok, omitting the parameter means "the model's default", which on some models is the most expensive setting — for a seat working against a 20-second action timeout that would mean timing out every turn and silently falling back to the rule-based move. At the defaultMinimalthinking level the provider asks for the least reasoning the chosen model accepts:nonefor the families listed inGrokProvider.ReasoningOptionalFamilies, otherwiselow.That list is an allowlist, deliberately.
lowis accepted by every Grok reasoning model, so a model nobody has added yet degrades to "cheap"; guessing the other way would sendnoneto a model that cannot disable reasoning and have the request rejected outright. Add a family when xAI documents that its reasoning can be switched off — versioned and variant ids (grok-4.3-fast) are matched too.
presence_penalty, frequency_penalty and stop are rejected by Grok
reasoning models and are never sent.
Personas
Personas are markdown templates embedded from Agents/Llm/Prompts/, one per
language (en, ja, zhs), selected by LlmPromptTemplate:
CUTE_JK— a cheerful high-school-girl table companion.MESUGAKI— a smug, teasing brat.BUNNY_PET— a doting rabbit-eared companion that treats the human players as its owners: it answers questions about its own hand and wait, and it obeys a human's instruction about which action to take even when that contradicts the reference move. Other AI/LLM seats are explicitly not owners.
Seat labels are persona-aware (LlmPromptBuilder.RoleLabel), so each persona
sees the humans at the table framed the way it should treat them.
Adding AIs to a room
AIs are added via the owner-only add_ai { AiType } request
(RoomServiceImpl.AddAi): the server validates the type, assigns a negative id,
adds the agent, and auto-readies it. AiType.Dummy → DefaultAI,
AiType.RuleBased → RuleBasedAI, and AiType.Llm → LlmAI (whose LlmAiConfig
is validated and live-pinged before the seat is created; the API token lives only
for the room's lifetime and is never persisted). Because TryEndGame auto-readies
AIs, a room of bots will keep starting new games on its own.