anvilsign in

collin/mahjong

master / README.md

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