Skip to main content

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.zip from 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:

  1. Mortal’s recommended action
  2. Mortal’s values for alternatives
  3. 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 detail paragraph 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:

  1. Contradiction — summary must not recommend a different tile than mortal_best.
  2. Invented facts — no claims about waits or danger not present in features / state.
  3. 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, including statusesschema.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>, and sensei 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_liveexplain) 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:

  1. Clone shanten-sensei-overlay as a sibling of this repo, or install shanten-sensei from PyPI inside the overlay venv.
  2. Install overlay deps; place an Akagi-compatible Mortal .pth in the overlay models/ folder.
  3. python main.pyStart Browser (Chromium) or enable Safari companion → Overlay on, Autoplay off.
  4. Join a friend / practice / vs-AI game (not ranked).
  5. 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:

  1. Phase 1 — post-game review only (safest, preferred default).
  2. Phase 2 — practice / vs-AI only; never marketed as a ranked win tool.
  3. 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


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 with scripts/extract_diverge.py)
  • Full reports with mjai_log enrich 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

shanten_sensei-0.1.1.tar.gz (534.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

shanten_sensei-0.1.1-py3-none-any.whl (211.9 kB view details)

Uploaded Python 3

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

Hashes for shanten_sensei-0.1.1.tar.gz
Algorithm Hash digest
SHA256 7a7aa60795b748de2a5b7ebe8e316871f7cac8a45e00d56240b5a118518f2559
MD5 f7b01ac16bce376c53a757cc06b675ee
BLAKE2b-256 50a0f53c4d41b4d6475f3995bf0de9a024ccf23810e6484e3eaf36d5ee55a357

See more details on using hashes here.

Provenance

The following attestation bundles were made for shanten_sensei-0.1.1.tar.gz:

Publisher: publish.yml on rclarke009/shanten_sensei

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for shanten_sensei-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 20c9584e230641e7a60d8ea29e357cfb95012933acc04646bc6d07db32a3ea67
MD5 04d91c2394dfbf35045c79d5e725feb8
BLAKE2b-256 2faeec019e7b2a798b657099e7380c8f98e8e7b28fa683a7f158713fbfdd4cb2

See more details on using hashes here.

Provenance

The following attestation bundles were made for shanten_sensei-0.1.1-py3-none-any.whl:

Publisher: publish.yml on rclarke009/shanten_sensei

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page