The Mathematics of Dungeon Crawls: Pursuit-Evasion Puzzle

Animation
Algorithms
History
Author

Vincent Alexander Croft

Published

June 18, 2025

The Mathematics of Dungeon Crawls: Pursuit-Evasion Puzzle

Imagine a policeman standing at the central junction of a Y-shaped underground labyrinth, with three identical corridors radiating out like the arms of a fork. A crafty thief has hidden somewhere in this maze, fully aware of the cop’s every move before it happens. Both cop and thief move at the same speed. The challenge is to determine: under what conditions, if any, can the cop guarantee to catch the thief? This deceptively simple setup – one cop, one thief, and a Y-shaped dungeon – is a distilled version of classic pursuit-evasion problems, blending logical strategy with geometry and graph theory.

doom

This puzzle resonates with a long mathematical tradition. In the early 20th century Richard Rado posed the famous lion and man problem: a man and a lion start in a circular arena, each able to move as fast as the other. Can the lion catch the man? The surprising answer is yes: despite the man’s clever attempts to stay on the perimeter, the lion can head toward the center and gradually tighten a noose around the man, leveraging geometry to force capture. That continuous pursuit game teaches us about invariant strategies and gradually reducing options for the evader.

On discrete networks, the analogous game is the cops and robber game on graphs, popularized in the 1980s. In that version both cop and robber occupy vertices and move turn by turn along edges; importantly, the cop sees the robber’s location. One of its early results is that any tree network is “cop-win”: a single cop, with perfect visibility, can always trap a robber on a tree by successively moving to shrink the robber’s accessible region. However, our Y-dungeon puzzle is different in two key ways: the thief is invisible (the cop doesn’t know where he is) and has advance knowledge of the cop’s strategy. This flips the scenario into the realm of graph-search or pursuit-evasion games with hidden evaders, rather than the original cops-and-robbers visibility game.

Graph searching brings in concepts like “contamination” and “recontamination.” The labyrinth starts fully contaminated (the thief could be anywhere). When the cop visits a corridor, that path is momentarily cleared, but once he leaves, the thief might recontaminate it by sneaking back. A monotonic strategy is one where once an area is cleared, the evader can never return there – in effect, searchers “guard” cleared regions against reentry. In general graphs, monotonic search often requires more searchers than non-monotonic search, because keeping things permanently clear usually means leaving behind a guard. Our Y-dungeon is a simple tree with a branch point of degree three, and it turns out this simple geometry already captures the crux of monotonic-search difficulty: with only one searcher (our cop), one cannot guard multiple branches at once.

The Y-Shaped Dungeon

Imagine three straight corridors—A, B, and C—each of identical length, meeting at a central junction. The cop moves at twice the thief’s speed but can only see ten feet down any passage. The thief, hidden somewhere in the network and privy to the cop’s exact plan, can dash between branches whenever the cop’s back is turned.

If the cop bolts down corridor A, the thief uses the brief window to slip into B or C, staying just beyond the ten-foot line of sight. The cop, having rushed the full length of A and found no one, returns to the junction and immediately finds the thief has re-contaminated a different branch. A second race—this time down B—sees the thief dart into whichever corridor remains unsearched. The thief’s perfect knowledge and the symmetry of the three arms mean that, without further structure, any finite probing of A then B then C simply hands the thief time to stay one branch ahead.

To break the stalemate, the cop adopts a “double-back” sweep. He ventures ten feet down A, then turns and uses his speed advantage to retrace those ten feet before the thief can slip back into A. At that moment, the thief must lie either at least ten feet down B (having outrun the cop’s brief inspection of A) or farther into C. The cop then commits to corridor B, this time pushing twenty feet beyond the junction before returning. In the time it takes him to cover those forty feet round-trip, the thief can extend his hiding depth by up to forty feet—but crucially any path into A or C is blocked by the cop’s prior sweeps of length ten and now twenty.

When the cop next stands at the junction—having swept A (ten-foot out-and-back) then B (twenty-foot)—the thief must be hiding either at least twenty feet down C or ten feet deeper into B. Since C represents the largest unexplored interval, the cop chooses to sweep down corridor C. By going thirty feet into C and doubling back, the cop ensures that any attempt by the thief to backtrack into A or B would require him to exceed his available distance budget.

