collin/mahjong
67a30800c94a7a04ea0b7a37233d6afa3a6fae2c / README.md
RenderedSource
| 1 | # 台灣麻將 — four-player mahjong, on one touchscreen or over the wire |
| 2 | |
| 3 | Sixteen-tile Taiwanese mahjong for four people sitting around a single laptop. |
| 4 | All four hands are on screen at once, each rotated to face its own edge and |
| 5 | each face down until its player taps it — the piece of card everybody used to |
| 6 | lay over their strip, done by the table itself. Every label is Chinese with |
| 7 | English underneath; the tiles themselves stay Chinese, because that is what the |
| 8 | tiles say. |
| 9 | |
| 10 | **開始** is the only button on the lobby (繼續對局 above it when there is a |
| 11 | save), and it says nothing else, because the table says the rest — three strips |
| 12 | offering 坐下 and a QR in the middle of the felt. It deals |
| 13 | you in at the bottom edge with the computer on the other three, on this device, |
| 14 | without a word said to any server. Everything people call a mode is that table |
| 15 | with 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 | |
| 25 | Each of those swaps one seat and redeals nothing, and each undoes: a hand goes |
| 26 | back to the computer with a tap, and one that loses its device gets given back |
| 27 | to 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 | ``` |
| 32 | npm install |
| 33 | npm run dev # then open the browser full-screen (F11) |
| 34 | npm 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 | |
| 45 | Seat 0 is the bottom edge, and play runs 0 → 1 (right) → 2 (top) → 3 (left), |
| 46 | which is the normal counter-clockwise 東南西北 order. |
| 47 | |
| 48 | Each strip shows, from the player's edge inwards: nameplate (seat wind, 莊 |
| 49 | marker, 聽 badge, chip count) → concealed hand → action buttons → melds and |
| 50 | flowers. Discards are not in the strips: they are thrown into the middle and |
| 51 | stay there for the hand, the way they would be on a table. (A phone has no |
| 52 | middle 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 | |
| 224 | There were three buttons on this lobby — 單人對局, 四人同桌, 線上對戰 — and |
| 225 | they were never three games. Every party table played its unclaimed seats |
| 226 | hotseat-style at the screen already; every hotseat table would have taken a |
| 227 | phone if it had had a room to put it in; every online table filled its empty |
| 228 | chairs with the same computer players the solo game is made of. They differed |
| 229 | in who was holding which hand, and that is not something a lobby should have to |
| 230 | know before the tiles are dealt. |
| 231 | |
| 232 | So there is one button, and the question it used to ask is answered |
| 233 | continuously instead. A hand is played by one of three things — **the computer, |
| 234 | somebody at this screen, or a device of its own** — and any of them becomes any |
| 235 | other while the hand is in progress, with nothing redealt and nobody consulted |
| 236 | but 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 |
| 245 | what keeps a game against three computers off the wire. 開始 deals and then |
| 246 | joins a room in the background, so there is a code for the QR tile to carry |
| 247 | from the first tile — but all `publishTable` writes is a seat map. The game |
| 248 | itself is published the moment somebody actually walks into the room |
| 249 | (`publishGameIfOwed`, off `room.onJoin`) and not one move before it, so an |
| 250 | evening played alone costs one handshake and then silence. Nothing waits on any |
| 251 | of it: the deal happens first, on this device, and a page that never reaches a |
| 252 | server plays a full game. |
| 253 | |
| 254 | The engine is the page's own either way. `NetTable` is a wrapper that goes on |
| 255 | around a live `Game`, not a second one, so a room arriving mid-hand changes |
| 256 | nothing 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 |
| 259 | every other tile is, white face and dark-green ink, and it is a tile all the |
| 260 | way down. It |
| 261 | is a body in the pool like any other (`TablePool.joinTile`): a discard thrown |
| 262 | at it knocks it aside and turns it, it can be picked up and put somewhere less |
| 263 | in the way or flicked off across the cloth, the same one light over the table |
| 264 | works out its sides from whatever angle it has ended up at, and it throws the |
| 265 | same shadow. Nothing about it is a dialog. Its face is painted rather than cut |
| 266 | out 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 |
| 268 | back in the middle when a hand is dealt, because by then the pile it was shoved |
| 269 | to the edge of has been swept away. |
| 270 | |
| 271 | No room means no tile. A table that cannot reach a server is a table with one |
| 272 | fewer 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 |
| 275 | and the undo, carrying `?room=…&seat=N`. Scanning it lands on that chair |
| 276 | without anybody having to say which one they are in: the hand leaves the shared |
| 277 | screen for good — face down there, not under a card that lifts — and the phone |
| 278 | gets it face up with the claim buttons and the throw. It wears the same white |
| 279 | tile face as the one on the felt, so a code to scan looks the same wherever it |
| 280 | turns up. |
| 281 | |
| 282 | **A hand that loses its device is kept for fifteen seconds and then given to |
| 283 | the computer** (`EMPTY_SEAT_GRACE`). A phone locking itself looks exactly like |
| 284 | a 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 |
| 286 | play carries on. Nothing is lost either way — coming back takes the seat |
| 287 | straight off the computer again, and so does one tap on it at the table. The |
| 288 | same countdown covers the screen closing its lid: the room records which device |
| 289 | the table is being played at (`screen` in room state), and hands with neither a |
| 290 | device 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 |
| 294 | business writing it), so 繼續對局 restores here and the room, if one is wanted |
| 295 | again, 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 |
| 298 | device is playing more than one of them — which is the definition of a screen |
| 299 | several people are sitting round. One hand is one person, whether they are |
| 300 | playing three computers or holding their own tiles on their own phone, and |
| 301 | covering your own tiles from yourself is a tap in the way. |
| 302 | |
| 303 | ## Playing online |
| 304 | |
| 305 | Every device that is not the table is on one room relay of our own: |
| 306 | `server/rooms.ts`, |
| 307 | a couple hundred lines of Node on `ws`. The relay knows nothing about mahjong: |
| 308 | rooms with codes, host election by join order, one bag of shared state only the |
| 309 | host 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 |
| 311 | every move, and plays intents the other devices send as events. The engine was |
| 312 | already a pure JSON state machine, so nothing in `src/game/` knows the network |
| 313 | exists — the same trick `autoplay.ts` plays, stretched over a wire. The client |
| 314 | end of the wire is `src/net/room.ts`; the rest of `src/net/` is the game |
| 315 | riding 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 | |
| 335 | Design 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 | ``` |
| 364 | npm run dev # vite, with the relay on /ws of the same origin |
| 365 | npm run build && npm run serve # production: dist/ + relay, one process |
| 366 | hag deploy # onto hagrid, behind Caddy |
| 367 | ``` |
| 368 | |
| 369 | Both commands listen on every interface, and both say where that is: |
| 370 | |
| 371 | ``` |
| 372 | on this network: http://192.168.86.237:5173 |
| 373 | ``` |
| 374 | |
| 375 | That is the address the QR tile on the felt carries, so `npm run dev` on a |
| 376 | laptop and a phone on the same wifi is the whole setup — nothing to configure, |
| 377 | nothing to install, no tunnel needed. (Fedora's default firewall zone already |
| 378 | allows inbound 1025–65535/tcp; a stricter one wants the port opening.) |
| 379 | |
| 380 | The 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 |
| 382 | stack; in production `server/index.ts` serves the built `dist/` and the relay |
| 383 | from one port (`PORT`, default 8080). Anything further away than the wifi wants |
| 384 | TLS in front — a reverse proxy with a certificate — since phones only get |
| 385 | camera and fullscreen on https. |
| 386 | |
| 387 | `hag deploy` builds the image, pushes it to the private registry and recreates |
| 388 | the container on the host — `Dockerfile` and `compose.yaml` at the repo root, |
| 389 | the same shape every project there uses. There is no volume, because there is |
| 390 | no 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 |
| 392 | address belongs to a docker bridge nobody can reach, the relay knows better |
| 393 | than to hand one out, and without it there is no QR on the felt at all. |
| 394 | |
| 395 | Rooms live in memory. A restart drops them; the next visitor on an old link |
| 396 | recreates the room empty, and a host mid-game republishes its state on the |
| 397 | next move. Joining a room writes the room into the address bar, so the page |
| 398 | URL — the host's included — is the invite link. |
| 399 | |
| 400 | A QR pointing at `localhost` is a QR only the host's own machine can scan, and |
| 401 | that is the one machine that does not need it. So the server gives the invite |
| 402 | links a face somebody else can reach, in this order: |
| 403 | |
| 404 | 1. `PUBLIC_URL`, if it is set. |
| 405 | 2. 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. |
| 409 | 3. **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 | |
| 416 | Every QR and invite link wears that origin instead of the page's, so a table |
| 417 | opened at `http://localhost:5173` still hands out a code that works. If the |
| 418 | tunnel dies, or `rsgrok` is not on the PATH (`RSGROK_BIN` points elsewhere), |
| 419 | the 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 |
| 422 | particular seat — and it is uppercase. Both are for the QR's sake: `?` and `=` |
| 423 | are outside QR's alphanumeric alphabet, so one of them anywhere in the string |
| 424 | forces the whole code into byte mode at eight bits a character instead of |
| 425 | five and a half. Uppercased and query-free, `HTTP://192.168.86.237:5173/UV9WTU` |
| 426 | is a 25-module code where `http://192.168.86.237:5173/?room=UV9WTU` is 29 — and |
| 427 | 25 is the floor, since the next size down holds 25 characters and the address |
| 428 | alone is 27. One segment, not two, because the page is served with relative |
| 429 | asset URLs. `?room=` and `?seat=` are still read, so links people already have |
| 430 | keep working, and both servers answer any extensionless page request with the |
| 431 | game so the path resolves at all. |
| 432 | |
| 433 | Identity is a UUID, and it is **not** `crypto.randomUUID` — that exists only in |
| 434 | a secure context, which `http://192.168.…` is not, so every phone that scanned |
| 435 | the tile would have thrown before it reached the room. |
| 436 | `crypto.getRandomValues` is not gated the same way and is what actually |
| 437 | generates it. |
| 438 | |
| 439 | The 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 |
| 441 | instead of burning a fresh name each run. A name someone else already holds |
| 442 | kills that first attempt before it prints a URL; the retry then goes out |
| 443 | nameless and takes whatever it is given. |
| 444 | |
| 445 | ## On a phone |
| 446 | |
| 447 | The table assumes four people sitting around a screen lying flat, and about |
| 448 | 770px in both directions before the middle is worth looking at. A phone has |
| 449 | neither, so under `(max-height: 620px), (max-width: 820px)` it switches to the |
| 450 | compact layout: nothing is rotated, everything reads upright for the one person |
| 451 | holding it, the other three seats shrink to cards showing what is public about |
| 452 | them, and the depth that frees up goes to your hand, which is allowed to wrap. |
| 453 | |
| 454 | There is no centre square there — the middle collapses to a one-line bar showing |
| 455 | what 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 |
| 457 | is tap-twice or 打出, exactly as it was. The pool and the throw are a full-size |
| 458 | feature. |
| 459 | |
| 460 | The breakpoint is written down once, in `COMPACT_QUERY` in `src/ui/compact.ts`, |
| 461 | which sets a `compact` class on `<html>` for the CSS to key off. |
| 462 | |
| 463 | ## Computer players |
| 464 | |
| 465 | Single player seats you at the bottom and gives seats 1-3 to the computer. |
| 466 | Their tiles go face down and their 聽 / 過水 badges come off, since both would |
| 467 | give the hand away; everything public — the pool, melds, flowers, chips — stays |
| 468 | exactly as it is, and everyone turns their hand over when the hand ends. They |
| 469 | throw their tiles in like anyone else, and under the same rule: a computer seat |
| 470 | whose 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`) |
| 473 | watches the same state the screen does, and when the seat being waited on |
| 474 | belongs to the computer it calls the identical method the button would have |
| 475 | called — `discard`, `respond`, `declareConcealedKong`. So scoring, sound, |
| 476 | saving, 過水 and 包牌 all work without a special case, and a bot cannot make a |
| 477 | move a person could not. It never acts for you and never closes your claim |
| 478 | window: 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 |
| 481 | judged at its *resting* size — the (5 − melds) × 3 + 1 tiles you hold between a |
| 482 | discard 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 |
| 497 | alone throws whatever is fastest and pays for it; these bots read the table |
| 498 | first, off the face-up table only — discard rows, exposed melds, the 過水 locks |
| 499 | the 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 | |
| 516 | Whether any of that changes the discard depends on the bot's own hand. At 聽牌 |
| 517 | it pushes — nothing short of 包牌 is worth breaking a ready hand for. Two or |
| 518 | three away with somebody live across the table and it will give up a whole 向聽 |
| 519 | step to throw something safe, which is what folding is. |
| 520 | |
| 521 | The estimate is checked against ground truth in the tests rather than assumed: |
| 522 | over the tiles bots actually considered, the ones the model rated below 0.2 deal |
| 523 | in 0% of the time and the ones above 0.8 deal in 10.5%, cleanly monotonic in |
| 524 | between. Switching the read on cuts deal-ins by about 13% over a few hundred |
| 525 | hands, takes hands from ~30 discards to ~38, and moves the draw rate from |
| 526 | almost nothing to about one hand in ten — which is what a table where people |
| 527 | stop feeding each other actually looks like. |
| 528 | |
| 529 | What they know is still only what they can see. `unseenFor` counts the four |
| 530 | copies of each tile and subtracts the bot's own hand plus every discard and |
| 531 | exposed meld on the table. Neither module ever reads an opponent's concealed |
| 532 | tiles or looks at the wall. |
| 533 | |
| 534 | There is no difficulty setting, and the reading stops at the discard: claims are |
| 535 | still judged purely on speed, and a bot will take a 碰 that walks it into |
| 536 | trouble. |
| 537 | |
| 538 | 向聽 lives in `src/game/shanten.ts`, apart from `hu.ts`, because the two want |
| 539 | different things: the win check has to be exact, and this one has to be fast — |
| 540 | it runs a few hundred times for every discard a bot considers. The tests hold |
| 541 | them against each other over random hands, since they work in completely |
| 542 | different ways and any disagreement is a bug in the fast one. |
| 543 | |
| 544 | Undo behaves differently against the computer: rewinding into the middle of its |
| 545 | turn would only hand the move straight back to it, so one press goes back to |
| 546 | the last point *you* had a decision to make, computer replies and all. |
| 547 | |
| 548 | ## Saving |
| 549 | |
| 550 | The game state is plain data, so a save is just its JSON in `localStorage`, |
| 551 | rewritten after every move. Close the lid, refresh, or run the battery flat and |
| 552 | nothing is lost — the lobby offers **繼續對局 Resume** above 開始, showing the |
| 553 | round, hand number, chip counts and when it was saved. Which seats the computer |
| 554 | holds is part of the state, so a save comes back with the same hands in the |
| 555 | same places, and everybody who was on a phone scans back in exactly as they did |
| 556 | the first time — a room is opened around a resumed table the same way it is |
| 557 | opened around a fresh one, when somebody wants one. It is written by whichever |
| 558 | device is running the game (`engine.persist = authority`), so the mirrors on |
| 559 | everyone else's screens never fight it for the slot. A |
| 560 | save is validated before it is offered (four players, 144 tiles accounted for), |
| 561 | so a truncated or hand-edited one is ignored rather than loaded into a broken |
| 562 | table. `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 | |
| 602 | Ambiguous 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 |
| 607 | each. 拉莊 is billed to the dealer alone when the dealer is a payer, and added |
| 608 | to the whole hand when the dealer wins. |
| 609 | |
| 610 | House 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 | |
| 615 | A sound at a mahjong table comes from somewhere. You know whose 碰 that was |
| 616 | because it came from your left, and you know a tile has been thrown hard at the |
| 617 | far corner without watching it go. Both of those are cheap to keep, and both |
| 618 | are 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 |
| 621 | the tile — the seat that threw, called, or won — and leaves it off the things |
| 622 | that belong to the table rather than to anybody: the deal, a 流局, an undo. The |
| 623 | engine says whose only because a sound has to come from somewhere; what that |
| 624 | means 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 |
| 627 | screen* — 0 the near edge, 1 the right, 2 across, 3 the left — because that is |
| 628 | what the ear wants and the seat number is not: a device holding one hand turns |
| 629 | the 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 |
| 631 | lowpass that opens all the way for the near edge and closes to 5 kHz for the |
| 632 | far 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 |
| 634 | three people's arms, none of which carry treble. Tiles out in the middle skip |
| 635 | the edges entirely and are placed continuously, panned by where on the felt |
| 636 | they actually landed; the physics knows that to the pixel. Buses are built once |
| 637 | per spot and quantised, so a pool full of skidding tiles reuses a handful |
| 638 | rather than building one per contact. Old Safari has no `StereoPannerNode`; it |
| 639 | plays where it always did. |
| 640 | |
| 641 | **Where there is a table and phones round it, the words come out of the |
| 642 | player.** The split the sound should take is the one the room already has: the |
| 643 | table makes the noise, because the tiles are on the table, and the phone in |
| 644 | somebody's hand says their calls, because the calls are theirs. So the table's |
| 645 | screen drops the *voice* for a seat while keeping the clack, and the phone |
| 646 | holding that seat says 碰 and names its own discards and makes no table noise |
| 647 | at all. |
| 648 | |
| 649 | **Only a phone claims that job**, and that is the whole of how the split knows |
| 650 | where it applies. A phone is small enough to be in a hand and a hand is in the |
| 651 | room; a friend playing from their own laptop three cities away is not, and |
| 652 | never takes the words away from anybody's table. It is a proxy rather than a |
| 653 | fact — software cannot know who is sitting where — but it is the one property |
| 654 | that actually correlates with it, and it fails in the harmless direction: |
| 655 | the worst a wrong guess costs is a call said twice in two rooms. |
| 656 | |
| 657 | That only works if the phone really is going to say it, so it is asked rather |
| 658 | than assumed. `sound.voiceLive()` is the honest answer — sound on, 報牌 on, the |
| 659 | audio hardware awake, the pack decoded — and every device reports it to the |
| 660 | host whenever it changes (`EV_VOICE`). The host works out, seat by seat, which |
| 661 | hands have a device that will speak for them and publishes that as `voices` in |
| 662 | room state; a nameplate wearing 🔊 next to its 📱 is a seat the table has gone |
| 663 | quiet for on purpose. Mute the phone, put it to sleep, hand the seat back — |
| 664 | the flag drops and the table takes the calls back mid-hand. The device the |
| 665 | table is being played at never counts, even when it holds seats: it *is* the |
| 666 | table, and a table that fell silent on the grounds that it was about to speak |
| 667 | would say nothing at all. |
| 668 | |
| 669 | A browser will not start audio before the page has been touched, and a phone |
| 670 | that arrived by QR may never touch anything that asks for it by name — so the |
| 671 | first touch anywhere unlocks it, which is also the moment it can start |
| 672 | claiming the job. |
| 673 | |
| 674 | ## The voice pack |
| 675 | |
| 676 | A mahjong table only ever says about fifty things — 42 tile names and a handful |
| 677 | of 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 |
| 679 | the browser happens to have. On Linux that is usually espeak-ng, which is |
| 680 | intelligible but sounds like a modem; and on a machine with no Chinese voice at |
| 681 | all the feature would silently do nothing. Shipping the audio makes playback |
| 682 | instant, identical everywhere, and lets a call be cut off mid-word when the |
| 683 | next 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 | ``` |
| 689 | node scripts/voice.mjs # → public/voice/*.mp3 + manifest.json |
| 690 | PIPER_MODEL=/path/to/voice.onnx node scripts/voice.mjs # a different voice |
| 691 | ``` |
| 692 | |
| 693 | The wording lives in that script, and some of it is deliberately not the bare |
| 694 | tile character: 東 alone is a direction where 東風 is the tile, and the dragons |
| 695 | are 紅中 / 發財 / 白板 the way they are actually called — which also gives the |
| 696 | phonemiser enough context to get the tone right, since 中 on its own is as |
| 697 | likely to come out zhòng. |
| 698 | |
| 699 | The clips here were rendered with piper's `zh_CN-huayan-medium`. If you |
| 700 | redistribute this app, check that voice's model card in |
| 701 | [piper-voices](https://huggingface.co/rhasspy/piper-voices) for the terms |
| 702 | attached to it, the same way you would the tile art below — or re-render the |
| 703 | pack 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 |
| 709 | the installed binary's GResource — a 43×2 sprite sheet (second row is the |
| 710 | highlighted variant, used for a lifted tile). `public/tiles/back.png` is the |
| 711 | blank tile from its **smooth** theme, tinted jade, since a solitaire game has no |
| 712 | face-down art of its own. |
| 713 | |
| 714 | How a tile is drawn on the canvas — the outline round it and the shadow under |
| 715 | it — lives in `src/ui/tileArt.ts`, and `npm run dev` serves **`/tiles.html`**, a |
| 716 | page of every face at every angle over the felt with a slider on each of those |
| 717 | numbers and the finished block ready to paste back. None of it can be judged |
| 718 | from the values: it is 246 white against a dark green at 35 pixels across, and |
| 719 | the only way to know is to look at it and move something. (`tiles.html` is not |
| 720 | one of the build's entry points, so it does not ship.) |
| 721 | |
| 722 | The sheet is not a picture of a tile's *face*. It is a picture of a whole tile, |
| 723 | drawn in an oblique projection, with its own sides painted down the left and |
| 724 | along the bottom and its own light coming from the top right — a 16px bevel on a |
| 725 | 128px cell. That is fine while every tile stands the same way up, and wrong the |
| 726 | moment they are scattered: rotating the sprite rotates a pre-rendered solid, so |
| 727 | a pool of discards ends up with a different sun on each tile. |
| 728 | |
| 729 | So the art is cropped back to the flat face, and the solid is worked out |
| 730 | instead. A tile is a box lying on cloth: take the four corners of the rotated |
| 731 | rectangle, offset them by the projection (`depthAngle`, down and to the left, |
| 732 | the same direction the art used), and fill the two or three quads whose edges |
| 733 | the base falls away from — each shaded from the direction that edge faces *on |
| 734 | screen*. Turn the tile and the same physical side comes round to face a |
| 735 | different way and its shading changes with it, which is the whole point. No 3D |
| 736 | engine, no z-buffer: one projection vector and a dot product per edge. |
| 737 | |
| 738 | Each cell of the sheet is cut out into its own canvas the first time it is |
| 739 | wanted. `drawImage` with a source rectangle is allowed to sample past that |
| 740 | rectangle when it resamples, and the row under every face is the highlighted |
| 741 | variant, whose border is bright blue — so a tile at any angle other than a right |
| 742 | one wore a blue fringe along one edge. Square on it never showed, which is why |
| 743 | it looked like a rotation bug rather than a spritesheet one. |
| 744 | |
| 745 | A tile used to be drawn as two passes — a slab of ivory offset down the screen |
| 746 | with the face on top, so a sliver of the tile's own side showed along the bottom |
| 747 | edge. At the size a discard is actually drawn that never read as thickness; it |
| 748 | read as a second, paler shape stuck to the bottom of every tile. Shading it |
| 749 | better did not help, because the problem was that it was there. What gives a |
| 750 | tile its weight now is the shadow and the outline, both of which are true at |
| 751 | any size. |
| 752 | |
| 753 | That art is **GPL-2.0-or-later**. Fine for playing at home; if you ever |
| 754 | distribute this app, either honour the GPL or swap the two files for art of your |
| 755 | own — `SPRITE_COL` in `tiles.ts` is the only mapping that would need updating. |
| 756 | |
| 757 | Known gaps and house rules that aren't implemented yet are listed in |
| 758 | [TODO.md](TODO.md). |
| 759 | |
| 760 | ## Layout |
| 761 | |
| 762 | ``` |
| 763 | public/tiles/ sprite sheet + tile back |
| 764 | src/game/tiles.ts tile codes, wall, shuffle, sprite mapping |
| 765 | src/game/hu.ts hand decomposition, 聽 detection, wait shapes |
| 766 | src/game/shanten.ts 向聽 / 進張 counting, for the computer players |
| 767 | src/game/bot.ts what a computer player discards and claims (pure) |
| 768 | src/game/danger.ts reading the table: who looks ready, which tiles are hot |
| 769 | src/game/autoplay.ts when it does it, and how long it appears to think |
| 770 | src/game/tai.ts 台 scoring |
| 771 | src/game/engine.ts state machine: deal, turns, claim resolution, settlement |
| 772 | src/game/ctl.ts TableCtl — the surface the UI drives, local or networked |
| 773 | src/game/wall.ts what is left of the square, and what still blocks a throw |
| 774 | src/net/room.ts the wire itself: join, reconnect, state, events |
| 775 | src/net/session.ts the room: host duties, seats, intents, migration |
| 776 | src/net/table.ts TableCtl over the wire: mirror + intents + hand overlay |
| 777 | src/net/protocol.ts what crosses the wire, in one place |
| 778 | src/net/handOrder.ts your own arrangement, kept on your own device |
| 779 | src/ui/Controller.tsx a hand held on a phone, plus the seat picker for any device |
| 780 | server/rooms.ts the relay: rooms, host election, state fan-out |
| 781 | server/index.ts production: dist/ and the relay from one port |
| 782 | src/table/physics.ts the discard pool: rectangles with weight, pure and testable |
| 783 | src/table/geometry.ts the table measured — colliders, launch points, throw gate |
| 784 | src/table/pool.ts game state ⇄ tiles in the middle, by diffing the discards |
| 785 | src/ui/tileArt.ts one tile drawn on canvas — sides, shadow, and the QR face |
| 786 | src/ui/ TileView, Hand (drag and throw), Seat, Center, Pool, Help |
| 787 | ``` |
| 788 | |
| 789 | ## The tiles in the middle |
| 790 | |
| 791 | The pool is a small rigid-body solver (`src/table/physics.ts`) that knows nothing |
| 792 | about mahjong, the DOM, or the clock: fixed 1/120s steps, no randomness of its |
| 793 | own, and everything in table pixels. Given the same throws it produces the same |
| 794 | pile every time, which is what lets a refresh mid-hand come back to the pile you |
| 795 | had rather than a freshly scattered one — positions are derived from the hand |
| 796 | number and each tile's place in its thrower's discards, never saved. |
| 797 | |
| 798 | The pile is handled directly. Press on a discard and it comes up out of the |
| 799 | pile, carried over the top of the others rather than ploughing through them; |
| 800 | let go slowly and it is set down there, flick and it goes off across the felt at |
| 801 | the speed it left your fingers — the same throw a tile gets out of a hand, made |
| 802 | from the middle of the table instead. Pressing on bare felt does nothing, and |
| 803 | the press passes through to whatever was really under it: the tiles are drawn on |
| 804 | a canvas with no elements to hit, so the hit test is against the physics and the |
| 805 | listener is a capturing one on the table itself. |
| 806 | |
| 807 | A flick is the whole of a throw: the direction and the speed it leaves at are |
| 808 | the hand's, and nothing between the fingers and the felt changes either. The one |
| 809 | thing the table decides is *height* — with a standing wall in front of the |
| 810 | throw a flat tile would only skid into it, so it is lofted over instead, exactly |
| 811 | as far as it was thrown and simply in the air for the first part of the way. A |
| 812 | tile that carries past the square and out into the open stays there. (It used to |
| 813 | be re-aimed at the middle of the square when the flick's line missed it, which |
| 814 | put the tile on a line nobody had thrown and read, fairly, as a bounce off |
| 815 | nothing.) |
| 816 | |
| 817 | What stops a tile is other tiles. There is no fence: the standing stacks of the |
| 818 | wall, the sets people have laid down and the sixteen each of them is holding are |
| 819 | all measured as colliders, so the pile is held in by the ring of tiles round it |
| 820 | exactly as it is on a real table, and spills through whatever gaps the wall has |
| 821 | been eaten into. The table's own edge is a backstop at the screen edge, there |
| 822 | only so a tile that got past all of that isn't lost. |
| 823 | |
| 824 | A tile that only ever slid would look like a puck. What makes a pool of discards |
| 825 | look like one is that every tile in it has come to rest at some angle nobody |
| 826 | chose, and that is a thing to *earn* rather than to scatter in. A tile turns |
| 827 | because of where it was hit. |
| 828 | |
| 829 | So a contact happens somewhere in particular. The separating axis test says which |
| 830 | way to push and by how much; `contactOf` says where, and the arm from there to |
| 831 | the tile's middle is what a turn is made of. A tile shouldered square in the back |
| 832 | is pushed along and does not turn at all. The same shove, moved along until it is |
| 833 | catching one end of the tile, turns it instead, and which way round follows from |
| 834 | which end. What touches is a footprint and not a point: a tile lying square to a |
| 835 | wall meets it along the whole of one side and is pushed through its own middle |
| 836 | however far along the wall it is, and the same tile at forty-five degrees meets |
| 837 | it on one corner and swings on round it. Everything a tile can hit is another |
| 838 | tile, so the standing stacks, the laid-down sets and the table's own straight |
| 839 | edge are all the same contact with the same friction across the face of it. |
| 840 | |
| 841 | Two things keep that honest. The first is that a tile is not free to pivot. It is |
| 842 | lying flat on cloth, and the cloth under the whole of its face resists the turn |
| 843 | for as long as the contact lasts, so only a tenth of the moment a knock would |
| 844 | give a free rectangle actually reaches it (`FELT_HOLD`). Without that, a tile off |
| 845 | the corner of a stack comes away spinning like a top. The second is that the felt |
| 846 | is one patch of cloth doing one thing: what it spends stopping the slide it has |
| 847 | not got left for the turn, so the two share a single friction budget rather than |
| 848 | running as two brakes side by side. What that buys is the *ending*. A tile |
| 849 | flicked hard with a little turn on it used to stop turning early and skate on |
| 850 | looking dead, and one barely pushed with a lot of turn on it stopped dead and |
| 851 | went on spinning where it lay. They now run out together. |
| 852 | |
| 853 | The turn a tile arrives with is the flick's own. `ui/flick.ts` reads a release |
| 854 | the same way wherever it was made, out of a hand or off the pile in the middle, |
| 855 | and the spin it reports is the angle the stroke swung through between its first |
| 856 | half and its second, over the time it took. That is a rate, in rad/s, which is |
| 857 | what a wrist actually does: a straight flick turns nothing however hard it is |
| 858 | thrown, and the same curve made twice as fast puts twice the turn on the tile. |
| 859 | |
| 860 | It is drawn on a canvas that spans the **whole table**, not just the centre. |
| 861 | That is deliberate: `.slot` and `.seat` both clip their own contents, which is |
| 862 | why a tile can't be animated out of a strip as an element. On the canvas there is |
| 863 | nothing to clip it, so a tile lifted out of a hand crosses the table in one |
| 864 | piece. |
| 865 | |
| 866 | The square is the whole wall: eighteen stacks of two a side, four sides, 144 |
| 867 | tiles, the way it is built on a table. It used to be rebuilt to what was *left* |
| 868 | after a hand had been dealt — barely half of it — because eighteen full-size |
| 869 | stacks a side wants about 560px and no ordinary window has that between the top |
| 870 | and bottom strips. That bought a square at the price of it not being the wall: |
| 871 | a side ran out of stacks before it reached its corner, so the four of them never |
| 872 | met. |
| 873 | |
| 874 | Nothing gives now, and in particular the tile does not. Every tile on the table |
| 875 | is one size — `--tile-w` in styles.css, in a hand, in the wall, and lying in the |
| 876 | middle — because they are the same tiles, and a tile that changed size between |
| 877 | the wall and your hand read as a different, smaller set sitting in the middle. |
| 878 | The 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 |
| 881 | being bigger than the room between the strips is a fact about a 144-tile wall, |
| 882 | not a thing to solve by shrinking it. |
| 883 | |
| 884 | The four walls are placed by CSS alone: each is pinned to its own edge of the |
| 885 | opening with `left`/`top`, runs its whole length from the corner it is built |
| 886 | from, and so carries one wall-depth past the far corner and over the end of the |
| 887 | next wall along. Every corner of the square is covered by exactly one wall and |
| 888 | no two overlap — that is the pinwheel. Nothing is rotated; a square wants no |
| 889 | rotation to describe it. |
| 890 | |
| 891 | The overhang is exactly one wall-depth, which is what it takes to close a right |
| 892 | angle: 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 |
| 894 | wall's length plus one depth across, and leaves that length *less* one depth in |
| 895 | the middle — which is the opening, and which is what `.wall-ring` itself is. |
| 896 | |
| 897 | The CSS only places what it is told: `.wall-side` is a row of stacks, laid across |
| 898 | on 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 |
| 900 | whether or not there is still a stack standing on it, because with the break |
| 901 | anywhere but a corner a wall is eaten from its *middle* — a row that closed the |
| 902 | gap up would drag the whole tail of the wall along the table behind it. |
| 903 | |
| 904 | Where the four walls **stand** is found at the table itself rather than on a page |
| 905 | of sliders: take hold of a wall in a real game and push it. It goes where the |
| 906 | finger 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 |
| 909 | straight back in. Nothing about the hand moves: the wall is the same wall in the |
| 910 | same 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 |
| 912 | gone. |
| 913 | |
| 914 | ## 擲骰 — where the wall is broken |
| 915 | |
| 916 | The dealer throws three dice to open a hand, and the total does two jobs. It |
| 917 | counts round the seats — the dealer being one, and the count going the way the |
| 918 | turn goes — to pick whose wall is opened. Then the same number counts stacks in |
| 919 | from the right-hand end of *that* wall, and the break is behind them: the first |
| 920 | tile drawn is the next one along, and the drawing runs away to the left from |
| 921 | there, on round the square. |
| 922 | |
| 923 | The square makes that cheap to say. Every wall is laid down from the corner its |
| 924 | own player's right hand falls on — that is what the pinwheel is — so the run of |
| 925 | 72 places already starts at each seat's right, and the break is simply so many |
| 926 | places into the side the dice picked. `breakAt()` is those two lines. |
| 927 | |
| 928 | Nothing about the *tiles* turns on it: the wall was shuffled before it was built, |
| 929 | so which stack is drawn first is decided either way. What it moves is where in |
| 930 | the middle of the table the gap opens and who has to reach furthest for the next |
| 931 | draw — which is the whole of what it does at a table too. The throw is made from |
| 932 | the same seeded rng that shuffled, so a seed replays a hand's gap as well as its |
| 933 | tiles. |
| 934 | |
| 935 | Where the wall opened is thrown for once and then lived with, which is right at |
| 936 | a table and awkward to look at: half the question about the square is what it |
| 937 | looks 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 |
| 939 | break all the way round, and `game.reroll()` throws afresh. It moves the gap and |
| 940 | nothing else: the tiles were shuffled before the wall was built, so nobody's |
| 941 | hand changes and the draw order is the draw order. |
| 942 | Nothing about the square is calculated twice. Its size lives entirely in CSS |
| 943 | (`--ws`, `--wd`), so `geometry.ts` **measures** it instead of restating that |
| 944 | arithmetic: `WallRing` already renders every stack as a real element tagged with |
| 945 | its index, and the colliders are those elements' bounding rects. That one |
| 946 | mapping is what makes the wall a physical object — the thing a thrown tile |
| 947 | bounces off, and the thing the throw gate casts its ray at. |
| 948 | |
| 949 | ### Seeing the colliders |
| 950 | |
| 951 | Because every boundary is measured off the DOM rather than written down, the |
| 952 | only honest way to check a bounce is to draw the boxes in the coordinate space |
| 953 | the tiles are drawn in. Add `?colliders` to the address, or call `colliders()` |
| 954 | from 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 | |
| 968 | Colliders mode is also a **throwing sandbox**: a flick throws for real but is not a |
| 969 | discard, so the tile stays in your hand, the turn never passes and the computer |
| 970 | players hold still. Throw the same tile at the same corner as many times as you |
| 971 | like. Sixty stay on the table before the oldest is swept off; `pool.clearLoose()` |
| 972 | sweeps them all. Tapping a tile to discard does nothing in this mode — throwing |
| 973 | is the thing being tested. |
| 974 | |
| 975 | Add `pause=1` (`?colliders&pause=1`, or `pause()` from the console) to **stop |
| 976 | dead on contact**. The physics halts on the fixed step that resolved it and |
| 977 | drops the rest of the frame, so what's on screen is where the tile touched |
| 978 | rather than where it had got to by the end of the frame. Space carries on, → |
| 979 | takes 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 |
| 982 | doesn't have to be poked first. In a dev build `pool` is the live pool: the |
| 983 | overlay draws `pool.world.walls`, the physics' own copy rather than a fresh |
| 984 | measurement, so if what's drawn ever disagrees with the DOM it is the world that |
| 985 | went stale — which `pool.measuredAt` will show, since the stamp carries every |
| 986 | hand's shape. |
| 987 | |
| 988 | ## What is left of the wall |
| 989 | |
| 990 | Four walls of eighteen full-size stacks want about 560px a side. No ordinary |
| 991 | window has that between the top and bottom strips, and the tile is not allowed |
| 992 | to shrink to make it fit — a tile in the wall is the same tile you play with, so |
| 993 | drawing it smaller was a visible lie. |
| 994 | |
| 995 | What saves it is that nobody ever sees a whole wall: four hands come off the |
| 996 | front before the table is on screen, so barely half of it is left by then. So |
| 997 | only the stacks still standing are drawn, and they are laid out around a ring |
| 998 | cut to the **middle** rather than to eighteen — `ringLayout` in `game/wall.ts` |
| 999 | measures how many stacks a side can take (`WallRing` renders one hidden cell and |
| 1000 | asks CSS how big it came out) and shares what is left over the four sides in |
| 1001 | proportion. Every seat keeps a wall in front of it instead of the remainder |
| 1002 | piling into one corner, and the order runs all the way round, so the break point |
| 1003 | is still at the head of it and the 底牌 tail still at the end. |
| 1004 | |
| 1005 | Shuffling the wall along as it is eaten costs nothing to look at, because every |
| 1006 | stack shows the same green back — and it is what people do to a half-eaten wall |
| 1007 | anyway. |
| 1008 | |
| 1009 | `pool.ts` never listens for events. It **diffs** the discards against what it is |
| 1010 | already drawing, keyed by `seat:index` — and those keys never shift, because |
| 1011 | discards are only ever pushed and a claim only ever pops the one on top. So a |
| 1012 | key that appeared is a tile to throw in and a key that went is a tile to take |
| 1013 | off, which makes undo, 下一局, a claim and resuming a saved game all the same |
| 1014 | code path. The engine has no idea any of it exists, and the save format did not |
| 1015 | change. |