DEV Community

SEN LLC
SEN LLC

Posted on

A Mahjong Tile Sorting Practice Game — 34 Tiles, Strict Ordering, Timer-Based

A Mahjong Tile Sorting Practice Game — 34 Tiles, Strict Ordering, Timer-Based

Japanese mahjong has a strict tile ordering: man 1-9, then pin 1-9, then sou 1-9, then honors (East, South, West, North, White, Green, Red). Experienced players can sort a 14-tile hand in under 5 seconds. Beginners take 30+. This game drills the pattern with a timer and best-time tracking.

Mahjong hand sorting isn't glamorous, but it's a fundamental skill. Tournaments require you to display your hand in sorted order so scores can be verified. Home games just look cleaner. And the act of sorting is a mental warm-up for thinking about what you need and what you don't.

🔗 Live demo: https://sen.ltd/portfolio/mahjong-sort/
📦 GitHub: https://github.com/sen-ltd/mahjong-sort

Screenshot

Features:

  • 34 unique tile types (man, pin, sou, honors)
  • Random hand generation from 136-tile deck (duplicates allowed)
  • Drag to sort, submit to check
  • Timer + best times (localStorage)
  • Hint mode
  • Configurable hand size (5, 13, 14 tiles)
  • Japanese / English UI
  • Mobile touch support
  • Zero dependencies, 36 tests

The canonical order

export function tileOrder(tile) {
  if (tile.suit === 'man') return tile.value - 1;       // 0-8
  if (tile.suit === 'pin') return 9 + tile.value - 1;   // 9-17
  if (tile.suit === 'sou') return 18 + tile.value - 1;  // 18-26
  // honors: E, S, W, N, White, Green, Red
  const HONORS = ['E', 'S', 'W', 'N', 'White', 'Green', 'Red'];
  return 27 + HONORS.indexOf(tile.value);
}
Enter fullscreen mode Exit fullscreen mode

34 unique types (3 suits × 9 values = 27, plus 7 honors = 34). The order is memorized by every serious mahjong player — not just because it's traditional, but because it makes hand reading faster. Once tiles are sorted, you see runs and pairs at a glance instead of scanning.

Fisher-Yates over a 136-tile deck

Real mahjong has 4 of each tile (136 total). The random hand generator simulates drawing from a shuffled deck:

export function generateHand(count, simplified = false) {
  const deck = [];
  const tileTypes = simplified ? ALL_TILES.filter(t => t.suit !== 'honor') : ALL_TILES;
  for (const type of tileTypes) {
    for (let i = 0; i < 4; i++) {
      deck.push({ ...type, id: `${type.suit}${type.value}-${i}` });
    }
  }
  // Fisher-Yates shuffle
  for (let i = deck.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [deck[i], deck[j]] = [deck[j], deck[i]];
  }
  return deck.slice(0, count);
}
Enter fullscreen mode Exit fullscreen mode

This is more realistic than random-with-replacement: you can draw two copies of the same tile (common) but never four of a kind outside of 4 identical tiles existing in the 136-deck.

isCorrectOrder and compareTiles

export function compareTiles(a, b) {
  return tileOrder(a) - tileOrder(b);
}

export function isCorrectOrder(tiles) {
  for (let i = 1; i < tiles.length; i++) {
    if (compareTiles(tiles[i - 1], tiles[i]) > 0) return false;
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

isCorrectOrder allows ties (duplicates) but flags any backward step. So two identical tiles side-by-side is fine, but pin 5 followed by pin 3 is not.

Touch drag for mobile

HTML5 drag-and-drop doesn't work on mobile without extra work. The alternative: track touchstart, touchmove, and touchend, and on touchend use elementFromPoint to figure out where the tile was dropped:

tile.addEventListener('touchend', (e) => {
  const touch = e.changedTouches[0];
  const target = document.elementFromPoint(touch.clientX, touch.clientY);
  if (target?.classList.contains('tile')) {
    swapTiles(draggingTile, target);
  }
});
Enter fullscreen mode Exit fullscreen mode

Good enough for sorting. A full touch drag-and-drop library would give a better ghost effect, but adds complexity that isn't needed for this use case.

Series

This is entry #72 in my 100+ public portfolio series.

Top comments (0)