Continuing this pattern, each successive sweep explores half again as much new corridor as the last: twenty, thirty, thirty-five, thirty-seven and a half, and so on. These distances form a geometric sequence converging to just under 40 feet. If the total corridor length is under fifty, one of the sweeps will reach the far end before the thief can escape into a previously cleared branch. Beyond fifty feet, however, the geometric climb never quite reaches the end, and the thief can keep slipping past the cop’s sight line infinitely.

Thus the puzzle’s heart is a simple numerical bound. By labelling the arms A, B, C, fixing the cop’s ten-foot visibility and double-speed, and enforcing a rigid out-and-back schedule of ten, twenty, thirty… feet, one finds that fifty feet is the precise maximum length that still guarantees eventual capture. Any longer, and the thief’s perfect foresight and nimble flight will outpace the cop’s methodical sweeps forever.

Mathematical Foundations of Tree Searching

At the heart of the Y-shaped dungeon puzzle lies a fundamental graph parameter known as the search number, which quantifies the minimum number of searchers required to clear a graph of an invisible adversary under monotonic strategies. In rigorous terms, one defines a search strategy on a graph as a sequence of moves by searchers that “sweep” edges or occupy vertices so that once an area is cleared, it never becomes contaminated again. The pursuit-evasion community showed early on that this search number coincides with several other graph width parameters, bridging discrete geometry and algorithmic game theory.

A closely related measure is the pathwidth of a graph, which arises in decomposition theory. A path decomposition arranges the graph’s vertices into a linear sequence of overlapping bags, each covering edges locally; the minimum bag size minus one defines the graph’s pathwidth. Remarkably, for any graph \(G\), the search number \(S(G)\) under edge-search rules satisfies

\[ S(G)\;=\;\text{pw}(G)\;+\;1, \]

where \(\text{pw}(G)\) denotes the pathwidth of \(G\). This elegant identity shows that clearing a graph of an intelligent, invisible evader demands exactly one more searcher than the width of the narrowest linear decomposition of the graph.

When \(G\) is a simple “star” or Y-tree—one central node attached to three leaves—its pathwidth is one, implying a search number of two. In concrete terms, a single cop (searcher) cannot simultaneously guard all three branches against recontamination, because as soon as he leaves one branch, the evader can slip past the cleared frontier back into it. Only with two cops—one holding the junction and the other probing a corridor—can the search remain monotonic and guarantee eventual capture. This precise threshold underlies why any nontrivial Y-maze eludes a lone pursuer of equal speed: the tree’s intrinsic linear width forces a requirement of two searchers.

The concept of recontamination underpins these results. A search strategy is monotonic if once an edge or vertex is declared clear, the evader can never re-enter it. In pursuit-evasion on trees, monotonic strategies have been shown to be as powerful as general strategies—any optimal non-monotonic plan can be converted into a monotonic one without increasing the number of searchers. Thus, the equality \(S(G)=\text{pw}(G)+1\) holds whether one considers edge searches, node searches, or mixed search variants, and it reduces the analysis of pursuit-evasion games on trees to well-understood width computations.

Algorithms for computing pathwidth on trees run in linear time, making the determination of search number tractable in these cases. One constructs an optimal path decomposition by rooting the tree, computing sizes of subtrees, and sequencing them to minimize overlap—standard dynamic-programming techniques suffice for trees though the problem becomes NP-hard for general graphs. This computational efficiency matches the puzzle’s simplicity: although the thief’s perfect foresight seems to grant him endless evasion, one can algorithmically certify the minimum number of searchers needed, or conversely prove that a lone cop cannot succeed unless reinforced or unless the corridor lengths collapse to a trivial case.

(Quantum?) Computational Complexity of Pursuit-Evasion

