Local Testing
How to simulate games locally with riichienv before joining ranked play
Overview
RiichiLab's game server uses riichienv's
core engine (riichienv-core) to run games.
You can use the Python package riichienv to simulate games locally
without running the game server. This lets you test and debug your bot logic before connecting to the online server.
Prerequisites
- Python 3.12+
pip install riichienv— game engine with simulation and observation APIspip install websockets— only needed if connecting to the online server
Implementing an Agent
Your bot is an Agent class that implements an act(obs: Observation) -> Action method.
The Observation provides the current game state visible to your player,
and legal_actions() returns the list of valid actions you can take.
from riichienv import Action, ActionType, Observationclass MyAgent: """Your bot agent. Implement act() to return an Action.""" def act(self, obs: Observation) -> Action: actions = obs.legal_actions() # Win if possible for a in actions: if a.action_type == ActionType.Tsumo or a.action_type == ActionType.Ron: return a # Declare riichi if possible for a in actions: if a.action_type == ActionType.Riichi: return a # Default: discard the drawn tile (tsumogiri) for a in actions: if a.action_type == ActionType.Discard: return a # Pass on claims return Action(ActionType.Pass)Running a Local Simulation
Use riichienv.RiichiEnv to run a complete game locally.
No game server, no WebSocket — just pure Python:
import jsonfrom riichienv import RiichiEnvagent = MyAgent()# Create a game environment# game_mode: 1 = East-only, 2 = East-South (hanchan)# seed: fixed seed for reproducibility (optional)env = RiichiEnv(game_mode=2, seed=42)# Get initial observations for all playersobservations = env.get_observations()while not env.done(): # Find the player who needs to act for pid in range(4): obs = observations[pid] actions = obs.legal_actions() if actions: # Your agent decides the action action = agent.act(obs) observations = env.step(action) break# Game finished — check resultsprint("Scores:", env.scores())print("Ranks:", env.ranks())# Full MJAI log is availablefor event in env.mjai_log: print(json.dumps(event, ensure_ascii=False)) This runs a full hanchan (East-South) game with your agent controlling all 4 players. You can also assign different agents to different seats.
Observation & request_action
When connecting to RiichiLab's online game server, your bot receives request_action messages via WebSocket.
This is the only event type your bot needs to handle — all other events
(like tsumo, dahai, etc.)
are informational. When replying, echo the request's request_id so the server can bind your reply to the
request deterministically (see the MJAI Protocol page).
Each request_action includes an observation key containing a base64-encoded serialized Observation object. Deserialize it with Observation.deserialize_from_base64() to get a full riichienv Observation with legal actions, hand tiles, and game state:
# In a request_action message from the server:# {# "type": "request_action",# "request_id": 42,# "time": {"grace_ms": 3000, "bank_ms": 15000, "deadline_ms": 18000},# "possible_actions": [...],# "observation": "base64-encoded-string..."# }from riichienv import Observation# Deserialize the observationobs = Observation.deserialize_from_base64(msg["observation"])# Available methods:obs.legal_actions() # -> list[Action] — valid actions to choose fromobs.new_events() # -> list[str] — new MJAI events (JSON strings)obs.hand # -> list[int] — current hand tiles (136-format)obs.player_id # -> int — your seat number (0-3)obs.to_dict() # -> dict — full observation as a dictionary# For neural network bots:obs.encode_extended_win_projection() # -> feature encodingobs.encode_discard_history_decay() # -> discard history features Note: For 3-player games, use Observation3P instead of Observation.
The API is the same — just replace Observation.deserialize_from_base64() with Observation3P.deserialize_from_base64().
WebSocket Bot Example
Here's how to connect the same Agent to the online game server via WebSocket:
import asyncioimport jsonimport websocketsfrom riichienv import Observationasync def bot(url: str): async with websockets.connect(url, additional_headers=HEADERS) as ws: my_seat = None agent = MyAgent() while True: msg = json.loads(await ws.recv()) match msg["type"]: case "start_game": my_seat = msg.get("id", 0) case "request_action": # Deserialize the observation from the server 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": breakTOKEN = "your-bot-token-here"HEADERS = {"Authorization": f"Bearer {TOKEN}"}asyncio.run(bot("wss://game.riichi.dev/ws/validate")) The key difference from local simulation: instead of calling env.get_observations() directly, you deserialize the observation field from the server's request_action message. The same Agent.act() logic works in both modes.
Use your bot token with wss://game.riichi.dev/ws/validate when testing this flow against the
live server. See Bot Validation for the
activation requirements.
Architecture Note
RiichiLab's game server is built on riichienv-core (the Rust crate).
The Python package riichienv wraps the same Rust core via PyO3,
so local simulation behavior matches the online server exactly.
By testing locally first, you can iterate quickly without needing network connectivity or waiting for matchmaking.
