anvilsign in

collin/mahjong

RenderedSource

台灣麻將 — four-player mahjong, on one touchscreen or over the wire

Sixteen-tile Taiwanese mahjong for four people sitting around a single laptop. All four hands are on screen at once, each rotated to face its own edge and each face down until its player taps it — the piece of card everybody used to lay over their strip, done by the table itself. Every label is Chinese with English underneath; the tiles themselves stay Chinese, because that is what the tiles say.

開始 is the only button on the lobby (繼續對局 above it when there is a save), and it says nothing else, because the table says the rest — three strips offering 坐下 and a QR in the middle of the felt. It deals you in at the bottom edge with the computer on the other three, on this device, without a word said to any server. Everything people call a mode is that table with different hands changing hands, one at a time, mid-game:

  • somebody sits down at this screen and taps a hand the computer has been playing — the tiles turn over and are theirs;
  • somebody scans a seat's QR and takes that hand onto their phone;
  • somebody opens the invite link from three cities away and takes one from their own laptop, with the table turned so their seat is the near edge.

Each of those swaps one seat and redeals nothing, and each undoes: a hand goes back to the computer with a tap, and one that loses its device gets given back to the computer on its own so the table never stops. See one table, computer players and playing online.

npm install
npm run dev      # then open the browser full-screen (F11)
npm test         # rules engine, the bots, and 200 randomly-played hands

Screen layout

                    ┌──── 對家 (rotated 180°) ────┐
  上家 (rotated 90°) │  wall, and the discard pool │ 下家 (rotated −90°)
                    └──── 自家 (upright, near you) ┘

Seat 0 is the bottom edge, and play runs 0 → 1 (right) → 2 (top) → 3 (left), which is the normal counter-clockwise 東南西北 order.

Each strip shows, from the player's edge inwards: nameplate (seat wind, 莊 marker, 聽 badge, chip count) → concealed hand → action buttons → melds and flowers. Discards are not in the strips: they are thrown into the middle and stay there for the hand, the way they would be on a table. (A phone has no middle to throw into, so the compact layout keeps a discard row per seat — see on a phone.)

