devlog • 4 min read

Sector-based Racetrack Representation

Overview

For Switchgrass, I wanted to use a simple, sector-based approach for track representation.

As I was designing both the racetrack and the AI, I decided on the following constraints:

  1. 2D Track Representation: simplifies math required while sufficient for my racetrack
  2. Piecewise-Linear Racing Line: this gave good results, partly due to the way AI forces were being applied
  3. Player-based Learning: I wanted to leverage a Player’s ‘run’ data to find an optimal line
  4. Simple Editor Tooling: my racetrack was still in progress and I needed to be able to make changes

Racetrack Representation

Track representation of main path and racing line

TrackNode

A TrackNode consists of a single ‘node’ of the track. It stores a width and the point of the ‘racing line’ on it.

public class TrackNode : MonoBehaviour
{
    public float width = 10;
    [Range(-1, 1)] public float racingLine;
    public TrackNode next;
    public TrackNode prev;
}

This component is assigned to GameObjects and their Transforms are used to represent the Track.

TrackNodes form a doubly-linked list and include utilities to calculate length recursively, and 2D point-in-Quad testing.

Calculate Track Length

I wanted to dynamically calculate track length so I had greater flexibility with shortcuts later, as well as to avoid accidentally using old, invalid data.

public void CalculateLength()
{
    // Start Node
    if (prev is null)
    {
        length = 0;
        return;
    }

    // Need to (re)calculate the length of previous nodes
    if (prev.length < 0)
    {
        prev.CalculateLength();
    }
    
    var toPrev = Vector3.Distance(GetRacingLinePoint(), prev.GetRacingLinePoint());
    length = prev.length + toPrev;
}

The member variable length was initialized to -1 and updated as necessary.

Since the racing line could be player-defined, via tracking a ‘run’, a recursive approach gave me good results.

Sectors

A ‘Sector’ is defined as the area between the current TrackNode and the previous one. To track which sector an AI racer (or the Player) was in, I used a simple point in quad check.

For this simple approach to work, however, meant track sectors (the space between two consecutive TrackNodes) were limited to being convex. This was fine, as concave track sectors would provide subpar results for AI steering logic, anyway.

public bool SectorContains(Vector3 testPosition)
{
    // Get corner points (top left, top right, bottom left, bottom right) and test point in 2D
    ....

    // Calculate edge vectors
    var left = tl2D - bl2D;
    var right = tr2D - br2D;
    var top = tr2D - tl2D;
    var bottom = br2D - bl2D;
    
    // Bounds check using 2D 'Cross' (Wedge) product
    return left.Cross(test2D - bl2D) < 0 
            && right.Cross(test2D - tr2D) > 0 
            && top.Cross(test2D - tr2D) < 0 
            && bottom.Cross(test2D - br2D) > 0;
}

Alternate Paths and Shortcuts

To implement ‘fairer’ AI behaviour, I used the simplest trick in the book — a railroad-lever style shortcut. This is only activated when the Player has a sufficient lead, ensuring fairer-feeling gameplay. Since the AI was trained on player runs (mine, in particular), it was a fairly fearsome opponent.

Branched Track Node

A variant of TrackNode that holds both a ‘normal’ and ‘alternate’ next node. The next node can then be toggled on-demand.

public override bool TryGetNextNode(out TrackNode nextNode)
{
    if (!_useAlternate || alternate is null)
    {
        return base.TryGetNextNode(out nextNode);
    }
    
    nextNode = alternate;
    return true;
}

Other than some helpful gizmo overrides, it was fairly simple.

Probabilistic Track Node

I also wanted to support a ‘failure’ path, since the payoff from the shortcut was too good, this would make it a more high-risk situation.

This was simply a BranchedTrackNode with fuzzy logic. Uses a simple cutoff to decide whether to use the regular or alternate next node.

public override bool TryGetNextNode(out TrackNode nextNode)
{
    // When asked for the next node, pick either the 'regular' one or alternate, based on a set probability.
    SetUseAlternate(Random.value < alternateProbability);
    return base.TryGetNextNode(out nextNode);
}

Editor Tooling

A combination of vertex snapping and custom editor tooling made setting up this track relatively easy.

The Track Node Editor tool has the following:

  • Extend the track by holding E at the end node
  • Insert a node between two selected nodes by pressing I.

To make development easier, I bound this tool to the T key (if a TrackNode was selected).


Closing Thoughts

I enjoyed dipping my toes into racetrack-representation. The decision to spend some time making a simple editor tool paid off, and it’s something I plan to do a lot more of in the future.


References

  1. G. Biasillo. “Representing a race track for AI.” In AI Game Programming Wisdom, edited by Steve Rabin
  2. Simon Tomlinson and Nic Melder. “An Architecture Overview for AI in Racing Games.” In Game AI Pro, edited by Steve Rabin