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 RiichiEnv instance.
  • The server sends a base64-encoded RiichiEnv Observation in each request_action message.
  • 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_game so you know your seat ID.
  • Decode msg["observation"] from each request_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 sending action.to_mjai().

Related Docs

See MJAI Protocol for the message schema and Local Testing for more on RiichiEnv observations and offline simulation.