Playing

  • Your hand is face down at a shared screen, and a tap turns it over. It covers itself again when the hand is dealt and when your turn ends — the two moments the tiles stop being needed, and without them a hand turned over once stays turned over all evening, which is the same as never having covered it. 蓋牌 beside 理牌 puts it back sooner. While it is covered the only button the strip has is 看牌: every other one names a tile — 打出 五萬, 吃 with these two — and would say through the card what is under it. The 看牌 of whoever the table is waiting on wears a gold ring, since whose go it is was never a secret. Nothing is covered where the screen is playing one hand, which is a screen that belongs to one player — three computers and you, or your own tiles on your own device.
  • The tile you just drew is held apart from the sorted hand with a gold ring and a 摸 label, instead of being sorted invisibly into it. It merges into the hand once you discard. 補花 replacements and kong replacements are marked the same way.
  • The wall is drawn in the centre as a square of four staggered sides, laid out like a # the way it is built on a real table — every corner covered by one wall carrying past the end of the next. All 144 tiles, at the size of the tiles in your hand, because they are the same tiles. The gold stack is the break point you draw from, and where that is depends on what the dealer threw: stacks shrink to a single tile and then vanish as they are used, so the gap opens wherever the dice opened the wall and grows from there. The 16-tile 底牌 tail at the other end is where kong and flower replacements come from, so the square is eaten from both directions at once. See 擲骰.
  • Discard — drag a tile out of your row and it comes up out of your hand and follows your finger anywhere on the table; let go and it goes in, from where you let go and at the speed you let go at. Dragging along the row is still arranging your hand — which of the two you meant is decided once, from whether the movement is mostly along the row or out of it, and then it sticks. Let go back over your own hand and the tile goes back. Tapping a tile twice, or the 打出 button, still works and lobs it in for you.
  • The discard pool — every tile thrown lands in the middle and stays there until the hand ends, with real weight: it skids, turns, knocks the tiles already there out of its way, and comes to rest against them. Nothing is ever stacked on anything — tiles are solved as rectangles, so two lying at an angle lean on each other rather than overlapping. The tile still to be claimed is the lit one.
  • Over the wall or across the table — which of the two ways a tile goes in is not a setting, it is whatever the wall allows. At the start of a hand the square is a solid barrier and a discard has to be lobbed over it, so the flick's speed decides how far across the pool it lands. As the wall is eaten away, gaps open in front of one seat and then another, and from then on that seat can slide a tile in instead — flat across the cloth at exactly the speed it was flicked. Which it will be is a ray cast at the stacks actually standing there, so a gap off to one side counts, and a stack worn down to a single tile is low enough to go over. A seat whose wall is still up says 牆未開 on its nameplate, so a lob is never a mystery.
  • Throw it badly and it ends up in somebody's tiles — the middle stops exactly where their hand starts, so a tile that comes off the pool arrives in their lap, and one that falls short lands out by their seat. Either way their row takes the knock and rocks back, and with 報牌 on they tell you about it — 喂,小心點 if you are lucky, 是在哈囉 or 你牌品很差欸 if you are not, and never the same line twice running. How hard it got there only decides how loudly. Never your own edge: you cannot be barged by your own discard.
  • Arranging — drag any tile in your hand to reorder it; 理牌 sorts it back into suit order. Hands are never auto-sorted after the deal, so an arrangement you set up survives draws, claims and kongs.
  • Rules — the ? on your nameplate opens a bilingual rules panel anchored to your own edge of the table and rotated to face you.
  • Claiming — after a discard, every seat that can claim gets 胡 / 槓 / 碰 / 吃 / 過 buttons on their own strip, and they resolve by priority (胡 > 槓/碰 > 吃; ties go to the player nearest the discarder in turn order). The table never blocks on a claim. The next player gets a 摸牌 button and may draw whenever they like, which shuts the window on anyone who hasn't called — exactly like shouting 碰 before the next player picks up. A claim already declared still stands, so calling in time always wins the tile. If everyone answers first, play advances on its own without the extra tap. The seat that draws next gets no separate 過 button, because for them the two are the same move: picking up is how you decline. Their button says 摸牌 either way; what they would be giving up by pressing it is the 碰 or the 吃 sitting next to it on the bar, so it does not have to be spelled out on the tile as well. Wanting to decline but leave the window open for everyone else is just waiting, which is what not pressing anything already does. (On a phone the button still reads 過.摸牌 — a phone's bar is a row with room to write on, and the hand it belongs to is not on the same screen as the table.)
  • On your turn — 自摸, 暗槓 and 加槓 appear automatically when legal. 加槓 offers everyone else a 搶槓 chance.
  • Where the buttons go — on the same row as your tiles, at the right-hand end of it, where the tile you are about to throw already is. The bar is out of the strip's flow, so a button arriving moves nothing: 打出 appearing when you lift a tile used to shove the melds and flowers above it up the strip. Every button on the bar is given one height for the same reason. A bar with more on it than the room beside the hand stacks up the strip in tile-wide lines, most urgent call at the bottom, rather than running off the end of it. And the room for the first line is in the tile size itself: a strip is sized to hold sixteen tiles, the slot it keeps for the tile it is about to draw, and one button — which is why the tiles down the sides of a short window are the size they are.
  • What the buttons say — the call is written down the tile the way the character on a tile's face is, one on top of the next in the middle of the face, with the English along the bottom edge. Two characters stacked are the size a tile's own character is; two side by side had to be shrunk to fit across it. A call too long for the height (電腦代打) takes a second column to the left of the first, which is where the next column of vertical Chinese goes. 打出 names no tile: the one it would throw is lifted out of the row beside it wearing a gold ring, so saying 五萬 on the button as well only crowds the face.
  • Whose go it is — a screen with four people round it is the one place a game can go quiet without anybody noticing: three of them are watching the table and the fourth is looking at their phone. So the table says it loudly. The strip of the seat being waited on lights from its inner edge, their name goes gold at the far end of it, and seven seconds of gold drain along the edge that faces the middle — from both ends towards the centre, because two of the four strips are upside down from wherever you are sitting. Nothing happens when it reaches zero. No tile is thrown for you and no seat is skipped; the line the bar leaves behind breathes, and the table goes on waiting. It is a nudge, not a rule. Said on a covered hand too — whose turn it is was never one of the things the card is hiding — and only where the screen is playing more than one hand, since a screen playing one has nobody to tell. A phone at the table gets the same seven seconds across its top, a gold rim edge to edge (red for a claim window), and one short buzz as the game comes round to it.
  • On the keys — ← and → walk the selection along your hand and space throws the tile that is lifted. How long you hold space is how hard it goes: the tile draws back out of the hand while the key is down and leaves when you let go, so a tap slides it in and a long hold sends it across the square. Escape puts it back. The wall still decides whether it can be slid or has to be lobbed, exactly as with a finger.
  • When a hand ends — a big arrow drops onto the winner's edge of the table. It lives inside their strip, so it turns with the seat and lands on the right person wherever they are sitting, and 一炮多響 lights up every seat that called. A 流局 has no winner to point at, so it turns the hands over instead: who was 聽牌 and the tiles they were waiting on, the same reveal as at a real table.
  • The gear — beside the ? on every nameplate, and it opens that player's own copy of the table's controls: undo, sound, full screen, 設定. Anchored in their strip and turned to face their edge, like the rules panel, so what you open reads the right way up to you and to nobody else. These used to be a bar in the middle of the table, which is the one place that belongs to everybody — the pile lands there and it is upside down to two of the four.
  • Undo — 復原 in that panel takes back the last action, naming what it will undo (復原 玩家 2 打 五萬). It is in all four panels rather than only the seat that made the mistake, because whoever spots the mis-tap should be able to reach it. Twenty actions deep, cleared when the next hand is dealt. Snapshots live outside the saved state, so an undo does not survive a refresh: what you can take back is what happened while everyone was still watching it happen.
  • Sound — on by default, muted from the 🔊 button behind any seat's gear, or from settings. A dry clack as a tile goes down, and a distinct two-note chime when a claim window opens, which is how a slow player notices their 碰 is available before the next player draws it shut. Everything is synthesised with a few oscillators (src/game/sound.ts) rather than sampled, so there is nothing to load and the cue lands on the same frame as the tile. Browsers refuse to start audio before the page has been clicked, so the first sound anyone hears is the deal — triggered by the button that starts it.
  • 報牌 (voice) — a second switch under sound calls the game out loud: 碰, 吃, 槓, 胡了, 自摸, and the name of every tile as it is discarded — 三條, 五萬, 東風. A flower says 補花 and then which one. A new call cuts off one still being spoken; at table pace the newest is the only one that matters. See the voice pack for where the audio comes from.
  • 方位音 (sound by seat) — a third switch, on by default. Every noise comes out of the edge its seat is sitting at: 上家 to your left, 下家 to your right, and 對家 across the table, where it is quieter and has the top taken off it — a metre of air and three people's arms do not carry 12 kHz. So you can tell whose 碰 that was without looking up, which is the whole point of hearing it. Tiles out in the middle are placed properly rather than by edge: the physics knows to the pixel where a tile landed, and that is where the knock comes from. A device holding one hand turns the table so that hand is at the bottom, and the sound turns with it. See audio, by seat.
  • Settings — 設定 from the lobby, or from the game-over panel: player names, 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in localStorage separately from the save, so they carry over to the next game. Stakes are locked once a game is under way — they'd otherwise rewrite chips already won.

One table

