Maze#
In this example letters travel from the center of a randomly generated maze to a row of
targets at the bottom, spelling out a piece of text supplied on the command line. Each
letter is routed independently with a randomly chosen search algorithm — DFS,
BFS, GBFS
or AStar — and all letters search and animate
concurrently on a small thread pool, so the demo doubles as a side-by-side race between the
four algorithms.
Maze "hello, world!" [--seed N] [--randomness F] [--binomial F] [-w N] [-h N]
Building the maze#
A maze is exactly the case LatticeGraph exists for: a regular
grid whose edges are always just its cardinal neighbours. Rather than the general-purpose
Graph (a forward-star edge store, built for arbitrary/explicit
topology), the maze uses LatticeGraph, which derives edges from index math and each
node’s blocked flag on every call instead of storing them — no edges to allocate when
the grid is built, and no forward-star fixup cost when isolate
walls a cell off during carving.
The node type is a small custom struct, MazeNode, rather than the library’s
NodeDefault:
struct MazeNode {
MazeNode() = default;
MazeNode(JsonBox::Object o) : /* ... */ {}
JsonBox::Value toJson() const { /* ... */ }
// data
std::size_t index = 0;
bool blocked = false;
int x = 0;
int y = 0;
};
using GraphT = EvoAI::LatticeGraph<MazeNode>;
The x/y fields are the only reason this example doesn’t just use NodeDefault:
DefaultTraversalPolicy detects
them at compile time and switches its heuristic from an always-0.0 stub to real Euclidean
distance. Without them, A* silently degrades into plain Dijkstra and GBFS has no ranking
signal to search by at all — neither would actually be searching toward the goal, which
would defeat the point of racing four algorithms with visibly different behaviour.
The maze itself is grown from a fully-connected lattice (LatticeGraph’s constructor
is that lattice — there’s no separate factory call), then carved by
GrowingTree starting from the cell at the
center of the grid. GrowingTree is templated on the graph type and only needs
forEachOutgoingEdge/isolate, so it runs unmodified on LatticeGraph exactly as
it does on Graph:
GraphT buildMaze(const SimConfig& cfg) {
GraphT g(cfg.rows, cfg.cols, false);
for (std::size_t i = 0; i < g.size(); ++i) {
g[i].x = static_cast<int>(i % cfg.cols);
g[i].y = static_cast<int>(i / cfg.cols);
}
MazeAlg::GrowingTree<GraphT> growingTree;
growingTree(g, g[cfg.startIdx], cfg.randomness, cfg.binomial);
return g;
}
randomness and binomial tune the character of the generated maze — left unset on the
command line, they default to two draws from the seeded RNG, so a given --seed always
reproduces the same maze, the same algorithm assignment, and the same race.
Opening up the start and the targets#
Growing Tree can wall off the exact cell it started from, and the bottom row will not have a
clear opening anywhere by construction, so the demo manually carves room for both. Unlike
Graph, LatticeGraph has no connectToX()/connect() to call: since every
forEachOutgoingEdge call derives edges from .blocked and index math fresh, unblocking
a cell is the whole operation — connectivity to any already-open neighbour comes back on its
own, in both directions, with nothing left to reconnect by hand:
void clearCenter(GraphT& g, const SimConfig& cfg) {
const int rows = static_cast<int>(cfg.rows);
const int cols = static_cast<int>(cfg.cols);
for (int dy = -cfg.clearRY; dy <= cfg.clearRY; ++dy) {
for (int dx = -cfg.clearRX; dx <= cfg.clearRX; ++dx) {
int r = static_cast<int>(cfg.centerRow) + dy;
int c = static_cast<int>(cfg.centerCol) + dx;
if (r < 0 || r >= rows || c < 0 || c >= cols) continue;
std::size_t idx = static_cast<std::size_t>(r) * cfg.cols
+ static_cast<std::size_t>(c);
g[idx].blocked = false;
}
}
}
One target cell per letter is opened the same way along the bottom row, spaced out and centered under the maze, along with the cell directly above it so a search can actually arrive into the target from the rest of the maze:
std::vector<std::size_t> setupTargets(
GraphT& g, const SimConfig& cfg, const std::string& text)
{
const int cols = static_cast<int>(cfg.cols);
const int bottomRow = static_cast<int>(cfg.rows) - 1;
int firstCol = static_cast<int>(cfg.centerCol) - static_cast<int>(text.size()) / 2;
if (firstCol < 1) firstCol = 1;
std::vector<std::size_t> targets;
for (std::size_t i = 0; i < text.size(); ++i) {
int c = firstCol + static_cast<int>(i);
if (c >= cols - 1) c = cols - 2;
std::size_t idx = static_cast<std::size_t>(bottomRow) * cfg.cols
+ static_cast<std::size_t>(c);
g[idx].blocked = false;
if (bottomRow > 0) {
std::size_t northIdx = static_cast<std::size_t>(bottomRow - 1) * cfg.cols
+ static_cast<std::size_t>(c);
g[northIdx].blocked = false;
}
targets.push_back(idx);
}
return targets;
}
Assigning an algorithm to each letter#
Every character of the input text gets its own LetterState: which target it is
walking towards, and which of the four algorithms will compute its route. The algorithm is
picked uniformly at random, independently per letter:
enum class Algorithm { DFS, BFS, GBFS, ASTAR };
static Algorithm pickRandomAlgorithm(std::mt19937& rng) {
return static_cast<Algorithm>(std::uniform_int_distribution<int>(0, 3)(rng));
}
struct LetterState {
char ch = '\0';
std::atomic<std::size_t> currentIdx{0};
std::size_t goalIdx = 0;
Algorithm algo = Algorithm::ASTAR;
std::atomic<bool> finished {false};
};
Searching concurrently#
A small thread pool pulls letters one at a time from a TaskQueue. Each worker owns a
single SearchContext and
DefaultTraversalPolicy,
created once before the task loop and reused for every letter that worker processes, so
every worker reads the shared maze through const references without needing any locking
around the search itself:
// created once per worker thread, outside the task loop
PFA::SearchContext ctx;
PolicyT policy;
while (taskQueue.pop(task)) {
std::vector<const GraphT::Node_t*> path;
const auto& startNode = graph[ls.currentIdx.load()];
const auto& goalNode = graph[ls.goalIdx];
switch (ls.algo) {
case Algorithm::DFS: { PFA::DFS<GraphT,PolicyT> a; path = a(graph,startNode,goalNode,ctx,policy); break; }
case Algorithm::BFS: { PFA::BFS<GraphT,PolicyT> a; path = a(graph,startNode,goalNode,ctx,policy); break; }
case Algorithm::GBFS: { PFA::GBFS<GraphT,PolicyT> a; path = a(graph,startNode,goalNode,ctx,policy); break; }
case Algorithm::ASTAR:{ PFA::AStar<GraphT,PolicyT>a; path = a(graph,startNode,goalNode,ctx,policy); break; }
}
// ...
}
Each algorithm calls ctx.reset(graph.size()) internally before searching. Because
SearchContext’s buffers only grow and
reset() is just a generation-counter bump, reusing one ctx per worker means only the
first search on each thread pays the cost of growing those buffers – every later search on
that thread, including across different letters and different algorithms, reuses the same
capacity for free. This is also why sharing one ctx across DFS/BFS/GBFS/AStar calls on
the same worker is safe: nothing about the buffers is algorithm-specific.
Animating and rendering#
Once a path is found, the worker walks it in a small number of evenly spaced frames, storing
each step into the letter’s atomic currentIdx:
if (!path.empty()) {
constexpr std::size_t maxFrames = 40;
const std::size_t stride = std::max(std::size_t{1}, path.size() / maxFrames);
for (std::size_t step = 0; step < path.size(); step += stride) {
ls.currentIdx.store(path[step]->index, std::memory_order_relaxed);
std::this_thread::sleep_for(std::chrono::milliseconds(30));
}
}
A separate render loop on the main thread redraws the whole grid at a fixed rate, reading
each letter’s currentIdx to place it on the grid and color it by its assigned algorithm
(cyan for DFS, green for BFS, yellow for GBFS, magenta for A*), so the maze walls, the moving
letters, and the still-empty targets are all visible at once while the searches race to
finish.
The full code is here