Implement a Fuzzy Logic Agent to control a physically simulated racecar on procedurally generated racetracks.
The objective is to maximize sustained speed while avoiding crashes, stalls, or reversing.
Control is achieved by designing fuzzy rules that output Throttle and Steering in the range [-1, 1], using only the provided fuzzy logic framework.
TODO
Implement fuzzy steering control
Implement fuzzy throttle control
Ensure full rule coverage (no gaps)
Tune for all 4 tracks
Remove all debug/hardcoded controls before submission
Setup
Work in: Assets/Scripts/GameAIStudentWork/FuzzyVehicle.cs
Use provided:
AIVehicle API (vehicle control + telemetry)
Fuzzy Logic Library (rule system + defuzzification)
Racetrack API (track sampling, curvature, forward direction)
Test scenes:
RaceTrackFZ (very curvy)
WindingRaceTrackFZ (moderate)
FastSweepersRaceTrackFZ (fast turns)
DragRaceFZ (straight)
Optional:
Use human driving scene to understand physics:
/Scenes/<TrackType>RaceTrackHuman
Implementation
Starter code: FuzzyVehicle.cs
FuzzyVehicle.cs
// compile_check// Remove the line above if you are submitting to GradeScope for a grade. But leave it if you only want to check// that your code compiles and the autograder can access your public methods.using System;using System.Collections;using System.Collections.Generic;using UnityEngine;using GameAI;// All the Fuzzusing Tochas.FuzzyLogic;using Tochas.FuzzyLogic.MembershipFunctions;using Tochas.FuzzyLogic.Evaluators;using Tochas.FuzzyLogic.Mergers;using Tochas.FuzzyLogic.Defuzzers;using Tochas.FuzzyLogic.Expressions;using Tochas.FuzzyLogic.Utils;using static Tochas.FuzzyLogic.FuzzyCrossfade;using static Tochas.FuzzyLogic.FuzzyDiscreteSet;using static Tochas.FuzzyLogic.FuzzyVisualize; namespace GameAI{ public class FuzzyVehicle : AIVehicle { // TODO create some Fuzzy Set enumeration types, and member variables for: // Fuzzy Sets (input and output), one or more Fuzzy Value Sets, and Fuzzy // Rule Sets for each output. // Also, create some methods to instantiate each of the member variables // Here are some basic examples to get you started // Enums/ Human-readable states enum FzOutputThrottle {Brake, Coast, Accelerate } enum FzOutputWheel { TurnLeft, GoStraight, TurnRight } enum FzInputSpeed { Slow, Medium, Fast } // Fuzzy Logic Membership sets FuzzySet<FzInputSpeed> fzSpeedSet; FuzzySet<FzOutputThrottle> fzThrottleSet; FuzzySet<FzOutputWheel> fzWheelSet; // FUzzy Logic rule sets FuzzyRuleSet<FzOutputThrottle> fzThrottleRuleSet; FuzzyRuleSet<FzOutputWheel> fzWheelRuleSet; // Set input/outputs FuzzyValueSet fzInputValueSet = new FuzzyValueSet(); // These are used for debugging (see ApplyFuzzyRules() call // in Update() FuzzyValueSet mergedThrottle = new FuzzyValueSet(); FuzzyValueSet mergedWheel = new FuzzyValueSet(); private FuzzySet<FzInputSpeed> GetSpeedSet() { FuzzySet<FzInputSpeed> set = null; // TODO: Define this fuzzy input variable using GenerateCrossfadeFuzzySet<T>(). // Each enum label should have overlapping triangular or trapezoidal DoM functions. // Example (pseudocode): // set = GenerateCrossfadeFuzzySet<FzInputSpeed>( ... ); // replace the following: set = new FuzzySet<FzInputSpeed>(); // You can then print the ASCII visualization for debugging: // Debug.Log(RenderFuzzySetAscii(set)); return set; } private FuzzySet<FzOutputThrottle> GetThrottleSet() { FuzzySet<FzOutputThrottle> set = null; // TODO: Define this fuzzy output variable using GenerateDiscreteFuzzySet<T>(). // Example (pseudocode): // set = GenerateDiscreteFuzzySet<FzOutputThrottle>( ... ); // replace the following: set = new FuzzySet<FzOutputThrottle>(); // Use RenderFuzzySetAscii(set) to visualize the shape of your output terms. return set; } private FuzzySet<FzOutputWheel> GetWheelSet() { FuzzySet<FzOutputWheel> set = null; // TODO: Define this fuzzy output variable using GenerateDiscreteFuzzySet<T>(). // Each enum label should have a representative crisp value for steering direction. // Example (pseudocode): // set = GenerateDiscreteFuzzySet<FzOutputWheel>( ... ); // replace the following: set = new FuzzySet<FzOutputWheel>(); // You may test with: Debug.Log(RenderFuzzySetAscii(set)); return set; } private FuzzyRuleSet<FzOutputThrottle> GetThrottleRuleSet(FuzzySet<FzOutputThrottle> throttle) { FuzzyRule<FzOutputThrottle>[] rules = { // TODO: Add some rules. Here is an example // (Note: these aren't necessarily good rules) If(FzInputSpeed.Slow).Then(FzOutputThrottle.Accelerate), If(FzInputSpeed.Medium).Then(FzOutputThrottle.Coast), If(FzInputSpeed.Fast).Then(FzOutputThrottle.Brake), // More example syntax //If(And(FzInputSpeed.Fast, Not(FzFoo.Bar)).Then(FzOutputThrottle.Accelerate), }; return new FuzzyRuleSet<FzOutputThrottle>(throttle, rules); } private FuzzyRuleSet<FzOutputWheel> GetWheelRuleSet(FuzzySet<FzOutputWheel> wheel) { FuzzyRule<FzOutputWheel>[] rules = { // TODO: Add some rules. }; return new FuzzyRuleSet<FzOutputWheel>(wheel, rules); } protected override void Awake() { base.Awake(); StudentName = "George P. Burdell"; // DO NOT INITIALIZE FUZZY STUFF HERE!!! Use Start() instead. } protected override void Start() { base.Start(); // TODO: You can initialize a bunch of Fuzzy stuff here like more fuzzy inputs fzSpeedSet = this.GetSpeedSet(); fzThrottleSet = this.GetThrottleSet(); fzThrottleRuleSet = this.GetThrottleRuleSet(fzThrottleSet); fzWheelSet = this.GetWheelSet(); fzWheelRuleSet = this.GetWheelRuleSet(fzWheelSet); } System.Text.StringBuilder strBldr = new System.Text.StringBuilder(); override protected void Update() { // TODO Do all your input fuzzification here and then // pass your fuzzy rule sets to ApplyFuzzyRules() // Remove the following hardcode calls once you get your fuzzy rules working. // You can leave one hardcoded while you work on the other. // Both steering and throttle must be implemented with variable // control and not fixed/hardcoded! HardCodeSteering(0f); HardCodeThrottle(0.5f); // Simple example of fuzzification of vehicle state // The Speed is fuzzified and stored in fzInputValueSet fzSpeedSet.Evaluate(Speed, fzInputValueSet); // ApplyFuzzyRules evaluates your rules and assigns Thottle and Steering accordingly // Also, some intermediate values are passed back for debugging purposes // Defuzzification output values as defined below are automatically assigned by ApplyFuzzyRules. // Throttle: [-1f, 1f] -1 is full brake, 0 is neutral, 1 is full throttle // Steering: [-1f, 1f] -1 if full left, 0 is neutral, 1 is full right // Note that you MUST use ApplyFuzzyRules(). You cannot direclty assign Throttle and Steering. ApplyFuzzyRules<FzOutputThrottle, FzOutputWheel>( fzThrottleRuleSet, fzWheelRuleSet, fzInputValueSet, // access to intermediate state for debugging out var throttleRuleOutput, out var wheelRuleOutput, ref mergedThrottle, ref mergedWheel ); // Use vizText for debugging output // You might also use Debug.DrawLine() to draw vectors on Scene view // When you are done implementing, you can comment out this entire if statement. if (vizText != null) { strBldr.Clear(); strBldr.AppendLine($"Demo Output"); strBldr.AppendLine($"Comment out before submission"); // You will probably want to selectively enable/disable printing // of certain fuzzy states or rules as you progress. DiagnosticPrintFuzzyValueSet<FzInputSpeed>(fzInputValueSet, strBldr); DiagnosticPrintRuleSet<FzOutputThrottle>(fzThrottleRuleSet, throttleRuleOutput, strBldr); DiagnosticPrintRuleSet<FzOutputWheel>(fzWheelRuleSet, wheelRuleOutput, strBldr); vizText.text = strBldr.ToString(); } // Keep the base Update call at the end, after all your FuzzyVehicle code so that // control inputs can be processed properly (e.g. Throttle, Steering). base.Update() must be called or // autograder will fail. base.Update(); } }}
Core Idea
Compute crisp inputs from:
Speed
Angle to track direction
Distance from center line
Future/lookahead position
Convert to fuzzy values
Apply rules → defuzzify → output control
Core API (DO NOT BYPASS)
ApplyFuzzyRules( throttleFRS, steerFRS, fuzzyInput, out throttleRuleOutput, out steeringRuleOutput, ref mergedThrottle, ref mergedSteering);
This is the ONLY valid way to set:
Throttle
Steering
Key Inputs to Consider
Speed
Signed angle to track forward
Distance from center
Lookahead direction
futurePos = currentPos + velocity * lookaheadT;
Example Rule Ideas
Steering:
“If angle is left → steer left”
“If far right of track → steer left”
Throttle:
“If slow → accelerate”
“If turning sharply → reduce throttle”
“If straight + aligned → max throttle”
Requirements
Smooth continuous control (no hard switches)
Overlapping fuzzy sets (for interpolation)
≥ 3 output states per control
Multiple rules firing simultaneously
Full input coverage (no dead zones)
Important Restrictions
❌ No direct setting of Throttle or Steering
❌ No HardCodeThrottle/Steering in final submission
#!/usr/bin/env python3import sysimport xml.etree.ElementTree as ETimport re# --------------------------# Usage check# --------------------------if len(sys.argv) < 2: print("Usage: parseUnityTestResults.py <path_to_unity_test_results.xml>") sys.exit(1)test_results_path = sys.argv[1]# --------------------------# Parse XML# --------------------------try: tree = ET.parse(test_results_path) root = tree.getroot()except Exception as e: print(f"Error reading XML: {e}") sys.exit(1)# --------------------------# Overall total duration# --------------------------total_duration = root.get("duration")if total_duration is not None: total_duration = float(total_duration)# --------------------------# Regex for scores# --------------------------score_re = re.compile(r"Estimated Total Score:\s*([\d.]+)%")weighted_re = re.compile(r"Estimated Weighted Grade Contribution \(wt:\s*([\d.]+)\):\s*([\d.]+)")# --------------------------# Collect results# --------------------------mini_total = 0.0results = []for test_case in root.findall(".//test-case"): name = test_case.get("name") result = test_case.get("result") mini_duration = None est_score = None weighted_score = None extra_credit = False output_elem = test_case.find("output") if output_elem is not None and output_elem.text: for line in output_elem.text.strip().splitlines(): # Mini duration parsing if "| Duration:" in line: parts = line.split("| Duration:") mini_duration = parts[1].strip() try: mini_total += float(mini_duration.replace("s", "").strip()) except: pass # Estimated scores m_score = score_re.search(line) if m_score: est_score = float(m_score.group(1)) m_weighted = weighted_re.search(line) if m_weighted: weighted_score = float(m_weighted.group(2)) # Extra credit detection if "Extra credit earned" in line or "Extra credit only earned if no wipeouts" in line: extra_credit = True results.append({ "name": name, "result": result, "mini_duration": mini_duration, "est_score": est_score, "weighted_score": weighted_score, "extra_credit": extra_credit })# --------------------------# Print summary# --------------------------print("="*80)print("SUMMARY".center(80))print("="*80)print(f"{'Test Name':<35} {'Result':<8} {'Mini Time':<15} {'Est %':<7} {'Weighted':<7}")for r in results: name_display = r["name"] if r["extra_credit"]: name_display += "*" # mark extra credit mini_time = r["mini_duration"] if r["mini_duration"] else "-" est_score = f"{r['est_score']:.1f}" if r["est_score"] is not None else "-" weighted = f"{r['weighted_score']:.1f}" if r["weighted_score"] is not None else "-" print(f"{name_display:<35} {r['result']:<8} {mini_time:<15} {est_score:<7} {weighted:<7}")# Extra credit noteif any(r["extra_credit"] for r in results): print("\n*Extra credit earned")print("-"*80)print(f"Sum of all Mini Durations: {mini_total:.2f} s ({mini_total/60:.2f} min)")if total_duration: OVERHEAD_SECONDS = 135 total_predicted = total_duration + OVERHEAD_SECONDS print(f"Actual Total Duration from XML: {total_duration:.2f} s ({total_duration/60:.2f} min)") print(f"Predicted Total Duration (headless + overhead): {total_predicted:.2f} s ({total_predicted/60:.2f} min)")# Total weighted contribution & final scoretotal_weighted = sum(r["weighted_score"] for r in results if r["weighted_score"] is not None)print(f"\nTotal Weighted Grade Contribution: {total_weighted:.2f}")print(f"Final Score: {total_weighted:.2f}%")print("Waiting for the debugger to disconnect...")
Sample Results
================================================================================
SUMMARY
================================================================================
Test Name Result Mini Time Est % Weighted
Race_Curvy_5m* Passed - 31.1 9.3
Race_DragRace_1m* Passed - 20.0 2.0
Race_FastSweepers_5m* Passed - 20.0 6.0
Race_Winding_5m* Passed - 22.9 6.9
*Extra credit earned
--------------------------------------------------------------------------------
Sum of all Mini Durations: 0.00 s (0.00 min)
Actual Total Duration from XML: 216.21 s (3.60 min)
Predicted Total Duration (headless + overhead): 351.21 s (5.85 min)
Total Weighted Grade Contribution: 24.19
Final Score: 24.19%
Waiting for the debugger to disconnect...
Completed
TODO
Read assignment spec
Basic steering rules
Basic throttle rules
Lookahead tuning
Pass all 4 tracks
Final cleanup (remove debug)
Racetrack + Physics Notes
Warning
Do not call anything marked INTERNAL or directly manipulate wheels or performance metrics.
Procedurally Generated Tracks
Based on Seb Lague’s Path-Creator
Modified for:
real-time procedural generation
runtime sampling
Racetrack API (key idea)
Use Racetrack to get:
curvature
position along track
forward direction
lookahead info
👉 These should be your crisp inputs to fuzzy logic
Unity’s Vector3 represents both points and vectors, though conceptually they are different:
A point identifies a location
A vector represents a direction and magnitude
Key relationships
Direction from A to B:
B−A
Translate a point:
point+vector=new point
Average of two points:
2A+B
Adding two points directly is undefined (except for averages like above)
Useful Unity Functions
Vector3.SignedAngle(a, b, axis)
→ signed angle (use for steering)
Vector3.Distance(a, b)
→ distance
Vector3.Normalize(v)
→ unit vector
Vector3.Project(a, b)
→ projection (useful for track alignment)
Important Edge Case
If velocity = (0,0,0):
No direction
Normalization may break
Can cause NaNs
👉 Always guard against:
division by zero
invalid normalization
Tips
FuzzyPlotter.py
import numpy as npimport matplotlib.pyplot as plt"""MODULE: Membership Function PlotterDESCRIPTION: WHAT THIS DOES: A utility for visualizing triangular and shoulder membership functions. Intended for experimentation and testing purposes only. WHAT THIS DOES NOT DO: Suggest which membership functions to use. Suggest coordinate values.USAGE: Modify the driver section to input desired test values. This script does not recommend specific configurations or applications.AUTHOR: אקסה עאמר (noharghazi) 28 June, 2025DISCLAIMER: Provided "as is" without warranty of any kind."""def triangular_membership_function(x, p0_x, p1_x, p2_x): """ Calculates the membership value for a triangular fuzzy set. Args: x (float or np.array): The input value(s). p0_x (float): The x-coordinate of the left base point (membership 0). p1_x (float): The x-coordinate of the peak point (membership 1). p2_x (float): The x-coordinate of the right base point (membership 0). Returns: float or np.array: The membership value(s) for the given x. """ # Ensure p0_x <= p1_x <= p2_x for a valid triangle if not (p0_x <= p1_x <= p2_x): raise ValueError("Invalid triangular membership function coordinates: p0_x <= p1_x <= p2_x must hold.") # Convert x to numpy array if it's a single float for vectorized operations x = np.array(x) # Initialize membership values to 0 mu = np.zeros_like(x, dtype=float) # Let's create this general shape: # # ^ # / \ # / \ # / \ # _____/ \_____ # p0_x p1_x p2_x # Rising slope mask_rising = (x >= p0_x) & (x < p1_x) mu[mask_rising] = (x[mask_rising] - p0_x) / (p1_x - p0_x) # Falling slope mask_falling = (x >= p1_x) & (x <= p2_x) mu[mask_falling] = (p2_x - x[mask_falling]) / (p2_x - p1_x) # Clamp values between 0 and 1 (though calculations should already ensure this) mu = np.clip(mu, 0.0, 1.0) return mudef shoulder_membership_function(x, start_val, p0_coords, p1_coords, end_val): """ Calculates the membership value for a shoulder fuzzy set (left or right). Args: x (float or np.array): The input value(s). start_val (float): The starting x-value of the universe of discourse. p0_coords (tuple): A tuple (x0, y0) for the first control point. p1_coords (tuple): A tuple (x1, y1) for the second control point. end_val (float): The ending x-value of the universe of discourse. Returns: float or np.array: The membership value(s) for the given x. """ x0, y0 = p0_coords x1, y1 = p1_coords # Convert x to numpy array if it's a single float for vectorized operations x = np.array(x) # Initialize membership values to 0 mu = np.zeros_like(x, dtype=float) # Determine if it's a left or right shoulder based on y-coordinates # (Watch the general shapes!) if y0 == 1 and y1 == 0: # Left shoulder (starts at 1, goes to 0) # Left Shoulder # _____ # \ # \ # \ # \_____ # x0 x1 # # Before x0, membership is 1 mu[x <= x0] = 1.0 # Between x0 and x1, linear decrease mask_slope = (x > x0) & (x < x1) mu[mask_slope] = 1 - (x[mask_slope] - x0) / (x1 - x0) # After x1, membership is 0 mu[x >= x1] = 0.0 elif y0 == 0 and y1 == 1: # Right shoulder (starts at 0, goes to 1) # Right Shoulder # _____ # / # / # / # _____/ # x0 x1 # # Before x0, membership is 0 mu[x <= x0] = 0.0 # Between x0 and x1, linear increase mask_slope = (x > x0) & (x < x1) mu[mask_slope] = (x[mask_slope] - x0) / (x1 - x0) # After x1, membership is 1 mu[x >= x1] = 1.0 else: raise ValueError("Shoulder membership function expects y-coordinates (0,1) or (1,0) for p0 and p1.") # Clamp values between 0 and 1 mu = np.clip(mu, 0.0, 1.0) return mudef plot_membership_function(x_values, y_values, title, ax, color='blue', label=None): """ Plots a single membership function on a given matplotlib axes. Args: x_values (np.array): Array of x-coordinates. y_values (np.array): Array of membership values (y-coordinates). title (str): Title for the plot. ax (matplotlib.axes.Axes): The axes object to plot on. color (str): Color of the plot line. label (str, optional): Label for the legend. Defaults to None. """ ax.plot(x_values, y_values, color=color, linewidth=2, label=label) ax.set_title(title) ax.set_xlabel("Universe of Discourse (x)") ax.set_ylabel("Membership ($\mu(x)$)") ax.set_ylim([-0.1, 1.1]) # Slightly extend y-axis for better visualization ax.grid(True, linestyle='--', alpha=0.7) ax.set_facecolor('#f9f9f9') # Light background for the plot area ax.spines['top'].set_visible(False) ax.spines['right'].set_visible(False) ax.spines['left'].set_color('#cccccc') ax.spines['bottom'].set_color('#cccccc') ax.tick_params(axis='x', colors='#555555') ax.tick_params(axis='y', colors='#555555')# --- Demonstration of Usage ---if __name__ == "__main__": # Define the universe of discourse x_range = np.linspace(0, 100, 500) # From 0 to 100 with 500 points # --- Example 1: Right Shoulder Membership Function (rShoulder) --- rshoulder_p0 = (50.00, 0.00) rshoulder_p1 = (75.00, 1.00) rshoulder_mu = shoulder_membership_function(x_range, 0.0, rshoulder_p0, rshoulder_p1, 100.0) # --- Example 2: Left Shoulder Membership Function (lShoulder) --- lshoulder_p0 = (25.00, 1.00) lshoulder_p1 = (50.00, 0.00) lshoulder_mu = shoulder_membership_function(x_range, 0.0, lshoulder_p0, lshoulder_p1, 100.0) # --- Example 3: Triangular Membership Function (Tri) --- tri_p0_x = 33.33 tri_p1_x = 50.00 tri_p2_x = 66.67 tri_mu = triangular_membership_function(x_range, tri_p0_x, tri_p1_x, tri_p2_x) # --- Plotting all functions overlaid --- fig, ax = plt.subplots(1, 1, figsize=(10, 7)) # Create a single subplot fig.suptitle("Fuzzy Membership Functions", fontsize=16, fontweight='bold', color='#333333') fig.text(0.385, 0.5, "A u t h o r : n o h a r g h a z i", fontsize=10, color='#333333', alpha=0.5) fig.patch.set_facecolor('#f0f0f0') # Background color for the figure plot_membership_function(x_range, rshoulder_mu, "", ax, color='#e74c3c', label='Right Shoulder') # Red plot_membership_function(x_range, lshoulder_mu, "", ax, color='#3498db', label='Left Shoulder') # Blue plot_membership_function(x_range, tri_mu, "", ax, color='#27ae60', label='Triangular') # Green ax.legend(loc='upper right') # Add a legend to distinguish functions ax.set_title("Combined View") # Set a title for the single plot plt.tight_layout(rect=[0, 0.03, 1, 0.96]) # Adjust layout to prevent title overlap plt.show() print("\n--- Individual Membership Function Values (Example) ---") test_x = np.array([20, 40, 50, 55, 80])ls, tr, rs = shoulder_membership_function(test_x, 0, lshoulder_p0, lshoulder_p1, 100), triangular_membership_function(test_x, tri_p0_x, tri_p1_x, tri_p2_x), shoulder_membership_function(test_x, 0, rshoulder_p0, rshoulder_p1, 100) # Console debug for our sample points for label, values in zip(["test_x values:", "test_x leftS:", "test_x tri:", "test_x rightS:"], [test_x, ls, tr, rs]): print(label, end = ' ') for val in values: print(f"{val:>8.3f}", end = '') print() # blank line between sections