There were three buttons on this lobby — 單人對局, 四人同桌, 線上對戰 — and they were never three games. Every party table played its unclaimed seats hotseat-style at the screen already; every hotseat table would have taken a phone if it had had a room to put it in; every online table filled its empty chairs with the same computer players the solo game is made of. They differed in who was holding which hand, and that is not something a lobby should have to know before the tiles are dealt.

So there is one button, and the question it used to ask is answered continuously instead. A hand is played by one of three things — the computer, somebody at this screen, or a device of its own — and any of them becomes any other while the hand is in progress, with nothing redealt and nobody consulted but the person doing it.

played bybecomes a person herebecomes a phone
電腦the bots in autoplay.tstap the strip — 坐下its QR, from its gear
這裡this screen, face down under a tap—its QR, from its gear
手機whoever scanned inthey leave → the computer—

Nothing touches the network until a phone is wanted. A table of people and computers is four hands and an engine; there is nothing for a server to do, and src/net/ is never constructed. The room is opened by the first thing that needs one — the 用手機拿牌 in a seat's gear — and when it opens it publishes the hand in progress rather than dealing over the top of it (NetSession.publishTable). The engine is the page's own either way: NetTable is a wrapper that goes on around a live Game, not a second one, so opening a room costs a structuredClone and changes nothing on screen except that the QRs start working.

Each seat's QR is its own, and lives in that seat's gear beside the sound and the undo, carrying ?room=…&seat=N. Scanning it lands on that chair without anybody having to say which one they are in: the hand leaves the shared screen for good — face down there, not under a card that lifts — and the phone gets it face up with the claim buttons and the throw. There is a QR for the whole table in the middle of the felt as well, but only while the table is still being sat down at; the moment the first tile is thrown it steps aside, because from then on the middle is where the tiles go.

A hand that loses its device is kept for fifteen seconds and then given to the computer (EMPTY_SEAT_GRACE). A phone locking itself looks exactly like a phone leaving for good and only one of them means it, so the nameplate shows ⏳ and the table waits; when the wait is up the computer picks the tiles up and play carries on. Nothing is lost either way — coming back takes the seat straight off the computer again, and so does one tap on it at the table. The same countdown covers the screen closing its lid: the room records which device the table is being played at (screen in room state), and hands with neither a device nor a table behind them go the same way.

The save follows the table. Whichever device is running the game writes localStorage (engine.persist = authority; every mirror has it off, and no business writing it), so 繼續對局 restores here and the room, if one is wanted again, opens behind it exactly as it did the first time.

Covering is arithmetic, not a mode. A hand starts face down when this device is playing more than one of them — which is the definition of a screen several people are sitting round. One hand is one person, whether they are playing three computers or holding their own tiles on their own phone, and covering your own tiles from yourself is a tap in the way.

Playing online

Every device that is not the table is on one room relay of our own: server/rooms.ts, a couple hundred lines of Node on ws. The relay knows nothing about mahjong: rooms with codes, host election by join order, one bag of shared state only the host may write, and events fanned out to everyone else. One client — the host — runs the engine, publishes the whole GameState as room state after every move, and plays intents the other devices send as events. The engine was already a pure JSON state machine, so nothing in src/game/ knows the network exists — the same trick autoplay.ts plays, stretched over a wire. The client end of the wire is src/net/room.ts; the rest of src/net/ is the game riding it.

  • A device that holds one hand shows that hand. A phone shows just the hand, face up, with the claim buttons — and the throw: flick a tile up off the top of the phone and it sails in from your edge of the common screen, at the speed and angle you let go of it (NetThrow, mapped into table coordinates by TablePool.throwFromNet). Anything bigger shows the whole table instead, turned so that seat is the bottom edge, with everyone else's tiles face down. Same room, same seat map; the only thing deciding between them is how much screen there is.
  • A device that holds no hand yet gets the seat picker, and every seat is offered — including the ones the computer is playing, which is the ordinary way in, and the ones somebody already holds, which is how two people play one hand off two devices. A seat's own QR names its chair and skips the picker.
  • The device the table was opened on goes on showing the table: the felt, the middle, the wall, and every hand no phone has taken, covered-with-a-tap like a hotseat game. Hold the 📱 tag on a nameplate to show the table's QR again for a player who lost theirs. With sound on, the phone in each player's hand is what says their calls and the table goes quiet for that seat — see audio, by seat.

Design notes, in the order they bit:

  • Seats are claims on a shared map, seats: {ids, name}[] in room state, arbitrated by the host. Your own hand's arrangement never goes over the wire — the state carries a multiset and each device keeps its own order (src/net/handOrder.ts), because a round-trip inside a drag gesture is lag you can feel.
  • The host can change. The relay re-elects — the earliest joiner still connected — when the host leaves; every other client already mirrors the full state, so the new host starts its engine from what it was just watching and the game carries on.
  • A hidden host must keep hosting. Browsers suspend requestAnimationFrame and throttle timers in background tabs, so nothing in src/net/ runs on a loop: state writes flush on a microtask, everything inbound arrives over the WebSocket (whose delivery is not throttled), and dead connections are the server's ping loop's problem. A table screen somebody tabbed away from keeps answering.
  • Identity is a UUID in sessionStorage, chosen by the device and taken at its word by the relay. The seat map is keyed on it, so a reload or a wifi blip walks back into its own seat. No auth — this is a home server for one table of friends.
  • Fairness is social, not cryptographic. Room state carries the whole game, wall and hands included — a friend with devtools open can cheat. Moving the engine into the relay would fix that; the server is ours now, so only the work stands in the way (TODO).

Running it

npm run dev                       # vite, with the relay on /ws of the same origin
npm run build && npm run serve    # production: dist/ + relay, one process

