Hong Kong Mahjong mascot peeking
Hong Kong Mahjong
Log inRegister

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 games100
Recorded decision points11,749
Discards5,700
Claim decisions (pass, chow, pung, kong, win)1,189
Draws from the wall4,854
Casual tables (no faan minimum)44 games
Advanced tables (3 faan minimum)40 games
Tutorial tables16 games
Games ending in a win75
Games ending in a wall draw25

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

FieldTypeMeaning
game_idstringSequential label assigned during export, game_0001 upwards. Carries no information and is not the original database key.
rulesetstringdefault for casual tables, advanced for 3 faan minimum tables, bootcamp for guided tutorial games.
min_fanintMinimum 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_bootcampboolTrue for tutorial games. These are instructional rather than competitive and most analyses should exclude them.
is_drawboolTrue when the wall was exhausted with no winner.
move_countintLength of the moves array.
winner_seatintSeat 0 to 3 that declared the win. Absent on draws.
win_by_self_drawboolPresent and true when the winner drew their own winning tile. 20 games in this sample.
playersarrayAlways 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.
movesarrayThe decision by decision record. Documented in the next section.
winning_handarrayThe 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_breakdownobjectKeyed 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_scoresobjectPoint 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.

FieldTypeMeaning
seqintPosition in the game, starting at 0.
actorintSeat taking the action, 0 to 3.
phasestringdraw, discard or claim. Claim rows are the interesting ones: they are decision points created by somebody else discarding.
actionstringWhat was chosen: draw, discard, pass, chow, pung, kong, add_kong, concealed_kong, win.
tileobjectThe tile the action concerns: the tile drawn, the tile discarded, or the discard being claimed.
handarrayThe complete concealed hand of the actor at that moment, unsorted. On a discard row it includes the tile about to be discarded.
actor_meldsarrayThe 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.
othersarrayThe 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_remainingintTiles 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_discardobjectOn claim rows, the tile that triggered the decision.
offersarrayThe 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_optionsarrayWhen a chow is on offer, every legal pair of hand tiles that could form it. An array of two tile arrays.
chow_witharrayThe 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_msintMilliseconds 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.

srMeaning
dots1 to 9Circles, 筒
bamboo1 to 9Sticks, 索
characters1 to 9Craks, 萬
wind1 to 4East, South, West, North in that order
dragon1 to 3Red, 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 0 to Seat 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_breakdown appears on 42 of the 75 wins and final_scores on 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 hand array 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.

Related articles

🀄 Start Playing

🀄
Beginner Game

Relaxed pace, any winning hand counts. Great for learning the flow of a real game.

10 s turnsChicken hand OK
Sign up to play
🏆
Advanced Game

Standard Hong Kong rules with 3-faan minimum. Faster pace for experienced players.

7 s turns3-faan minimum
Sign up to play
From the team behind Hong Kong Mahjong
Hou²Hou² — Learn Hong Kong Cantonese
The Cantonese people actually speak in Hong Kong — situation-led, tone-serious, offline.
Learn more →