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 — put a piece of card or plastic over your strip so the others can't see your tiles. Every label is Chinese with English underneath; the tiles themselves stay Chinese, because that is what the tiles say.
Nobody else around? 單人對局 Single player on the lobby gives you the bottom seat and hands the other three to the computer — same rules, same table, same scoring. See computer players.
Friends elsewhere? 線上對戰 Online game puts everyone at the same table from their own devices, and 手機派對 Party mode keeps the laptop as the table while phones scan a QR to hold their own tiles — see 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
- 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
#with the corners open, the way it's built on a real table. The gold stack is the break point you draw from, stacks shrink to a single tile and then vanish as they're used, and the dimmed tail is the 16-tile 底牌 that ends the hand — which is also the end kong and flower replacements are taken from, so the square is eaten from both directions at once. Only what is still standing is drawn, and it is evened up over the four sides as it goes, which is what lets every tile left in it be the size of a tile in your hand — see what is left of the wall. - 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 draw button reads 過.摸牌 while they have something they could have called instead. Wanting to decline but leave the window open for everyone else is just waiting, which is what not pressing anything already does.
- On your turn — 自摸, 暗槓 and 加槓 appear automatically when legal. 加槓 offers everyone else a 搶槓 chance.
- Where the buttons go — the strip is built from its edge inwards, so the action bar holds a fixed height whether or not anything is in it. Otherwise the melds above it shuffled along every time you lifted a tile and 打出 五萬 appeared, since a button naming a tile stands taller than a bare 過. Every button on the bar is given that one height for the same reason.
- 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.
- Undo — a 復原 button in the middle of the table takes back the last
action, naming what it will undo (
復原 玩家 2 打 五萬). It sits in the centre rather than on a seat 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 in the middle of the
table 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.
- Settings — 設定 from the lobby, or from the game-over panel: player names,
底 / 台 / starting chips, the house-rule switches below, and sound. Kept in
localStorageseparately 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.
Playing online
Two networked modes, both built on a 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.
- 線上對戰 Online game — everyone on their own device. Whoever opens the room picks 線上對戰 and gets a seat lobby; friends who open the same link land in it, sit down, and the host deals. Empty seats go to the computer, which runs on the host client — that is what stands in for a server on the classic (free) tier. Each screen draws the same full table, turned so your own seat is the bottom edge; other people's tiles are face down. Joining mid-game takes over a vacant or computer seat, so a dropped friend can reload and carry on.
- 手機派對 Party mode — the laptop stays the table, exactly like hotseat,
and each seat's corner of the felt wears a QR. Scanning one loads a
controller on the phone: just that 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 byTablePool.throwFromNet). The seat's tiles go face down on the shared screen and its QR hides; hold the 📱 tag on the nameplate to show it again for a player who lost theirs. A phone that leaves hands the seat back to the laptop. Seats never lock: a second phone scanning the same QR joins the same hand — two people playing one hand, whoever acts first acts.
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 party 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 the two ways to
start a fresh one, showing whether it was a solo or a hotseat game, the round,
hand number, chip counts and when it was saved. Which seats the computer holds
is part of the state, so a save resumes as the kind of game it was. 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.
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.
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 the phone at the party table: your tiles and the throw
src/ui/OnlineLobby.tsx seats and the deal button, before an online game
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.
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.
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.
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.