The relay always lives at /ws on the page's own origin. In development vite.config.ts attaches it to vite's server, so npm run dev is the whole stack; in production server/index.ts serves the built dist/ and the relay from one port (PORT, default 8080), listening on the LAN by default so phones on the same wifi can scan straight in. Anything further away wants TLS in front — a reverse proxy with a certificate — since phones only get camera and fullscreen on https.

Rooms live in memory. A restart drops them; the next visitor on an old link recreates the room empty, and a host mid-game republishes its state on the next move. Joining a room writes ?room= back into the address bar, so the page URL — the host's included — is the invite link.

A QR pointing at localhost is a QR only the host's machine can scan, so the server always gives the invite links a public face: PUBLIC_URL if set, else it runs its own rsgrok tunnel (the house ngrok replacement) — spawned the moment the server knows its port, the https URL read off the tunnel-up line, no :4040 inspection API (another agent may own that port). Every QR and invite link wears that origin instead of the page's. If the tunnel dies, or rsgrok is not on the PATH (RSGROK_BIN points elsewhere), links fall back to the page's own address and the tunnel is retried on later joins.

The tunnel always asks for the same subdomain — mahjong-table, or whatever TUNNEL_NAME says — so restarts land back on the URL people already have instead of burning a fresh name each run. A name someone else already holds kills that first attempt before it prints a URL; the retry then goes out nameless and takes whatever it is given.

On a phone

The table assumes four people sitting around a screen lying flat, and about 770px in both directions before the middle is worth looking at. A phone has neither, so under (max-height: 620px), (max-width: 820px) it switches to the compact layout: nothing is rotated, everything reads upright for the one person holding it, the other three seats shrink to cards showing what is public about them, and the depth that frees up goes to your hand, which is allowed to wrap.

There is no centre square there — the middle collapses to a one-line bar showing what the wall square was telling you anyway — so there is nowhere to throw a tile to. The compact layout therefore keeps a discard row per seat, and discarding is tap-twice or 打出, exactly as it was. The pool and the throw are a full-size feature.

The breakpoint is written down once, in COMPACT_QUERY in src/ui/compact.ts, which sets a compact class on <html> for the CSS to key off.

Computer players

Single player seats you at the bottom and gives seats 1-3 to the computer. Their tiles go face down and their 聽 / 過水 badges come off, since both would give the hand away; everything public — the pool, melds, flowers, chips — stays exactly as it is, and everyone turns their hand over when the hand ends. They throw their tiles in like anyone else, and under the same rule: a computer seat whose wall is open slides them across, and one still walled in lobs them over.

The engine does not know the bots exist. AutoPlay (src/game/autoplay.ts) watches the same state the screen does, and when the seat being waited on belongs to the computer it calls the identical method the button would have called — discard, respond, declareConcealedKong. So scoring, sound, saving, 過水 and 包牌 all work without a special case, and a bot cannot make a move a person could not. It never acts for you and never closes your claim window: if the table is waiting on you, it waits.

How they play (src/game/bot.ts) is one idea applied everywhere. A hand is judged at its resting size — the (5 − melds) × 3 + 1 tiles you hold between a discard and your next draw — by its 向聽 first and by its 進張 count second.

  • Discard — try every distinct tile, keep the one that leaves the best resting hand. Ties go to whatever is hardest to build on: a lone honour with most of its copies already gone, before a lone 五萬 that still has neighbours to meet.
  • 碰 / 吃 — only when the hand that comes out the other side is strictly closer to home. Melding costs concealment and flexibility, so a claim that merely holds the 向聽 steady is declined — which is why the bots pass on pungs that would narrow a two-sided wait to a single tile.
  • 槓 — judged more kindly: it pays 台 and fetches a replacement, so standing still is good enough.
  • 胡 — always taken.

What they watch you do (src/game/danger.ts) is the other half. Efficiency alone throws whatever is fastest and pays for it; these bots read the table first, off the face-up table only — discard rows, exposed melds, the 過水 locks the nameplates already show. Two separate questions:

  • How close does each seat look? Turns taken (a hand is usually settled inside ten goes each, long before the wall looks finished, so counting the wall is the wrong clock), melds exposed, and what they have been throwing lately. Nobody discards 五條 out of a hand that still needs shaping — it comes out once the shape is done, so late middle tiles are the tell. A 過水 lock is a confession: to be locked out of a tile you had to have been able to win on it.
  • How likely is this tile the one they want? An honour can only be caught by a pair or a triplet; a 五萬 sits in the middle of every run through it. A tile they discarded themselves was not their tile when it went down — not proof now, but the best evidence there is. A tile with no copies left unseen cannot be a pair or triplet wait at all. Two melds in one suit means stay out of that suit. And two dragon pungs down means the third dragon is not going anywhere near the table, because 包牌 bills the feeder for the entire hand.

Whether any of that changes the discard depends on the bot's own hand. At 聽牌 it pushes — nothing short of 包牌 is worth breaking a ready hand for. Two or three away with somebody live across the table and it will give up a whole 向聽 step to throw something safe, which is what folding is.

The estimate is checked against ground truth in the tests rather than assumed: over the tiles bots actually considered, the ones the model rated below 0.2 deal in 0% of the time and the ones above 0.8 deal in 10.5%, cleanly monotonic in between. Switching the read on cuts deal-ins by about 13% over a few hundred hands, takes hands from ~30 discards to ~38, and moves the draw rate from almost nothing to about one hand in ten — which is what a table where people stop feeding each other actually looks like.

What they know is still only what they can see. unseenFor counts the four copies of each tile and subtracts the bot's own hand plus every discard and exposed meld on the table. Neither module ever reads an opponent's concealed tiles or looks at the wall.

There is no difficulty setting, and the reading stops at the discard: claims are still judged purely on speed, and a bot will take a 碰 that walks it into trouble.

向聽 lives in src/game/shanten.ts, apart from hu.ts, because the two want different things: the win check has to be exact, and this one has to be fast — it runs a few hundred times for every discard a bot considers. The tests hold them against each other over random hands, since they work in completely different ways and any disagreement is a bug in the fast one.

