Hong Kong Mahjong Replay Dataset
Riichi mahjong has twenty years of freely downloadable Tenhou records, which is the reason almost every serious piece of mahjong analysis ever written is about riichi. Hong Kong old style has nothing comparable. Everyone building a bot, a coach or a statistical model for HKOS assembles a private corpus, and none of those corpora can be compared with each other.
This page publishes a first sample towards fixing that: 100 complete games from this app, fully anonymised, with the entire visible game state recorded at every single decision. It is small on purpose. The point of releasing it now is to get the format criticised before a larger dump is produced in a shape nobody else can use.
Zipped JSON, 392 KB compressed and about 16 MB expanded. Released under CC BY 4.0: use it commercially, train on it, redistribute it, just credit the source.
What is in the sample
| Complete games | 100 |
| Recorded decision points | 11,749 |
| Discards | 5,700 |
| Claim decisions (pass, chow, pung, kong, win) | 1,189 |
| Draws from the wall | 4,854 |
| Casual tables (no faan minimum) | 44 games |
| Advanced tables (3 faan minimum) | 40 games |
| Tutorial tables | 16 games |
| Games ending in a win | 75 |
| Games ending in a wall draw | 25 |
The rules being played are Hong Kong old style with no flowers or seasons, a 136 tile wall, and either no faan minimum or a 3 faan minimum. The two rulesets behave very differently and are worth treating as separate distributions rather than pooling them. If you are not familiar with the scoring, the faan table and the rules guide cover what the hand keys mean.
File format
One JSON file containing a single array. Each element is one complete game. Fields that do not apply to a game are simply absent rather than null, so read defensively.
[
{
"game_id": "game_0001",
"ruleset": "advanced",
"min_fan": 3,
"is_bootcamp": false,
"is_draw": false,
"move_count": 118,
"winner_seat": 2,
"win_by_self_draw": true,
"players": [ { "seat": 0, "wind": "E", "name": "Seat 0" }, ... ],
"moves": [ { "seq": 0, "actor": 0, "phase": "draw", ... }, ... ],
"winning_hand": [ { "s": "dots", "r": 3 }, ... ],
"score_breakdown": { "2": { "total": 4, "items": [ ... ] } },
"final_scores": { "0": -8, "1": -8, "2": 24, "3": -8 }
},
...
]Game fields
| Field | Type | Meaning |
|---|---|---|
| game_id | string | Sequential label assigned during export, game_0001 upwards. Carries no information and is not the original database key. |
| ruleset | string | default for casual tables, advanced for 3 faan minimum tables, bootcamp for guided tutorial games. |
| min_fan | int | Minimum faan required to declare a win. 0 or 3 in this sample. This single number reshapes the whole decision tree, so it is the most important conditioning variable in the file. |
| is_bootcamp | bool | True for tutorial games. These are instructional rather than competitive and most analyses should exclude them. |
| is_draw | bool | True when the wall was exhausted with no winner. |
| move_count | int | Length of the moves array. |
| winner_seat | int | Seat 0 to 3 that declared the win. Absent on draws. |
| win_by_self_draw | bool | Present and true when the winner drew their own winning tile. 20 games in this sample. |
| players | array | Always four entries: seat (0 to 3), wind (E, S, W, N) and name, which is always the literal string Seat N. See the anonymisation section below. |
| moves | array | The decision by decision record. Documented in the next section. |
| winning_hand | array | The concealed tiles the winner still held at the moment of winning. Melded sets are not repeated here, they are in the final move actor_melds. Present for all 75 wins, so lengths vary from 2 to 14 tiles depending on how much of the hand was exposed. |
| score_breakdown | object | Keyed by winner seat index. Contains total faan and an items array of { key, name, fan }, for example { "key": "seven_pairs", "name": "Seven Pairs", "fan": 4 }. Present for 42 games only, see the known gaps section. |
| final_scores | object | Point change per seat, keyed by seat index as a string. Present for 56 games. Some entries carry only the winner seat. |
Move fields
Every element of moves is a full snapshot of what the acting player could see at that instant, followed by what they actually did. This is the part that makes the file usable as supervised training data without any replay reconstruction: each row is already a state and action pair.
| Field | Type | Meaning |
|---|---|---|
| seq | int | Position in the game, starting at 0. |
| actor | int | Seat taking the action, 0 to 3. |
| phase | string | draw, discard or claim. Claim rows are the interesting ones: they are decision points created by somebody else discarding. |
| action | string | What was chosen: draw, discard, pass, chow, pung, kong, add_kong, concealed_kong, win. |
| tile | object | The tile the action concerns: the tile drawn, the tile discarded, or the discard being claimed. |
| hand | array | The complete concealed hand of the actor at that moment, unsorted. On a discard row it includes the tile about to be discarded. |
| actor_melds | array | The exposed sets belonging to the actor, each { t, tiles, from } where t is chow, pung or kong and from is the seat index the claimed tile came from, as a string. from is absent for concealed kongs. |
| others | array | The other three seats as the actor sees them: seat, hand_size, melds and the full ordered discards pile. No concealed tiles ever appear here. |
| wall_remaining | int | Tiles left in the live wall, which is 67 tiles. Starts at 67 and drops by exactly one on every draw, with no gaps. It does not move across a kong, because kong replacement tiles are taken from a 16 tile reserve rather than from the live wall. |
| last_discard | object | On claim rows, the tile that triggered the decision. |
| offers | array | The legal actions available on a claim row, for example ["pass", "pung"] or ["pass", "win", "pung"]. This is the action mask, and it is why the claim rows can be trained on directly. |
| chow_options | array | When a chow is on offer, every legal pair of hand tiles that could form it. An array of two tile arrays. |
| chow_with | array | The pair actually used, when a chow was taken. Together with chow_options this gives a clean second decision problem: which chow, given that you chow. |
| ts_ms | int | Milliseconds elapsed since the start of that game. Relative, not a wall clock, so it carries no date or time of day. Useful as a rough proxy for how long a decision took. |
One consequence is worth spelling out, because it is the most useful property of the file and it is easy to miss. Every row carries the complete concealed hand of the seat that is acting, and a hand can only change on that seat's own draw, discard or claim, all of which are rows. So all four hands are known at every instant of every game. Nothing has to be reconstructed. That means you can derive labels no player at the table could have had, for example exactly what each opponent was waiting on at the moment somebody chose to fold, which is the supervision that defence models normally have no source for.
Tile encoding
Every tile anywhere in the file is the same two key object: s for suit and r for rank.
| s | r | Meaning |
|---|---|---|
| dots | 1 to 9 | Circles, 筒 |
| bamboo | 1 to 9 | Sticks, 索 |
| characters | 1 to 9 | Craks, 萬 |
| wind | 1 to 4 | East, South, West, North in that order |
| dragon | 1 to 3 | Red, Green, White in that order |
There are no flower or season tiles. The wall is 136 tiles, four copies of each of the 34 types.
Loading it
import json, collections
games = json.load(open("game_replays_sample_anon.json"))
# Every discard decision on 3 faan minimum tables, excluding tutorials.
rows = [
(m["hand"], m["others"], m["wall_remaining"], m["tile"])
for g in games
if g["ruleset"] == "advanced" and not g["is_bootcamp"]
for m in g["moves"]
if m["phase"] == "discard"
]
print(len(rows), "discard decisions")
# Claim decisions come with their own action mask.
claims = [m for g in games for m in g["moves"] if m["phase"] == "claim"]
print(collections.Counter(m["action"] for m in claims))Anonymisation: what is deliberately not here
Nothing in the file identifies a player, a session or a date. Specifically, the following were removed before export and are not recoverable from what remains:
- Database identifiers and every player account or guest identifier.
- Player display names. All four seats are the literal strings
Seat 0toSeat 3. - Creation dates and wall clock timestamps. Only per game relative offsets remain.
- Every marker of whether a seat was a human or a bot. That includes the obvious per player flag, the per move flag, the human and bot counts, and the identifier prefixes that leaked the same information through the score dictionary keys. The score dictionaries are re-keyed to seat index for this reason.
The bot labels being gone is worth stating plainly, because it is the field people ask for first. A mix of human and bot play is in here and you cannot separate it. That is a real limitation for anyone wanting to train only on human demonstrations, and it is the main open question on this release: whether a coarse, non identifying quality signal would be more useful than the current clean removal.
Known gaps
Stated up front rather than discovered by whoever loads the file first.
- The wall is not stored, only mostly recoverable. Every draw is logged in order and
wall_remainingdrops by exactly one each time, so the drawn portion of the wall reconstructs exactly, and for the 25 games that ended with an exhausted wall the whole live wall reconstructs. Two things do not: the order of the 16 tile reserve that is held back and never drawn, and kong replacement tiles, which come out of that reserve without being logged as draw rows and have to be inferred by diffing the actor's hand across the kong. The practical cost is that exact counterfactual continuation past the real end of a won game is not possible, only sampling over orderings consistent with what was observed. Storing the wall outright is under consideration for a second release. - Scoring coverage is incomplete.
score_breakdownappears on 42 of the 75 wins andfinal_scoreson 56 games. Earlier recordings predate those fields. Where a breakdown is present it is only for the winning seat. - Skill level is mixed and unlabelled. This is not strong play and it is not consistent play. Treat it as a distribution of real decisions, not as a source of correct answers.
- No hand ordering guarantees. The
handarray is in internal order, not sorted. Sort it yourself before comparing hands. - One table variant only. No flowers, no seasons, one specific set of faan values. Records from a playgroup with different table rules will not be directly comparable, which is the deeper problem this format does not yet solve.
Feedback on the format is the point
This is a sample rather than a corpus. Before recording changes are made and a larger dump is produced, the format should be one other people can actually use. The questions that matter most: is the per decision snapshot the right unit, or would a compact event log plus a replayer be better? How should table rule variation be expressed so records from different playgroups stay comparable? And is the complete removal of bot labels the right call, or does it destroy more value than it protects?
If you have opinions, they are wanted before the next version rather than after it.
The games in this dataset came from real play
Every record here was produced by people playing Hong Kong mahjong in the app, against each other and against machine learning opponents trained on exactly this kind of data. It is free on both platforms.
Frequently asked questions
What is in the Hong Kong mahjong replay dataset?
100 complete games and 11,749 decision points. Each decision point records the acting seat, their full concealed hand, their melds, every opponent visible melds and discard pile, tiles left in the wall, the legal actions available at that moment and the action actually taken.
Can I tell which players were bots?
No. Every identifier, name and bot flag has been removed. Players appear only as seat 0 to seat 3 with their seat wind.
Does the dataset include the wall order?
Not directly, but most of it is recoverable. Every draw is logged in order, so the drawn portion of the wall reconstructs exactly, and for the 25 games that ended with an exhausted wall the entire live wall reconstructs. What cannot be recovered is the order of the 16 tile reserve that is never drawn. Storing the wall outright is under consideration for a second release.
What licence is the dataset released under?
Creative Commons Attribution 4.0. Commercial use, model training and redistribution are all fine, with credit.
Why does Hong Kong mahjong need an open dataset at all?
Riichi has twenty years of freely available Tenhou records, which is why almost all of the analytical literature on mahjong is riichi literature. Hong Kong old style has no equivalent archive, so every developer rebuilds a private corpus from scratch and none of it is comparable. This sample is a first attempt at a shared starting point.
