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
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);
}
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);
}
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;
}
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);
}
});
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.
- 📦 Repo: https://github.com/sen-ltd/mahjong-sort
- 🌐 Live: https://sen.ltd/portfolio/mahjong-sort/
- 🏢 Company: https://sen.ltd/

Top comments (0)