Undo behaves differently against the computer: rewinding into the middle of its turn would only hand the move straight back to it, so one press goes back to the last point you had a decision to make, computer replies and all.

Saving

The game state is plain data, so a save is just its JSON in localStorage, rewritten after every move. Close the lid, refresh, or run the battery flat and nothing is lost — the lobby offers 繼續對局 Resume above 開始, showing the round, hand number, chip counts and when it was saved. Which seats the computer holds is part of the state, so a save comes back with the same hands in the same places, and everybody who was on a phone scans back in exactly as they did the first time — a room is opened around a resumed table the same way it is opened around a fresh one, when somebody wants one. It is written by whichever device is running the game (engine.persist = authority), so the mirrors on everyone else's screens never fight it for the slot. A save is validated before it is offered (four players, 144 tiles accounted for), so a truncated or hand-edited one is ignored rather than loaded into a broken table. VERSION in save.ts retires old saves if the state shape changes.

Rules implemented

  • 144 tiles (four of each suit/honour, eight flowers), 16-tile hands, dealer draws the 17th.
  • 補花 at the deal and on every drawn flower, replacements from the back of the wall.
  • 吃 only from 上家; 碰/槓/胡 from anyone. 明槓, 暗槓, 加槓, 搶槓, 槓上開花.
  • 流局 when 16 tiles remain (Rules.wallReserve); the dealer keeps the deal on a draw or on a dealer win (連莊), otherwise the deal passes and the round wind advances every four passes. A full 四圈 game is 16 dealer passes.

House rules (switches in Rules, all on the settings screen)

  • 過水 sacredDiscard (on) — pass on a tile you could have won with and it is dead to you until your own next draw; the seat shows a 過水 badge listing what it is locked out of, so the missing 胡 button is never a mystery. A draw from either end of the wall lifts it. sacredClearedByClaim (off) decides whether taking a 吃 / 碰 / 槓 lifts it too — tables genuinely differ.
  • 一炮多響 multipleWinners (off) — one discard pays out to every seat that calls on it, each settled separately against the discarder. With it off the tile goes to the caller nearest the discarder. Either way the table now waits for other seats that can win before settling, so the nearest seat wins the tile rather than the quickest hand — and the 摸牌 button still closes the window on anyone dithering.
  • 包牌 liability (on) — feeding the pung that completes a visible 大三元 or 大四喜 makes the feeder answer for the whole hand, in place of all three payers. Only the seat that fed the last of those sets is on the hook, and only if it came off a discard: a hand that assembled them itself, or closed the set with a 暗槓, has nobody to blame.

台 scoring (src/game/tai.ts)

自摸 1 · 門清 1 · 門清自摸 +1 · 全求人 2 · 平胡 2 · 五門齊 2 · 正花 1 each · 花槓 2 · 八仙過海 8 · 圈風 / 門風 1 each · 三元牌 1 each · 小三元 4 · 大三元 8 · 小四喜 8 · 大四喜 16 · 碰碰胡 4 · 混一色 4 · 清一色 8 · 字一色 16 · 三暗刻 2 / 四暗刻 5 / 五暗刻 8 · 獨聽 · 單釣 1 · 搶槓 1 · 槓上開花 1 · 海底撈月 1 · 河底撈魚 1 · 天胡 16 · 地胡 16 · 人胡 8 · 莊家 1 (連N拉N → 2N+1)

Ambiguous hands are decomposed every legal way and scored at the best reading.

Payment (Game.settle): one unit is 底 + 台 × 台值 (DEFAULT_RULES = 底 3, 台 1, 100 chips each). 放槍一家付 — the discarder alone pays one unit; on 自摸 all three pay one unit each. 拉莊 is billed to the dealer alone when the dealer is a payer, and added to the whole hand when the dealer wins.

House rules vary a lot; the tai table and payments are plain data/functions in tai.ts and types.ts if yours differ.

Audio, by seat

A sound at a mahjong table comes from somewhere. You know whose 碰 that was because it came from your left, and you know a tile has been thrown hard at the far corner without watching it go. Both of those are cheap to keep, and both are gone the moment everything comes out of the middle of one screen.

Every cue is a seat's. SoundCue carries a seat alongside the kind and the tile — the seat that threw, called, or won — and leaves it off the things that belong to the table rather than to anybody: the deal, a 流局, an undo. The engine says whose only because a sound has to come from somewhere; what that means in the room is entirely the UI's business, as it always was.

A seat becomes a place. App.tsx turns a seat into a position round this screen — 0 the near edge, 1 the right, 2 across, 3 the left — because that is what the ear wants and the seat number is not: a device holding one hand turns the table so that hand is at the bottom, and the sound has to turn with it. game/sound.ts gives each position a bus of its own: a pan, a gain, and a lowpass that opens all the way for the near edge and closes to 5 kHz for the far one. Distance is two things at once — quieter, and with the top taken off — because between you and 對家 there is a metre of air, a wall of tiles, and three people's arms, none of which carry treble. Tiles out in the middle skip the edges entirely and are placed continuously, panned by where on the felt they actually landed; the physics knows that to the pixel. Buses are built once per spot and quantised, so a pool full of skidding tiles reuses a handful rather than building one per contact. Old Safari has no StereoPannerNode; it plays where it always did.

Where there is a table and phones round it, the words come out of the player. The split the sound should take is the one the room already has: the table makes the noise, because the tiles are on the table, and the phone in somebody's hand says their calls, because the calls are theirs. So the table's screen drops the voice for a seat while keeping the clack, and the phone holding that seat says 碰 and names its own discards and makes no table noise at all.

