Mortal Migration Guide
How to move a Mortal-style MJAI bot from local RiichiEnv simulation to RiichiLab WebSocket play
Overview
RiichiEnv already documents that its observation API is compatible with Mortal's MJAI event-processing flow. The reference implementation is in RiichiEnv's Compatibility with Mortal guide.
The practical migration to RiichiLab is straightforward: keep the same MortalAgent.act() implementation, but replace env.reset() / env.step() with WebSocket receive/send around request_action.
RiichiEnv Compatibility Pattern
This is the RiichiEnv-side pattern described in the upstream guide. Mortal consumes MJAI events through obs.new_events(), produces an MJAI response, and RiichiEnv converts it back into a legal action with obs.select_action_from_mjai().
obs.new_events() is a per-player delta stream. It returns the MJAI events that are new for that player since their previous observation. On a player's first observation in a hand, this may include start_game, start_kyoku, and earlier events from the hand.
from riichienv import RiichiEnv, Action, GameRulefrom model import load_modelclass MortalAgent: def __init__(self, player_id: int): self.player_id = player_id # Initialize your libriichi.mjai.Bot or equivalent self.model = load_model(player_id, "./mortal_v4.pth") def act(self, obs) -> Action: resp = None for event in obs.new_events(): resp = self.model.react(event) action = obs.select_action_from_mjai(resp) assert action is not None, "Mortal must return a legal action" return actionenv = RiichiEnv(game_mode="4p-red-half", rule=GameRule.default_tenhou())agents = {pid: MortalAgent(pid) for pid in range(4)}obs_dict = env.reset()while not env.done(): actions = {pid: agents[pid].act(obs) for pid, obs in obs_dict.items()} obs_dict = env.step(actions)print(env.scores(), env.ranks())What Changes on RiichiLab
- You no longer create a local
RiichiEnvinstance. - The server sends a base64-encoded RiichiEnv
Observationin eachrequest_actionmessage. - You decode that observation, feed
obs.new_events()into Mortal, and send the chosen MJAI response back over WebSocket. obs.new_events()is already scoped to unseen events for your seat, so you should not maintain a second event cursor on top of it.- The rest of the protocol messages are informational. You only send a response when you receive
request_action.
WebSocket Migration Example
The example below keeps the same Mortal processing loop and only changes the transport layer. It receives server events over WebSocket, decodes the observation, lets Mortal react to the embedded MJAI event stream, and sends the resulting action back as MJAI JSON.
import asyncioimport jsonimport websocketsfrom riichienv import Observationfrom model import load_modelclass MortalAgent: def __init__(self, player_id: int): self.player_id = player_id # Initialize your libriichi.mjai.Bot or equivalent self.model = load_model(player_id, "./mortal_v4.pth") def act(self, obs): resp = None for event in obs.new_events(): resp = self.model.react(event) action = obs.select_action_from_mjai(resp) assert action is not None, "Mortal must return a legal action" return actionasync def run_mortal_bot(url: str, token: str): headers = {"Authorization": f"Bearer {token}"} agent = None async with websockets.connect(url, additional_headers=headers) as ws: while True: msg = json.loads(await ws.recv()) match msg["type"]: case "start_game": seat = msg["id"] agent = MortalAgent(seat) case "request_action": obs = Observation.deserialize_from_base64(msg["observation"]) action = agent.act(obs) resp = json.loads(action.to_mjai()) # Echo request_id for deterministic binding if "request_id" in msg: resp["request_id"] = msg["request_id"] await ws.send(json.dumps(resp)) case "end_game": break case _: # Mortal consumes these events through obs.new_events(), # so no direct response is needed here. passasyncio.run( run_mortal_bot( url="wss://game.riichi.dev/ws/ranked", token="YOUR_BOT_TOKEN", ))Migration Checklist
- Keep your existing Mortal model wrapper that exposes
react(event). - Instantiate the agent after
start_gameso you know your seat ID. - Decode
msg["observation"]from eachrequest_action. - Use
obs.new_events()directly as the per-seat unseen-event stream instead of maintaining a separate event log or extra cursor. - Convert Mortal's MJAI output with
obs.select_action_from_mjai()before sendingaction.to_mjai().
Related Docs
See MJAI Protocol for the message schema and Local Testing for more on RiichiEnv observations and offline simulation.
