Post

State Machine: Deciding State and Path with Situational Rules

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.

State Machine structure 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:

StatePurposeSituation
🔵 GB_TRACKFollow the global raceline• Nothing interferes with the path ahead
• Positioned close to the raceline
🟡 TRAILINGKeep a safe distance ahead• An obstacle is on the path ahead but overtaking is impossible
🔴 OVERTAKEOvertake the opponent on the avoidance line• A safe avoidance path exists
🟢 RECOVERYReturn smoothly to the global raceline• Departed from the global raceline
⚪ STARTAccelerate early on a hand-tuned launch trajectory• A fast launch is desired
• The starting position is far from the raceline
⚪ FTGONLYDrive 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 functionStateStay condition
GlobalTrackingTransitionGB_TRACKDelegates to the common ruling
TrailingTransitionTRAILINGDelegates to the common ruling
OvertakingTransitionOVERTAKE• Stays while the avoidance path is valid and the car ahead remains
• Delegates to the common ruling when broken
RecoveryTransitionRECOVERY• Stays while the recovery path is valid and the car is still off the raceline
• Delegates to the common ruling when broken
StartTransitionSTART• Stays while the launch path is safe and the car is riding it
• Delegates to the common ruling when broken
FTGOnlyTransitionFTGONLYDelegates 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 as RECOVERY.
  • 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.

ObstacleTransition evaluates 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 → TRAILING

Even 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.

This post is licensed under CC BY 4.0 by the author.