anvilsign in

collin/mahjong

RenderedSource

台灣麻將 — four-player hotseat on one touchscreen

Sixteen-tile Taiwanese mahjong for four people sitting around a single laptop. All four hands are on screen at once, each rotated to face its own edge — put a piece of card or plastic over your strip so the others can't see your tiles. Every label is Chinese with English underneath; the tiles themselves stay Chinese, because that is what the tiles say.

npm install
npm run dev      # then open the browser full-screen (F11)
npm test         # rules-engine tests, incl. 200 randomly-played hands

Screen layout

                    ┌──── 對家 (rotated 180°) ────┐
  上家 (rotated 90°) │      wall / discards       │ 下家 (rotated −90°)
                    └──── 自家 (upright, near you) ┘

Seat 0 is the bottom edge, and play runs 0 → 1 (right) → 2 (top) → 3 (left), which is the normal counter-clockwise 東南西北 order.

Each strip shows, from the player's edge inwards: nameplate (seat wind, 莊 marker, 聽 badge, chip count) → concealed hand → action buttons → melds and flowers → that player's discard row.

Playing

  • The tile you just drew is held apart from the sorted hand with a gold ring and a 摸 label, instead of being sorted invisibly into it. It merges into the hand once you discard. 補花 replacements and kong replacements are marked the same way.
  • The wall is drawn in the centre as a square: four staggered sides of 18 stacks of two, the way it's built on a real table. The gold stack is the break point you draw from, stacks shrink to a single tile and then vanish as they're used, and the dimmed tail is the 16-tile 底牌 that ends the hand — which is also the end kong and flower replacements are taken from, so the square is eaten from both directions at once.
  • Discard — tap a tile to lift it, tap again (or the 打出 button) to throw it. Action buttons sit on the right of your own strip, within thumb reach.
  • Arranging — drag any tile in your hand to reorder it; 理牌 sorts it back into suit order. Hands are never auto-sorted after the deal, so an arrangement you set up survives draws, claims and kongs.
  • Rules — the ? on your nameplate opens a bilingual rules panel anchored to your own edge of the table and rotated to face you.
  • Claiming — after a discard, every seat that can claim gets 胡 / 槓 / 碰 / 吃 / 過 buttons on their own strip, and they resolve by priority (胡 > 槓/碰 > 吃; ties go to the player nearest the discarder in turn order). The table never blocks on a claim. The next player gets a 摸牌 button and may draw whenever they like, which shuts the window on anyone who hasn't called — exactly like shouting 碰 before the next player picks up. A claim already declared still stands, so calling in time always wins the tile. If everyone answers first, play advances on its own without the extra tap.
  • On your turn — 自摸, 暗槓 and 加槓 appear automatically when legal. 加槓 offers everyone else a 搶槓 chance.
  • Undo — a 復原 button in the middle of the table takes back the last action, naming what it will undo (復原 玩家 2 打 五萬). It sits in the centre rather than on a seat because whoever spots the mis-tap should be able to reach it. Twenty actions deep, cleared when the next hand is dealt. Snapshots live outside the saved state, so an undo does not survive a refresh: what you can take back is what happened while everyone was still watching it happen.
  • Sound — off by default. A dry clack as a tile goes down, and a distinct two-note chime when a claim window opens, which is how a slow player notices their 碰 is available before the next player draws it shut. Everything is synthesised with a few oscillators (src/game/sound.ts) rather than sampled, so there is nothing to load and the cue lands on the same frame as the tile. Toggle it from the 🔊 button next to 復原, or from the settings screen.
  • 報牌 (voice) — a second switch under sound calls the game out loud: 碰, 吃, 槓, 胡了, 自摸, and the name of every tile as it is discarded — 三條, 五萬, 東風. A flower says 補花 and then which one. A new call cuts off one still being spoken; at table pace the newest is the only one that matters. See the voice pack for where the audio comes from.
  • Settings — 設定 from the lobby, or from the game-over panel: player names, 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in localStorage separately from the save, so they carry over to the next game. Stakes are locked once a game is under way — they'd otherwise rewrite chips already won.

Saving

The game state is plain data, so a save is just its JSON in localStorage, rewritten after every move. Close the lid, refresh, or run the battery flat and nothing is lost — the lobby offers 繼續對局 Resume alongside 開新局 New game, showing the round, hand number, chip counts and when it was saved. A save is validated before it is offered (four players, 144 tiles accounted for), so a truncated or hand-edited one is ignored rather than loaded into a broken table. VERSION in save.ts retires old saves if the state shape changes.

Rules implemented

  • 144 tiles (four of each suit/honour, eight flowers), 16-tile hands, dealer draws the 17th.
  • 補花 at the deal and on every drawn flower, replacements from the back of the wall.
  • 吃 only from 上家; 碰/槓/胡 from anyone. 明槓, 暗槓, 加槓, 搶槓, 槓上開花.
  • 流局 when 16 tiles remain (Rules.wallReserve); the dealer keeps the deal on a draw or on a dealer win (連莊), otherwise the deal passes and the round wind advances every four passes. A full 四圈 game is 16 dealer passes.

