Shanten Sensei
An AI coach that explains why a riichi mahjong move is good or bad — grounded in Mortal, not vibes.
Shanten Sensei sits on top of existing open-source Mahjong Soul tooling. It does not reinvent board reading or AI evaluation. It adds the missing layer: plain-language coaching for beginners who learn best when someone is looking over their shoulder.
Download for Mac (live coaching)
Not a developer? Start here:
- Install on Mac — download
Install-Shanten-Sensei.zipfrom Releases; Safari companion is the default path. - Overlay Releases — macOS app bundle when published.
Practice / friend / vs-AI only — not for ranked. Developer setup (Terminal, Chromium, two-repo clone): docs/live-setup.md.
The problem
People learning riichi mahjong (e.g. on Mahjong Soul) want live, over-the-shoulder feedback: why that discard was wrong, why keeping this tile opens a better wait, why defense matters on this turn.
Existing tools already solve the hard parts:
| Tool | Role |
|---|---|
| Mortal | Strongest open riichi AI — action values + recommendations |
| mjai-reviewer | Post-game log review via Mortal |
| MahjongCopilot / Akagi | Live Majsoul websocket → Mortal → overlay |
What they give you is the what (optimal discard, efficiency numbers). What beginners need is the why in one or two clear sentences. That “engine math → human explanation” layer is not really productized yet. That’s this project.
Non-goals
- Building a stronger mahjong AI than Mortal
- Pixel/OCR vision of the board (Majsoul is WebGL; wrong approach)
- Ranked / live-ladder assistance against real players
- Letting an LLM invent its own discard opinions
Core design rule
The LLM translates Mortal. It never evaluates the hand itself.
Language models are weak mahjong players and will confidently invent wrong advice. Mortal is near-superhuman. Every explanation must be pinned to:
- Mortal’s recommended action
- Mortal’s values for alternatives
- Derived facts (shanten, ukeire, danger) from real libraries
Get this wrong → confident liar. Get this right → the coach we actually want.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Existing plumbing (reuse) │
│ │
│ Majsoul WebSocket │
│ │ │
│ ▼ │
│ protobuf decode (liqi.json) │
│ │ │
│ ▼ │
│ mjai events ──► Mortal ──► recommended action + values │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Shanten Sensei (this project) │
│ │
│ On player turn (or post-game diverge): │
│ │
│ 1. mjai game state (hand, discards, dora, turn, scores) │
│ 2. Mortal output (best action + alternative scores) │
│ 3. derived features (shanten, ukeire, suji / one-chance danger) │
│ │
│ │ │
│ ▼ │
│ explain(state, mortal, features) ──► 1–2 sentence coach text │
│ │
│ Shown in review UI or overlay ("Why?" button) │
└─────────────────────────────────────────────────────────────────┘
Interface of record: the mjai protocol — a stream of JSON events (tsumo, dahai, pon, reach, etc.). Upstream tools already converge here; we consume that stream and Mortal’s outputs.
Explainer module spec
Function
explain(game_state, mortal_output, features) → Explanation
Inputs
| Input | Contents |
|---|---|
game_state |
Current hand, visible discards, dora indicators, turn/honba, scores, calls, riichi flags — from mjai |
mortal_output |
Recommended action; value / Q for each legal candidate; confidence if available |
features |
Shanten, ukeire (acceptance count), danger tags (suji, one-chance, genbutsu), point-situation hints, and hand statuses (see below) |
Hand statuses (UI + features)
Surface these as a persistent status strip in the review UI / overlay (not only inside “Why?” text). Derive from mjai state + libraries — never invent via the LLM.
| Status | Meaning | Source |
|---|---|---|
| Closed / open (menzen) | Hand has no calls vs has chi/pon/kan | Calls in game_state |
| Tenpai | Ready hand (shanten == 0) |
Shanten library |
| Shanten | Distance to tenpai | Shanten library |
| Ukeire / waits | Acceptance count + waiting tiles | Ukeire library |
| Wait shape | e.g. ryanmen, kanchan, penchan, tanki, shanpon | Derived from wait set |
| Furiten | Permanent (discards ∩ waits) and/or temporary (this turn) | Discards + wait set |
| Riichi / ippatsu | Self declared riichi; ippatsu window if applicable | mjai reach + timing |
| Dora | Dora in hand; visible dora indicators | Hand + dora indicators |
| Danger tags | suji / one-chance / genbutsu on candidate discards | Danger heuristics |
Output
Explanation {
summary: string // 1–2 sentences, beginner-friendly
focus: "efficiency" | "defense" | "value" | "tempo" | "mixed"
pinned_action: string // Mortal's recommended action (must match)
contrasted_action?: string // player's choice when it differed
}
Constraints
- Never recommend an action Mortal did not rank highly (pin to Mortal’s top choice, or explain the gap vs. the player’s pick).
- Prefer concrete tile language (“5-sou”, “ryanmen wait”) over jargon dumps; introduce terms when used.
- Default length: one or two sentences. Optional
detailparagraph behind a second-click “More” in review (grounded facts only). - Trigger model: on demand (“Why?”) — not every tile — to control cost and attention.
Suggested feature sources
Reuse existing Python mahjong libraries where possible for shanten / ukeire / danger rather than asking the LLM to invent them. Mortal remains the source of truth for which action; features only help the verbalization.
Prompt design (v0)
System role (essence):
You are a beginner-friendly riichi mahjong coach. You explain Mortal’s recommendation using only the game state, Mortal scores, and derived features provided. You do not invent better moves. If the player’s move differs, say why Mortal’s choice is better in efficiency, safety, or point situation. One or two sentences. Plain English.
User payload shape:
{
"player_action": "dahai 5s",
"mortal_best": "dahai 9p",
"mortal_scores": { "dahai 9p": 0.42, "dahai 5s": 0.11, "...": "..." },
"hand": ["1m", "2m", "3m", "..."],
"shanten": 0,
"ukeire": { "count": 6, "tiles": ["4s", "7s"] },
"statuses": {
"menzen": true,
"tenpai": true,
"furiten": false,
"temporary_furiten": false,
"riichi": false,
"ippatsu": false,
"wait_shape": "ryanmen",
"dora_in_hand": ["5m"],
"visible_dora": ["4m"]
},
"danger": { "9p": "genbutsu" },
"context": { "turn": 8, "riichi_opponents": 0, "score_diff": "even" }
}
Failure modes to guard in the prompt / post-checks:
- Contradiction — summary must not recommend a different tile than
mortal_best. - Invented facts — no claims about waits or danger not present in
features/ state. - Wall of jargon — reject / regenerate if over a short length budget.
Build order
Phase 1 — Post-game explainer (MVP)
Goal: After a finished Majsoul game, for every turn where the player differed from Mortal, generate a plain-English explanation.
Why first: Zero real-time complexity, no live client hook, no ToS risk from in-game assistance. Useful in a weekend if mjai-reviewer is already producing diverge turns.
Deliverables:
- CLI or small web UI: paste Majsoul replay / log → list of diverge turns → status strip + “Why?” text per turn
explain()module with grounded prompt- Fixture tests: sample mjai + Mortal JSON → explanation contains pinned action and does not invent alternatives
Phase 1 checklist
- Document how to obtain a Majsoul log / run mjai-reviewer locally (or use its web app output) — see
docs/phase1-contract.md - Define a JSON schema for
(game_state, mortal_output, features)per turn, includingstatuses—schema.py - Implement feature extraction (shanten, ukeire, basic danger) from libraries
- Implement hand statuses: menzen, tenpai, furiten (permanent + temporary), riichi/ippatsu, wait shape, dora in hand / visible dora
- Review UI: persistent status strip for the above (plus shanten / ukeire); “Why?” remains on demand —
sensei serve - Implement
explain()with an LLM API (env-based key; model configurable) + offline template fallback - Add contradiction check: explanation must mention / pin Mortal’s action
- CLI:
sensei explain <entry.json>,sensei review <report.json>, andsensei serve <report.json> - Golden fixtures: 5 diverge turns (1 synthetic + 4 real from a practice log); more via
scripts/extract_diverge.py - README section: “Practice / review only — not for ranked assistance”
Phase 2 — Live overlay (practice / vs-AI only)
Goal: Fork MahjongCopilot; wire Sensei explain() behind a Why? button on the existing overlay.
How to play live: step-by-step setup + in-game flow → docs/live-setup.md
Dual clients (Chromium + Safari companion on macOS): docs/dual-client-architecture.md · proxy/cert precautions: docs/proxy-trust-precautions.md · play steps: docs/live-setup.md
Repos:
| Piece | Location |
|---|---|
Explainer (turn_from_live → explain) |
This repo — docs/phase2-kickoff.md |
| Overlay fork (GPL-3.0) | shanten-sensei-overlay |
Constraints:
- Practice / friend / vs-AI only — not ranked ladder against humans
- On-demand LLM calls only
- Same grounding rules as Phase 1
- Sibling repos: do not merge the GPL fork into this tree
Deliverables (kickoff):
- Fork + Sensei adapter + mode gate
- Overlay UX: Mortal recommendation + status strip + Why?
- In-app practice-only banner; Why? disabled on ranked / unknown mode
How to play live (short)
Mac users: see docs/install-mac.md (download Install-Shanten-Sensei.zip from Releases).
Developers / from source:
- Clone shanten-sensei-overlay as a sibling of this repo, or install
shanten-senseifrom PyPI inside the overlay venv. - Install overlay deps; place an Akagi-compatible Mortal
.pthin the overlaymodels/folder. python main.py→ Start Browser (Chromium) or enable Safari companion → Overlay on, Autoplay off.- Join a friend / practice / vs-AI game (not ranked).
- On your turn, press Why? for a grounded explanation.
Full commands, API key, and troubleshooting: docs/live-setup.md.
What it takes
| Layer | Status |
|---|---|
| Majsoul protocol decode | Exists (liqi.json, upstream tools) |
| mjai event stream | Exists |
| Mortal evaluation | Exists |
| Overlay / review UI | Exists (fork) |
| Explainer + prompt + grounding | This repo |
Stack expectation: Python, comfort calling an LLM API. Protocol and engine work are reuse/fork, not greenfield.
Risks
| Risk | Mitigation |
|---|---|
| Majsoul protocol changes break decoders | Depend on upstream liqi.json updates; Phase 1 can use offline logs |
| LLM cost | “Why?” on demand; short completions; cache identical states |
| Hallucination | Pin to Mortal; pass numeric features; post-validate pinned action |
| ToS / bans | No ranked live assist; document practice-only for Phase 2 |
| Scope creep into “better AI” | Refuse LLM-only evaluation paths |
Ethical / ToS stance
Real-time client hooks for Majsoul assistance can violate the game’s terms of service and is bannable in ranked play if detected. This project’s learning stance:
- Phase 1 — post-game review only (safest, preferred default).
- Phase 2 — practice / vs-AI only; never marketed as a ranked win tool.
- Prefer teaching understanding over automating optimal play for ladder climbing.
Success criteria
A beginner can play a practice game, open a review (or press Why?), and get an explanation like:
Keeping the 5-sou instead of the 9-pin preserves a two-sided wait and you’re still one away from riichi; the 9-pin is a dead end tile with far fewer acceptances.
…and that sentence matches Mortal’s numbers, not a chatbot’s guess.
Related projects
- Mortal — riichi AI
- mjai-reviewer — post-game Mortal review
- MahjongCopilot — live guidance + overlay (upstream of our fork)
- shanten-sensei-overlay — Sensei Why? on Copilot
- Akagi — live analysis, swappable models
- mjai — event protocol
Status
Phase 1 ready (post-game explainer + local review UI). Phase 2 live overlay is in the sibling fork.
- Live play how-to:
docs/live-setup.md - Contract:
docs/phase1-contract.md - Phase 2 live contract:
docs/phase2-kickoff.md - Schema / features /
explain()/live.py:src/shanten_sensei/ - Golden fixtures:
fixtures/diverge_001/…diverge_005/(cut more withscripts/extract_diverge.py) - Full reports with
mjai_logenrich rivers / dora / genbutsu for the status strip - License: Apache-2.0 (this repo); overlay fork remains GPL-3.0
- Overlay: shanten-sensei-overlay
uv venv .venv
uv pip install -e ".[dev]" --python .venv/bin/python
# optional: put OPENAI_API_KEY or SENSEI_API_KEY in a repo-root .env (auto-loaded)
uv run --python .venv/bin/python sensei explain fixtures/diverge_002/entry.json
uv run --python .venv/bin/python sensei review fixtures/review_mini/report.json
# local review UI (Why? uses the API key; Offline explanation if unavailable):
uv run --python .venv/bin/python sensei serve fixtures/review_mini/report.json
# or a full mjai-reviewer export:
# uv run --python .venv/bin/python sensei review game_logs/review.json
# uv run --python .venv/bin/python sensei serve game_logs/review.json
# cut another fixture from a report:
# uv run --python .venv/bin/python scripts/extract_diverge.py game_logs/review.json \
# --kyoku 0 --honba 0 --junme 3 -o fixtures/diverge_00N/entry.json
uv run --python .venv/bin/python pytest
Practice / review only — not for ranked assistance.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file shanten_sensei-0.1.1.tar.gz.
File metadata
- Download URL: shanten_sensei-0.1.1.tar.gz
- Upload date:
- Size: 534.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7a7aa60795b748de2a5b7ebe8e316871f7cac8a45e00d56240b5a118518f2559
|
|
| MD5 |
f7b01ac16bce376c53a757cc06b675ee
|
|
| BLAKE2b-256 |
50a0f53c4d41b4d6475f3995bf0de9a024ccf23810e6484e3eaf36d5ee55a357
|
Provenance
The following attestation bundles were made for shanten_sensei-0.1.1.tar.gz:
Publisher:
publish.yml on rclarke009/shanten_sensei
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
shanten_sensei-0.1.1.tar.gz -
Subject digest:
7a7aa60795b748de2a5b7ebe8e316871f7cac8a45e00d56240b5a118518f2559 - Sigstore transparency entry: 2387856162
- Sigstore integration time:
-
Permalink:
rclarke009/shanten_sensei@62297808d1b40cf510e31606c9f1386cc669136a -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/rclarke009
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@62297808d1b40cf510e31606c9f1386cc669136a -
Trigger Event:
release
-
Statement type:
File details
Details for the file shanten_sensei-0.1.1-py3-none-any.whl.
File metadata
- Download URL: shanten_sensei-0.1.1-py3-none-any.whl
- Upload date:
- Size: 211.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
20c9584e230641e7a60d8ea29e357cfb95012933acc04646bc6d07db32a3ea67
|
|
| MD5 |
04d91c2394dfbf35045c79d5e725feb8
|
|
| BLAKE2b-256 |
2faeec019e7b2a798b657099e7380c8f98e8e7b28fa683a7f158713fbfdd4cb2
|
Provenance
The following attestation bundles were made for shanten_sensei-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on rclarke009/shanten_sensei
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
shanten_sensei-0.1.1-py3-none-any.whl -
Subject digest:
20c9584e230641e7a60d8ea29e357cfb95012933acc04646bc6d07db32a3ea67 - Sigstore transparency entry: 2387856166
- Sigstore integration time:
-
Permalink:
rclarke009/shanten_sensei@62297808d1b40cf510e31606c9f1386cc669136a -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/rclarke009
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@62297808d1b40cf510e31606c9f1386cc669136a -
Trigger Event:
release
-
Statement type: