Fair Play

This page describes how Agari shuffles tiles and what information the CPU opponents use when they play. The relevant source code is included so the behavior can be inspected directly.

The shuffle

Every hand begins from the full tile set: 136 tiles at four players, 108 in sanma (2m through 8m are left out). It holds the manzu, pinzu, and souzu suits, the four winds, the three dragons, and the red fives (if you have them enabled in settings). That set is shuffled with a Fisher-Yates shuffle, driven by a ChaCha-based cryptographic random number generator that is rooted in your operating system's entropy. Each new game produces an independent, uniform shuffle with no carryover between games. In single-player, Agari additionally records that game's seed, so you can see it and optionally replay the exact same walls.

Here is the function from the game's source that builds and shuffles the wall.

/// Build a new shuffled wall with the given configuration.
pub fn new<R: Rng>(rng: &mut R, config: &WallConfig, variant: Variant) -> Self {
    let mut tiles = build_tile_set(config, variant);
    tiles.shuffle(rng);
    Self::from_arrangement(tiles)
}

from_arrangement then splits the shuffled tiles: the last 14 become the dead wall and the rest are the live wall.

pub fn from_arrangement(tiles: Vec<TileInstance>) -> Self {
    let variant = match tiles.len() {
        YONMA_TILES => Variant::Yonma,
        SANMA_TILES => Variant::Sanma,
        n => panic!("a wall arrangement must be exactly 136 or 108 tiles, got {n}"),
    };
    let live_len = tiles.len() - DEAD_WALL_TILES;
    let dead_tiles: [TileInstance; 14] = tiles[live_len..]
        .try_into()
        .expect("dead wall must be 14 tiles");
    let dead = DeadWall::from_tiles(&dead_tiles, variant);

    let live = tiles[..live_len].to_vec();

    Self { live, dead }
}

build_tile_set assembles the tile set, shuffle performs the Fisher-Yates pass, and the result is divided into the live wall and the dead wall: 122 live and 14 dead at four players, 94 live and 14 dead in sanma.

The rng passed into that function is created fresh for each game. In multiplayer, it is seeded directly from the operating system.

let mut rng = rand::rngs::StdRng::from_entropy();

In single-player, the game first draws a 64-bit master seed from the operating system, then derives the wall's generator from it.

// A fresh master seed from the operating system's entropy.
let master_seed = rand::random::<u64>();

// The wall generator for each hand is derived from that master seed.
let mut rng = rand::rngs::StdRng::seed_from_u64(wall_seed(master_seed, 0));

StdRng is the rand crate's standard generator (currently the ChaCha stream cipher). Both from_entropy() and rand::random() seed from your operating system's entropy source, so the shuffle is uniform and unpredictable either way. The single-player master seed is recorded and shown to you, so you can replay the exact same walls by entering its code when you start a new game. On a replay, the master seed comes from that code instead of being drawn fresh. Multiplayer walls are not recorded or shown. A seed reproduces the walls, but a multiplayer game also depends on the live choices of the other human players, which a seed cannot capture, so a recorded seed could not reproduce the match.

What the CPU opponents can see

Beyond its own concealed hand, a CPU reads only information visible to everyone at the table. This includes discards, called sets, revealed dora indicators, riichi declarations, seat winds, current scores, a hand shown face up by open riichi, and in sanma the pulled North tiles. The function below counts the public tiles the AI can account for across all of these. In sanma it also counts 2m through 8m as fully seen, since those tiles are not in the set at all.

pub fn visible_counts(round: &RoundState, viewer: Seat) -> [u8; 34] {
    let mut counts = [0u8; 34];
    // Sanma: 2m to 8m are not in the wall. Counting them as fully seen is
    // exactly what every ukeire, kabe and safety reading needs to know, and
    // it needs no other change anywhere in the agent.
    if round.is_sanma() {
        for count in &mut counts[1..=7] {
            *count = 4;
        }
    }
    for player in &round.players {
        // Pulled Norths (sanma) lie face up.
        for ti in &player.nuki {
            counts[tile_index(ti.tile)] += 1;
        }
        if player.seat != viewer {
            for ti in round.open_riichi_revealed_hand(player.seat) {
                counts[tile_index(ti.tile)] += 1;
            }
        }
        for discard in &player.discards {
            if !discard.was_called {
                counts[tile_index(discard.tile.tile)] += 1;
            }
        }
        for meld in &player.melds {
            for ti in &meld.tiles {
                counts[tile_index(ti.tile)] += 1;
            }
        }
    }
    for &t in &round.wall.dead.dora_indicators[..round.wall.dead.revealed_dora] {
        counts[tile_index(t.tile)] += 1;
    }
    counts
}

The information the CPUs read about another player is their discards, their called sets, whether they have declared riichi, the hand they show by open riichi, in sanma their pulled North tiles, their seat, and their score, all of which are visible to everyone at the table.

The CPUs never read another player's concealed hand and never read the order of tiles in the wall.

Continue and New Game

Selecting Continue on a single-player match restores the exact saved state, including the wall, so play resumes from where it ended. New Game draws a fresh master seed and generates a new shuffle, unless you enter a seed code, in which case the walls are reproduced from that code.