House rules (switches in Rules, all on the settings screen)

  • 過水 sacredDiscard (on) — pass on a tile you could have won with and it is dead to you until your own next draw; the seat shows a 過水 badge listing what it is locked out of, so the missing 胡 button is never a mystery. A draw from either end of the wall lifts it. sacredClearedByClaim (off) decides whether taking a 吃 / 碰 / 槓 lifts it too — tables genuinely differ.
  • 一炮多響 multipleWinners (off) — one discard pays out to every seat that calls on it, each settled separately against the discarder. With it off the tile goes to the caller nearest the discarder. Either way the table now waits for other seats that can win before settling, so the nearest seat wins the tile rather than the quickest hand — and the 摸牌 button still closes the window on anyone dithering.
  • 包牌 liability (on) — feeding the pung that completes a visible 大三元 or 大四喜 makes the feeder answer for the whole hand, in place of all three payers. Only the seat that fed the last of those sets is on the hook, and only if it came off a discard: a hand that assembled them itself, or closed the set with a 暗槓, has nobody to blame.

台 scoring (src/game/tai.ts)

自摸 1 · 門清 1 · 門清自摸 +1 · 全求人 2 · 平胡 2 · 五門齊 2 · 正花 1 each · 花槓 2 · 八仙過海 8 · 圈風 / 門風 1 each · 三元牌 1 each · 小三元 4 · 大三元 8 · 小四喜 8 · 大四喜 16 · 碰碰胡 4 · 混一色 4 · 清一色 8 · 字一色 16 · 三暗刻 2 / 四暗刻 5 / 五暗刻 8 · 獨聽 · 單釣 1 · 搶槓 1 · 槓上開花 1 · 海底撈月 1 · 河底撈魚 1 · 天胡 16 · 地胡 16 · 人胡 8 · 莊家 1 (連N拉N → 2N+1)

Ambiguous hands are decomposed every legal way and scored at the best reading.

Payment (Game.settle): one unit is 底 + 台 × 台值 (DEFAULT_RULES = 底 3, 台 1, 100 chips each). 放槍一家付 — the discarder alone pays one unit; on 自摸 all three pay one unit each. 拉莊 is billed to the dealer alone when the dealer is a payer, and added to the whole hand when the dealer wins.

House rules vary a lot; the tai table and payments are plain data/functions in tai.ts and types.ts if yours differ.

The voice pack

A mahjong table only ever says about fifty things — 42 tile names and a handful of calls — so the whole vocabulary is rendered ahead of time into public/voice/ (~330 kB of mp3) rather than left to whatever speech synthesis the browser happens to have. On Linux that is usually espeak-ng, which is intelligible but sounds like a modem; and on a machine with no Chinese voice at all the feature would silently do nothing. Shipping the audio makes playback instant, identical everywhere, and lets a call be cut off mid-word when the next one lands, all through the same Web Audio graph as the chimes.

scripts/voice.mjs is the authoring step, not part of npm run build. It needs piper and ffmpeg on PATH:

node scripts/voice.mjs                      # → public/voice/*.mp3 + manifest.json
PIPER_MODEL=/path/to/voice.onnx node scripts/voice.mjs   # a different voice

The wording lives in that script, and some of it is deliberately not the bare tile character: 東 alone is a direction where 東風 is the tile, and the dragons are 紅中 / 發財 / 白板 the way they are actually called — which also gives the phonemiser enough context to get the tone right, since 中 on its own is as likely to come out zhòng.

The clips here were rendered with piper's zh_CN-huayan-medium. If you redistribute this app, check that voice's model card in piper-voices for the terms attached to it, the same way you would the tile art below — or re-render the pack with a voice whose terms suit you, which is a single command.

Tile art

public/tiles/tiles.svg is the postmodern tileset from gnome-mahjongg, extracted from the installed binary's GResource — a 43×2 sprite sheet (second row is the highlighted variant, used for a lifted tile). public/tiles/back.png is the blank tile from its smooth theme, tinted jade, since a solitaire game has no face-down art of its own.

That art is GPL-2.0-or-later. Fine for playing at home; if you ever distribute this app, either honour the GPL or swap the two files for art of your own — SPRITE_COL in tiles.ts is the only mapping that would need updating.

Known gaps and house rules that aren't implemented yet are listed in TODO.md.

Layout

public/tiles/        sprite sheet + tile back
src/game/tiles.ts    tile codes, wall, shuffle, sprite mapping
src/game/hu.ts       hand decomposition, 聽 detection, wait shapes
src/game/tai.ts      台 scoring
src/game/engine.ts   state machine: deal, turns, claim resolution, settlement
src/ui/              TileView, Hand (drag), Seat (rotated strip), Center, Help