Why the order of checks matters

Even if the AI reads the coordinates correctly, there is no guarantee that its move is legal. Rule checking must be deterministic code, separate from the model's probabilities. The same board and the same coordinate must always give the same result, otherwise testing and reproducing failures are impossible.

Step 1: Check the range and the empty point

First check that the row and column are within 0–18. Next, check that the intersection is empty. If this fails there is no need to copy the board or remove stones, so the cheapest checks go first.

Step 2: Compute captures on a temporary board

Copy the current board, place a stone on the candidate point and find the opponent groups above, below, left and right of it. Any group with zero liberties is removed from the temporary board. Several groups can be captured at once, so the check must not stop at the first capture.

  1. Place the candidate stone on the temporary board.
  2. Find the adjacent opponent groups and their liberties.
  3. Remove every opponent group without liberties.
  4. Count the liberties of the candidate stone's own group again.

Step 3: Tell suicide apart from ko

If your own group still has zero liberties after removing the opponent's stones, the move is suicide and is rejected. If a capture created new liberties, the move is legal. Finally, compare the whole temporary board with the previous board to block an immediate ko recapture. Permanently banning a single coordinate would also block legal moves after the board has changed, so you must compare board states.

Step 4: Feed the rejection reason back into the model request

Do not just put the illegal coordinate in an exclusion list; record the reason as well, such as "stone already there", "suicide" or "ko repetition". The retry request names the rejected coordinate to reduce repeated answers. If the maximum number of retries is exceeded, the server does not play a random move; it tells the user about the failure and writes the board and the responses to the error log.

Tests you must write

  • A board where a single corner stone is captured
  • A board where two groups are captured at once
  • A move that captures and is therefore not suicide
  • A move that really is suicide
  • A ko where only the immediate recapture is forbidden

These five boards must be checkable by calling only the rule functions, without the model.

The actual code for this step

This is the rule engine of the Qt client. It checks the range and the empty point first, captures all the opponent stones on a copied board, then checks for suicide and finally blocks ko by comparing against every board seen so far. The rejection reason is returned as a MoveRejectReason.

src/goboard.cppc++ · 66 lines
bool GoBoard::isLegalMove(int row, int col, Stone who, MoveRejectReason *outReason) const
{
    auto setReason = [outReason](MoveRejectReason r) {
        if (outReason)
            *outReason = r;
    };
    setReason(MoveRejectReason::None);

    if (m_status != GameStatus::Playing || m_phase != GamePhase::Playing) {
        setReason(MoveRejectReason::GameNotPlaying);
        return false;
    }
    const int N = m_size;
    if (!inBounds(N, row, col)) {
        setReason(MoveRejectReason::OutOfBounds);
        return false;
    }
    if (who != Stone::Black && who != Stone::White) {
        setReason(MoveRejectReason::OutOfBounds);
        return false;
    }
    if (m_cells[row * N + col] != Stone::Empty) {
        setReason(MoveRejectReason::Occupied);
        return false;
    }

    GoBoard sim = *this; // 복사 후 시뮬레이션
    const Stone opp = opponent(who);
    sim.m_cells[row * N + col] = who;

    int captured = 0;
    QVector<int> visited(N * N, 0);
    for (int d = 0; d < 4; ++d) {
        const int nr = row + kDR[d];
        const int nc = col + kDC[d];
        if (!inBounds(N, nr, nc))
            continue;
        const int nidx = nr * N + nc;
        if (sim.m_cells[nidx] != opp || visited[nidx])
            continue;
        QVector<int> grp;
        QVector<int> libs;
        sim.collectGroup(nr, nc, &grp, &libs);
        for (int gi : grp)
            visited[gi] = 1;
        if (libs.isEmpty()) {
            for (int gi : grp) {
                sim.m_cells[gi] = Stone::Empty;
                ++captured;
            }
        }
    }

    if (sim.liberties(row, col) == 0) {
        setReason(MoveRejectReason::Suicide);
        return false;
    }

    if (sim.wouldRecreatePastPosition(sim.packed())) {
        setReason(MoveRejectReason::KoViolation);
        return false;
    }

    Q_UNUSED(captured);
    return true;
}

Model used: Qwen3-VL-8B-Instruct + LoRA adapter (lora_mix_qwen_r5). The full source and a description of the model are in the post "8. The full source code and the models used".