The problem of searching a graph for an adversary is computationally intractable in general: deciding the minimum number of searchers (the search number) needed to clear an arbitrary graph is NP-hard, though on trees the closely related measure of pathwidth can be computed in linear time. Pursuit-evasion on trees therefore admits efficient classical strategies when reinforced by the correct number of searchers, but outside that regime one faces exponential complexity. Quantum algorithms—most notably Grover’s unstructured search and quantum-walk-based methods—promise quadratic or even exponential speedups for locating marked vertices in certain graph families by exploiting superposition and interference. The interplay between a graph’s topology (its branching factor, pathwidth, and connectivity) and the achievable quantum query complexity underpins ongoing efforts to find genuine quantum advantage in adversarial search tasks.

In contrast, when the underlying graph is a tree, pathwidth (and therefore the search number) can be computed in linear time. Dynamic programming over a rooted tree allows one to determine optimal “bags” in a path decomposition and hence the minimum monotonic searcher requirement without exponential blowup. This tractability aligns with the intuition that trees avoid cycles and complex entanglements; a single searcher can clear a path or a simple tree only if the pathwidth is zero (i.e. the graph is itself a path), whereas more branched trees demand multiple searchers to prevent recontamination. The Y-shaped dungeon, a star with three leaves, has pathwidth one and search number two; this matches the earlier insight that one cop can never guard all three branches simultaneously.

More than just a fun D&D puzzle?

This little maze encapsulates broader mathematical themes. It highlights the importance of adversarial knowledge: the thief’s perfect foresight forces the cop into a worst-case planning mode. It also shows how bounding strategies can work or fail. The cop might attempt to bound the thief’s possible position – “the thief must be either in branch A, B, or C” – but each move only swaps uncertainty from one branch to another. In algorithmic terms, one might think of trying an iterative deepening or binary search approach down the corridors, gradually narrowing where the thief can hide. Yet here, unlike a numeric binary search, the adversary moves too. This is akin to designing an algorithm that must cope with a worst-case adversarial input that changes as the algorithm probes; the cop’s path-planning must account for the thief’s responsive moves.

In computer science and robotics, similar problems arise in path planning and area search. Imagine a security robot that must clear a building knowing an intruder could be hiding and listening. Or consider clearing a cave network for unwanted animals. The cop’s dilemma mirrors those of a search algorithm on a network with unknown hostile agents. Many results in computational theory address how many searchers (or what sensing capabilities) are needed to find an evader on various graph shapes. The Y-maze is the simplest non-path graph, yet it already requires more than one searcher for a guaranteed clear sweep if the intruder is cunning.

There are even analogies in risk management and finance. A common theme is constructing strategies that withstand adversarial or worst-case scenarios. For example, a financial manager might stress-test a portfolio against worst-case market moves. Similarly, our cop must “stress-test” his chase plan against the thief’s worst-case hiding strategy. The mathematics of bounding risk or ensuring a hedge against any move has the same flavor: you assume the worst possible adversary action and plan to contain it. In our dungeon, the question is: how to hedge against the thief’s next move? And the answer is that with one cop and equal speeds, any hedge is insufficient unless trivial conditions hold.

So yeah. The Y-shaped pursuit-evasion puzzle is more than a parlor trick. It elegantly illustrates how simple geometry (three branches) and equal speeds lead to a nontrivial strategic outcome. Though the corridors are short and the scene unpretentious, the interplay of visibility, adversarial knowledge, and movement makes the problem rich. The cop’s failure – unless corridors were trivially small or another cop is added – reveals a core insight: even in a tiny network, one must carefully consider the power of recontamination and foresight. This narrative, stripped down to its essentials, echoes through graph theory, game theory, and algorithmic search. Whether one is herding animals, rooting out computer viruses, or catching thieves in dungeons, the same mathematics of pursuit and evasion applies. The Y-dungeon stands as a compact laboratory for these ideas, showing how a thoughtfully simple puzzle can illuminate deep strategic principles.

Appendix: Code for the animations