Only a phone claims that job, and that is the whole of how the split knows where it applies. A phone is small enough to be in a hand and a hand is in the room; a friend playing from their own laptop three cities away is not, and never takes the words away from anybody's table. It is a proxy rather than a fact — software cannot know who is sitting where — but it is the one property that actually correlates with it, and it fails in the harmless direction: the worst a wrong guess costs is a call said twice in two rooms.

That only works if the phone really is going to say it, so it is asked rather than assumed. sound.voiceLive() is the honest answer — sound on, 報牌 on, the audio hardware awake, the pack decoded — and every device reports it to the host whenever it changes (EV_VOICE). The host works out, seat by seat, which hands have a device that will speak for them and publishes that as voices in room state; a nameplate wearing 🔊 next to its 📱 is a seat the table has gone quiet for on purpose. Mute the phone, put it to sleep, hand the seat back — the flag drops and the table takes the calls back mid-hand. The device the table is being played at never counts, even when it holds seats: it is the table, and a table that fell silent on the grounds that it was about to speak would say nothing at all.

A browser will not start audio before the page has been touched, and a phone that arrived by QR may never touch anything that asks for it by name — so the first touch anywhere unlocks it, which is also the moment it can start claiming the job.

The voice pack

A mahjong table only ever says about fifty things — 42 tile names and a handful of calls — so the whole vocabulary is rendered ahead of time into public/voice/ (~330 kB of mp3) rather than left to whatever speech synthesis the browser happens to have. On Linux that is usually espeak-ng, which is intelligible but sounds like a modem; and on a machine with no Chinese voice at all the feature would silently do nothing. Shipping the audio makes playback instant, identical everywhere, and lets a call be cut off mid-word when the next one lands, all through the same Web Audio graph as the chimes.

scripts/voice.mjs is the authoring step, not part of npm run build. It needs piper and ffmpeg on PATH:

node scripts/voice.mjs                      # → public/voice/*.mp3 + manifest.json
PIPER_MODEL=/path/to/voice.onnx node scripts/voice.mjs   # a different voice

The wording lives in that script, and some of it is deliberately not the bare tile character: 東 alone is a direction where 東風 is the tile, and the dragons are 紅中 / 發財 / 白板 the way they are actually called — which also gives the phonemiser enough context to get the tone right, since 中 on its own is as likely to come out zhòng.

The clips here were rendered with piper's zh_CN-huayan-medium. If you redistribute this app, check that voice's model card in piper-voices for the terms attached to it, the same way you would the tile art below — or re-render the pack with a voice whose terms suit you, which is a single command.

Tile art

public/tiles/tiles.svg is the postmodern tileset from gnome-mahjongg, extracted from the installed binary's GResource — a 43×2 sprite sheet (second row is the highlighted variant, used for a lifted tile). public/tiles/back.png is the blank tile from its smooth theme, tinted jade, since a solitaire game has no face-down art of its own.

How a tile is drawn on the canvas — the outline round it and the shadow under it — lives in src/ui/tileArt.ts, and npm run dev serves /tiles.html, a page of every face at every angle over the felt with a slider on each of those numbers and the finished block ready to paste back. None of it can be judged from the values: it is 246 white against a dark green at 35 pixels across, and the only way to know is to look at it and move something. (tiles.html is not one of the build's entry points, so it does not ship.)

The sheet is not a picture of a tile's face. It is a picture of a whole tile, drawn in an oblique projection, with its own sides painted down the left and along the bottom and its own light coming from the top right — a 16px bevel on a 128px cell. That is fine while every tile stands the same way up, and wrong the moment they are scattered: rotating the sprite rotates a pre-rendered solid, so a pool of discards ends up with a different sun on each tile.

So the art is cropped back to the flat face, and the solid is worked out instead. A tile is a box lying on cloth: take the four corners of the rotated rectangle, offset them by the projection (depthAngle, down and to the left, the same direction the art used), and fill the two or three quads whose edges the base falls away from — each shaded from the direction that edge faces on screen. Turn the tile and the same physical side comes round to face a different way and its shading changes with it, which is the whole point. No 3D engine, no z-buffer: one projection vector and a dot product per edge.

Each cell of the sheet is cut out into its own canvas the first time it is wanted. drawImage with a source rectangle is allowed to sample past that rectangle when it resamples, and the row under every face is the highlighted variant, whose border is bright blue — so a tile at any angle other than a right one wore a blue fringe along one edge. Square on it never showed, which is why it looked like a rotation bug rather than a spritesheet one.

A tile used to be drawn as two passes — a slab of ivory offset down the screen with the face on top, so a sliver of the tile's own side showed along the bottom edge. At the size a discard is actually drawn that never read as thickness; it read as a second, paler shape stuck to the bottom of every tile. Shading it better did not help, because the problem was that it was there. What gives a tile its weight now is the shadow and the outline, both of which are true at any size.

That art is GPL-2.0-or-later. Fine for playing at home; if you ever distribute this app, either honour the GPL or swap the two files for art of your own — SPRITE_COL in tiles.ts is the only mapping that would need updating.

Known gaps and house rules that aren't implemented yet are listed in TODO.md.

Layout

public/tiles/        sprite sheet + tile back
src/game/tiles.ts    tile codes, wall, shuffle, sprite mapping
src/game/hu.ts       hand decomposition, 聽 detection, wait shapes
src/game/shanten.ts  向聽 / 進張 counting, for the computer players
src/game/bot.ts      what a computer player discards and claims (pure)
src/game/danger.ts   reading the table: who looks ready, which tiles are hot
src/game/autoplay.ts when it does it, and how long it appears to think
src/game/tai.ts      台 scoring
src/game/engine.ts   state machine: deal, turns, claim resolution, settlement
src/game/ctl.ts      TableCtl — the surface the UI drives, local or networked
src/game/wall.ts     what is left of the square, and what still blocks a throw
src/net/room.ts      the wire itself: join, reconnect, state, events
src/net/session.ts   the room: host duties, seats, intents, migration
src/net/table.ts     TableCtl over the wire: mirror + intents + hand overlay
src/net/protocol.ts  what crosses the wire, in one place
src/net/handOrder.ts your own arrangement, kept on your own device
src/ui/Controller.tsx a hand held on a phone, plus the seat picker for any device
server/rooms.ts      the relay: rooms, host election, state fan-out
server/index.ts      production: dist/ and the relay from one port
src/table/physics.ts the discard pool: rectangles with weight, pure and testable
src/table/geometry.ts the table measured — colliders, launch points, throw gate
src/table/pool.ts    game state ⇄ tiles in the middle, by diffing the discards
src/ui/              TileView, Hand (drag and throw), Seat, Center, Pool, Help

The tiles in the middle

The pool is a small rigid-body solver (src/table/physics.ts) that knows nothing about mahjong, the DOM, or the clock: fixed 1/120s steps, no randomness of its own, and everything in table pixels. Given the same throws it produces the same pile every time, which is what lets a refresh mid-hand come back to the pile you had rather than a freshly scattered one — positions are derived from the hand number and each tile's place in its thrower's discards, never saved.

The pile is handled directly. Press on a discard and it comes up out of the pile, carried over the top of the others rather than ploughing through them; let go slowly and it is set down there, flick and it goes off across the felt at the speed it left your fingers — the same throw a tile gets out of a hand, made from the middle of the table instead. Pressing on bare felt does nothing, and the press passes through to whatever was really under it: the tiles are drawn on a canvas with no elements to hit, so the hit test is against the physics and the listener is a capturing one on the table itself.

A flick is the whole of a throw: the direction and the speed it leaves at are the hand's, and nothing between the fingers and the felt changes either. The one thing the table decides is height — with a standing wall in front of the throw a flat tile would only skid into it, so it is lofted over instead, exactly as far as it was thrown and simply in the air for the first part of the way. A tile that carries past the square and out into the open stays there. (It used to be re-aimed at the middle of the square when the flick's line missed it, which put the tile on a line nobody had thrown and read, fairly, as a bounce off nothing.)

