State Machine: Deciding State and Path with Situational Rules
The UNICORN Racing team aims for robust driving that responds appropriately to diverse situations. The State Machine’s role is to make predictable decisions from the situations encountered while driving and the planners’ outputs, deciding the driving goal, path, and speed.
The State Machine reaches its conclusions not through sophisticated theory or optimization, but by organizing racing intuition into situational rules. This post covers how those rules judge, how they combine into a single decision — the working principle.
Why a State Machine Is Needed
Autonomous racing demands more than driving fast alone — it demands the ability to handle diverse situations.
Modules that judge and plan together based on cost have the advantage of optimizing all the way to control at once — but their results are inherently hard to predict. Since UNICORN Racing aims for predictable results and separates planning from control, it builds and operates individual planners that handle diverse situations without relying on cost.
This structure needs a decider that weighs the planners’ outputs together with the current situation and determines the driving goal and the actual path and speed to drive. The State Machine is that decider.
The State Machine structure: taking candidate paths from multiple planners plus situation information, and selecting one path
Every frame, the State Machine decides the behavior state and the path used for that behavior as a pair. For example, “the behavior is following the car ahead (TRAILING), but the path in use is the global raceline” — state and path are separated, then managed as a pair.
The STATEs
Each state is defined to handle a specific situation. The main states the State Machine handles, with their purpose and situation:
| State | Purpose | Situation |
|---|---|---|
| 🔵 GB_TRACK | Follow the global raceline | • Nothing interferes with the path ahead • Positioned close to the raceline |
| 🟡 TRAILING | Keep a safe distance ahead | • An obstacle is on the path ahead but overtaking is impossible |
| 🔴 OVERTAKE | Overtake the opponent on the avoidance line | • A safe avoidance path exists |
| 🟢 RECOVERY | Return smoothly to the global raceline | • Departed from the global raceline |
| ⚪ START | Accelerate early on a hand-tuned launch trajectory | • A fast launch is desired • The starting position is far from the raceline |
| ⚪ FTGONLY | Drive reactively without path planning | • The overtake-impossible condition has persisted |
The Paths Used for Judgment
Judgment and transition are ultimately the process of choosing which of several candidate paths to use.
The State Machine does not build paths itself. The global raceline, the avoidance path, the recovery path, and the launch trajectory are each generated by a separate planner and published as topics, which the State Machine subscribes to and stores. Each path is re-emitted every frame by its planner, and the State Machine keeps them in per-type caches as judgment candidates.
Received paths are not used as-is — a velocity profile computed from curvature is layered on. A planner’s path carries only the shape (geometry) of the points to pass, with no speed information. The speed margin differs by the state’s character; the recovery path gets a conservative speed with motor/brake limits halved (safety_factor 0.5), prioritizing stability during the return.
These candidate paths, cached with speeds layered on, become what the judgment criteria evaluate.
Judgment Criteria
Related code: the _check_* methods in state_machine/state_machine/state_machine_node.py
Since each STATE handles a different situation and purpose, deciding when to move to which state requires examining the current situation along several axes. The State Machine defines these as judgment criteria and composes every transition rule as a combination of these criteria.
- Obstacle presence ahead — checks whether an opponent is present within a set distance ahead, and whether we are closing in on it.
- Path collision safety — judges whether following a candidate path is collision-free. It looks not only at current obstacle positions but computes, from the opponent’s predicted trajectory, the predicted positions and time-to-collision (TTC) of both cars — reactions have latency, and the opponent moves in the meantime, so empty now does not mean safe.
- Raceline proximity — checks whether the car is close enough to the global raceline in both position and heading. Heading matters because sitting on the line while pointing askew is not yet stably attached.
- Path followability — checks whether the car can actually follow the path: the path must be close enough to the car, and its endpoint must still lie ahead of the car. A path that exists but is too far, or whose end has already been passed, cannot be followed.
- Path freshness — checks whether a planner’s path is stale. A path is valid only while its planner keeps re-emitting it every frame; one that has stopped updating is no longer trusted.
- Persistent low-speed stall — checks whether an excessively low speed persists beyond a set time during
TRAILING.
Transitions: Combining Criteria into a State Decision
Related code: state_machine/state_machine/state_transitions.py
A transition function combines the judgment criteria and returns the next state and the next path to use as a pair — and the two are not always the same. The state says what behavior to perform now; the path says which candidate path that behavior uses; they are paired within a single ruling. For example, even in TRAILING — blocked and following — the path actually tracked may be the recovery path.
Per-STATE Functions: Judging Only Whether to Stay
Each state has one transition function of its own, and the function for the currently active state handles that frame’s transition. These functions mostly judge only whether to keep the current state. If the stay condition holds, the state persists; if it breaks, rather than deciding the next state themselves they defer to the common ruling functions.
| Transition function | State | Stay condition |
|---|---|---|
GlobalTrackingTransition | GB_TRACK | Delegates to the common ruling |
TrailingTransition | TRAILING | Delegates to the common ruling |
OvertakingTransition | OVERTAKE | • Stays while the avoidance path is valid and the car ahead remains • Delegates to the common ruling when broken |
RecoveryTransition | RECOVERY | • Stays while the recovery path is valid and the car is still off the raceline • Delegates to the common ruling when broken |
StartTransition | START | • Stays while the launch path is safe and the car is riding it • Delegates to the common ruling when broken |
FTGOnlyTransition | FTGONLY | Delegates to the common ruling |
The STATE Transition
The actual decision of state and path is concentrated in two common ruling functions that split the situation in two: with no obstacle ahead, NonObstacleTransition handles it; with one, ObstacleTransition — and the per-state functions above flow into one of these.
NonObstacleTransition(no obstacle): with nothing blocking ahead, collisions are not weighed — if attached to the raceline,GB_TRACK; if still away from it and the recovery path passes freshness and followability, the return continues asRECOVERY.ObstacleTransition(obstacle present): takes the raceline, the recovery path, and the avoidance path in order, evaluates each against criteria like freshness and collision safety, and sets state and path together according to which candidate passes.
ObstacleTransitionevaluates the candidates from the top, descending only when the step above fails to hold.① Attached to the raceline and that path passes collision safety →
GB_TRACK② Otherwise, if the recovery path passes freshness and collision safety →
RECOVERY③ Failing that, if the avoidance path passes freshness and collision safety →
OVERTAKE④ If nothing passes →
TRAILINGEven in the final
TRAILING, the path actually followed may be the recovery path or the raceline — state and path are returned as distinct pairs.
Local Waypoints
Related code: state_machine/state_machine/states.py
Once judgment and transition fix the path source, the local path actually handed to the controller is built from that source’s cache. From the avoidance/recovery path cache, the closest point to the car is found, and a set number of points (n_loc_wpnts, default 80) is cut from there.
If the cut falls short of n_loc_wpnts, the global raceline is appended behind it to make up the length, so the path connects naturally onto the raceline from where the avoidance ends. If the cache has been invalidated and there is nothing to cut, an empty path is returned, and the outer loop swaps the path source back to the global raceline so the controller never stalls.
What Makes the Rules Robust
State rules start from intuition — but to finish a run without incident on a real car, that intuition must not waver under momentary noise or latency. The UNICORN Racing stack reinforces it with the following devices.
- Only executable paths are selected. Paths that fail the collision check (prediction included), the on-path check, or freshness never reach the controller in the first place.
- State and path source are separated. As covered above, the tracked path stays continuous even when the state changes, so the target never jumps.
- Oscillation at boundaries is suppressed (hysteresis). So the state does not flutter when a condition blinks at its boundary, TTLs and timers hold it briefly — e.g. OVERTAKE persists for a moment right after passing the opponent.
- Latency is compensated with prediction. Accounting for both cars moving while we react, the collision check uses predicted positions and time-to-collision (TTC).
- An empty path is never emitted. If the chosen path is empty for any reason, the state ruling stands but the path is filled with the global raceline so the controller never stalls.
Limitations
This structure is not all upside. In exchange for the robustness of predictable behavior, it carries the limitation that strategic maneuvers built around the opponent are hard to produce. Unlike approaches that optimize actions per timestep over both cars’ states and the track, the UNICORN Racing stack generates paths with geometry-based planners, layers speed from curvature, and follows with a separate geometry-based controller. With the path generator, evaluator, and controller each separated, it is hard to plan path and speed as one intent matched to the opponent’s motion.
In particular, speed derived from curvature carries no driving intent. Even a trajectory meant to decelerate will accelerate if its curvature is small, so the timing of deceleration and acceleration is delegated to longitudinal control like TRAILING. In the process, the acceleration timing needed while avoiding dynamic obstacles is sometimes missed. In upper leagues where the gap to the opponent is small, these slight delays translate directly into losses — sometimes the reason an overtake fails.
Wrap-up
This post covered how the State Machine works: evaluating the candidate paths from multiple planners and the situation with judgment criteria (_check_*), and deciding state and path as a pair every frame. The per-STATE functions judge only whether to stay, the real decisions are concentrated in the two common ruling functions, and devices like hysteresis, TTC prediction, and empty-path prevention make the rule-based judgment robust in practice.
