05: Retracked
The sixth Procedural Note - describes necessary revisions made to Track generation.
This is the sixth Procedural Note.
The fifth promised, on or before 2/6, a demo showing off the visual look of Rampant on the Tracks.
That's still coming, as is the follow-up gameplay demo. This Note describes neither of those, but necessary revisions made to the Track generation logic.
Most of those revisions are due to networkX.
networkX " is a Python package for the creation, manipulation, and study of the structure, dynamics, and functions of complex networks ".
That overlaps with this game's conception of a Track - a set of Nodes, some of which are starting points and some of which are Destinations, connected by paths.
Using networkX to manage transforming a Voronoi diagram into a Track simplified Track generation - I didn't have to store the current state of the transformation myself, or handle updating that state.
The simplified logic can be seen here - it's a module, so that I can easily use it in a PythonAnywhere web service.
TrackGenerator.generateTrack() handles Track generation.
The method takes several parameters:
@staticmethod
def generateTrack(
diagramWidth: int,
diagramHeight: int,
numWalkersOnTrack: int,
numDestinationsOnTrack: int,
diagramEdgePercentageToProcess: float,
newConnectionAngleMinQuantile: float,
lonelyConnectionMinLengthQuantile: float,
connectionLengthVertexPadding: float,
connectionLengthNodeBuffer: float
) -> Track:
diagramWidth, diagramHeight: Specifies the width and height of the initial diagram - used to calculate the returned 2D coordinates describing the diagram.
numWalkersOnTrack, numDestinationsOnTrack: Specifies the number of Walkers we expect to traverse the track & the number of Destinations - used to calculate numRegions for the initial Voronoi diagram.
diagramEdgePercentageToProcess: Specifies the percentage of " smallest diagram " edges to reconnect.
newConnectionAngleMinQuantile: Specifies the percentage used to determine initialDiagramMinAcceptableAngle, used to calculate the minimum angle a reconnection must make with all other connected edges on both vertices to be accepted.
lonelyConnectionMinLengthQuantile: Specifies the percentage used to determine existingConnectionEdgeMinQuantile, the minimum length a Track must be >= after all reconnections are done.
(Getting rid of too-small, awkward-looking edges where one of the vertices is only connected to that edge - " lonely connections " - improves the look of the Track, as well as removes potential Walker paths that are only dead ends.)
connectionLengthVertexPadding, connectionLengthNodeBuffer: Specifies the values used to place Stops on a connection edge such that they don't look too close to a Node or aren't too closely bunched.
(Stops are a new addition to gameplay - locations that a Walker can replenish its finite resources from. These will be detailed more in an update to come - for now, think of them as part of the " track " motif, " stops on the way ".)
The generation logic is:
- create the base Voronoi diagram (numRegions =
numWalkersOnTrack * numDestinationsOnTrack * 2; region site coordinates randomly generated) - translate the diagram into the graph existingConnections
- compute all possible connections not in existingConnections as the graph potentialConnections
- sort existingConnections edges by stored edge length
- reconnect
floor(len(diagramEdgesSorted) * diagramEdgePercentageToProcess)- delete the edge to be reconnected
- determine the nodes " disconnected " (newly made unreachable from a specified vertex) by this deletion with respect to both edge vertex0 and vertex1 (
graphs.Ops.removeEdgeAndReturnDisconnected()) - take the potentialConnection for each vertex in
disconnectedByDeletionVertex0, to each vertex in `disconnectedByDeletionVertex`` if the edge length would be greater than that of the deleted edge - narrow the potentialConnections down to the ones such that, for each edge already connected to vertex0 and vertex1, the angle the new connection makes would be >= initialDiagramMinAcceptableAngle
- if we have potentialConnections, randomly pick one as maybeViableConnection
- handle maybeViableConnection potentially intersecting with existing edges (if it does, calculate the smaller edges formed by all intersections and add those to existingConnections - if it doesn't, just add maybeViableConnection)
- after reconnections
- delete all intersection edges such that the deletion would disconnect any node already connected to either of the intersection edge's vertices
- delete all " lonely connections " with edge length < existingConnectionEdgeMinQuantile
- calculate minEdgeStopLengthQuantile, the value >= ((connectionLengthVertexPadding + connectionLengthNodeBuffer) * 100)% of all existingConnections edge lengths
- add Stops to all edges with length >= minEdgeStopLengthQuantile
- Stops are ((connectionLengthVertexPadding * 100)% of edgeLength)) away from either edge's vertices and have at least ((connectionLengthNodeBuffer * 100)% of edgeLength) of distance between them
- Stops are generated sequentially - we pick points such that they satisfy the previous constraints until there isn't enough space left to maintain those constraints
The generated Track information is stored in an instance of
@dataclass(frozen=True)
class Track:
nodes: dict[uuid4, Point]
stops: dict[uuid4, tuple[Point]]
edges: dict[uuid4, EdgeVertexInfo]
and returned to the method caller.
The resulting Track has strong visual variety and potential for challenging gameplay.
Examining one run with the help of Matplotlib, we can see it transforms the original diagram

into a Track:

The result preserves some of the appeal of the original diagram - the wide variation in edge lengths and the angles made by edges that share vertices.
Its reconnections add visual and gameplay - take a look at the shortest edge in this subsection of the original diagram

and how easy it is to move between its two points. Compare that with the longer path that needs to be taken in the Track:

(Note also the variation in Stop placement - in positioning, in spaces between, and in the edges that don't have it.)
For further comparison, visit my newly created website jpshh.com. The Demos link under Rampant on the Tracks lists both the demo using pure Voronoi diagrams and a more recent one using the PythonAnywhere endpoint getTrack:

As with that previous endpoint, getTrack is cURLable:
curl --request POST \
--url https://jpshh.pythonanywhere.com/getTrack \
--header 'Content-Type: application/json' \
--data '{
"diagramWidth": 600,
"diagramHeight": 600,
"numWalkersOnTrack": 3,
"numDestinationsOnTrack": 3,
"diagramEdgePercentageToProcess": 0.50,
"newConnectionAngleMinQuantile": 0.45,
"lonelyConnectionMinLengthQuantile": 0.25,
"connectionLengthVertexPadding": 0.20,
"connectionLengthNodeBuffer": 0.10
}'
What's next?
Now that necessary refinements for Track generation have been made, the next step is the previously promised visual demo.
That should be out by 2/20. Visuals have a more immediate appeal than code and illustrative diagrams - I'll be sharing them, and the process of generating them, on places like Reddit.
Subscribe to this newsletter if you'd like to get those directly in your inbox. Or if you're just curious.