What stops a tile is other tiles. There is no fence: the standing stacks of the wall, the sets people have laid down and the sixteen each of them is holding are all measured as colliders, so the pile is held in by the ring of tiles round it exactly as it is on a real table, and spills through whatever gaps the wall has been eaten into. The table's own edge is a backstop at the screen edge, there only so a tile that got past all of that isn't lost.

A tile that only ever slid would look like a puck. What makes a pool of discards look like one is that every tile in it has come to rest at some angle nobody chose, and that is a thing to earn rather than to scatter in. A tile turns because of where it was hit.

So a contact happens somewhere in particular. The separating axis test says which way to push and by how much; contactOf says where, and the arm from there to the tile's middle is what a turn is made of. A tile shouldered square in the back is pushed along and does not turn at all. The same shove, moved along until it is catching one end of the tile, turns it instead, and which way round follows from which end. What touches is a footprint and not a point: a tile lying square to a wall meets it along the whole of one side and is pushed through its own middle however far along the wall it is, and the same tile at forty-five degrees meets it on one corner and swings on round it. Everything a tile can hit is another tile, so the standing stacks, the laid-down sets and the table's own straight edge are all the same contact with the same friction across the face of it.

Two things keep that honest. The first is that a tile is not free to pivot. It is lying flat on cloth, and the cloth under the whole of its face resists the turn for as long as the contact lasts, so only a tenth of the moment a knock would give a free rectangle actually reaches it (FELT_HOLD). Without that, a tile off the corner of a stack comes away spinning like a top. The second is that the felt is one patch of cloth doing one thing: what it spends stopping the slide it has not got left for the turn, so the two share a single friction budget rather than running as two brakes side by side. What that buys is the ending. A tile flicked hard with a little turn on it used to stop turning early and skate on looking dead, and one barely pushed with a lot of turn on it stopped dead and went on spinning where it lay. They now run out together.

The turn a tile arrives with is the flick's own. ui/flick.ts reads a release the same way wherever it was made, out of a hand or off the pile in the middle, and the spin it reports is the angle the stroke swung through between its first half and its second, over the time it took. That is a rate, in rad/s, which is what a wrist actually does: a straight flick turns nothing however hard it is thrown, and the same curve made twice as fast puts twice the turn on the tile.

It is drawn on a canvas that spans the whole table, not just the centre. That is deliberate: .slot and .seat both clip their own contents, which is why a tile can't be animated out of a strip as an element. On the canvas there is nothing to clip it, so a tile lifted out of a hand crosses the table in one piece.

The square is the whole wall: eighteen stacks of two a side, four sides, 144 tiles, the way it is built on a table. It used to be rebuilt to what was left after a hand had been dealt — barely half of it — because eighteen full-size stacks a side wants about 560px and no ordinary window has that between the top and bottom strips. That bought a square at the price of it not being the wall: a side ran out of stacks before it reached its corner, so the four of them never met.

Nothing gives now, and in particular the tile does not. Every tile on the table is one size — --tile-w in styles.css, in a hand, in the wall, and lying in the middle — because they are the same tiles, and a tile that changed size between the wall and your hand read as a different, smaller set sitting in the middle. The square is built from that tile and is however big that comes to: wallSquare() in game/wall.ts takes the tile and hands back the lengths, and WallRing measures one rendered cell to find out what CSS made it. The wall being bigger than the room between the strips is a fact about a 144-tile wall, not a thing to solve by shrinking it.

The four walls are placed by CSS alone: each is pinned to its own edge of the opening with left/top, runs its whole length from the corner it is built from, and so carries one wall-depth past the far corner and over the end of the next wall along. Every corner of the square is covered by exactly one wall and no two overlap — that is the pinwheel. Nothing is rotated; a square wants no rotation to describe it.

