Golog is a toy implementation of the board game Go, written in Prolog. It is about 175 lines of code. This is a walkthrough of how it works — and, along the way, an introduction to Prolog for readers who haven't used it.

Most programming languages are imperative: you describe a sequence of steps and the machine follows them. Prolog is different. You write down facts and rules — statements about what is true — and then pose queries. Prolog searches for ways to satisfy your query given what it knows.

A fact looks like this:

player(black).
player(white).

This asserts that black and white are players. A rule has a head and a body, separated by :- (read as "if"):

after(black, white).
after(white, black).

These are actually facts too (rules with no conditions), but the pattern scales up. When you query ?- player(X)., Prolog searches its database and returns X = black, then X = white on backtracking. X is a variable — capitalized, unbound until unified with something.

In most languages, a function has a fixed directionality: inputs go in, output comes out. In Prolog, predicates are relations — they can be queried in any direction, depending on which arguments are bound.

Consider after/2. The obvious query asks who follows black:

?- after(black, X).
X = white.

But the same predicate works in reverse — who plays before white?

?- after(X, white).
X = black.

Or used as a check, with both arguments bound:

?- after(black, white).
true.

?- after(white, white).
false.

The same pattern holds for neighbor/3. Forward: find all neighbors of a point and the direction to each.

?- neighbor([2,2], N, Dir).
N = [2,3], Dir = up  ;
N = [2,1], Dir = down ;
N = [1,2], Dir = left ;
N = [3,2], Dir = right.

Reverse: which point has [2,3] as its upward neighbor?

?- neighbor(P, [2,3], up).
P = [2,2].

And point/1 works both as a validator and a generator. Bound argument: is this a valid point?

?- point([3,3]).
true.

?- point([9,9]).
false.

Unbound: enumerate every point on the board.

?- point(X).
X = [0,0] ;
X = [0,1] ;
X = [0,2] ;
...

This dual role — checking and generating — is what makes Prolog predicates composable in ways that functions are not. neighbor/3 used as a generator inside reaches is what lets the graph search work without writing an explicit loop.

Go has several competing rule sets. The Tromp-Taylor rules are unusual: they're written with mathematical precision, designed to be complete and unambiguous. They're sometimes called the Logical Rules of Go, which makes them a natural fit for a logic programming language.

The essential rules are:

  1. The board is an N×N grid. Each intersection is occupied by black, white, or empty.
  2. Two stones of the same color are connected if they are adjacent horizontally or vertically, or both connected to a common stone.
  3. A liberty is an empty intersection adjacent to a group of connected stones.
  4. A stone or group with no liberties is captured and removed.
  5. A move: place a stone on an empty intersection. Remove any opponent groups with no liberties. Then remove any of your own groups with no liberties.
  6. A board position may not repeat (positional super-ko).
  7. Score = your stones + empty intersections that only your stones can reach.

Each of these maps almost directly to a predicate in golog.

A point is a pair of coordinates. The point/1 predicate enumerates all valid points on the board:

size(5).

point([X, Y]) :-
  size(N),
  Limit is N-1,
  between(0, Limit, X),
  between(0, Limit, Y).

between/3 is a built-in that generates integers in a range on backtracking. So point/1 doesn't just check whether a given coordinate is valid — it can also enumerate all 25 points if called with an unbound variable. This dual role (checking and generating) is characteristic of well-written Prolog.

Board state is stored as an associative array mapping points to colors, using SWI-Prolog's assoc library. A fresh board initializes every point to empty:

newBoard(Board) :-
  findall(P-empty, point(P), Pairs),
  list_to_assoc(Pairs, Board).

findall/3 collects all solutions to a goal into a list — here, every P-empty pair for every valid point P. list_to_assoc/2 then converts that into a balanced tree.

Rule 2 requires knowing which points are adjacent. neighbor/3 defines the four cardinal directions:

neighbor([X, Y], [X1, Y1], up) :-
  X1 is X,
  Y1 is Y + 1,
  point([X1, Y1]).

The point([X1, Y1]) check at the end is what enforces board boundaries — a neighbor only exists if it's a valid point. The other three directions follow the same pattern. Because the boundary check calls point/1, which consults size/1, changing the board size in one place propagates everywhere.

Rule 3 defines a liberty as an empty intersection adjacent to a group. Rule 4 says a group with no liberties is captured. In golog, both are expressed through a single predicate, reaches/3, which checks whether a stone can "reach" a point of a given color through a chain of same-color stones:

reaches(Point, Board, Color) :-
  reaches(Point, Board, Color, []),
  !.

reaches(Point, Board, Color, _) :-
  pointColor(Board, Point, Color).

reaches(Point, Board, Color, _) :-
  neighbor(Point, Neighbor, _),
  pointColor(Board, Neighbor, Color).