import matplotlib.pyplot as plt
import numpy as np
import matplotlib.animation as animation
from matplotlib.patches import Circle
# Define the length of each corridor
L = 5.0  # can be changed to any value but the steps are in fractions of this length so it doesn't really matter
# Define the center point of the Y-shaped dungeon
center = np.array([0.0, 0.0])
# Define the angles for the three branches (in radians)
angles = [0, 2 * np.pi / 3, 4 * np.pi / 3]  # 0°, 120°, 240°
# Compute end points of the three branches
branches = [center + L * np.array([np.cos(a), np.sin(a)]) for a in angles]

# Plot the dungeon
fig, ax = plt.subplots(figsize=(6, 6))
for end in branches:
    ax.plot([center[0], end[0]], [center[1], end[1]], 'k-', lw=2)

ax.set_aspect('equal')
ax.set_xlim(-L-1, L+1)
ax.set_ylim(-L-1, L+1)
ax.set_title("Y-shaped Dungeon")
ax.set_axis_off()

# Positions
cop_pos = center  # starts at the center
sight_radius = 1.0
sight_circle = Circle(cop_pos, sight_radius, color='blue', alpha=0.3)
ax.add_patch(sight_circle)

robber_pos = center + .24 * branches[2]  # waits out of sight on branch 0
robber_path_1 = np.array([robber_pos - i * branches[2] for i in np.linspace(0,.24,6)])
robber_path0 = np.array([center + i * branches[0] for i in np.linspace(0,.24,6)])
robber_path1 = np.ones((8,2))*robber_path0[-1]
robber_path2 = np.array([robber_path0[-1] - i * branches[0] for i in np.linspace(0,.2,5)])
robber_path3 = np.array([center + i * branches[1] for i in np.linspace(0,.24,6)])
robber_path4 = np.ones((9,2))*robber_path3[-1]
robber_path5 = np.array([robber_path3[-1] - i * branches[1] for i in np.linspace(0,.2,5)])
robber_path6 = np.array([center + i * branches[2] for i in np.linspace(0,.24,6)])
robber_path7 = np.ones((9,2))*robber_path6[-1]
robber_path = np.concatenate([robber_path_1,robber_path0,
                              robber_path1,robber_path2,
                              robber_path3,robber_path4,
                              robber_path5,robber_path6,
                              robber_path7])

cop_path1 = np.array([center + i * branches[1] for i in np.linspace(0,.8,10)])
cop_path2 = np.array([center + i * branches[2] for i in np.linspace(0,.8,10)])
cop_path3 = np.array([center + i * branches[0] for i in np.linspace(0,.8,10)])
cop_path = np.concatenate([cop_path1,np.flip(cop_path1,axis=0),
                           cop_path2,np.flip(cop_path2,axis=0),
                           cop_path3,np.flip(cop_path3,axis=0)])


# Add cop and robber
cop = ax.scatter(cop_path[:,0], cop_path[:,1])
robber = ax.scatter(robber_path[:,0], robber_path[:,1])

def update(frame):
    # for each frame, update the data stored on each artist.
    cop_pos = cop_path[frame]
    robber_pos = robber_path[frame]
    # update sight circle
    sight_circle.center = cop_pos
    cop.set_offsets(cop_pos)
    robber.set_offsets(robber_pos)
    return (cop,robber,sight_circle)

ani = animation.FuncAnimation(fig=fig, func=update, frames=60, interval=40)
writer = animation.PillowWriter(fps=15,
                                 metadata=dict(artist='Me'),
                                 bitrate=1800)
ani.save('dungeon1.gif', writer=writer);

# Plot the dungeon
fig, ax = plt.subplots(figsize=(6, 6))
for end in branches:
    ax.plot([center[0], end[0]], [center[1], end[1]], 'k-', lw=2)

ax.set_aspect('equal')
ax.set_xlim(-L-1, L+1)
ax.set_ylim(-L-1, L+1)
ax.set_title("The Double-Back")
ax.set_axis_off()

# Positions
cop_pos = center  # starts at the center
sight_radius = 1.0
sight_circle = Circle(cop_pos, sight_radius, color='blue', alpha=0.3)
ax.add_patch(sight_circle)