The overhang is exactly one wall-depth, which is what it takes to close a right angle: two bands of thickness t meeting at an interior angle ψ overlap by t / tan(ψ/2), and at ninety degrees that is t. So the whole square measures a wall's length plus one depth across, and leaves that length less one depth in the middle — which is the opening, and which is what .wall-ring itself is.

The CSS only places what it is told: .wall-side is a row of stacks, laid across on the two flat walls and down on the two upright ones, and --wall-len from WallRing is the only length it is given. Every place in the wall is rendered whether or not there is still a stack standing on it, because with the break anywhere but a corner a wall is eaten from its middle — a row that closed the gap up would drag the whole tail of the wall along the table behind it.

Where the four walls stand is found at the table itself rather than on a page of sliders: take hold of a wall in a real game and push it. It goes where the finger goes, and lets go saying where it landed — [wall] top pushed to 0.5,-4.9 — top 0.5,-4.9 · right 0,0 · … in the console, in tiles, which is the form WALL_PLACED in game/wall.ts is written in, so what a push finds pastes straight back in. Nothing about the hand moves: the wall is the same wall in the same order and the next draw is still the next draw. There used to be a /wall.html for this; the table is the honest place to do it, so that page is gone.

擲骰 — where the wall is broken

The dealer throws three dice to open a hand, and the total does two jobs. It counts round the seats — the dealer being one, and the count going the way the turn goes — to pick whose wall is opened. Then the same number counts stacks in from the right-hand end of that wall, and the break is behind them: the first tile drawn is the next one along, and the drawing runs away to the left from there, on round the square.

The square makes that cheap to say. Every wall is laid down from the corner its own player's right hand falls on — that is what the pinwheel is — so the run of 72 places already starts at each seat's right, and the break is simply so many places into the side the dice picked. breakAt() is those two lines.

Nothing about the tiles turns on it: the wall was shuffled before it was built, so which stack is drawn first is decided either way. What it moves is where in the middle of the table the gap opens and who has to reach furthest for the next draw — which is the whole of what it does at a table too. The throw is made from the same seeded rng that shuffled, so a seed replays a hand's gap as well as its tiles.

Where the wall opened is thrown for once and then lived with, which is right at a table and awkward to look at: half the question about the square is what it looks like opened at each of the seventy-two places it can be opened at. game.reroll(1, 1, 1) through game.reroll(6, 6, 6) from the console walks the break all the way round, and game.reroll() throws afresh. It moves the gap and nothing else: the tiles were shuffled before the wall was built, so nobody's hand changes and the draw order is the draw order. Nothing about the square is calculated twice. Its size lives entirely in CSS (--ws, --wd), so geometry.ts measures it instead of restating that arithmetic: WallRing already renders every stack as a real element tagged with its index, and the colliders are those elements' bounding rects. That one mapping is what makes the wall a physical object — the thing a thrown tile bounces off, and the thing the throw gate casts its ray at.

Seeing the colliders

Because every boundary is measured off the DOM rather than written down, the only honest way to check a bounce is to draw the boxes in the coordinate space the tiles are drawn in. Add ?colliders to the address, or call colliders() from the console while a hand is running:

drawnis
solid rectangle at the screen edgethe 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
solid box on every tilea 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
dashed rectanglethe wall square's opening — where a throw is aimed. It stops nothing, which is why the pile spills out of it
dotted rectangle, insetwhere the moving tile's own centre may be against the table's edge, which is its half width in from it
box hugging each pool tilethe rotated rectangle it really collides with
dotted box round the moving tilethe upright box the straight edges use instead. It comes apart from the rotated one as the tile turns

| 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 | | 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 |

Colliders mode is also a throwing sandbox: a flick throws for real but is not a discard, so the tile stays in your hand, the turn never passes and the computer players hold still. Throw the same tile at the same corner as many times as you like. Sixty stay on the table before the oldest is swept off; pool.clearLoose() sweeps them all. Tapping a tile to discard does nothing in this mode — throwing is the thing being tested.

Add pause=1 (?colliders&pause=1, or pause() from the console) to stop dead on contact. The physics halts on the fixed step that resolved it and drops the rest of the frame, so what's on screen is where the tile touched rather than where it had got to by the end of the frame. Space carries on, → takes one step, ↑ takes ten; the console says what it stopped on.

colliders(false) turns it off again. It repaints on toggle, so a settled table doesn't have to be poked first. In a dev build pool is the live pool: the overlay draws pool.world.walls, the physics' own copy rather than a fresh measurement, so if what's drawn ever disagrees with the DOM it is the world that went stale — which pool.measuredAt will show, since the stamp carries every hand's shape.

What is left of the wall

Four walls of eighteen full-size stacks want about 560px a side. No ordinary window has that between the top and bottom strips, and the tile is not allowed to shrink to make it fit — a tile in the wall is the same tile you play with, so drawing it smaller was a visible lie.

What saves it is that nobody ever sees a whole wall: four hands come off the front before the table is on screen, so barely half of it is left by then. So only the stacks still standing are drawn, and they are laid out around a ring cut to the middle rather than to eighteen — ringLayout in game/wall.ts measures how many stacks a side can take (WallRing renders one hidden cell and asks CSS how big it came out) and shares what is left over the four sides in proportion. Every seat keeps a wall in front of it instead of the remainder piling into one corner, and the order runs all the way round, so the break point is still at the head of it and the 底牌 tail still at the end.

Shuffling the wall along as it is eaten costs nothing to look at, because every stack shows the same green back — and it is what people do to a half-eaten wall anyway.

pool.ts never listens for events. It diffs the discards against what it is already drawing, keyed by seat:index — and those keys never shift, because discards are only ever pushed and a claim only ever pops the one on top. So a key that appeared is a tile to throw in and a key that went is a tile to take off, which makes undo, 下一局, a claim and resuming a saved game all the same code path. The engine has no idea any of it exists, and the save format did not change.