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.
| Platform | Decompress (read) | Compress (write) |
|---|---|---|
| Python | zlib.decompress(b, -15) | compressobj(6, DEFLATED, -15) |
| Node.js | zlib.inflateRawSync(buf) | zlib.deflateRawSync(buf) |
| Browser | DecompressionStream('deflate-raw') | CompressionStream('deflate-raw') |
| C (zlib) | inflateInit2(&s, -15) | deflateInit2(&s, lvl, Z_DEFLATED, -15, 8, 0) |
| Go | flate.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:
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:
- 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)
- 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() % nis 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
- 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.
- 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
- 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.
- 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. - Derived seeds. A match's hand n uses
matchSeed * 0x9E3779B97F4A7C15 + n * 0xBF58476D1CE4E5B9; a tournament's match i usestournamentSeed * 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
- Base64-decode β raw inflate β JSON parse.
- Check
format == "mjpf"and spec MAJOR == 1. - Refuse if any
requiresflag is unknown (name it). - Parse
game.seedas a 64-bit integer from a string. - Ignore unknown top-level fields, advisory
uses, and unknownextkeys. - Never read personal data (there is none) and never execute
extvalues.