Rankings — Full Details
Two Numbers, Two Purposes
Every player carries two scores in the database, and they are computed independently from the same approved-game event:
- Rating (
players.rating) — an Elo-flavored number that drives the Power Rankings leaderboard. Can go up and down. Both winners and losers move. - Total Points (
players.total_points) — a lifetime NMJL-style payout total shown on each player's own page. Monotonically non-decreasing. Only the winner of a hand gains points; losers never lose total points.
Total Points answers "how much would I have collected at a real table over my whole mahjong career?" Rating answers "who's actually playing well lately?"
Current Default Values
All of these live in the app_settings row and are editable from the Admin → Settings tab without a redeploy:
Line Points (NMJL Card)
Every win is tied to a specific line on the current National Mah Jongg League card. That line carries:
- point_value — typically 25, 30, 35, 40, 50, 60, or 75. Hard hands pay more.
- concealed_only — line can only be won concealed. Triggers the concealed multiplier automatically.
- is_singles_and_pairs — line cannot legally use jokers, so the jokerless bonus is suppressed (otherwise it would be free money).
linePoints below always refers to the point_value of the exact line the winner declared.
Rating — Per-Game Formula
Step 1 — Expected score (Elo)
winnerExpected = 1 / (1 + 10^((avgOpponentRating − winnerRating) / 400))
Standard Elo against the average of opponent ratings. A 400-point gap = ~10× expected win odds.
Step 2 — Winner gain
Step 3 — Loser losses
Note: the discarder pays a 2× share, but the other losers are NOT discounted. The total rating change of the table is not zero-sum — line points and bonuses are added on top of the Elo exchange.
Special cases
- Self-pick: no discarder. Every loser pays
baseLossequally. - Wall game:
is_wall_game = true. No winner, no discarder. Zero rating change for everyone. Still counted as a game played. - Discard win with no discarder recorded: falls back to a flat split (every loser pays
baseLoss).
Worked Example
4-player table. Winner rated 1050. Three losers rated 1000, 980, 1020 (avg 1000). Winner declared a 35-point line, not concealed, used a joker. Lost the hand off a discard from the 980 player.
Same hand self-picked instead: all three losers would have lost ≈ −3.05.
Total Points — NMJL Payout Logic
Added to players.total_points for the winner only. Models how chips would actually change hands at a real table:
Concealed multiplier is not applied to total points — only to rating. This keeps the total points number numerically aligned with NMJL table-payout math.
"Games Played" & Win Rate
- Every approved game increments the games-played count for every participant listed in
game_participants, including wall games. - Win rate = wins ÷ games_played. Wall games count in the denominator but never in the numerator, so wall-heavy nights gently drag your win rate down — which matches table reality.
- Pending and rejected games do not count for anything.
What We're Not Tracking (Yet)
To keep submission fast and friendly, we deliberately skip several optional NMJL penalties:
- Feeding a 2-exposure hand (−10) or 3-exposure / 2-exposure-on-quint hand (−25).
- Misnamed discard penalties (−10 to −25 depending on whether it gave mahjong).
- Dead-hand penalties on wall games (−10).
- Per-section difficulty multipliers (the column exists on
nmjl_sectionsbut isn't currently fed into the formula).
If we ever want them, they belong in the admin approval flow — not on the submitter — so the table consensus is what gets recorded.
Tunable Knobs (Admin)
All editable on Admin → Settings, applied immediately to all future approvals. Past games keep the deltas they were approved with.
- starting_rating — what new players begin at.
- k_factor — how aggressive the Elo swings are. Higher = bigger upsets, more volatility.
- jokerless_bonus — flat reward added to both rating and total points for a no-joker win.
- concealed_bonus_multiplier — multiplies linePoints in the rating formula for concealed-only hands. Default 0.2 = +20%.
- discard_penalty_multiplier — how many baseLoss shares the discarder eats. Default 2.0.
