anvilsign in

collin/mahjong

RenderedSource

1# 台灣麻將 — four-player hotseat on one touchscreen
2
3Sixteen-tile Taiwanese mahjong for four people sitting around a single laptop.
4All four hands are on screen at once, each rotated to face its own edge — put a
5piece of card or plastic over your strip so the others can't see your tiles.
6Every label is Chinese with English underneath; the tiles themselves stay
7Chinese, because that is what the tiles say.
8
9Nobody else around? **單人對局 Single player** on the lobby gives you the
10bottom seat and hands the other three to the computer — same rules, same
11table, same scoring. See [computer players](#computer-players).
12
13```
14npm install
15npm run dev # then open the browser full-screen (F11)
16npm test # rules engine, the bots, and 200 randomly-played hands
17```
18
19## Screen layout
20
21```
22 ┌──── 對家 (rotated 180°) ────┐
23 上家 (rotated 90°) │ wall, and the discard pool │ 下家 (rotated −90°)
24 └──── 自家 (upright, near you) ┘
25```
26
27Seat 0 is the bottom edge, and play runs 0 → 1 (right) → 2 (top) → 3 (left),
28which is the normal counter-clockwise 東南西北 order.
29
30Each strip shows, from the player's edge inwards: nameplate (seat wind, 莊
31marker, 聽 badge, chip count) → concealed hand → action buttons → melds and
32flowers. Discards are not in the strips: they are thrown into the middle and
33stay there for the hand, the way they would be on a table. (A phone has no
34middle to throw into, so the compact layout keeps a discard row per seat — see
35[on a phone](#on-a-phone).)
36
37## Playing
38
39- **The tile you just drew** is held apart from the sorted hand with a gold ring
40 and a 摸 label, instead of being sorted invisibly into it. It merges into the
41 hand once you discard. 補花 replacements and kong replacements are marked the
42 same way.
43- **The wall** is drawn in the centre as a square: four staggered sides of 18
44 stacks of two, the way it's built on a real table. The gold stack is the break
45 point you draw from, stacks shrink to a single tile and then vanish as they're
46 used, and the dimmed tail is the 16-tile 底牌 that ends the hand — which is
47 also the end kong and flower replacements are taken from, so the square is
48 eaten from both directions at once.
49- **Discard** — drag a tile out of your row and it comes up out of your hand and
50 follows your finger anywhere on the table; let go and it goes in, from where
51 you let go and at the speed you let go at. Dragging *along* the row is still
52 arranging your hand — which of the two you meant is decided once, from whether
53 the movement is mostly along the row or out of it, and then it sticks. Let go
54 back over your own hand and the tile goes back. Tapping a tile twice, or the
55 打出 button, still works and lobs it in for you.
56- **The discard pool** — every tile thrown lands in the middle and stays there
57 until the hand ends, with real weight: it skids, turns, knocks the tiles
58 already there out of its way, and comes to rest against them. Nothing is ever
59 stacked on anything — tiles are solved as rectangles, so two lying at an angle
60 lean on each other rather than overlapping. The tile still to be claimed is
61 the lit one.
62- **Over the wall or across the table** — which of the two ways a tile goes in is
63 not a setting, it is whatever the wall allows. At the start of a hand the
64 square is a solid barrier and a discard has to be *lobbed* over it, so the
65 flick's speed decides how far across the pool it lands. As the wall is eaten
66 away, gaps open in front of one seat and then another, and from then on that
67 seat can *slide* a tile in instead — flat across the cloth at exactly the speed
68 it was flicked. Which it will be is a ray cast at the stacks actually standing
69 there, so a gap off to one side counts, and a stack worn down to a single tile
70 is low enough to go over. A seat whose wall is still up says 牆未開 on its
71 nameplate, so a lob is never a mystery.
72- **Arranging** — drag any tile in your hand to reorder it; 理牌 sorts it back
73 into suit order. Hands are never auto-sorted after the deal, so an arrangement
74 you set up survives draws, claims and kongs.
75- **Rules** — the `?` on your nameplate opens a bilingual rules panel anchored to
76 your own edge of the table and rotated to face you.
77- **Claiming** — after a discard, every seat that *can* claim gets 胡 / 槓 / 碰 /
78 吃 / 過 buttons on their own strip, and they resolve by priority (胡 > 槓/碰 >
79 吃; ties go to the player nearest the discarder in turn order).
80 **The table never blocks on a claim.** The next player gets a 摸牌 button and
81 may draw whenever they like, which shuts the window on anyone who hasn't
82 called — exactly like shouting 碰 before the next player picks up. A claim
83 already declared still stands, so calling in time always wins the tile. If
84 everyone answers first, play advances on its own without the extra tap.
85 The seat that draws next gets no separate 過 button, because for them the two
86 are the same move: picking up *is* how you decline. Their draw button reads
87 過.摸牌 while they have something they could have called instead. Wanting to
88 decline but leave the window open for everyone else is just waiting, which is
89 what not pressing anything already does.
90- **On your turn** — 自摸, 暗槓 and 加槓 appear automatically when legal.
91 加槓 offers everyone else a 搶槓 chance.
92- **Where the buttons go** — the strip is built from its edge inwards, so the
93 action bar holds a fixed height whether or not anything is in it. Otherwise
94 the melds above it shuffled along every time you lifted a tile
95 and 打出 五萬 appeared, since a button naming a tile stands taller than a bare
96 過. Every button on the bar is given that one height for the same reason.
97- **When a hand ends** — a big arrow drops onto the winner's edge of the table.
98 It lives inside their strip, so it turns with the seat and lands on the right
99 person wherever they are sitting, and 一炮多響 lights up every seat that
100 called. A 流局 has no winner to point at, so it turns the hands over instead:
101 who was 聽牌 and the tiles they were waiting on, the same reveal as at a real
102 table.
103- **Undo** — a 復原 button in the middle of the table takes back the last
104 action, naming what it will undo (`復原 玩家 2 打 五萬`). It sits in the
105 centre rather than on a seat because whoever *spots* the mis-tap should be
106 able to reach it. Twenty actions deep, cleared when the next hand is dealt.
107 Snapshots live outside the saved state, so an undo does not survive a
108 refresh: what you can take back is what happened while everyone was still
109 watching it happen.
110- **Sound** — on by default, muted from the 🔊 button in the middle of the
111 table or from settings. A dry clack as a tile goes down, and a distinct
112 two-note chime when a claim window opens, which is how a slow player notices
113 their 碰 is available before the next player draws it shut. Everything is
114 synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled,
115 so there is nothing to load and the cue lands on the same frame as the tile.
116 Browsers refuse to start audio before the page has been clicked, so the first
117 sound anyone hears is the deal — triggered by the button that starts it.
118- **報牌 (voice)** — a second switch under sound calls the game out loud: 碰,
119 吃, 槓, 胡了, 自摸, and the name of every tile as it is discarded — 三條,
120 五萬, 東風. A flower says 補花 and then which one. A new call cuts off one
121 still being spoken; at table pace the newest is the only one that matters.
122 See [the voice pack](#the-voice-pack) for where the audio comes from.
123- **Settings** — 設定 from the lobby, or from the game-over panel: player names,
124 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in
125 `localStorage` separately from the save, so they carry over to the next game.
126 Stakes are locked once a game is under way — they'd otherwise rewrite chips
127 already won.
128
129## On a phone
130
131The table assumes four people sitting around a screen lying flat, and about
132770px in both directions before the middle is worth looking at. A phone has
133neither, so under `(max-height: 620px), (max-width: 820px)` it switches to the
134compact layout: nothing is rotated, everything reads upright for the one person
135holding it, the other three seats shrink to cards showing what is public about
136them, and the depth that frees up goes to your hand, which is allowed to wrap.
137
138There is no centre square there — the middle collapses to a one-line bar showing
139what the wall square was telling you anyway — so there is nowhere to throw a tile
140*to*. The compact layout therefore keeps a discard row per seat, and discarding
141is tap-twice or 打出, exactly as it was. The pool and the throw are a full-size
142feature.
143
144The breakpoint is written down once, in `COMPACT_QUERY` in `src/ui/compact.ts`,
145which sets a `compact` class on `<html>` for the CSS to key off.
146
147## Computer players
148
149Single player seats you at the bottom and gives seats 1-3 to the computer.
150Their tiles go face down and their 聽 / 過水 badges come off, since both would
151give the hand away; everything public — the pool, melds, flowers, chips — stays
152exactly as it is, and everyone turns their hand over when the hand ends. They
153throw their tiles in like anyone else, and under the same rule: a computer seat
154whose wall is open slides them across, and one still walled in lobs them over.
155
156**The engine does not know the bots exist.** `AutoPlay` (`src/game/autoplay.ts`)
157watches the same state the screen does, and when the seat being waited on
158belongs to the computer it calls the identical method the button would have
159called — `discard`, `respond`, `declareConcealedKong`. So scoring, sound,
160saving, 過水 and 包牌 all work without a special case, and a bot cannot make a
161move a person could not. It never acts for you and never closes your claim
162window: if the table is waiting on you, it waits.
163
164**How they play** (`src/game/bot.ts`) is one idea applied everywhere. A hand is
165judged at its *resting* size — the (5 − melds) × 3 + 1 tiles you hold between a
166discard and your next draw — by its 向聽 first and by its 進張 count second.
167
168- **Discard** — try every distinct tile, keep the one that leaves the best
169 resting hand. Ties go to whatever is hardest to build on: a lone honour with
170 most of its copies already gone, before a lone 五萬 that still has neighbours
171 to meet.
172- **碰 / 吃** — only when the hand that comes out the other side is *strictly*
173 closer to home. Melding costs concealment and flexibility, so a claim that
174 merely holds the 向聽 steady is declined — which is why the bots pass on
175 pungs that would narrow a two-sided wait to a single tile.
176- **槓** — judged more kindly: it pays 台 and fetches a replacement, so standing
177 still is good enough.
178- **胡** — always taken.
179
180**What they watch you do** (`src/game/danger.ts`) is the other half. Efficiency
181alone throws whatever is fastest and pays for it; these bots read the table
182first, off the face-up table only — discard rows, exposed melds, the 過水 locks
183the nameplates already show. Two separate questions:
184
185- **How close does each seat look?** Turns taken (a hand is usually settled
186 inside ten goes each, long before the wall looks finished, so counting the
187 wall is the wrong clock), melds exposed, and what they have been throwing
188 lately. Nobody discards 五條 out of a hand that still needs shaping — it comes
189 out once the shape is done, so late middle tiles are the tell. A 過水 lock is
190 a confession: to be locked out of a tile you had to have been able to win on
191 it.
192- **How likely is this tile the one they want?** An honour can only be caught by
193 a pair or a triplet; a 五萬 sits in the middle of every run through it. A tile
194 they discarded themselves was not their tile when it went down — not proof
195 now, but the best evidence there is. A tile with no copies left unseen cannot
196 be a pair or triplet wait at all. Two melds in one suit means stay out of that
197 suit. And two dragon pungs down means the third dragon is not going anywhere
198 near the table, because 包牌 bills the feeder for the entire hand.
199
200Whether any of that changes the discard depends on the bot's own hand. At 聽牌
201it pushes — nothing short of 包牌 is worth breaking a ready hand for. Two or
202three away with somebody live across the table and it will give up a whole 向聽
203step to throw something safe, which is what folding is.
204
205The estimate is checked against ground truth in the tests rather than assumed:
206over the tiles bots actually considered, the ones the model rated below 0.2 deal
207in 0% of the time and the ones above 0.8 deal in 10.5%, cleanly monotonic in
208between. Switching the read on cuts deal-ins by about 13% over a few hundred
209hands, takes hands from ~30 discards to ~38, and moves the draw rate from
210almost nothing to about one hand in ten — which is what a table where people
211stop feeding each other actually looks like.
212
213What they know is still only what they can see. `unseenFor` counts the four
214copies of each tile and subtracts the bot's own hand plus every discard and
215exposed meld on the table. Neither module ever reads an opponent's concealed
216tiles or looks at the wall.
217
218There is no difficulty setting, and the reading stops at the discard: claims are
219still judged purely on speed, and a bot will take a 碰 that walks it into
220trouble.
221
222向聽 lives in `src/game/shanten.ts`, apart from `hu.ts`, because the two want
223different things: the win check has to be exact, and this one has to be fast —
224it runs a few hundred times for every discard a bot considers. The tests hold
225them against each other over random hands, since they work in completely
226different ways and any disagreement is a bug in the fast one.
227
228Undo behaves differently against the computer: rewinding into the middle of its
229turn would only hand the move straight back to it, so one press goes back to
230the last point *you* had a decision to make, computer replies and all.
231
232## Saving
233
234The game state is plain data, so a save is just its JSON in `localStorage`,
235rewritten after every move. Close the lid, refresh, or run the battery flat and
236nothing is lost — the lobby offers **繼續對局 Resume** above the two ways to
237start a fresh one, showing whether it was a solo or a hotseat game, the round,
238hand number, chip counts and when it was saved. Which seats the computer holds
239is part of the state, so a save resumes as the kind of game it was. A
240save is validated before it is offered (four players, 144 tiles accounted for),
241so a truncated or hand-edited one is ignored rather than loaded into a broken
242table. `VERSION` in `save.ts` retires old saves if the state shape changes.
243
244## Rules implemented
245
246- 144 tiles (four of each suit/honour, eight flowers), 16-tile hands, dealer
247 draws the 17th.
248- 補花 at the deal and on every drawn flower, replacements from the back of the
249 wall.
250- 吃 only from 上家; 碰/槓/胡 from anyone. 明槓, 暗槓, 加槓, 搶槓, 槓上開花.
251- 流局 when 16 tiles remain (`Rules.wallReserve`); the dealer keeps the deal on a
252 draw or on a dealer win (連莊), otherwise the deal passes and the round wind
253 advances every four passes. A full 四圈 game is 16 dealer passes.
254
255### House rules (switches in `Rules`, all on the settings screen)
256
257- **過水 `sacredDiscard`** (on) — pass on a tile you could have won with and it
258 is dead to you until your own next draw; the seat shows a 過水 badge listing
259 what it is locked out of, so the missing 胡 button is never a mystery. A draw
260 from either end of the wall lifts it. **`sacredClearedByClaim`** (off) decides
261 whether taking a 吃 / 碰 / 槓 lifts it too — tables genuinely differ.
262- **一炮多響 `multipleWinners`** (off) — one discard pays out to every seat that
263 calls on it, each settled separately against the discarder. With it off the
264 tile goes to the caller nearest the discarder. Either way the table now waits
265 for other seats that *can* win before settling, so the nearest seat wins the
266 tile rather than the quickest hand — and the 摸牌 button still closes the
267 window on anyone dithering.
268- **包牌 `liability`** (on) — feeding the pung that completes a visible 大三元
269 or 大四喜 makes the feeder answer for the whole hand, in place of all three
270 payers. Only the seat that fed the *last* of those sets is on the hook, and
271 only if it came off a discard: a hand that assembled them itself, or closed
272 the set with a 暗槓, has nobody to blame.
273
274### 台 scoring (`src/game/tai.ts`)
275
276自摸 1 · 門清 1 · 門清自摸 +1 · 全求人 2 · 平胡 2 · 五門齊 2 · 正花 1 each ·
277花槓 2 · 八仙過海 8 · 圈風 / 門風 1 each · 三元牌 1 each · 小三元 4 · 大三元 8 ·
278小四喜 8 · 大四喜 16 · 碰碰胡 4 · 混一色 4 · 清一色 8 · 字一色 16 ·
279三暗刻 2 / 四暗刻 5 / 五暗刻 8 · 獨聽 · 單釣 1 · 搶槓 1 · 槓上開花 1 ·
280海底撈月 1 · 河底撈魚 1 · 天胡 16 · 地胡 16 · 人胡 8 · 莊家 1 (連N拉N → 2N+1)
281
282Ambiguous hands are decomposed every legal way and scored at the best reading.
283
284**Payment** (`Game.settle`): one unit is `底 + 台 × 台值`
285(`DEFAULT_RULES` = 底 3, 台 1, 100 chips each).
286放槍一家付 — the discarder alone pays one unit; on 自摸 all three pay one unit
287each. 拉莊 is billed to the dealer alone when the dealer is a payer, and added
288to the whole hand when the dealer wins.
289
290House rules vary a lot; the tai table and payments are plain data/functions in
291`tai.ts` and `types.ts` if yours differ.
292
293## The voice pack
294
295A mahjong table only ever says about fifty things — 42 tile names and a handful
296of calls — so the whole vocabulary is rendered ahead of time into
297`public/voice/` (~330 kB of mp3) rather than left to whatever speech synthesis
298the browser happens to have. On Linux that is usually espeak-ng, which is
299intelligible but sounds like a modem; and on a machine with no Chinese voice at
300all the feature would silently do nothing. Shipping the audio makes playback
301instant, identical everywhere, and lets a call be cut off mid-word when the
302next one lands, all through the same Web Audio graph as the chimes.
303
304`scripts/voice.mjs` is the authoring step, not part of `npm run build`. It needs
305[piper](https://github.com/OHF-Voice/piper1-gpl) and ffmpeg on PATH:
306
307```
308node scripts/voice.mjs # → public/voice/*.mp3 + manifest.json
309PIPER_MODEL=/path/to/voice.onnx node scripts/voice.mjs # a different voice
310```
311
312The wording lives in that script, and some of it is deliberately not the bare
313tile character: 東 alone is a direction where 東風 is the tile, and the dragons
314are 紅中 / 發財 / 白板 the way they are actually called — which also gives the
315phonemiser enough context to get the tone right, since 中 on its own is as
316likely to come out zhòng.
317
318The clips here were rendered with piper's `zh_CN-huayan-medium`. If you
319redistribute this app, check that voice's model card in
320[piper-voices](https://huggingface.co/rhasspy/piper-voices) for the terms
321attached to it, the same way you would the tile art below — or re-render the
322pack with a voice whose terms suit you, which is a single command.
323
324## Tile art
325
326`public/tiles/tiles.svg` is the **postmodern** tileset from
327[gnome-mahjongg](https://gitlab.gnome.org/GNOME/gnome-mahjongg), extracted from
328the installed binary's GResource — a 43×2 sprite sheet (second row is the
329highlighted variant, used for a lifted tile). `public/tiles/back.png` is the
330blank tile from its **smooth** theme, tinted jade, since a solitaire game has no
331face-down art of its own.
332
333That art is **GPL-2.0-or-later**. Fine for playing at home; if you ever
334distribute this app, either honour the GPL or swap the two files for art of your
335own — `SPRITE_COL` in `tiles.ts` is the only mapping that would need updating.
336
337Known gaps and house rules that aren't implemented yet are listed in
338[TODO.md](TODO.md).
339
340## Layout
341
342```
343public/tiles/ sprite sheet + tile back
344src/game/tiles.ts tile codes, wall, shuffle, sprite mapping
345src/game/hu.ts hand decomposition, 聽 detection, wait shapes
346src/game/shanten.ts 向聽 / 進張 counting, for the computer players
347src/game/bot.ts what a computer player discards and claims (pure)
348src/game/danger.ts reading the table: who looks ready, which tiles are hot
349src/game/autoplay.ts when it does it, and how long it appears to think
350src/game/tai.ts 台 scoring
351src/game/engine.ts state machine: deal, turns, claim resolution, settlement
352src/game/wall.ts what is left of the square, and what still blocks a throw
353src/table/physics.ts the discard pool: rectangles with weight, pure and testable
354src/table/geometry.ts the table measured — colliders, launch points, throw gate
355src/table/pool.ts game state ⇄ tiles in the middle, by diffing the discards
356src/ui/ TileView, Hand (drag and throw), Seat, Center, Pool, Help
357```
358
359## The tiles in the middle
360
361The pool is a small rigid-body solver (`src/table/physics.ts`) that knows nothing
362about mahjong, the DOM, or the clock: fixed 1/120s steps, no randomness of its
363own, and everything in table pixels. Given the same throws it produces the same
364pile every time, which is what lets a refresh mid-hand come back to the pile you
365had rather than a freshly scattered one — positions are derived from the hand
366number and each tile's place in its thrower's discards, never saved.
367
368It is drawn on a canvas that spans the **whole table**, not just the centre.
369That is deliberate: `.slot` and `.seat` both clip their own contents, which is
370why a tile can't be animated out of a strip as an element. On the canvas there is
371nothing to clip it, so a tile lifted out of a hand crosses the table in one
372piece.
373
374Nothing about the square is calculated twice. Its size lives entirely in CSS
375(`--ring`, `--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating
376that arithmetic: `WallRing` already renders every stack as a real element tagged
377with its index, and the colliders are those elements' bounding rects. That one
378mapping is what makes the wall a physical object — the thing a thrown tile
379bounces off, and the thing the throw gate casts its ray at.
380
381`pool.ts` never listens for events. It **diffs** the discards against what it is
382already drawing, keyed by `seat:index` — and those keys never shift, because
383discards are only ever pushed and a claim only ever pops the one on top. So a
384key that appeared is a tile to throw in and a key that went is a tile to take
385off, which makes undo, 下一局, a claim and resuming a saved game all the same
386code path. The engine has no idea any of it exists, and the save format did not
387change.