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