Stations Pathfinder
A command-line railway dispatcher that plans the fewest turns, not the shortest route.
Overview
We built a Go command-line railway scheduler that moves multiple trains through shared stations and tracks. Candidate schedules are compared by simulated turn count, with routing heuristics intended to reduce contention rather than simply choose the shortest path.
- context
- Coursework
- status
- Archived learning project
- team
- 2 people
- role
- Shared responsibilities
- started
- Aug 2024
- setting
- Command-line railway scheduling exercise over supplied network maps.
my contribution
My main contributions were the routing and train-scheduling implementation, validation improvements, debugging and documentation. My teammate contributed the initial brainstorming and error-checking implementation, which I subsequently extended.
- Go
Key decisions
- Optimise simulated turn count
- Route length is only a proxy. I compared completed schedules by the number of global turns needed to move all trains.
- Include contention in route selection
- The ranking combines path length, shared stations and bottleneck penalties, then checks candidate choices through the movement simulator.
Demonstrated results
- Track occupancy became an explicit simulation rule
- The implementation checks track and station use per turn, including a documented fix for two trains attempting the same track.
- Validation can report multiple map problems
- The -ae mode collects errors instead of stopping at the first one, with an annotated map fixture covering invalid stations and connections.
- Candidate scores can be inspected in a worked example
- The documented network exposes how shared paths affect scores and why equal-length candidates can still need simulation to choose between them.
Known limitations
- The search has no practical scaling guarantee
- Enumerating simple paths can grow combinatorially on dense graphs. A depth cap makes the search finite, not necessarily fast or globally optimal.
- Repeated runs mitigate rather than remove nondeterminism
- Map iteration affects tie-breaking. The program keeps the best of repeated runs for smaller fleets and uses one run above twenty trains.
The objective is not distance
The acceptance criterion is the turn count. In main.go, one variable holds the
objective:
var leastTurns int = math.MaxInt32
...
if turnsCount1 < leastTurns {
bestMoves = moves1
leastTurns = turnsCount1
}
The final objective is turn count, although path length is one term in the ranking heuristic. Routes of equal length can still take different numbers of turns because of contention at intermediate stations.
What a turn actually is
One tick of a global clock. In each turn, every train may traverse at most one track, and:
- a track may carry only one train per turn
- a station may hold only one train
- the start station can always be left, and the destination can always be entered
// Start station can always be left
if occupyingTrain, occupied := stationOccupancy[from]; occupied && occupyingTrain != trainID {
return false
}
// End station can always be entered
if to == endStation {
return true
}
That last pair of exceptions is why the problem is interesting: a train is never stuck at its origin, and never locked out of its destination.
Not Dijkstra
The obvious assumption is wrong. There is no shortest-path search here at all. The algorithm enumerates every simple path by depth-first search with backtracking:
for neighbor := range net.Connections[current] {
if !visited[neighbor] {
dfs(net, neighbor, end, visited, append(path, neighbor), allPaths, maxDepth)
}
}
visited[current] = false // unmark on the way out
Then it ranks them. There is a textbook container/heap min-heap in the same
file, fully implemented with Len, Less, Swap, Push and Pop. It is
dead code. It appears nowhere except its own definition.
The search enumerates candidates up to its depth limit and ranks them with heuristics rather than proving a globally optimal schedule. Depth is capped at 100 and maps above 10,000 stations are rejected, but a dense graph can still produce an impractically large number of simple paths within those bounds.
The heuristic predicts contention, not length
score := 2*pathLength +
(sharedStationScore / float64(numTrains)) +
float64(bottleneckPenalty) / float64(numTrains)
Three terms, sorted ascending to minimise:
2 * len(path): longer is worse, and it dominatesshared / numTrains: overlap with other candidate paths is a penalty, normalised by fleet size. Two trains cannot occupy the same station in the same turn, so candidates that share stations will contendbottleneck / numTrains: a station appearing on more paths than there are trains is a structural chokepoint
A worked example
The README's own example: jungle → desert, 10 trains, over a 14-station network.
DFS finds exactly three simple paths:
| Path | len | shared | score | |
|---|---|---|---|---|
| p0 | jungle → farms → mountain → wetlands → desert | 5 | 7 | 10.70 |
| p1 | jungle → farms → mountain → treetop → desert | 5 | 7 | 10.70 |
| p2 | jungle → farms → downtown → metropolis → industrial → desert | 6 | 6 | 12.60 |
farms appears on all three paths; jungle and desert are endpoints shared by
definition. No station exceeds 10 occurrences, so the bottleneck term is zero
throughout.
The six-station route loses despite being a viable shape, and the two five-station
routes tie at 10.70. That is because wetlands and treetop each appear on
only one path and so cannot differentiate them. Resolving a genuine tie is the
simulator's job. The next section is about that.
Ten iterations, because randomness broke determinism
Go randomises map iteration order. The DFS visits neighbours in a different order on every run, greedy tie-breaks land differently, and identical input produced different turn counts. For fleets up to twenty trains, the program runs the search ten times and keeps the best simulated result; larger fleets use one run:
// Running pathfinding in loop to mitigate randomness, choosing the best solution
loops := 10
if numTrains > 20 {
loops = 1
}
That loop is not a performance hack. It is an admission that the scoring proxy does not reliably predict turn count. So both ranking functions are simulated on every iteration, and the winner is whichever produced fewer turns.
Validation you can read at a glance
A -ae flag collects every error instead of exiting at the first, which is
why the repository contains an errormap fixture annotated with the failure each
line provokes:
Duplicate: "jungle" appears again
Duplicate coordinates: "4,4"
invalid!station,7,7
negative,-1,-1
jungle-jungle
nonexistent1-nonexistent2
Thirteen distinct checks in total, including unreachable endpoints, self-connections, dangling edges, stations at identical coordinates, and illegal characters in names.
Dependencies and search limits
The no-third-party-dependency claim is grounded in go.mod, which declares no
external requirements, and the source imports, which are all standard-library or
project-local packages. The standard-library imports are bufio, flag, fmt,
math, os, path/filepath, sort, strconv and strings. Absence of go.sum
alone would not prove the claim.
Repeated search is a heuristic workaround. It improves the chance of finding a better simulated schedule without establishing determinism, a runtime guarantee or global optimality.
Interactive models
Explore the model below to inspect the implementation. This is a reconstruction from the project, not a live service.
Route contention and turn simulation
RailwaySolver
Minimum-turns ranking over every simple path. Drag the fleet size to change the contention terms.
score = 2·len + shared/10 + bottleneck/10
p0 → 10.70
recorded in the README as 10.70 · bottleneck stations none above fleet size
candidates
2 paths tie at 10.70. p0 and p1 differ only in stations that appear on one path, so the contention term cannot separate them. That is why the program runs the simulator rather than trusting this score.
station frequency across all paths
- desert ×3
- farms ×3
- jungle ×3
- mountain ×2
- downtown ×1
- industrial ×1
- metropolis ×1
- treetop ×1
- wetlands ×1
The source has a complete container/heap min-heap that is dead code, and no Dijkstra anywhere — the search enumerates every simple path and ranks by this proxy. At ten trains the bottleneck term is zero for every station, so distance and contention decide it and the tie goes to the simulator.
client:visible
Stack and source figures
how it fits together
- solver
-
-
Exhaustive simple-path DFSEvery simple path enumerated with backtracking, then ranked. There is no Dijkstra in the codebase. A textbook container/heap min-heap is present and is dead code. -
Ten-iteration loopGo randomises map iteration order, so identical input could produce different results. The search runs ten times and keeps the best measured turn count.
-
- simulation
-
-
Global turn clockOne tick advances every train by at most one track; a track and a station each hold one train per turn, with the origin and destination exempt.
-
- dependencies
-
-
Standard library onlygo.mod declares no external requirements, and the source imports only standard-library and project-local packages. 1,340 lines total.
-
- LOC
- 1,340 Across 10 files. No external requirements in go.mod; imports are standard-library or project-local.
- commits
- 53
- build window
- 17 days 2024-08-12 to 2024-08-28.
- input checks
- 13 Distinct validation failures, all collected by one flag.
Full issue record · 4 entries
what broke, and what happened
- 2 fixed / 4
- 2 workaround / 4
Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.
- fixed
The first two algorithms were abandoned for taking over a minute, then calculating endlessly
Commit 2024-08-18: "Added better? pathfinding algorithm, takes in some cases over minute to calculate". Commit 2024-08-21: "New solution that takes all the available paths and calculates endlesly...."
then: Replaced with exhaustive simple-path enumeration plus the turn simulator, which terminates because depth is capped at 100 and the parser rejects maps over 10,000 stations.
- workaround
Two ranking functions disagreed with each other, so both were kept
Commit 2024-08-24: "problem.. sometime works, but other times older version works, added comparison for both, should choose the one with least moves"
then: Never resolved in principle. Both scoring functions are simulated on every iteration and the one that produced fewer turns wins, so the disagreement is measured rather than decided.
- workaround
Go randomises map iteration order, so results changed between runs of identical input
Mitigated twice: an alphabetical tiebreak for equal scores, and a ten-iteration loop that keeps the best measured run.
then: Mitigated, not eliminated. An alphabetical tiebreak plus the loop at `main.go:93`, which keeps the best of ten runs. Go still randomises iteration order, so identical input can differ between invocations.
- fixed
Two trains could occupy the same track in one turn
Commit 2024-08-27: "Added check so same track wont get used in same turn"
then: Occupancy check added to the simulator, so a track already claimed in the current turn is no longer selectable.