MJAI Protocol
WebSocket message reference for bot developers
Connection
Bots communicate with the gameserver over WebSocket text frames. Each message is a single JSON object. Binary frames are ignored.
| Endpoint | Auth | Description |
|---|---|---|
| /ws/ranked | Authorization: Bearer BOT_TOKEN | Ranked play. Requires an active bot. |
| /ws/validate | Authorization: Bearer BOT_TOKEN | Validation game for pending bots. |
| /status | None | Server status (JSON, HTTP GET). |
Message Flow
The server drives the game loop. Bots only send messages in response to request_action events.
Each request_action carries a request_id and your time budget; the bot echoes the request_id in its reply, and the server confirms every processed reply with an action_ack receipt.
Server-to-Bot Events
The server sends standard MJAI events as JSON text messages. Below are the key event types. The server may add new event types and fields at any time — bots must ignore unknown event types and unknown fields instead of erroring on them.
start_game
Sent once at the beginning. Contains your seat assignment.
{"type":"start_game","id":0} id — Your seat index (0-3).
start_kyoku
Sent at the start of each round.
{
"type": "start_kyoku",
"bakaze": "E",
"dora_marker": "2p",
"kyoku": 1,
"honba": 0,
"kyotaku": 0,
"oya": 0,
"tehais": [
["1m","3m","5m","7p","9s",...],
["?","?","?","?","?",...],
["?","?","?","?","?",...],
["?","?","?","?","?",...]
]
} tehais — Each player's starting hand. Only your own hand is visible; others are masked as "?".
tsumo
A player draws a tile. Other players' draws appear as "?".
{"type":"tsumo","actor":0,"pai":"3m"}dahai
A player discards a tile.
{"type":"dahai","actor":0,"pai":"3m","tsumogiri":true}chi / pon / kan
Meld declarations.
{"type":"chi","actor":1,"target":0,"pai":"5m","consumed":["4m","6m"]}
{"type":"pon","actor":2,"target":0,"pai":"E","consumed":["E","E"]}
{"type":"ankan","actor":0,"consumed":["1s","1s","1s","1s"]}reach
Riichi declaration. The discard tile is sent in a subsequent dahai event.
{"type":"reach","actor":0}hora
Win declaration.
{"type":"hora","actor":0,"target":1,"pai":"5m"}end_kyoku
Round ends with result details.
end_game
Game is over. Contains final scores. After receiving this, the bot should disconnect.
{"type":"end_game","scores":[30000,25000,20000,25000]}action_ack
Sent after the server processes (or substitutes) your response to a request_action. It tells you exactly what happened to your action. Simple bots can ignore it; it exists for debugging and for tracking your remaining time bank.
{"type":"action_ack","request_id":42,"status":"accepted",
"elapsed_ms":850,"bank_consumed_ms":0,"bank_ms":15000} | status | Meaning | Score effect |
|---|---|---|
| accepted | Your action was applied to the game | None |
| rejected | Parseable but not in possible_actions (includes reason, attempted, legal_types) | Chombo |
| unparseable | Your message could not be interpreted (includes reason) | Chombo |
| stale | A late or old-request_id reply was discarded | None |
| defaulted | You missed the deadline; the server played the included action for you | None (bank drops to 0) |
Example of a defaulted ack:
{"type":"action_ack","request_id":42,"status":"defaulted",
"action":{"type":"dahai","pai":"7s","tsumogiri":true},
"elapsed_ms":18001,"bank_consumed_ms":15000,"bank_ms":0}request_action
When it is your turn to act, the server sends a request_action event containing the request id, your time budget, the list of legal actions, and the current observation.
{
"type": "request_action",
"request_id": 42,
"time": {"grace_ms": 3000, "bank_ms": 15000, "deadline_ms": 18000},
"possible_actions": [
{"type": "dahai", "pai": "1m"},
{"type": "dahai", "pai": "3m"},
{"type": "reach"},
{"type": "hora"},
{"type": "none"}
],
"observation": "eyJwbGF5ZXJfaWQiOjAs..."
} request_id
A monotonically increasing integer, unique per request within the game. Echo it back in your response — the echo lets the server bind your reply to this exact request, so a reply that arrives late can never be misinterpreted as the answer to a newer request.
time
Your time budget for this request (see "Time Control" below). Always read these values from the message — they are server configuration and may change.
- grace_ms — free time per request; replies within this consume no bank.
- bank_ms — your remaining time bank for the current kyoku.
- deadline_ms —
grace_ms + bank_ms; if your reply does not arrive within this, the server substitutes a default action.
possible_actions
An array of MJAI-format actions that are currently legal. Your response must correspond to one of these.
| Type | Fields | Meaning |
|---|---|---|
| dahai | pai | Discard a tile |
| chi | pai, consumed | Claim chi (sequence meld) |
| pon | pai, consumed | Claim pon (triplet meld) |
| daiminkan | pai, consumed | Claim open kan from discard |
| ankan | consumed | Declare closed kan |
| kakan | pai, consumed | Extend pon to kan |
| reach | actor | Declare riichi |
| hora | actor, target, pai | Win (tsumo or ron) |
| ryukyoku | Nine terminals draw | |
| none | Pass / decline |
observation
The observation field is a base64-encoded RiichiEnv observation. Decode it with Observation for 4-player games and Observation3P for 3-player games.
Once decoded, it gives you the full game state from your perspective, including hand tiles, melds, discards, scores, dora indicators, and legal actions.
4-player decode example
from riichienv import Observation# msg is the parsed request_action JSON objectobs = Observation.deserialize_from_base64(msg["observation"])actions = obs.legal_actions()state = obs.to_dict() 3-player decode example
from riichienv import Observation3P# msg is the parsed request_action JSON objectobs = Observation3P.deserialize_from_base64(msg["observation"])actions = obs.legal_actions()state = obs.to_dict()Bot-to-Server Responses
Respond with a single MJAI-format JSON event, echoing the request_id of the request you are answering. The server ignores other unknown fields, so including extra fields is safe.
| Type | Required Fields | Notes |
|---|---|---|
| dahai | actor, pai | tsumogiri defaults to false |
| chi | actor, target, pai, consumed | consumed: 2 tiles from hand |
| pon | actor, target, pai, consumed | consumed: 2 tiles from hand |
| ankan | actor, consumed | consumed: all 4 tiles |
| kakan | actor, pai, consumed | Extend existing pon to kan |
| reach | actor | Discard follows as separate dahai |
| hora | actor | Server determines tsumo vs ron |
| none | Pass on a claim opportunity |
Examples:
{"type":"dahai","actor":0,"pai":"3m","tsumogiri":true,"request_id":42}
{"type":"chi","actor":1,"target":0,"pai":"5m","consumed":["4m","6m"],"request_id":42}
{"type":"reach","actor":0,"request_id":42}
{"type":"hora","actor":0,"target":2,"pai":"5m","request_id":42}
{"type":"none","request_id":42} request_id echo semantics
Echoing request_id is strongly recommended (it may become mandatory for ranked play in the future). The binding rules are:
| Your reply | Server behavior |
|---|---|
| Echoes the current request_id | Bound to that request and processed |
| Echoes an older request_id | Discarded as stale (action_ack with status stale); no penalty |
| Echoes an unknown / future request_id | Protocol violation — treated as unparseable (chombo) |
| No request_id (legacy client) | Bound by arrival order, with server-side stale-reply accounting |
Tile Notation
Standard MJAI tile strings:
| Suit | Tiles |
|---|---|
| Man (characters) | 1m 2m 3m 4m 5m 6m 7m 8m 9m |
| Pin (circles) | 1p 2p 3p 4p 5p 6p 7p 8p 9p |
| Sou (bamboo) | 1s 2s 3s 4s 5s 6s 7s 8s 9s |
| Winds | E (East) S (South) W (West) N (North) |
| Dragons | P (Haku) F (Hatsu) C (Chun) |
| Red fives | 5mr 5pr 5sr |
Masked tiles from other players appear as "?".
Time Control, Default Actions, and Penalties
Time control: grace + per-kyoku bank (default: 3s + 15s)
Each request_action comes with a time budget (the time field):
- Grace (default 3 s) — free time per request. Replies within the grace consume nothing.
- Bank (default 15 s) — a per-kyoku time bank, refilled at the start of every kyoku (no carry-over). When a reply takes longer than the grace, the excess is deducted from the bank. Both apply per player, in both your draw turns and claim opportunities.
The reply deadline for a request is grace + remaining bank (up to 18 s with a full bank, decaying to 3 s once the bank is empty). If the reply does not arrive by the deadline, the bank drops to 0 and the server substitutes a default action and continues:
- On your draw turn (WaitAct) — auto-discard of the drawn tile (tsumogiri).
- On a claim opportunity (WaitResponse) — pass (
{"type":"none"}).
A timeout alone is not penalized. The clock starts when the server sends request_action, so the budget includes the round-trip to your bot. Aim for under 500 ms of inference time on typical turns; the bank is headroom for occasional slow turns (GC pauses, deep search), not a budget to spend every turn.
Stale-reply handling
If your replies echo request_id, stale handling is fully deterministic: a reply carrying an old request_id is discarded (with an action_ack of status stale) no matter when it arrives, and is never bound to a newer request. A slow turn therefore costs you at most the substituted default action — never a chombo.
For legacy replies without request_id, the server falls back to reply accounting: each request expects exactly one reply, and after a timeout the next inbound message settles the owed reply and is discarded (the bookkeeping expires after 30 seconds as a safety backstop). Reply to every request_action, exactly once, in order.
Invalid actions trigger chombo (mangan penalty)
If the bot sends a parseable JSON action that is not in possible_actions (e.g. discarding a tile not in hand, calling pon when no pon is offered, declaring tsumo on a non-winning hand), or if the bot's message cannot be parsed at all, the engine fires its chombo path:
{"type":"ryukyoku","reason":"Error: Illegal Action by Player N","deltas":[ ... ]} Score effect (4-player):
- Offender is dealer (oya) — −12,000; each non-dealer +4,000.
- Offender is non-dealer — −8,000; dealer +4,000; each other non-dealer +2,000.
The round ends immediately and the dealer is retained (renchan). A chombo is also recorded on the bot's profile. Validate every response against possible_actions before sending.
Disconnect
If the WebSocket connection drops, the bot is treated as disconnected for the remainder of the game; every subsequent turn falls back to the default action without penalty (same as a timeout). Reconnecting mid-game is not supported.
