Imported from kan/macalc (
AGENTS.md). Install upstream withnpx skills add kan/macalc. Copyright stays with the author.
AGENTS.md
Project purpose
This repository contains a mobile-first Riichi Mahjong scoring game.
The primary goal is not to make a generic Mahjong calculator or a full Mahjong game. The goal is to train the player to look at a real winning hand and quickly make the same score declaration they would make at a physical table.
The intended core loop is:
- Show a completed winning hand and the relevant game context.
- Optionally show hints depending on difficulty.
- Let the player enter the actual score declaration.
- Grade immediately.
- Briefly explain the correct answer when useful.
- Repeat for about 10 questions.
- Show a run result with score, accuracy, speed, and useful learning feedback.
Optimize for short, repeatable smartphone sessions and learning through repetition.
Product principles
- Mobile portrait layout is the primary UI target.
- The game must feel fast. Avoid modal-heavy or lecture-like flows.
- Correct score declaration matters more than abstract total points.
- Ron: enter the payment, e.g.
5200. - Non-dealer tsumo: enter child/dealer payments, e.g.
1000 / 2000. - Dealer tsumo: enter the all-payment, e.g.
2000 all.
- Ron: enter the payment, e.g.
- A correct but slow answer is still correct. Time should affect bonus score, not basic correctness.
- Higher difficulty removes assistance rather than merely making arithmetic larger.
- The final/high difficulty target should approximate real table play as closely as practical.
- Explanations should be concise during a run. Detailed breakdowns may be available after answering or after the run.
- Do not turn the prototype into a full account/service platform before the core loop is proven fun and useful.
The two correctness invariants
Everything else in this document is a preference. These two are not.
1. Every question must be a legal win
A generated question must contain at least one yaku or yakuman. Han contributed only by dora does not make a hand winnable — at a real table that declaration is a chombo.
The scorer is the authority for whether a yaku exists. Enforce the check in exactly one place: the generator's accept/reject step. Never re-derive it in UI code, and never "rescue" a yakuless candidate by adding riichi to it after the fact.
The engine adapter must surface this state explicitly rather than flattening it:
type ScoreResult =
| { kind: 'scored'; han: number; fu: number; yaku: YakuResult[]; payments: Payments }
| { kind: 'no-yaku' }
| { kind: 'not-winning' }
A no-yaku result collapsed into { han: 0 } or into a thrown exception will eventually reach
the player as a real question.
Keep dora han separate from yaku han in the domain model. Merging them makes this invariant uncheckable after generation and makes the feedback breakdown less useful.
2. Inputs are always visible; only derived values are hidden
Assistance levels remove derived information: yaku, han, fu, dora count. They never remove input information: round wind, seat wind, win method, riichi status, melds, winning tile, dora indicators.
A question that hides an input is broken, not hard. No difficulty preset may violate this.
Situational information scope
Tiles alone do not determine han and fu. The supported set of situational inputs is tiered deliberately, because each tier costs generator complexity, UI complexity, and assets.
Tier A — prototype
In: round wind (east/south), seat wind (all four), ron/tsumo, open/closed, riichi, menzen tsumo as a derived value, and exactly one front dora indicator.
Out, and stated as explicit fixed rules on a ruleset info screen rather than silently omitted: ura dora, aka dora, kan of any type, ippatsu, double riichi, haitei, houtei, rinshan, chankan, tenhou/chiihou/renhou, honba, riichi sticks, pao, multi-ron, nagashi mangan.
Riichi is in because without it most randomly generated closed hands have no yaku at all, and because it is the most common real-table declaration input.
Tier B — after the core loop is proven
Cheap, because they are boolean flags that add han without changing hand structure or tile count: ippatsu, double riichi, haitei, houtei.
More work: aka dora (ruleset toggle plus a red-five tile variant), ura dora (requires riichi plus a second indicator row).
Tier C — separate milestone
Kan, in this order: ankan first, since closed kan of honors/terminals at 32 fu is the most instructive fu case in the game; then minkan and kakan; then kan dora and kan ura; then rinshan and chankan. Four-kan abortive situations, pao, and multi-ron stay out of scope.
Kan is deferred not because it is unimportant but because it changes displayed tile count to 15–18, needs a face-down tile asset, and adds a whole family of fu cases. It deserves its own milestone rather than a corner of the first vertical slice.
Dora display contract
Dora is presented as an indicator tile, never as a count. Reading the indicator is part of the
skill being trained. An assisted-level showDoraCount hint may display the derived count, but it
must come from the same structured data the scorer used and must never change the answer.
Indicator wrapping (9m → 1m, north → east, red dragon → white dragon) must have fixtures.
Wind and dealer
Dealer status is derived from seatWind === 'east'. Do not store isDealer independently; two
sources of truth for the same fact will desync in generated fixtures.
Difficulty model
Difficulty should be implemented as independently controllable assistance where possible,
rather than hard-coding all behavior into one monolithic difficulty switch.
Expected progression:
Intro / assisted
Show:
- winning hand
- dealer/non-dealer
- ron/tsumo
- round and seat wind
- riichi status
- dora indicator, plus the derived dora count
- yaku if useful
- han and fu, e.g.
30 fu / 3 han
Player mainly practices converting han/fu/context into the correct score declaration.
Intermediate
Show the hand and enough context to score it, but reduce hints:
- han may be shown while fu is hidden, or
- yaku may be shown while han/fu are hidden.
The player begins calculating fu and/or han, and begins reading the dora indicator unaided.
Standard / practical
Show:
- hand
- winning tile
- open/closed melds
- dealer/non-dealer
- ron/tsumo
- round/seat wind
- riichi status
- dora indicators
- situational yaku information that cannot be inferred from the tiles
Do not directly reveal yaku, han, fu, dora count, or score.
The player must determine the actual declaration from the hand.
Expert variants
Possible optional modifiers:
- unsorted hand ("no riipai") so the player must recognize the shape before scoring it
- reduced time bonus window
- unusual but valid fu patterns
- targeted weak-point drills
An unsorted-hand mode is intentionally extreme and should not be required for normal progression.
Run structure
Prototype default:
- 10 questions per run.
A run score should reward:
- correctness
- response speed
- consecutive correct answers, if combo scoring proves fun
- difficulty / amount of assistance removed
Do not make a timeout automatically wrong unless a future game mode explicitly calls for it.
Question selection should not be purely uniform random. A run should have useful variety across dimensions such as:
- dealer / non-dealer
- ron / tsumo
- low-value hands / mangan-or-higher hands
- fu values
- open / closed hands
- common special shapes such as chiitoitsu
- yaku source
Yaku source deserves specific attention. Left unconstrained, generate-and-filter converges on riichi-plus-dora for nearly every closed hand, which trains one narrow pattern while appearing varied on every other axis. The composer should cap riichi-sourced questions per run.
type YakuSource =
| 'riichi' // closed, riichi declared
| 'menzen-tsumo' // closed, tsumo, no riichi
| 'hand-pattern' // tanyao, yakuhai, pinfu, chiitoitsu, toitoi, honitsu, ...
Later, question weighting may adapt to the player's observed weak points.
Initial technical direction
Prototype as a client-side web app.
Preferred stack:
- TypeScript
- Vue 3
- Vite
- Vitest
Do not add a Go backend merely because Go is available. The prototype should remain a static SPA unless a concrete requirement needs a server.
A Go backend may be introduced later for features such as:
- shared accounts
- cloud progress sync
- global rankings
- server-authoritative challenges
- analytics requiring a backend
Keep game/domain logic isolated enough that adding a backend later does not require rewriting the UI.
Mahjong tile assets
Preferred initial asset source:
- FluffyStuff/riichi-mahjong-tiles
- SVG assets
- public domain / CC0
Vendor only the assets actually needed by the application, or use a build-time asset preparation step. Do not hotlink production assets from GitHub/CDNs.
Keep third-party attribution/license information in the repository even when attribution is not legally required.
Note that Tier B needs red-five variants and Tier C needs a face-down tile. Neither is a blocker, but do not assume the Tier A asset subset is final.
Mahjong scoring and hand generation
Do not implement a scoring engine from scratch before validating existing libraries.
Verify every candidate's existence, version, license, and engines field with npm view before
planning around it. Package names in a design document are not evidence that a package exists.
@pai-forge/riichi-mahjong
Confirmed present on npm as of 2026-09-07 (v0.4.2, ISC).
Strengths:
- TypeScript
- Pure ESM
- scoring / shanten functionality
- acceptance tests compare against the Python
mahjongreference implementation - ISC license
Treat this as a strong scoring-engine candidate.
riichi-hand-generator + riichi-score
Existence not confirmed by public search as of 2026-09-07. Confirm before depending on it.
Claimed strengths:
- generator accepts constraints such as han, fu, yaku, closed/open, wait type, and win method
- generated hands are verified by
riichi-score - explicit seeded generation is valuable for tests/reproducibility
Important:
- validate browser/Vite compatibility before depending on it in the application
- do not assume a Node-compatible package is automatically browser-safe
- the generator and scorer are designed as a pair; do not silently replace its verifier with another scorer
Known alternates
Not preferred, but worth knowing:
riichi-ts(MahjongPantheon) — TypeScript, exposes kuitan, aka, kiriage mangan and double-yakuman toggles. GPL-3.0-or-later; the copyleft terms are a real constraint for a distributed web app and must be settled before adoption, not after.riichi(takayama-lily) — MIT, but long unmaintained and built on string hand notation rather than structured input.
Selection rule
The spike is done. docs/ENGINE-DECISION.md records the outcome: riichi-score
(MIT) is the scorer and riichi-hand-generator (MIT) the generator, the pair
sharing one verifier. Both packages listed as unconfirmed above do exist;
re-read that document rather than the candidate notes above before revisiting
the choice.
The app must depend on Mahjong engines through a small internal adapter. UI/gameplay code must not import third-party scoring/generator libraries directly.
Suggested boundary:
export interface MahjongEngine {
score(input: ScoreInput): ScoreResult
}
export interface QuestionGenerator {
generate(spec: QuestionSpec, rng?: RandomSource): Question
}
Exact types may evolve. Preserve the boundary, not necessarily these names.
Source of truth and score validation
A generated question must contain enough structured information to reproduce its answer.
Never store only display strings such as "30符3翻 3900点" as the canonical answer.
Prefer structured values, e.g. conceptually:
type ScoreAnswer =
| { kind: 'ron'; points: number }
| { kind: 'tsumo-dealer'; each: number }
| { kind: 'tsumo-nondealer'; child: number; dealer: number }
Also retain:
- han, with dora han recorded separately
- fu
- yaku
- yaku source
- yakuman where relevant
- dealer status
- win method
- round and seat wind
- riichi status
- dora indicators as tiles, not as a count
- optional score/fu breakdown
The scorer is the authority for correctness. Avoid duplicating Mahjong point tables in game logic. A score table may be rendered as reference content on a feedback or study screen, but it must read from the engine rather than embedding its own copy of the values.
If generation and scoring use separate implementations, add cross-validation tests before trusting generated questions.
Question generation strategy
For the prototype, correctness and useful distribution matter more than clever generation.
Acceptable approaches, in priority order after the technical spike:
- Use a constraint-aware generator if it is browser-safe and produces sufficient diversity.
- Generate candidate valid hands and filter them through the scorer.
- Use a curated seed bank of verified hands as a temporary fallback.
Generate-and-filter is explicitly acceptable. With only ~10 questions per run, a theoretically elegant inverse scoring generator is not required.
Regardless of approach:
- the generator must count rejections by reason, including yakuless rejections, from the first version. That counter is what tells you when to stop tuning the filter and start constructing.
- open hands can use neither riichi nor menzen tsumo, so randomly generated open hands are yakuless most of the time. Pick the target yaku first for open hands — yakuhai, tanyao with kuitan on, toitoi, honitsu, chanta, ittsu, sanshoku — and build or filter toward it. Open hands are the first category to switch from filtering to construction if rejection rates demand it.
Question generation should support deterministic seeds for:
- unit tests
- reproducing bugs
- fixed daily/challenge sets in the future
Mahjong rules
Start with standard Japanese Riichi Mahjong.
Do not silently add local rules.
Rules that vary between tables/applications must be explicit configuration or deliberately fixed
and documented. The prototype's decisions live in docs/PLAN.md and are surfaced to the player on
a ruleset info screen; the table there is the source of truth, not this file. Two of them are load
bearing enough to restate:
- kuitan on. Beyond being standard modern play, turning it off removes open tanyao and sharply raises the open-hand yakuless rejection rate.
- kiriage mangan off. This preserves 4 han 30 fu = 7700 and 3 han 60 fu = 7700 as practice targets, which are exactly the declarations players get wrong.
When an engine supports configurable rules, wrap those settings in our own Ruleset type rather
than leaking package-specific options throughout the app.
Any rule the prototype fixes rather than supports must be shown to the player. A player who cannot tell whether ura dora exists in this app cannot trust their own answer.
UI requirements
Primary viewport: modern smartphone in portrait orientation.
The important content should fit without requiring scrolling during a question whenever reasonably possible.
Recommended vertical structure:
- compact context/status area — winds, ron/tsumo, riichi, dora indicator
- hand / winning tile / melds
- hints
- timer / score feedback
- large numeric input area
The dora indicator must be visually separated from the hand so it cannot be misread as part of it. Riichi status must be unmistakable, since it changes both the yaku and the player's expectations.
Input controls must be comfortably tappable with one hand.
For the prototype:
- use a calculator-like numeric keypad
- adapt answer fields to ron vs tsumo
- non-dealer tsumo fields are child-then-dealer, in declaration order, and this order is fixed
- make submit/confirm unambiguous
- support keyboard input on desktop for development/testing
Do not make the player type words such as all.
Represent the declaration semantically in the UI.
Hand rendering
Render tiles from structured tile data, not pre-rendered whole-hand images.
The model must distinguish:
- concealed tiles
- called melds
- kan types when Tier C lands
- winning tile
- dora indicators
- red fives when Tier B enables them
Sorted vs unsorted presentation must be a rendering/question option, not a different scoring representation.
Avoid relying on Unicode Mahjong glyphs as the primary production display; consistent SVG tile assets are preferred.
Game state
Keep game state explicit and serializable where practical.
Likely states:
idle
question
answered
run-result
Avoid hidden timing behavior spread across components.
Timer logic should be testable independently from visual animation.
Pause/cancel timers when:
- leaving a question
- restarting a run
- unmounting the game view
Persistence
For prototype progress/settings, browser-local storage is sufficient.
Persist only useful player state such as:
- settings
- unlocked modes, if implemented
- high scores
- aggregate practice statistics
Version persisted data so schema changes can be migrated or safely reset.
Do not build login/account synchronization in the first prototype.
Learning telemetry
Design result data so future weakness analysis is possible.
Record per question at least:
- question category / tags
- actual han/fu, with dora han separate
- yaku source
- dealer status
- win method
- correct/incorrect
- response duration
- difficulty / enabled hints
- submitted answer
Do not require a remote analytics service. Local aggregation is enough initially.
Useful future derived metrics include:
- error rate by fu
- error rate by ron/tsumo
- error rate by dealer/non-dealer
- error rate by yaku source
- time by hand category
- recurring confusion between nearby point values
- dora miscounts, detectable as answers that are correct at han − doraHan
Testing requirements
Mahjong correctness is more important than component test volume.
At minimum test:
Domain tests
- score adapter normalization, including the
no-yakuandnot-winningsignals - ron answer comparison
- dealer-tsumo answer comparison
- non-dealer-tsumo answer comparison
- common limit hands
- common fu/han combinations
- dora indicator wrapping: 9m → 1m, north → east, red dragon → white dragon
- double wind pair, where round wind equals seat wind
- rule configuration that the app exposes
Generator tests
- every generated question is a valid winning hand
- every generated question has at least one yaku; no question is dora-only
- a valid winning shape with no yaku is rejected, and adding dora to it does not make it accepted
- an open hand's yaku source is never riichi or menzen tsumo
- every generated question can be scored
- answer key matches the scoring result
- requested constraints are satisfied
- riichi-sourced questions stay within the composer's cap across many generated runs
- tile counts never exceed four copies, counting the dora indicator
- seeded generation is reproducible when supported
- batches have acceptable diversity
Game tests
- 10-question run completes correctly
- correct/incorrect grading
- time bonus boundaries
- score accumulation
- no timer continues after navigation/restart
- difficulty correctly controls hints
- no assistance preset hides an input value, including winds, riichi status, or dora indicators
- toggling any assistance flag never changes the graded answer
When a production bug involves a particular hand, add that hand as a regression fixture.
Development style
- Prefer simple TypeScript data structures over class-heavy design.
- Keep components focused on presentation and interaction.
- Put Mahjong/game rules in framework-independent modules.
- Avoid
anyin domain code. - Validate untrusted/persisted data at boundaries.
- Prefer pure functions for scoring normalization, grading, question selection, and score calculation.
- Do not prematurely introduce a state-management library; use Vue composition/reactivity until shared state actually warrants more.
- Do not prematurely introduce a backend.
- Do not prematurely optimize hand generation without measuring it on target mobile devices.
- Keep dependencies small and justified.
Commands
Do not invent repository commands.
| Purpose | Command |
|---|---|
| install | npm install |
| dev server | npm run dev |
| typecheck | npm run typecheck |
| unit tests | npm test (watch: npm run test:watch) |
| lint | npm run lint (npm run lint:fix to autofix) |
| format | npm run format |
| production build | npm run build (preview it with npm run preview) |
Re-vendoring tile assets is deliberately manual and needs a checkout of the upstream repository:
git clone --depth 1 https://github.com/FluffyStuff/riichi-mahjong-tiles.git /tmp/rmt
node scripts/vendor-tiles.mjs /tmp/rmt
Before declaring a task complete, run the applicable typecheck/tests/build.
npm run build typechecks first, so a type error fails the build rather than
shipping stale output — do not redirect its output away and assume it passed.
Documentation
Keep long-lived agent instructions here.
Keep changing implementation milestones, spikes, and backlog decisions in docs/PLAN.md.
When a major product/architecture decision changes:
- update the relevant source documentation,
- update tests if behavior changes,
- remove stale instructions rather than leaving contradictory historical notes.
Non-goals for the first prototype
Unless explicitly requested, do not implement:
- multiplayer Mahjong
- AI opponents
- full game/round simulation
- account system
- social features
- global leaderboard
- native mobile packaging
- backend API
- full replay/log import
- camera tile recognition
- every local Mahjong rule
- anything in Tier B or Tier C of the situational information scope
The first milestone succeeds if a player can repeatedly practice realistic score declarations from convincing winning hands on a phone-sized web UI.