robber_pos = center + .2 * branches[0]  # waits out of sight on branch 0
robber_path1 = np.ones((20,2))*robber_pos
robber_path2 = np.array([robber_pos - i * branches[0] for i in np.linspace(0,.2,5)])
robber_path3 = np.array([center + i * branches[1] for i in np.linspace(0,.20,5)])
robber_path4 = np.ones((10,2))*center
robber_path = np.concatenate([robber_path1,robber_path2,
                              robber_path3,robber_path4])

cop_path1 = np.array([center + i * branches[1] for i in np.linspace(0,.8,10)])
cop_path2 = np.array([center + i * branches[2] for i in np.linspace(0,.4,5)])
cop_path3 = np.ones((10,2))*cop_path2[0]
cop_path = np.concatenate([cop_path1,np.flip(cop_path1,axis=0),
                           cop_path2,np.flip(cop_path2,axis=0),
                           cop_path3])


# Add cop and robber
cop = ax.scatter(cop_path[:,0], cop_path[:,1])
robber = ax.scatter(robber_path[:,0], robber_path[:,1])

def update(frame):
    # for each frame, update the data stored on each artist.
    cop_pos = cop_path[frame]
    robber_pos = robber_path[frame]
    # update sight circle
    sight_circle.center = cop_pos
    cop.set_offsets(cop_pos)
    robber.set_offsets(robber_pos)
    return (cop,robber,sight_circle)

ani = animation.FuncAnimation(fig=fig, func=update, frames=40, interval=40)
ani.save('dungeon2.gif', writer=writer);

# Plot the dungeon
fig, ax = plt.subplots(figsize=(6, 6))
for end in branches:
    ax.plot([center[0], end[0]], [center[1], end[1]], 'k-', lw=2)

ax.set_aspect('equal')
ax.set_xlim(-L-1, L+1)
ax.set_ylim(-L-1, L+1)
ax.set_title("Hiding just out of reach")
ax.set_axis_off()

# Positions
cop_pos = center  # starts at the center
sight_radius = 1.0
sight_circle = Circle(cop_pos, sight_radius, color='blue', alpha=0.3)
ax.add_patch(sight_circle)

robber_pos = center + .64 * branches[2]  # waits out of sight on branch 0
robber_path1 = np.ones((25,2))*robber_pos
robber_path2 = np.array([robber_pos - i * branches[2] for i in np.linspace(0,.64,16)])
robber_path3 = np.array([center + i * branches[1] for i in np.linspace(0.04,.2,3)])
robber_path4 = np.ones((16,2))*robber_path3[-1]
robber_path = np.concatenate([robber_path1,robber_path2,
                              robber_path3,robber_path4])

cop_path1 = np.array([center + i * branches[1] for i in np.linspace(0,.8,10)])
cop_path2 = np.array([center + i * branches[2] for i in np.linspace(0.08,.4,4)])
cop_path3 = np.array([center + i * branches[0] for i in np.linspace(0,.64,8)])

cop_path = np.concatenate([cop_path1,np.flip(cop_path1,axis=0),
                           cop_path2,np.flip(cop_path2,axis=0),
                           cop_path3,np.flip(cop_path3,axis=0),
                           np.ones((16,2))*center])
# Add cop and robber
cop = ax.scatter(cop_path[:,0], cop_path[:,1])
robber = ax.scatter(robber_path[:,0], robber_path[:,1])

def update(frame):
    # for each frame, update the data stored on each artist.
    cop_pos = cop_path[frame]
    robber_pos = robber_path[frame]
    # update sight circle
    sight_circle.center = cop_pos
    cop.set_offsets(cop_pos)
    robber.set_offsets(robber_pos)
    return (cop,robber,sight_circle)

ani = animation.FuncAnimation(fig=fig, func=update, frames=60, interval=40)
writer = animation.PillowWriter(fps=5,
                                 metadata=dict(artist='Me'),
                                 bitrate=1800)
ani.save('dungeon3.gif', writer=writer);

values = [(i,i*.04,i*.04*10/.24) for i in range(40)]
steps = {}
for (i,j,k) in values:
    steps[i] = j
# Plot the dungeon
fig, ax = plt.subplots(figsize=(6, 6))
for end in branches:
    ax.plot([center[0], end[0]], [center[1], end[1]], 'k-', lw=2)