reaches(Point, Board, Color, Visited) :-
  neighbor(Point, Neighbor, _),
  pointColor(Board, Point, ChainColor),
  pointColor(Board, Neighbor, ChainColor),
  \+ member(Neighbor, Visited),
  append(Visited, [Neighbor], NewVisited),
  !,
  reaches(Neighbor, Board, Color, NewVisited).

This is a recursive graph search. The base cases handle the point itself and its immediate neighbors; the recursive case follows chains of same-colored stones through a visited list. \+ is negation-as-failure: \+ member(Neighbor, Visited) succeeds if Neighbor is not in Visited.

The cut (!) at the top prevents Prolog from backtracking into reaches once it has found a solution. Without it, Prolog would redundantly re-explore paths it has already walked.

With reaches defined, capture is a single line — exactly rule 4:

captured(Board, Point) :-
  \+ reaches(Point, Board, empty).

A stone is captured if it cannot reach an empty intersection. The rule reads like the rulebook.

Rule 5 requires removing all captured opponent groups after a move. isConsistent/3 does this for a given color:

isConsistent(Board, Color, NewBoard) :-
  coloredPoints(Color, Board, Stones),
  include(captured(Board), Stones, Captures),
  foldl(emptyPoint, Captures, Board, NewBoard).

include/3 filters a list to only elements satisfying a predicate — here, every stone of Color that is captured. foldl/4 then threads a fold over that list, emptying each captured point in turn. captured(Board) is a partial application: include will call it with each stone as the final argument.

The full move sequence is:

evolve(Board, Point, Player, NewBoard) :-
  stonePlaced(Board, Point, Player, IntermediateBoard),
  after(Player, Opponent),
  isConsistent(IntermediateBoard, Opponent, OpponentBoard),
  isConsistent(OpponentBoard, Player, NewBoard).

Opponent captures happen before self-captures. This ordering is significant: a move that looks suicidal might actually be legal if it first captures the surrounding stones. With the wrong ordering, that move would be rejected. With this ordering, it resolves correctly — no special case needed.

Rule 6 forbids any board position from repeating. repeatsHistory/2 checks the new board against a list of all previous positions:

repeatsHistory(Board, History) :-
  assoc_to_list(Board, Pairs),
  member(Prev, History),
  assoc_to_list(Prev, Pairs).

assoc_to_list/2 converts the associative array to a canonical sorted list, making structural comparison straightforward. The game loop calls \+ repeatsHistory(...) before accepting each move.

Rule 7: score is stones plus empty intersections reachable only by your color. reaches does the heavy lifting again:

scoreColor(Board, Color, X) :-
  coloredPoints(Color, Board, L),
  coloredPoints(empty, Board, Empties),
  include(scoreReaches(Board, Color), Empties, Reaches),
  length(Reaches, R),
  length(L, MyColor),
  X is MyColor + R.

An empty point counts toward a color's score if it reaches that color — i.e., if it's connected to that color through a chain of empty or same-color points. The score is the count of that color's stones plus the count of such empty points.

The game logic is clean throughout. Each Tromp-Taylor rule translates to a short predicate that reads close to the specification. The capture rule is one line. The ko rule is three. The ordering of operations in evolve handles a subtle edge case without any special-casing.

Where it gets messy is board state. Prolog has no mutable variables — every "update" produces a new binding. An early version represented the board as a list of lists and accumulated significant complexity threading state through every predicate. Switching to the assoc library cleaned this up considerably, but the fundamental challenge persists: Prolog excels at expressing what is true, and is more awkward about representing things that change.

That tension is probably the most honest takeaway from the project. The rules of Go, expressed declaratively, are remarkably compact. The data — the board, the history — is where the seams show.

The appeal of declarative programming is the appeal of working at the level of intent rather than procedure. You say what should be true; something else figures out how. Prolog was one of the earliest serious attempts to build that something else. It works remarkably well within its domain — logic, search, constraint satisfaction — and breaks down at the edges, as golog illustrates.

What drew me to language models for coding, years before the current wave of tools, was recognizing them as a different attempt at the same proposition. Instead of a logic engine inferring procedures from formal rules, a language model infers code from natural language description. The interface is looser, the domain is broader, and the failure modes are different — but the underlying ambition is the same: close the gap between specification and implementation.

Vibe coding is the logical endpoint of that trajectory. You describe intent, loosely, and accept whatever emerges. It is declarative programming with the most permissive specification language imaginable and the most opaque execution engine. What's interesting is that it works — not always, not perfectly, but often enough to be genuinely useful. The engine has internalized enough structure about how programs work that rough intent is frequently sufficient.

Prolog taught me that declarative approaches don't eliminate implementation complexity — they relocate it. The capture rule is one line, but reaches is twenty. The specification is clean; the machinery underneath is not. Language models make the same trade at a larger scale. The complexity doesn't disappear. It just moves somewhere you don't have to look at it.