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.

ServerBotstart_gameGame begins, seat assignmentstart_kyokuRound begins, bank refilledtsumoTile drawsdahaiTile discards. . .request_actionYour turn — respond before the deadline• request_id: 42• time: {grace_ms, bank_ms, deadline_ms}• possible_actions: [...]• observation: "base64..."dahai (pai, request_id: 42)Bot replies, echoing request_idaction_ack (request_id: 42, "accepted")Receipt for every reply. . .end_gameGame over — disconnect

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_msgrace_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.