ax.set_aspect('equal')
ax.set_xlim(-L-1, L+1)
ax.set_ylim(-L-1, L+1)
ax.set_title("Chasing Ghosts")
ax.set_axis_off()

# Positions
cop_pos = center  # starts at the center
sight_radius = 1.0
sight_circle = Circle(cop_pos, sight_radius, color='blue', alpha=0.3)
ax.add_patch(sight_circle)

cop_path1 = np.array([center + i * branches[1] for i in np.linspace(0,.96,11)])
cop_path2 = np.array([center + i * branches[2] for i in np.linspace(0.08,.48,5)])
cop_path3 = np.array([center + i * branches[0] for i in np.linspace(0,.72,9)])
cop_path4 = np.array([center + i * branches[2] for i in np.linspace(0.08,.8,9)])
cop_path5 = np.array([center + i * branches[0] for i in np.linspace(0,.88,11)])
cop_path6 = np.array([center + i * branches[2] for i in np.linspace(0.08,.96,11)])
cop_path = np.concatenate([cop_path1,np.flip(cop_path1[:-1],axis=0),
                           cop_path2,np.flip(cop_path2[:-1],axis=0),
                           cop_path3,np.flip(cop_path3[:-1],axis=0),
                           cop_path4,np.flip(cop_path4[:-1],axis=0),
                           cop_path5,np.flip(cop_path5[:-1],axis=0),
                           cop_path6,np.flip(cop_path6[:-1],axis=0)])

robber_path0 = np.ones((26,2))*center + .72 * branches[2]
robber_path1 = np.flip(np.array([center+ steps[i] * branches[2] for i in range(5,18)]),axis=0)
robber_path2 = np.flip(np.array([center+ steps[i] * branches[0] for i in range(7,23)]),axis=0)
robber_path3 = np.flip(np.array([center+ steps[i] * branches[2] for i in range(7,27)]),axis=0)
robber_path4 = np.flip(np.array([center+ steps[i] * branches[0] for i in range(7,27)]),axis=0)
robber_path5 = np.flip(np.array([center+ steps[i] * branches[2] for i in range(20,31)]),axis=0)
robber_path = np.concatenate([robber_path0,
                              robber_path1,robber_path2,
                              robber_path3,robber_path4,
                              robber_path5])

cop = ax.scatter(cop_path[:,0], cop_path[:,1])
robber = ax.scatter(robber_path[:,0], robber_path[:,1])

def update(frame):
    # for each frame, update the data stored on each artist.
    cop_pos = cop_path[frame]
    robber_pos = robber_path[frame]
    # update sight circle
    sight_circle.center = cop_pos
    cop.set_offsets(cop_pos)
    robber.set_offsets(robber_pos)
    return (cop,robber,sight_circle)

ani = animation.FuncAnimation(fig=fig, func=update, frames=cop_path.shape[0], interval=40)
writer = animation.PillowWriter(fps=5,
                                 metadata=dict(artist='Me'),
                                 bitrate=1800)
ani.save('dungeon4.gif', writer=writer);

dists = [] # distances searched
dists.append(20)
def back(x):
    return(x-x/2+20)

x = 20
for i in range(30):
    print(i,"=",x)
    x = back(x)
0 = 20
1 = 30.0
2 = 35.0
3 = 37.5
4 = 38.75
5 = 39.375
6 = 39.6875
7 = 39.84375
8 = 39.921875
9 = 39.9609375
10 = 39.98046875
11 = 39.990234375
12 = 39.9951171875
13 = 39.99755859375
14 = 39.998779296875
15 = 39.9993896484375
16 = 39.99969482421875
17 = 39.999847412109375
18 = 39.99992370605469
19 = 39.999961853027344
20 = 39.99998092651367
21 = 39.999990463256836
22 = 39.99999523162842
23 = 39.99999761581421
24 = 39.999998807907104
25 = 39.99999940395355
26 = 39.999999701976776
27 = 39.99999985098839
28 = 39.999999925494194
29 = 39.9999999627471