anvilsign in

collin/mahjong

RenderedSource

1# 台灣麻將 — four-player mahjong, on one touchscreen or over the wire
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
13Friends elsewhere? **線上對戰 Online game** puts everyone at the same table
14from their own devices, and **手機派對 Party mode** keeps the laptop as the
15table while phones scan a QR to hold their own tiles — see
16[playing online](#playing-online).
17
18```
19npm install
20npm run dev # then open the browser full-screen (F11)
21npm test # rules engine, the bots, and 200 randomly-played hands
22```
23
24## Screen layout
25
26```
27 ┌──── 對家 (rotated 180°) ────┐
28 上家 (rotated 90°) │ wall, and the discard pool │ 下家 (rotated −90°)
29 └──── 自家 (upright, near you) ┘
30```
31
32Seat 0 is the bottom edge, and play runs 0 → 1 (right) → 2 (top) → 3 (left),
33which is the normal counter-clockwise 東南西北 order.
34
35Each strip shows, from the player's edge inwards: nameplate (seat wind, 莊
36marker, 聽 badge, chip count) → concealed hand → action buttons → melds and
37flowers. Discards are not in the strips: they are thrown into the middle and
38stay there for the hand, the way they would be on a table. (A phone has no
39middle to throw into, so the compact layout keeps a discard row per seat — see
40[on a phone](#on-a-phone).)
41
42## Playing
43
44- **The tile you just drew** is held apart from the sorted hand with a gold ring
45 and a 摸 label, instead of being sorted invisibly into it. It merges into the
46 hand once you discard. 補花 replacements and kong replacements are marked the
47 same way.
48- **The wall** is drawn in the centre as a square of four staggered sides, laid
49 out like a `#` the way it is built on a real table — every corner covered by
50 one wall carrying past the end of the next. All 144 tiles, at the size of the
51 tiles in your hand, because they are the same tiles. The gold stack is the
52 break point you draw from, and where that is depends on what the dealer threw:
53 stacks shrink to a single tile and then vanish as they are used, so the gap
54 opens wherever the dice opened the wall and grows from there. The 16-tile 底牌
55 tail at the other end is where kong and flower replacements come from, so the
56 square is eaten from both directions at once. See
57 [擲骰](#擲骰--where-the-wall-is-broken).
58- **Discard** — drag a tile out of your row and it comes up out of your hand and
59 follows your finger anywhere on the table; let go and it goes in, from where
60 you let go and at the speed you let go at. Dragging *along* the row is still
61 arranging your hand — which of the two you meant is decided once, from whether
62 the movement is mostly along the row or out of it, and then it sticks. Let go
63 back over your own hand and the tile goes back. Tapping a tile twice, or the
64 打出 button, still works and lobs it in for you.
65- **The discard pool** — every tile thrown lands in the middle and stays there
66 until the hand ends, with real weight: it skids, turns, knocks the tiles
67 already there out of its way, and comes to rest against them. Nothing is ever
68 stacked on anything — tiles are solved as rectangles, so two lying at an angle
69 lean on each other rather than overlapping. The tile still to be claimed is
70 the lit one.
71- **Over the wall or across the table** — which of the two ways a tile goes in is
72 not a setting, it is whatever the wall allows. At the start of a hand the
73 square is a solid barrier and a discard has to be *lobbed* over it, so the
74 flick's speed decides how far across the pool it lands. As the wall is eaten
75 away, gaps open in front of one seat and then another, and from then on that
76 seat can *slide* a tile in instead — flat across the cloth at exactly the speed
77 it was flicked. Which it will be is a ray cast at the stacks actually standing
78 there, so a gap off to one side counts, and a stack worn down to a single tile
79 is low enough to go over. A seat whose wall is still up says 牆未開 on its
80 nameplate, so a lob is never a mystery.
81- **Throw it badly** and it ends up in somebody's tiles — the middle stops
82 exactly where their hand starts, so a tile that comes off the pool arrives in
83 their lap, and one that falls short lands out by their seat. Either way their
84 row takes the knock and rocks back, and with 報牌 on they tell you about it —
85 喂,小心點 if you are lucky, 是在哈囉 or 你牌品很差欸 if you are not, and
86 never the same line twice running. How hard it got there only decides how
87 loudly. Never your own edge: you cannot be barged by your own discard.
88- **Arranging** — drag any tile in your hand to reorder it; 理牌 sorts it back
89 into suit order. Hands are never auto-sorted after the deal, so an arrangement
90 you set up survives draws, claims and kongs.
91- **Rules** — the `?` on your nameplate opens a bilingual rules panel anchored to
92 your own edge of the table and rotated to face you.
93- **Claiming** — after a discard, every seat that *can* claim gets 胡 / 槓 / 碰 /
94 吃 / 過 buttons on their own strip, and they resolve by priority (胡 > 槓/碰 >
95 吃; ties go to the player nearest the discarder in turn order).
96 **The table never blocks on a claim.** The next player gets a 摸牌 button and
97 may draw whenever they like, which shuts the window on anyone who hasn't
98 called — exactly like shouting 碰 before the next player picks up. A claim
99 already declared still stands, so calling in time always wins the tile. If
100 everyone answers first, play advances on its own without the extra tap.
101 The seat that draws next gets no separate 過 button, because for them the two
102 are the same move: picking up *is* how you decline. Their draw button reads
103 過.摸牌 while they have something they could have called instead. Wanting to
104 decline but leave the window open for everyone else is just waiting, which is
105 what not pressing anything already does.
106- **On your turn** — 自摸, 暗槓 and 加槓 appear automatically when legal.
107 加槓 offers everyone else a 搶槓 chance.
108- **Where the buttons go** — on the same row as your tiles, at the right-hand end
109 of it, where the tile you are about to throw already is. The bar is out of the
110 strip's flow, so a button arriving moves nothing: 打出 五萬 appearing when you
111 lift a tile used to shove the melds and flowers above it up the strip. Every
112 button on the bar is given one height for the same reason.
113- **On the keys** — ← and → walk the selection along your hand and space throws
114 the tile that is lifted. How long you *hold* space is how hard it goes: the
115 tile draws back out of the hand while the key is down and leaves when you let
116 go, so a tap slides it in and a long hold sends it across the square. Escape
117 puts it back. The wall still decides whether it can be slid or has to be
118 lobbed, exactly as with a finger.
119- **When a hand ends** — a big arrow drops onto the winner's edge of the table.
120 It lives inside their strip, so it turns with the seat and lands on the right
121 person wherever they are sitting, and 一炮多響 lights up every seat that
122 called. A 流局 has no winner to point at, so it turns the hands over instead:
123 who was 聽牌 and the tiles they were waiting on, the same reveal as at a real
124 table.
125- **The gear** — beside the `?` on every nameplate, and it opens that player's
126 own copy of the table's controls: undo, sound, full screen, 設定. Anchored in
127 their strip and turned to face their edge, like the rules panel, so what you
128 open reads the right way up to you and to nobody else. These used to be a bar
129 in the middle of the table, which is the one place that belongs to everybody
130 — the pile lands there and it is upside down to two of the four.
131- **Undo** — 復原 in that panel takes back the last action, naming what it will
132 undo (`復原 玩家 2 打 五萬`). It is in all four panels rather than only the
133 seat that made the mistake, because whoever *spots* the mis-tap should be able
134 to reach it. Twenty actions deep, cleared when the next hand is dealt.
135 Snapshots live outside the saved state, so an undo does not survive a
136 refresh: what you can take back is what happened while everyone was still
137 watching it happen.
138- **Sound** — on by default, muted from the 🔊 button behind any seat's gear, or
139 from settings. A dry clack as a tile goes down, and a distinct
140 two-note chime when a claim window opens, which is how a slow player notices
141 their 碰 is available before the next player draws it shut. Everything is
142 synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled,
143 so there is nothing to load and the cue lands on the same frame as the tile.
144 Browsers refuse to start audio before the page has been clicked, so the first
145 sound anyone hears is the deal — triggered by the button that starts it.
146- **報牌 (voice)** — a second switch under sound calls the game out loud: 碰,
147 吃, 槓, 胡了, 自摸, and the name of every tile as it is discarded — 三條,
148 五萬, 東風. A flower says 補花 and then which one. A new call cuts off one
149 still being spoken; at table pace the newest is the only one that matters.
150 See [the voice pack](#the-voice-pack) for where the audio comes from.
151- **方位音 (sound by seat)** — a third switch, on by default. Every noise comes
152 out of the edge its seat is sitting at: 上家 to your left, 下家 to your right,
153 and 對家 across the table, where it is quieter and has the top taken off it —
154 a metre of air and three people's arms do not carry 12 kHz. So you can tell
155 whose 碰 that was without looking up, which is the whole point of hearing it.
156 Tiles out in the middle are placed properly rather than by edge: the physics
157 knows to the pixel where a tile landed, and that is where the knock comes
158 from. Online the table is turned so your own seat is at the bottom, and the
159 sound turns with it. See [audio, by seat](#audio-by-seat).
160- **Settings** — 設定 from the lobby, or from the game-over panel: player names,
161 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in
162 `localStorage` separately from the save, so they carry over to the next game.
163 Stakes are locked once a game is under way — they'd otherwise rewrite chips
164 already won.
165
166## Playing online
167
168Two networked modes, both built on a room relay of our own — `server/rooms.ts`,
169a couple hundred lines of Node on `ws`. The relay knows nothing about mahjong:
170rooms with codes, host election by join order, one bag of shared state only the
171host may write, and events fanned out to everyone else. One client — the
172*host* — runs the engine, publishes the whole `GameState` as room state after
173every move, and plays intents the other devices send as events. The engine was
174already a pure JSON state machine, so nothing in `src/game/` knows the network
175exists — the same trick `autoplay.ts` plays, stretched over a wire. The client
176end of the wire is `src/net/room.ts`; the rest of `src/net/` is the game
177riding it.
178
179- **線上對戰 Online game** — everyone on their own device. Whoever opens the
180 room picks 線上對戰 and gets a seat lobby; friends who open the same link
181 land in it, sit down, and the host deals. Empty seats go to the computer,
182 which runs on the host client — that is what stands in for a server on the
183 classic (free) tier. Each screen draws the same full table, turned so your
184 own seat is the bottom edge; other people's tiles are face down. Joining
185 mid-game takes over a vacant or computer seat, so a dropped friend can
186 reload and carry on.
187- **手機派對 Party mode** — the laptop stays the table, exactly like hotseat,
188 and each seat's corner of the felt wears a QR. Scanning one loads a
189 controller on the phone: just that hand, face up, with the claim buttons —
190 and the throw. Flick a tile up off the top of the phone and it sails in from
191 your edge of the common screen, at the speed and angle you let go of it
192 (`NetThrow`, mapped into table coordinates by `TablePool.throwFromNet`). The
193 seat's tiles go face down on the shared screen and its QR hides; hold the 📱
194 tag on the nameplate to show it again for a player who lost theirs. A phone
195 that leaves hands the seat back to the laptop. Seats never lock: a second
196 phone scanning the same QR joins the same hand — two people playing one
197 hand, whoever acts first acts. And with sound on, the phone in your hand is
198 what says your calls — see [audio, by seat](#audio-by-seat).
199
200Design notes, in the order they bit:
201
202- **Seats are claims on a shared map**, `seats: {ids, name}[]` in room state,
203 arbitrated by the host. Your own hand's arrangement never goes over the wire
204 — the state carries a multiset and each device keeps its own order
205 (`src/net/handOrder.ts`), because a round-trip inside a drag gesture is lag
206 you can feel.
207- **The host can change.** The relay re-elects — the earliest joiner still
208 connected — when the host leaves; every other client already mirrors the
209 full state, so the new host starts its engine from what it was just watching
210 and the game carries on.
211- **A hidden host must keep hosting.** Browsers suspend requestAnimationFrame
212 and throttle timers in background tabs, so nothing in `src/net/` runs on a
213 loop: state writes flush on a microtask, everything inbound arrives over the
214 WebSocket (whose delivery is not throttled), and dead connections are the
215 server's ping loop's problem. A party screen somebody tabbed away from
216 keeps answering.
217- **Identity is a UUID in sessionStorage**, chosen by the device and taken at
218 its word by the relay. The seat map is keyed on it, so a reload or a wifi
219 blip walks back into its own seat. No auth — this is a home server for one
220 table of friends.
221- **Fairness is social, not cryptographic.** Room state carries the whole
222 game, wall and hands included — a friend with devtools open can cheat.
223 Moving the engine into the relay would fix that; the server is ours now, so
224 only the work stands in the way (TODO).
225
226### Running it
227
228```
229npm run dev # vite, with the relay on /ws of the same origin
230npm run build && npm run serve # production: dist/ + relay, one process
231```
232
233The relay always lives at `/ws` on the page's own origin. In development
234`vite.config.ts` attaches it to vite's server, so `npm run dev` is the whole
235stack; in production `server/index.ts` serves the built `dist/` and the relay
236from one port (`PORT`, default 8080), listening on the LAN by default so
237phones on the same wifi can scan straight in. Anything further away wants TLS
238in front — a reverse proxy with a certificate — since phones only get camera
239and fullscreen on https.
240
241Rooms live in memory. A restart drops them; the next visitor on an old link
242recreates the room empty, and a host mid-game republishes its state on the
243next move. Joining a room writes `?room=` back into the address bar, so the
244page URL — the host's included — is the invite link.
245
246A QR pointing at `localhost` is a QR only the host's machine can scan, so the
247server always gives the invite links a public face: `PUBLIC_URL` if set, else
248it runs its own rsgrok tunnel (the house ngrok replacement) — spawned
249the moment the server knows its port, the https URL read off the tunnel-up
250line, no `:4040` inspection API (another agent may own that port). Every QR
251and invite link wears that origin instead of the page's. If the tunnel dies,
252or `rsgrok` is not on the PATH (`RSGROK_BIN` points elsewhere), links fall
253back to the page's own address and the tunnel is retried on later joins.
254
255The tunnel always asks for the same subdomain — `mahjong-table`, or whatever
256`TUNNEL_NAME` says — so restarts land back on the URL people already have
257instead of burning a fresh name each run. A name someone else already holds
258kills that first attempt before it prints a URL; the retry then goes out
259nameless and takes whatever it is given.
260
261## On a phone
262
263The table assumes four people sitting around a screen lying flat, and about
264770px in both directions before the middle is worth looking at. A phone has
265neither, so under `(max-height: 620px), (max-width: 820px)` it switches to the
266compact layout: nothing is rotated, everything reads upright for the one person
267holding it, the other three seats shrink to cards showing what is public about
268them, and the depth that frees up goes to your hand, which is allowed to wrap.
269
270There is no centre square there — the middle collapses to a one-line bar showing
271what the wall square was telling you anyway — so there is nowhere to throw a tile
272*to*. The compact layout therefore keeps a discard row per seat, and discarding
273is tap-twice or 打出, exactly as it was. The pool and the throw are a full-size
274feature.
275
276The breakpoint is written down once, in `COMPACT_QUERY` in `src/ui/compact.ts`,
277which sets a `compact` class on `<html>` for the CSS to key off.
278
279## Computer players
280
281Single player seats you at the bottom and gives seats 1-3 to the computer.
282Their tiles go face down and their 聽 / 過水 badges come off, since both would
283give the hand away; everything public — the pool, melds, flowers, chips — stays
284exactly as it is, and everyone turns their hand over when the hand ends. They
285throw their tiles in like anyone else, and under the same rule: a computer seat
286whose wall is open slides them across, and one still walled in lobs them over.
287
288**The engine does not know the bots exist.** `AutoPlay` (`src/game/autoplay.ts`)
289watches the same state the screen does, and when the seat being waited on
290belongs to the computer it calls the identical method the button would have
291called — `discard`, `respond`, `declareConcealedKong`. So scoring, sound,
292saving, 過水 and 包牌 all work without a special case, and a bot cannot make a
293move a person could not. It never acts for you and never closes your claim
294window: if the table is waiting on you, it waits.
295
296**How they play** (`src/game/bot.ts`) is one idea applied everywhere. A hand is
297judged at its *resting* size — the (5 − melds) × 3 + 1 tiles you hold between a
298discard and your next draw — by its 向聽 first and by its 進張 count second.
299
300- **Discard** — try every distinct tile, keep the one that leaves the best
301 resting hand. Ties go to whatever is hardest to build on: a lone honour with
302 most of its copies already gone, before a lone 五萬 that still has neighbours
303 to meet.
304- **碰 / 吃** — only when the hand that comes out the other side is *strictly*
305 closer to home. Melding costs concealment and flexibility, so a claim that
306 merely holds the 向聽 steady is declined — which is why the bots pass on
307 pungs that would narrow a two-sided wait to a single tile.
308- **槓** — judged more kindly: it pays 台 and fetches a replacement, so standing
309 still is good enough.
310- **胡** — always taken.
311
312**What they watch you do** (`src/game/danger.ts`) is the other half. Efficiency
313alone throws whatever is fastest and pays for it; these bots read the table
314first, off the face-up table only — discard rows, exposed melds, the 過水 locks
315the nameplates already show. Two separate questions:
316
317- **How close does each seat look?** Turns taken (a hand is usually settled
318 inside ten goes each, long before the wall looks finished, so counting the
319 wall is the wrong clock), melds exposed, and what they have been throwing
320 lately. Nobody discards 五條 out of a hand that still needs shaping — it comes
321 out once the shape is done, so late middle tiles are the tell. A 過水 lock is
322 a confession: to be locked out of a tile you had to have been able to win on
323 it.
324- **How likely is this tile the one they want?** An honour can only be caught by
325 a pair or a triplet; a 五萬 sits in the middle of every run through it. A tile
326 they discarded themselves was not their tile when it went down — not proof
327 now, but the best evidence there is. A tile with no copies left unseen cannot
328 be a pair or triplet wait at all. Two melds in one suit means stay out of that
329 suit. And two dragon pungs down means the third dragon is not going anywhere
330 near the table, because 包牌 bills the feeder for the entire hand.
331
332Whether any of that changes the discard depends on the bot's own hand. At 聽牌
333it pushes — nothing short of 包牌 is worth breaking a ready hand for. Two or
334three away with somebody live across the table and it will give up a whole 向聽
335step to throw something safe, which is what folding is.
336
337The estimate is checked against ground truth in the tests rather than assumed:
338over the tiles bots actually considered, the ones the model rated below 0.2 deal
339in 0% of the time and the ones above 0.8 deal in 10.5%, cleanly monotonic in
340between. Switching the read on cuts deal-ins by about 13% over a few hundred
341hands, takes hands from ~30 discards to ~38, and moves the draw rate from
342almost nothing to about one hand in ten — which is what a table where people
343stop feeding each other actually looks like.
344
345What they know is still only what they can see. `unseenFor` counts the four
346copies of each tile and subtracts the bot's own hand plus every discard and
347exposed meld on the table. Neither module ever reads an opponent's concealed
348tiles or looks at the wall.
349
350There is no difficulty setting, and the reading stops at the discard: claims are
351still judged purely on speed, and a bot will take a 碰 that walks it into
352trouble.
353
354向聽 lives in `src/game/shanten.ts`, apart from `hu.ts`, because the two want
355different things: the win check has to be exact, and this one has to be fast —
356it runs a few hundred times for every discard a bot considers. The tests hold
357them against each other over random hands, since they work in completely
358different ways and any disagreement is a bug in the fast one.
359
360Undo behaves differently against the computer: rewinding into the middle of its
361turn would only hand the move straight back to it, so one press goes back to
362the last point *you* had a decision to make, computer replies and all.
363
364## Saving
365
366The game state is plain data, so a save is just its JSON in `localStorage`,
367rewritten after every move. Close the lid, refresh, or run the battery flat and
368nothing is lost — the lobby offers **繼續對局 Resume** above the two ways to
369start a fresh one, showing whether it was a solo or a hotseat game, the round,
370hand number, chip counts and when it was saved. Which seats the computer holds
371is part of the state, so a save resumes as the kind of game it was. A
372save is validated before it is offered (four players, 144 tiles accounted for),
373so a truncated or hand-edited one is ignored rather than loaded into a broken
374table. `VERSION` in `save.ts` retires old saves if the state shape changes.
375
376## Rules implemented
377
378- 144 tiles (four of each suit/honour, eight flowers), 16-tile hands, dealer
379 draws the 17th.
380- 補花 at the deal and on every drawn flower, replacements from the back of the
381 wall.
382- 吃 only from 上家; 碰/槓/胡 from anyone. 明槓, 暗槓, 加槓, 搶槓, 槓上開花.
383- 流局 when 16 tiles remain (`Rules.wallReserve`); the dealer keeps the deal on a
384 draw or on a dealer win (連莊), otherwise the deal passes and the round wind
385 advances every four passes. A full 四圈 game is 16 dealer passes.
386
387### House rules (switches in `Rules`, all on the settings screen)
388
389- **過水 `sacredDiscard`** (on) — pass on a tile you could have won with and it
390 is dead to you until your own next draw; the seat shows a 過水 badge listing
391 what it is locked out of, so the missing 胡 button is never a mystery. A draw
392 from either end of the wall lifts it. **`sacredClearedByClaim`** (off) decides
393 whether taking a 吃 / 碰 / 槓 lifts it too — tables genuinely differ.
394- **一炮多響 `multipleWinners`** (off) — one discard pays out to every seat that
395 calls on it, each settled separately against the discarder. With it off the
396 tile goes to the caller nearest the discarder. Either way the table now waits
397 for other seats that *can* win before settling, so the nearest seat wins the
398 tile rather than the quickest hand — and the 摸牌 button still closes the
399 window on anyone dithering.
400- **包牌 `liability`** (on) — feeding the pung that completes a visible 大三元
401 or 大四喜 makes the feeder answer for the whole hand, in place of all three
402 payers. Only the seat that fed the *last* of those sets is on the hook, and
403 only if it came off a discard: a hand that assembled them itself, or closed
404 the set with a 暗槓, has nobody to blame.
405
406### 台 scoring (`src/game/tai.ts`)
407
408自摸 1 · 門清 1 · 門清自摸 +1 · 全求人 2 · 平胡 2 · 五門齊 2 · 正花 1 each ·
409花槓 2 · 八仙過海 8 · 圈風 / 門風 1 each · 三元牌 1 each · 小三元 4 · 大三元 8 ·
410小四喜 8 · 大四喜 16 · 碰碰胡 4 · 混一色 4 · 清一色 8 · 字一色 16 ·
411三暗刻 2 / 四暗刻 5 / 五暗刻 8 · 獨聽 · 單釣 1 · 搶槓 1 · 槓上開花 1 ·
412海底撈月 1 · 河底撈魚 1 · 天胡 16 · 地胡 16 · 人胡 8 · 莊家 1 (連N拉N → 2N+1)
413
414Ambiguous hands are decomposed every legal way and scored at the best reading.
415
416**Payment** (`Game.settle`): one unit is `底 + 台 × 台值`
417(`DEFAULT_RULES` = 底 3, 台 1, 100 chips each).
418放槍一家付 — the discarder alone pays one unit; on 自摸 all three pay one unit
419each. 拉莊 is billed to the dealer alone when the dealer is a payer, and added
420to the whole hand when the dealer wins.
421
422House rules vary a lot; the tai table and payments are plain data/functions in
423`tai.ts` and `types.ts` if yours differ.
424
425## Audio, by seat
426
427A sound at a mahjong table comes from somewhere. You know whose 碰 that was
428because it came from your left, and you know a tile has been thrown hard at the
429far corner without watching it go. Both of those are cheap to keep, and both
430are gone the moment everything comes out of the middle of one screen.
431
432**Every cue is a seat's.** `SoundCue` carries a `seat` alongside the kind and
433the tile — the seat that threw, called, or won — and leaves it off the things
434that belong to the table rather than to anybody: the deal, a 流局, an undo. The
435engine says whose only because a sound has to come from somewhere; what that
436means in the room is entirely the UI's business, as it always was.
437
438**A seat becomes a place.** `App.tsx` turns a seat into a *position round this
439screen* — 0 the near edge, 1 the right, 2 across, 3 the left — because that is
440what the ear wants and the seat number is not: online the table is turned so
441you are always at the bottom, and the sound has to turn with it.
442`game/sound.ts` gives each position a bus of its own: a pan, a gain, and a
443lowpass that opens all the way for the near edge and closes to 5 kHz for the
444far one. Distance is two things at once — quieter, and with the top taken off
445— because between you and 對家 there is a metre of air, a wall of tiles, and
446three people's arms, none of which carry treble. Tiles out in the middle skip
447the edges entirely and are placed continuously, panned by where on the felt
448they actually landed; the physics knows that to the pixel. Buses are built once
449per spot and quantised, so a pool full of skidding tiles reuses a handful
450rather than building one per contact. Old Safari has no `StereoPannerNode`; it
451plays where it always did.
452
453**At a party table, the words come out of the player.** Five devices in one
454room, and the split the sound should take is the one the room already has: the
455table makes the noise, because the tiles are on the table, and the phone in
456somebody's hand says their calls, because the calls are theirs. So the party
457screen drops the *voice* for a seat while keeping the clack, and the phone
458holding that seat says 碰 and names its own discards and makes no table noise
459at all.
460
461That only works if the phone really is going to say it, so it is asked rather
462than assumed. `sound.voiceLive()` is the honest answer — sound on, 報牌 on, the
463audio hardware awake, the pack decoded — and every device reports it to the
464host whenever it changes (`EV_VOICE`). The host works out, seat by seat, which
465hands have a device that will speak for them and publishes that as `voices` in
466room state; a nameplate wearing 🔊 next to its 📱 is a seat the table has gone
467quiet for on purpose. Mute the phone, put it to sleep, hand the seat back —
468the flag drops and the table takes the calls back mid-hand. The host's own
469device never counts, even when it holds seats: it is the table, and a table
470that fell silent on the grounds that it was about to speak would say nothing at
471all.
472
473A browser will not start audio before the page has been touched, and a phone
474that arrived by QR may never touch anything that asks for it by name — so the
475first touch anywhere unlocks it, which is also the moment it can start
476claiming the job.
477
478## The voice pack
479
480A mahjong table only ever says about fifty things — 42 tile names and a handful
481of calls — so the whole vocabulary is rendered ahead of time into
482`public/voice/` (~330 kB of mp3) rather than left to whatever speech synthesis
483the browser happens to have. On Linux that is usually espeak-ng, which is
484intelligible but sounds like a modem; and on a machine with no Chinese voice at
485all the feature would silently do nothing. Shipping the audio makes playback
486instant, identical everywhere, and lets a call be cut off mid-word when the
487next one lands, all through the same Web Audio graph as the chimes.
488
489`scripts/voice.mjs` is the authoring step, not part of `npm run build`. It needs
490[piper](https://github.com/OHF-Voice/piper1-gpl) and ffmpeg on PATH:
491
492```
493node scripts/voice.mjs # → public/voice/*.mp3 + manifest.json
494PIPER_MODEL=/path/to/voice.onnx node scripts/voice.mjs # a different voice
495```
496
497The wording lives in that script, and some of it is deliberately not the bare
498tile character: 東 alone is a direction where 東風 is the tile, and the dragons
499are 紅中 / 發財 / 白板 the way they are actually called — which also gives the
500phonemiser enough context to get the tone right, since 中 on its own is as
501likely to come out zhòng.
502
503The clips here were rendered with piper's `zh_CN-huayan-medium`. If you
504redistribute this app, check that voice's model card in
505[piper-voices](https://huggingface.co/rhasspy/piper-voices) for the terms
506attached to it, the same way you would the tile art below — or re-render the
507pack with a voice whose terms suit you, which is a single command.
508
509## Tile art
510
511`public/tiles/tiles.svg` is the **postmodern** tileset from
512[gnome-mahjongg](https://gitlab.gnome.org/GNOME/gnome-mahjongg), extracted from
513the installed binary's GResource — a 43×2 sprite sheet (second row is the
514highlighted variant, used for a lifted tile). `public/tiles/back.png` is the
515blank tile from its **smooth** theme, tinted jade, since a solitaire game has no
516face-down art of its own.
517
518How a tile is drawn on the canvas — the outline round it and the shadow under
519it — lives in `src/ui/tileArt.ts`, and `npm run dev` serves **`/tiles.html`**, a
520page of every face at every angle over the felt with a slider on each of those
521numbers and the finished block ready to paste back. None of it can be judged
522from the values: it is 246 white against a dark green at 35 pixels across, and
523the only way to know is to look at it and move something. (`tiles.html` is not
524one of the build's entry points, so it does not ship.)
525
526The sheet is not a picture of a tile's *face*. It is a picture of a whole tile,
527drawn in an oblique projection, with its own sides painted down the left and
528along the bottom and its own light coming from the top right — a 16px bevel on a
529128px cell. That is fine while every tile stands the same way up, and wrong the
530moment they are scattered: rotating the sprite rotates a pre-rendered solid, so
531a pool of discards ends up with a different sun on each tile.
532
533So the art is cropped back to the flat face, and the solid is worked out
534instead. A tile is a box lying on cloth: take the four corners of the rotated
535rectangle, offset them by the projection (`depthAngle`, down and to the left,
536the same direction the art used), and fill the two or three quads whose edges
537the base falls away from — each shaded from the direction that edge faces *on
538screen*. Turn the tile and the same physical side comes round to face a
539different way and its shading changes with it, which is the whole point. No 3D
540engine, no z-buffer: one projection vector and a dot product per edge.
541
542Each cell of the sheet is cut out into its own canvas the first time it is
543wanted. `drawImage` with a source rectangle is allowed to sample past that
544rectangle when it resamples, and the row under every face is the highlighted
545variant, whose border is bright blue — so a tile at any angle other than a right
546one wore a blue fringe along one edge. Square on it never showed, which is why
547it looked like a rotation bug rather than a spritesheet one.
548
549A tile used to be drawn as two passes — a slab of ivory offset down the screen
550with the face on top, so a sliver of the tile's own side showed along the bottom
551edge. At the size a discard is actually drawn that never read as thickness; it
552read as a second, paler shape stuck to the bottom of every tile. Shading it
553better did not help, because the problem was that it was there. What gives a
554tile its weight now is the shadow and the outline, both of which are true at
555any size.
556
557That art is **GPL-2.0-or-later**. Fine for playing at home; if you ever
558distribute this app, either honour the GPL or swap the two files for art of your
559own — `SPRITE_COL` in `tiles.ts` is the only mapping that would need updating.
560
561Known gaps and house rules that aren't implemented yet are listed in
562[TODO.md](TODO.md).
563
564## Layout
565
566```
567public/tiles/ sprite sheet + tile back
568src/game/tiles.ts tile codes, wall, shuffle, sprite mapping
569src/game/hu.ts hand decomposition, 聽 detection, wait shapes
570src/game/shanten.ts 向聽 / 進張 counting, for the computer players
571src/game/bot.ts what a computer player discards and claims (pure)
572src/game/danger.ts reading the table: who looks ready, which tiles are hot
573src/game/autoplay.ts when it does it, and how long it appears to think
574src/game/tai.ts 台 scoring
575src/game/engine.ts state machine: deal, turns, claim resolution, settlement
576src/game/ctl.ts TableCtl — the surface the UI drives, local or networked
577src/game/wall.ts what is left of the square, and what still blocks a throw
578src/net/room.ts the wire itself: join, reconnect, state, events
579src/net/session.ts the room: host duties, seats, intents, migration
580src/net/table.ts TableCtl over the wire: mirror + intents + hand overlay
581src/net/protocol.ts what crosses the wire, in one place
582src/net/handOrder.ts your own arrangement, kept on your own device
583src/ui/Controller.tsx the phone at the party table: your tiles and the throw
584src/ui/OnlineLobby.tsx seats and the deal button, before an online game
585server/rooms.ts the relay: rooms, host election, state fan-out
586server/index.ts production: dist/ and the relay from one port
587src/table/physics.ts the discard pool: rectangles with weight, pure and testable
588src/table/geometry.ts the table measured — colliders, launch points, throw gate
589src/table/pool.ts game state ⇄ tiles in the middle, by diffing the discards
590src/ui/ TileView, Hand (drag and throw), Seat, Center, Pool, Help
591```
592
593## The tiles in the middle
594
595The pool is a small rigid-body solver (`src/table/physics.ts`) that knows nothing
596about mahjong, the DOM, or the clock: fixed 1/120s steps, no randomness of its
597own, and everything in table pixels. Given the same throws it produces the same
598pile every time, which is what lets a refresh mid-hand come back to the pile you
599had rather than a freshly scattered one — positions are derived from the hand
600number and each tile's place in its thrower's discards, never saved.
601
602The pile is handled directly. Press on a discard and it comes up out of the
603pile, carried over the top of the others rather than ploughing through them;
604let go slowly and it is set down there, flick and it goes off across the felt at
605the speed it left your fingers — the same throw a tile gets out of a hand, made
606from the middle of the table instead. Pressing on bare felt does nothing, and
607the press passes through to whatever was really under it: the tiles are drawn on
608a canvas with no elements to hit, so the hit test is against the physics and the
609listener is a capturing one on the table itself.
610
611A flick is the whole of a throw: the direction and the speed it leaves at are
612the hand's, and nothing between the fingers and the felt changes either. The one
613thing the table decides is *height* — with a standing wall in front of the
614throw a flat tile would only skid into it, so it is lofted over instead, exactly
615as far as it was thrown and simply in the air for the first part of the way. A
616tile that carries past the square and out into the open stays there. (It used to
617be re-aimed at the middle of the square when the flick's line missed it, which
618put the tile on a line nobody had thrown and read, fairly, as a bounce off
619nothing.)
620
621What stops a tile is other tiles. There is no fence: the standing stacks of the
622wall, the sets people have laid down and the sixteen each of them is holding are
623all measured as colliders, so the pile is held in by the ring of tiles round it
624exactly as it is on a real table, and spills through whatever gaps the wall has
625been eaten into. The table's own edge is a backstop at the screen edge, there
626only so a tile that got past all of that isn't lost.
627
628It is drawn on a canvas that spans the **whole table**, not just the centre.
629That is deliberate: `.slot` and `.seat` both clip their own contents, which is
630why a tile can't be animated out of a strip as an element. On the canvas there is
631nothing to clip it, so a tile lifted out of a hand crosses the table in one
632piece.
633
634The square is the whole wall: eighteen stacks of two a side, four sides, 144
635tiles, the way it is built on a table. It used to be rebuilt to what was *left*
636after a hand had been dealt — barely half of it — because eighteen full-size
637stacks a side wants about 560px and no ordinary window has that between the top
638and bottom strips. That bought a square at the price of it not being the wall:
639a side ran out of stacks before it reached its corner, so the four of them never
640met.
641
642Nothing gives now, and in particular the tile does not. Every tile on the table
643is one size — `--tile-w` in styles.css, in a hand, in the wall, and lying in the
644middle — because they are the same tiles, and a tile that changed size between
645the wall and your hand read as a different, smaller set sitting in the middle.
646The square is built from that tile and is however big that comes to:
647`wallSquare()` in `game/wall.ts` takes the tile and hands back the lengths, and
648`WallRing` measures one rendered cell to find out what CSS made it. The wall
649being bigger than the room between the strips is a fact about a 144-tile wall,
650not a thing to solve by shrinking it.
651
652The four walls are placed by CSS alone: each is pinned to its own edge of the
653opening with `left`/`top`, runs its whole length from the corner it is built
654from, and so carries one wall-depth past the far corner and over the end of the
655next wall along. Every corner of the square is covered by exactly one wall and
656no two overlap — that is the pinwheel. Nothing is rotated; a square wants no
657rotation to describe it.
658
659The overhang is exactly one wall-depth, which is what it takes to close a right
660angle: two bands of thickness `t` meeting at an interior angle ψ overlap by
661`t / tan(ψ/2)`, and at ninety degrees that is `t`. So the whole square measures a
662wall's length plus one depth across, and leaves that length *less* one depth in
663the middle — which is the opening, and which is what `.wall-ring` itself is.
664
665The CSS only places what it is told: `.wall-side` is a row of stacks, laid across
666on the two flat walls and down on the two upright ones, and `--wall-len` from
667`WallRing` is the only length it is given. Every place in the wall is rendered
668whether or not there is still a stack standing on it, because with the break
669anywhere but a corner a wall is eaten from its *middle* — a row that closed the
670gap up would drag the whole tail of the wall along the table behind it.
671
672Where the four walls **stand** is found at the table itself rather than on a page
673of sliders: take hold of a wall in a real game and push it. It goes where the
674finger goes, and lets go saying where it landed — `[wall] top pushed to 0.5,-4.9
675— top 0.5,-4.9 · right 0,0 · …` in the console, in tiles, which is the form
676`WALL_PLACED` in `game/wall.ts` is written in, so what a push finds pastes
677straight back in. Nothing about the hand moves: the wall is the same wall in the
678same order and the next draw is still the next draw. There used to be a
679`/wall.html` for this; the table is the honest place to do it, so that page is
680gone.
681
682## 擲骰 — where the wall is broken
683
684The dealer throws three dice to open a hand, and the total does two jobs. It
685counts round the seats — the dealer being one, and the count going the way the
686turn goes — to pick whose wall is opened. Then the same number counts stacks in
687from the right-hand end of *that* wall, and the break is behind them: the first
688tile drawn is the next one along, and the drawing runs away to the left from
689there, on round the square.
690
691The square makes that cheap to say. Every wall is laid down from the corner its
692own player's right hand falls on — that is what the pinwheel is — so the run of
69372 places already starts at each seat's right, and the break is simply so many
694places into the side the dice picked. `breakAt()` is those two lines.
695
696Nothing about the *tiles* turns on it: the wall was shuffled before it was built,
697so which stack is drawn first is decided either way. What it moves is where in
698the middle of the table the gap opens and who has to reach furthest for the next
699draw — which is the whole of what it does at a table too. The throw is made from
700the same seeded rng that shuffled, so a seed replays a hand's gap as well as its
701tiles.
702
703In a dev build a panel in the corner of the table steps the total through every
704one three dice can make, 3 to 18, and redraws the square on each; **整牆 144**
705puts every tile back, which is what the square is built to and what it only ever
706is for the moment before the deal. `game.reroll(6, 6, 6)` does the same from the
707console.
708Nothing about the square is calculated twice. Its size lives entirely in CSS
709(`--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating that
710arithmetic: `WallRing` already renders every stack as a real element tagged with
711its index, and the colliders are those elements' bounding rects. That one
712mapping is what makes the wall a physical object — the thing a thrown tile
713bounces off, and the thing the throw gate casts its ray at.
714
715### Seeing the colliders
716
717Because every boundary is measured off the DOM rather than written down, the
718only honest way to check a bounce is to draw the boxes in the coordinate space
719the tiles are drawn in. Add `?colliders` to the address, or call `colliders()`
720from the console while a hand is running:
721
722| drawn | is |
723| --- | --- |
724| solid rectangle at the screen edge | the table itself. Nothing meets this in play — it is only there so a tile that got past everybody's tiles is still on the table afterwards |
725| solid box on every tile | a collider. Every tile on the table is one: the stacks still standing, the sets people have laid down, and the sixteen each of them is holding. Half stacks aren't drawn because they aren't colliders — a tile goes over them |
726| dashed rectangle | the wall square's opening — where a throw is *aimed*. It stops nothing, which is why the pile spills out of it |
727| dotted rectangle, inset | where the moving tile's own centre may be against the table's edge, which is its half width in from it |
728| box hugging each pool tile | the rotated rectangle it really collides with |
729| dotted box round the moving tile | the upright box the straight edges use instead. It comes apart from the rotated one as the tile turns |
730
731| blue rays from a throw | faint: the flick, as the tile was let go of. Bright: the line it was actually **sent** along, labelled `slide` or `lob`. These should lie on top of each other — the flick is the throw, and a tile leaving on a line nobody threw reads exactly like a bounce off something invisible |
732| yellow cross | where contact was *actually* made, fading over a second or so. One of these sitting in open felt with no box under it is a bounce off something that isn't a collider at all — a different bug from a box in the wrong place |
733
734Colliders mode is also a **throwing sandbox**: a flick throws for real but is not a
735discard, so the tile stays in your hand, the turn never passes and the computer
736players hold still. Throw the same tile at the same corner as many times as you
737like. Sixty stay on the table before the oldest is swept off; `pool.clearLoose()`
738sweeps them all. Tapping a tile to discard does nothing in this mode — throwing
739is the thing being tested.
740
741Add `pause=1` (`?colliders&pause=1`, or `pause()` from the console) to **stop
742dead on contact**. The physics halts on the fixed step that resolved it and
743drops the rest of the frame, so what's on screen is where the tile touched
744rather than where it had got to by the end of the frame. Space carries on, →
745takes one step, ↑ takes ten; the console says what it stopped on.
746
747`colliders(false)` turns it off again. It repaints on toggle, so a settled table
748doesn't have to be poked first. In a dev build `pool` is the live pool: the
749overlay draws `pool.world.walls`, the physics' own copy rather than a fresh
750measurement, so if what's drawn ever disagrees with the DOM it is the world that
751went stale — which `pool.measuredAt` will show, since the stamp carries every
752hand's shape.
753
754## What is left of the wall
755
756Four walls of eighteen full-size stacks want about 560px a side. No ordinary
757window has that between the top and bottom strips, and the tile is not allowed
758to shrink to make it fit — a tile in the wall is the same tile you play with, so
759drawing it smaller was a visible lie.
760
761What saves it is that nobody ever sees a whole wall: four hands come off the
762front before the table is on screen, so barely half of it is left by then. So
763only the stacks still standing are drawn, and they are laid out around a ring
764cut to the **middle** rather than to eighteen — `ringLayout` in `game/wall.ts`
765measures how many stacks a side can take (`WallRing` renders one hidden cell and
766asks CSS how big it came out) and shares what is left over the four sides in
767proportion. Every seat keeps a wall in front of it instead of the remainder
768piling into one corner, and the order runs all the way round, so the break point
769is still at the head of it and the 底牌 tail still at the end.
770
771Shuffling the wall along as it is eaten costs nothing to look at, because every
772stack shows the same green back — and it is what people do to a half-eaten wall
773anyway.
774
775`pool.ts` never listens for events. It **diffs** the discards against what it is
776already drawing, keyed by `seat:index` — and those keys never shift, because
777discards are only ever pushed and a claim only ever pops the one on top. So a
778key that appeared is a tile to throw in and a key that went is a tile to take
779off, which makes undo, 下一局, a claim and resuming a saved game all the same
780code path. The engine has no idea any of it exists, and the save format did not
781change.