πŸ€„MJPF

Implementing MJPF

You can read and write MJPF in any language with three standard pieces: a JSON parser, a raw DEFLATE codec, and base64. There is nothing Mahjong-specific about the transport.

The one gotcha that bites everyone read this

MJQR compression is raw DEFLATE (RFC 1951), not zlib-wrapped (RFC 1950).

The reference app uses Apple's NSData.compressed(using: .zlib), which β€” despite the name β€” emits a raw DEFLATE stream with no zlib header and no Adler-32 checksum. We verified this: the bytes begin ab 56 …, not 78 9c. If you decompress with the default zlib-wrapped mode you'll get an "incorrect header check" error. Always use the raw variant.

PlatformDecompress (read)Compress (write)
Pythonzlib.decompress(b, -15)compressobj(6, DEFLATED, -15)
Node.jszlib.inflateRawSync(buf)zlib.deflateRawSync(buf)
BrowserDecompressionStream('deflate-raw')CompressionStream('deflate-raw')
C (zlib)inflateInit2(&s, -15)deflateInit2(&s, lvl, Z_DEFLATED, -15, 8, 0)
Goflate.NewReader(r)flate.NewWriter(w, lvl)

Read a deal in Python

import base64, zlib, json

def read_mjqr(code: str) -> dict:
    raw = base64.b64decode(code.strip())
    js  = zlib.decompress(raw, -15)          # raw DEFLATE β€” note the -15
    doc = json.loads(js)
    assert doc["format"] == "mjpf", "not an MJPF document"
    major = int(doc["spec"].split(".")[0])
    assert major == 1, f"unsupported major version {major}"
    known = {"event-log", "branch", "result", "bonus-tiles"}
    missing = [c for c in doc.get("requires", []) if c not in known]
    assert not missing, f"unsupported required capability: {missing}"
    return doc

def play_deal(doc):
    g = doc["game"]
    seed   = int(g["seed"])                   # 64-bit β€” parse from STRING
    ruleset, tier = g["ruleset"], g["tier"]
    # build your wall from `seed` (see Determinism below) and play...

Write a deal in Node.js

const zlib = require('zlib');

function canonicalJSON(obj) {
  // sorted keys, compact β€” stable bytes
  return JSON.stringify(sortKeys(obj));
}
function writeMJQR(doc) {
  const json = Buffer.from(canonicalJSON(doc), 'utf8');
  return zlib.deflateRawSync(json).toString('base64');   // raw DEFLATE
}

const deal = {
  format: 'mjpf', spec: '1.3.0', kind: 'deal', requires: [], uses: [],
  game: { seed: '12648430', ruleset: 'riichi-lite', tier: 'wizard', mode: 'play',
          seats: 4, humanSeat: 0, recordSeat: 0, branchPointIndex: 0 }
};
console.log(writeMJQR(deal));

Validate a document

The reader algorithm is four checks (Β§3.1 of the spec): right magic, a compatible spec MAJOR, every required capability understood, then proceed and ignore the rest. A JSON Schema is provided so you can validate structure first:

mjpf.schema.json

Determinism: reproducing the exact deal

MJPF carries a seed and a rule-set id β€” not the wall. To play the identical hand, an implementation reproduces the reference wall-construction algorithm:

  1. RNG β€” SplitMix64. Seed the 64-bit state (if the seed is 0, use the constant below). Each next():
# SplitMix64 β€” the reference RNG (matches MahjongKit/SeededRNG)
state = seed or 0x9E3779B97F4A7C15
def next64():
    global state
    state = (state + 0x9E3779B97F4A7C15) & MASK64
    z = state
    z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & MASK64
    z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & MASK64
    return z ^ (z >> 31)
  1. Bounded draw β€” Lemire "nearly divisionless". The index is the high half of a 128-bit product, and the rejection test looks at the low half. A bare next64() % n is a plausible-looking substitute that gives a completely different (but equally uniform) wall β€” this is the single step most likely to make your deal disagree:
# draw an index in 0 ..< upper  (upper > 1)
def next_bounded(upper):
    product  = next64() * upper            # full 128-bit product
    result   = product >> 64               # high half β€” the value we return
    fraction = product & MASK64            # low half
    if fraction < upper:
        threshold = ((1 << 64) - upper) % upper   # == 2**64 % upper
        while fraction < threshold:
            product  = next64() * upper
            result   = product >> 64
            fraction = product & MASK64
    return result
  1. Tile order (before shuffling). Build the array in exactly this order β€” the index is the tile's deterministic id: each suit in characters (m), circles (p), bamboo (s) order, ranks 1…9, four copies each; then (unless the rule set drops honours) winds East, South, West, North Γ—4 and dragons red, green, white Γ—4; then, for sets that use them, the 8 bonus tiles plum, orchid, chrysanthemum, bamboo, spring, summer, autumn, winter; then any American jokers, appended last.
  2. Shuffle β€” Fisher–Yates, front to back. The swap partner is an offset from the current index. This is the single most likely place to diverge:
remaining = len(tiles)
i = 0
while remaining > 1:
    r = next_bounded(remaining)
    remaining -= 1
    tiles[i], tiles[i + r] = tiles[i + r], tiles[i]
    i += 1
  1. Dead wall. The last 14 tiles are the dead wall. Normal draws come from the front; kong/flower replacements come from the back, never crossing into the live wall.
  2. Opening deal β€” round robin, one tile at a time (not four at a time): for round in 0..<handSize: for seat in 0..<seatCount: hand[seat] += drawFront(). Flower replacement then runs seat 0 upward, each seat finishing before the next begins.
  3. Derived seeds. A match's hand n uses matchSeed * 0x9E3779B97F4A7C15 + n * 0xBF58476D1CE4E5B9; a tournament's match i uses tournamentSeed * 0x9E3779B97F4A7C15 + i * 0xD1B54A32D192ED03 + 0x7047 (all mod 264).

Check yourself before writing any UI. The conformance vectors include a wallPrefix: the first ten tiles dealt from a known seed. If those match, your PRNG, shuffle and deal order are right. If they don't, nothing downstream will ever agree, and no amount of rule-set work will fix it.

Event log (optional)

The resume kind carries an events array behind the event-log capability. In v1 the per-event JSON follows the reference engine's shape (a single key named for the event, tiles carried by kind + a stable id). The normative shape is the generated example-resume.mjpf.json. If you only implement deal (the common case for cross-app compete), you can ignore the event log entirely β€” just refuse documents that require event-log.

Checklist for a conformant reader

Get the schema & examples β†’