CoOp:Anything
게임을 검색해 협동 플레이 가능한 모드·단계·품질·커뮤니티 평점을 확인합니다
저장소 정보는 아직 수집하지 않았습니다.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗A playable Hong Kong old-style mahjong table for the browser, played around a shared tablet with everyone on their phone, held sideways. A game always takes at least two devices — the tablet as the table and one phone — and seats nobody takes are played by the computer. Built with Next.js and deployed to Vercel; the table runs on a few API routes backed by Upstash Redis.
Tiles and the deal
Play
Scoring
The exact ruleset is listed in the app under House rules and Faan table,
and lives in src/game/rules.ts. Hong Kong scoring is a
house-rules affair, so every number is in one file and is easy to change.
Computer seats play at the table's Difficulty, set on the tablet with the pace: Easy, Normal (the default) or Hard. A random player is kept as the measured baseline.
Normal. Counts shanten — how many tile changes a hand is from
completion — and discards whatever leaves the hand closest to ready, breaking
ties on how many useful tiles are still unseen, then on letting go of terminals
and honors first. It plays for the faan minimum: each candidate discard or claim
is judged against routes to a hand worth enough to win (a flush, all triplets,
staying concealed, honor sets already held — see
src/game/plan.ts), so it keeps a hand concealed or chases
a flush rather than finishing a hand it could not declare. It always declares a
win it is entitled to.
Easy. Normal's hand, but one discard in five is a tile it should have kept, and it claims any set that brings the hand closer without asking whether the hand will still be worth declaring. It never misses a win.
Hard. Normal's hand, played for what it is worth as well as for speed: every route still open is weighed by what it would pay, discounted for each step still to take (more steeply as the wall empties), and the richest is played for. Reading the discards for danger — throwing the safest tile once another hand looks ready, folding a poor hand — was built and measured, and left out: it cut this seat's deal-ins from 20% to 17%, but slowed it down enough that the people at the table won more (33% of hands against three such seats), not less.
How a person fares against each level, measured with one Normal seat standing in for the person against three computer seats, over 1,000 hands per row at the default 1-faan minimum (October 2026):
| Computer seats | Person wins | Person's points a hand | Washed out (four computers) |
|---|---|---|---|
| Easy | 42.4% | +2.92 | 2.5% |
| Normal | 27.0% | +0.01 | 0.0% |
| Hard | 26.2% | −0.13 | 0.0% |
Easy is a clear step down. Hard is only a small step up — at the 3-faan Hong Kong standard too (21.4% against 22.1%, −0.15 points a hand against +0.45, over 800 hands) — because at this table's rules the quickest hand is usually the richest one as well. A clearly stronger Hard needs a deeper search, which this does not have yet.
Normal against the random baseline, over 300 simulated all-AI hands per row (October 2026):
| Opponents | Table minimum | Hands won | Washed out | Avg. faan |
|---|---|---|---|---|
| Random | 0 faan | 3.0% | 97.0% | 2.33 |
| Random | 3 faan | 1.0% | 99.0% | 4.33 |
| Random | 5 faan | 0.3% | 99.7% | 6.00 |
| Normal | 0 faan | 100% | 0% | 1.58 |
| Normal | 3 faan | 82.7% | 17.3% | 3.40 |
| Normal | 5 faan | 22.0% | 78.0% | 5.59 |
At a 3-faan minimum about one hand in six washes out. At 5 faan most do: the route planner does not yet aim high enough, so 5 is better played against people than against the computer.
The shanten calculator decomposes each suit independently and caches per suit, since a candidate discard changes only one of the four groups. That took a greedy turn from 13.2 ms to 2.2 ms.
Adding another strategy is a single file: implement AiStrategy in
src/game/ai.ts and register it in STRATEGIES.
export interface AiStrategy {
name: string;
chooseTurnAction(state: GameState, seat: Seat, rng: Rng): AiTurnDecision;
chooseClaim(state: GameState, seat: Seat, options: ClaimOption[], rng: Rng): ClaimOption | null;
}
The game runs on a small server: three fixed tables (Table 1, 2 and 3), a tablet acting as the table, and everyone else on their phone. There is no game on one device alone, and no upright layout: phones play sideways, and a phone held upright turns its controller on its side.
Everyone opens the same table — chosen on the start page, opened the first time somebody arrives, so there are no room codes to pass around. The start page is a workbench: the three tables (with who is at each) and the two ways in — Be Table N on this screen and Sit down at Table N — on the left, and the settings on the right under a live preview of the table's look, in three tabs: House rules, Table & tiles, and This phone. A tablet opens on the look and a phone on its own layout; the tab left open is remembered.
The tablet takes the Table seat. A tablet that has lost its claim — a different browser, cleared storage, a replacement tablet — opens the table again and is offered Take over the table: the game carries on there as it stands, and the device that had it is told another one took over. Reset table is only for starting over.
Everyone else scans in. The tablet's lobby puts up one large QR code, for the next open chair, beside the list of chairs; once somebody sits there it moves on to the next. A name typed under the code goes with it, and tapping Show code on any chair puts that chair's code up instead — a taken chair's code lets a new phone take it over. A seated phone shows the chairs and a code to bring someone else in. Opening the table's address without a code, people tap the seat they want; a name is already in the box, so sitting down is one tap.
Before the deal the ‹ › buttons beside a seated person move them one chair round, trading places with whoever is there. People keep their names wherever they sit, a chair's default name included: Louis moved from South to West is still Louis. An empty chair offers a default (Calvin, Louis, Hanna or Clem) that nobody is using, so no two chairs say the same name.
Nothing is dealt until the table deals, so four people arriving one at a time all start the same hand. Only the tablet deals; phones on their own are told to open one. Waiting alone, you can play the computer in the meantime; the table offers to deal everyone in the moment a friend sits down.
Seats nobody takes are played by the computer, so three friends and one empty chair still works.
There is no password: anyone who can reach the URL can take a seat. That suits a
group who already share the link and not much else — the rate limiter is what
stops seat-grabbing, not authentication. REQUIRE_PASSWORD in
src/server/rooms.ts turns the shared password back on
in one line.
The table (a tablet in the middle) carries everything shared. Each player's discards sit in front of them, on the edge of their own block facing the middle — where the tiles would land at a real table — rather than stacked in one central pile. The middle keeps only the round, the wall count and whose turn it is. Alongside the pond each seat shows its score, melds, flowers, how many tiles it holds, and whether it is still deciding on a claim. It also prints the seating — Seat 1 East · bottom edge · Kris — so people know where to sit. It never receives anyone's concealed tiles.
It also drives the game: next hand (or each phone taps Ready, and the last one in deals it), redeal, restart and reset scores, end the game early (the hand being played goes unscored, and everyone goes to the final scores), change the faan minimum and free a seat. Pause stops a hand where it stands: no computer turn, no claim window and no turn clock runs, every phone says the table is paused and its hand cannot be played, and Resume gives everyone back the time they had. Nothing on the table cuts short a player's call on a discard: a pung, chow or kong being thought about runs its own clock.
Tap any tile in a pond and every copy of it on the table — in the ponds, the middle, the open sets and flowers, and hands turned up at the end — is ringed while the rest fade back, with a line saying how many are in sight; tap it again, tap Clear, or wait a few seconds to let it go. Size, under This tablet, draws the table Cosy, Normal, Compact or Extra compact: the discards, the racks and the table's own buttons together.
A phone shows only that player's own hand and the decisions that are theirs — discard, chow/pung/kong/win, pass — because the tablet is already showing everything else. With no table device in the room, phones fall back to the full view so the pond is visible somewhere.
localStorage. Every action carries it in the request body and
every poll in an Authorization header — never the URL, which ends up in
logs — and the server maps token → seat. Nobody can play a seat that is not
theirs.UPSTASH_REDIS_REST_URL=... # required in production
UPSTASH_REDIS_REST_TOKEN=...
MAHJONG_RESET_KEY=... # optional: lets the operator reset a game in progress
Multiplayer needs no password variable while REQUIRE_PASSWORD is false.
See .env.example. The start page at / stays fully static and
needs none of this.
npm install
npm run dev # http://localhost:3000
npm test # engine, room, server and component tests, plus the fuzz run
npm run typecheck # tsc --noEmit
npm run build # production build
npm run test:e2e # Playwright smoke tests against that build
CI (.github/workflows/ci.yml) runs all four on every pull request.
The project is a stock Next.js app — Vercel detects it with no configuration. The tables need the two Upstash variables above, or rooms live in one serverless instance's memory and appear to reset at random.
From the dashboard: import the Git repository at
vercel.com/new. Framework preset Next.js, build
command npm run build, output directory .next. All defaults.
From the CLI:
npm i -g vercel
vercel # preview deployment
vercel --prod # production
/ is prerendered as static content (○ (Static)) and served from the CDN
edge; the room pages and /api/rooms/* are server-rendered on demand.
A Table & tiles panel in the sidebar carries three independent settings,
remembered in localStorage between visits:
Each setting is a data- attribute on the document element consumed entirely by
CSS, so switching costs no React re-render, and a small inline script restores
the choice before first paint rather than flashing the default theme. Both tile
faces are always in the DOM with one hidden, which is why the switch is instant.
The tablet's own look goes out with the deal, and every phone takes it on once per game: the table, the faces, the body, the backs and the suit colors all start out matching. Anything a player changes after that stays theirs until the next deal. The Every tile sheet shows the back the tiles share as well as each face.
Light and dark tables get chart palettes selected for their own surface rather than one flipped from the other, each validated against the surface it actually sits on.
A footer strip carries the build version and the most recent changelog entry;
clicking it opens the full changelog, newest first. Entries live in
public/updates.txt, one per line, oldest first — add a
line there whenever you ship a user-visible change.
src/game/ Rules engine — no React, no DOM
tiles.ts Tile codes, the 144-tile set, display glyphs
melds.ts Meld shapes and chow partners
winning.ts Hand decomposition, special hands, waits
scoring.ts Faan patterns, exclusions, payout
rules.ts House ruleset — every tunable number
engine.ts The state machine: deal, draw, discard, claims, settlement
shanten.ts Distance-to-ready, hand acceptance, visible-tile counting
plan.ts Routes to a hand worth the faan minimum, for the opponents
ai.ts AiStrategy interface + the random, easy, normal and hard opponents
controller.ts Steps the table one beat at a time
sets.ts, waits.ts, helper.ts Hints: finished sets, waits, sets started
patternTiles.ts The tiles behind each faan pattern, for the result
summary.ts A finished game's standings and facts
replay.ts A finished hand rebuilt move by move from its log
history.ts The table's kept games, and each person's record across them
room.ts Multiplayer room model: seating, draining, redaction
presence.ts Away and heartbeat timings
tables.ts The three fixed tables
sound.ts Web Audio cues, synthesised at runtime
rng.ts Seeded PRNG (mulberry32) for reproducible games
src/server/ Room store (Upstash + memory, deflated) and the room service
src/app/api/ Room, claim, action, control and reset endpoints
src/hooks/ React bindings for a room and the phone's own settings
src/film/ The explainer film — hand-drawn canvas animation
src/components/ Tiles, pip artwork, the felt, the phone controller, settings, results
src/app/ Next.js App Router entry and styles
e2e/ Playwright smoke tests against the production build
public/updates.txt Changelog, oldest first
The engine is a pure state machine: every mutator takes a GameState and
returns a new one. That is what lets the test suite fuzz 150 complete hands
without a browser, asserting on every one that all 144 tiles are still
accounted for, that hand sizes match the melds exposed, that every chow is a
real run and every pung is three of a kind, and that the scores stay zero-sum.
A tile is a bone face set into a rounded body: the shell carries the edge and the drop shadow, an inset ring is the bevel catching the light from above, and a radial sheen sits over the top-left. Glyphs are given a light lip below the stroke and shade above, so they read as carved rather than printed. Tile backs get the same treatment with a moulded recessed panel.
Dots (筒) and Bamboo (索) are drawn as original SVG artwork, sized on a
0–100 square and stroked in currentColor so they stay crisp from the 22px
pond to the zoomed hand and recolor with the chosen palette:
Nothing is filled with the tile's face color: rings are strokes and canes are separate segments, so the suit's faint wash shows through the gaps rather than being covered by a near-match.
Characters (萬) and honors keep their glyph faces, which is already how those tiles look. Traditional sets color individual pips (a red five, a green one bamboo); we draw every pip in its suit color instead, since the suits have to stay apart at a glance and a red pip would read as a Red Dragon.
Suits are set far apart in both hue and lightness — blue Characters, burnt-orange Dots, green Bamboo, purple bonus tiles — with a matching wash across the tile face, which also keeps them separable for red-green color vision deficiency.
Discards stack six to a row in front of each seat, the way a pond is laid out at a real table, rather than in one long wrapping line. A tile claimed by another player leaves its space behind, outlined with the wind of whoever took it, so a pond still reads in the order it was thrown.
Tiles are animated so the table is readable at a glance: a discard flies in
from the direction of the player who threw it, a tile retrieved from the pond
pops into the meld that claimed it, and the tile you just drew slides into your
hand. All motion respects prefers-reduced-motion.
Tile clacks and the claim, kong, win and washout cues are synthesised at runtime with the Web Audio API — a filtered noise burst for the clack, short triangle blips for the pitched cues. Nothing is loaded as an audio file, so the deployment stays a single static bundle with no media to fetch. Cues are derived by diffing consecutive game states, which keeps the engine free of presentation concerns. Audio is created lazily on your first action, since browsers refuse to start it before a gesture, and every call is wrapped so a failed cue can never interrupt play. Toggle it with Sound.
At the end of a hand the tablet lays its result over the felt: who won and what it paid them, the winning hand in its sets with the tile it went out on ringed, the faan ledger, and who paid. The ledger shows the tiles behind each pattern when there are four or fewer of them — a dragon or wind set, a flower, the self-drawn tile; a pattern about the whole hand, such as a flush, shows none, since the hand is already above it. Who pays draws each seat's payment as a bar either side of one line, paid to the left and received to the right, with the signed amount and each seat's chips after it.
Each phone shows its own result in the space above the hand: what the hand cost or paid you and why ("You threw the winning tile, so you pay the full share"), the winner's hand and faan, and the table's chips. Hide puts it aside; Result brings it back.
Every settled hand is recorded — winner, faan, value, its biggest pattern, per-seat payments and the cumulative scores after it. When the game ends the tablet shows the final chips in order, four facts about the game (the biggest hand, self-draws, who threw the most winning tiles, washouts) and everyone's chips after each hand, as a line chart or, under Hand by hand, as a table. New game goes back to the lobby with everyone still seated; each phone shows its place. Share scores, on the tablet and on every phone, draws the final chips and the four facts as a square picture and hands it to the device's share sheet, or saves it where there is none.
When two or three players win on one discard, the result names them all, shows each winning hand and its faan, and Who pays marks the seats that paid half of each.
Replay hand, on the tablet's hand result, plays the hand back move by move from its log: the four ponds filling and the sets going down in order, each move said in words, stepped through, scrubbed or played. On a short tablet the final scores keep their heading — Share scores and New game with it — in view, and only the body under it scrolls.
History, in the table's settings, keeps the table's finished games (any that ended with at least one hand played; the last 50, for 180 days, under a key of their own so they outlive the room) and each person's record across them: games, firsts, average place, chips won or lost, wins and best hand. Names are matched whatever case they were typed in, and a chair the computer played is nobody's record.
The chart's four series use categorical slots 1–4 of a reference palette, validated against each theme's chart surface: on the dark themes, worst adjacent CVD ΔE 8.4, normal-vision ΔE 19.8, all four above 3:1 contrast. On Parchment three slots fall below 3:1 against the light surface, so the values are always readable another way. A colour follows the seat, not the standings. Identity is never carried by color alone — every line is named at its end, keyed in the final chips, and repeated in the table.
MAHJONG_RESET_KEY in an x-reset-key header; the browser tests do.prefers-reduced-motion.