PLUS – Mario Kart AI [Pt.1]

If you want the original code I started from, you can get it here:
https://github.com/mixandjam/MarioKart-Drift

Inspired by the wonderful work being done by André Cardoso on the Mix And Jam YouTube channel, I decided that I wanted to contribute to his work by adding something extra to what he has done. I decided to begin with the Mario Kart prototype that was built, since I love Mario Kart. The addition of choice? Artificial Intelligence!

Here is a quick preview of what I was able to accomplish

In the spirit of the Mix And Jam format, I decided to break things down into steps. Here goes nothing!

  1. Refactor
    1. We must refactor the player controller to not take inputs directly from a controller. Our AI will not be using a game-pad or keyboard, so we want our kart to be controllable by any source. We will pull out any code that collects input and then expose commands that can be called by outside sources in order to drive our kart around.
  2. Program The AI
    1. We need to create the “brain” that will drive the AI karts. I decided to make a more natural AI that attempts to navigate it’s environment procedurally instead of an AI that is far too perfect and unbeatable. This will give the racers more personality and make it possible to win against them as a player.
  3. Generate Data
    1. Computers and humans may share the same physical space, but they don’t have to share the same environment. To make our AI work, we need to help feed the AI data that will be used for it to make informed decisions. We will make a recording utility that will record actual player gameplay and generate a track layout using that data. The AI will then follow the nodes along this path when they move.

Before we get started, I wanted to just say that I had to time-box this effort since I am a busy guy. If I had spent more time on this it would probably have resulted in a more robust experience. I was simply trying to add a basic AI that could be used as a foundation for exploration for other Mix And Jam fans. Now without further ado… let’s get started!


Step 1 – Refactor

The needs for your AI can vary per project. I decided that the easiest thing to do was take all the input code inside the kart controller and move it out into a separate component named PlayerInputProvider for processing player input, and then create a new component named AIInputProvider for processing AI output. The job of both PlayerInputProvider and AIInputProvider is to do some type of processing and then send messages to their kart controller to instruct it on how to behave.

We need to extract input logic in order to allow both players and AI to use our kart logic

In André’s original code he uses the Unity “Input” static class to gain access to inputs. This actually made it pretty simple to extract all the input code. I did a search for “Input.” and was able to find all the places that need refactoring. To start with, I located the code where each unique input was being used and created my own class-level variables to imitate these values.

For example:

if (Input.GetButton("Fire1"))
    speed = acceleration;

Becomes

if (isAccelerating)
    speed = acceleration;

Then I just defined the class variable “isAccelerating”, which we will set later on. Note that I chose to make isAccelerating and most of the other variables nullable so that it was clear when they were not set. This means that a non nullable type such as a boolean, can be true, false, or null. This prevents small side-effects where some of your code fails to work properly the first time through because it was defaulted to a bad value, so I prefer to be clear about whether a variable is set or not.

Here is what the definition for “isAccelerating” looks like

private bool? isAccelerating = null;

Now we need to expose a function that can be called to set the value for isAccelerating. This function will be called by either a player or AI input provider.

This is what mine looked like

public void Accelerate()
{
    isAccelerating = true;
}

I basically replaced all the calls to Input with variables to the point where I ended up with something like this:

using Cinemachine;
using DG.Tweening;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering.PostProcessing;

public class KartController : MonoBehaviour
{
    private PostProcessVolume postVolume;
    private PostProcessProfile postProfile;

    public Transform kartModel;
    public Transform kartNormal;
    public Rigidbody sphere;

    public List<ParticleSystem> primaryParticles = new List<ParticleSystem>();
    public List<ParticleSystem> secondaryParticles = new List<ParticleSystem>();

    float speed, currentSpeed;
    float rotate, currentRotate;
    int driftDirection;
    float driftPower;
    int driftMode = 0;
    bool first, second, third;
    Color c;

    [Header("Bools")]
    public bool drifting;

    [Header("Parameters")]

    public float acceleration = 30f;
    public float steering = 80f;
    public float gravity = 10f;
    public LayerMask layerMask;

    [Header("Model Parts")]

    public Transform frontWheels;
    public Transform backWheels;
    public Transform steeringWheel;

    [Header("Particles")]
    public Transform wheelParticles;
    public Transform flashParticles;
    public Color[] turboColors;

    void Start()
    {
        postVolume = Camera.main.GetComponent<PostProcessVolume>();
        postProfile = postVolume.profile;

        for (int i = 0; i < wheelParticles.GetChild(0).childCount; i++)
        {
            primaryParticles.Add(wheelParticles.GetChild(0).GetChild(i).GetComponent<ParticleSystem>());
        }

        for (int i = 0; i < wheelParticles.GetChild(1).childCount; i++)
        {
            primaryParticles.Add(wheelParticles.GetChild(1).GetChild(i).GetComponent<ParticleSystem>());
        }

        foreach(ParticleSystem p in flashParticles.GetComponentsInChildren<ParticleSystem>())
        {
            secondaryParticles.Add(p);
        }
    }

    // ZAS: Nullable so that if no command is sent it is clear that no value is set
    private bool? shouldAccelerate = null;
    private float? horizontalMovement = null;
    private bool? shouldJump = null;

    // ZAS: These variables help us identify the one frame when jumping changes... similar to how GetButtonUp behaves
    private bool wasJumpingLastFrame = false;

    // ZAS: Informs the kart to accelerate during the next update call
    public void Accelerate()
    {
        shouldAccelerate = true;
    }

    // ZAS: Informs the kart to jump during the next update call
    public void Jump()
    {
        shouldJump = true;
    }

    // ZAS: Informs the kart to steer during the next update call. Range is -1f to 1f
    public void Steer(float amount)
    {
        horizontalMovement = amount;
    }

    void Update()
    {
        //Follow Collider
        transform.position = sphere.transform.position - new Vector3(0, 0.4f, 0);

        //Accelerate
        bool isAccelerating = shouldAccelerate ?? false;
        if (isAccelerating)
            speed = acceleration;

        //Steer
        float horizontalMovementThisFrame = horizontalMovement ?? 0f;
        if (horizontalMovementThisFrame != 0)
        {
            int dir = horizontalMovementThisFrame > 0 ? 1 : -1;
            float amount = Mathf.Abs((horizontalMovementThisFrame));
            Steer(dir, amount);
        }

        //Drift
        bool isJumping = shouldJump ?? false;
        bool jumpStateChangedThisFrame = isJumping != wasJumpingLastFrame;
        bool startedJumpingThisFrame = jumpStateChangedThisFrame && isJumping == true;
        if (startedJumpingThisFrame && !drifting && horizontalMovementThisFrame != 0)
        {
            drifting = true;
            driftDirection = horizontalMovementThisFrame > 0 ? 1 : -1;

            foreach (ParticleSystem p in primaryParticles)
            {
                p.startColor = Color.clear;
                p.Play();
            }

            kartModel.parent.DOComplete();
            kartModel.parent.DOPunchPosition(transform.up * .2f, .3f, 5, 1);

        }

        if (drifting)
        {
            float control = (driftDirection == 1) ? ExtensionMethods.Remap(horizontalMovementThisFrame, -1, 1, 0, 2) : ExtensionMethods.Remap(horizontalMovementThisFrame, -1, 1, 2, 0);
            float powerControl = (driftDirection == 1) ? ExtensionMethods.Remap(horizontalMovementThisFrame, -1, 1, .2f, 1) : ExtensionMethods.Remap(horizontalMovementThisFrame, -1, 1, 1, .2f);
            Steer(driftDirection, control);
            driftPower += powerControl;

            ColorDrift();
        }

        bool stoppedJumpingThisFrame = jumpStateChangedThisFrame && isJumping == false;
        if (stoppedJumpingThisFrame && drifting)
        {
            Boost();
        }

        currentSpeed = Mathf.SmoothStep(currentSpeed, speed, Time.deltaTime * 12f); speed = 0f;
        currentRotate = Mathf.Lerp(currentRotate, rotate, Time.deltaTime * 4f); rotate = 0f;

        //Animations    

        //a) Kart
        if (!drifting)
        {
            kartModel.localEulerAngles = Vector3.Lerp(kartModel.localEulerAngles, new Vector3(0, 90 + (horizontalMovementThisFrame * 15), kartModel.localEulerAngles.z), .2f);
        }
        else
        {
            float control = (driftDirection == 1) ? ExtensionMethods.Remap(horizontalMovementThisFrame, -1, 1, .5f, 2) : ExtensionMethods.Remap(horizontalMovementThisFrame, -1, 1, 2, .5f);
            kartModel.parent.localRotation = Quaternion.Euler(0, Mathf.LerpAngle(kartModel.parent.localEulerAngles.y, (control * 15) * driftDirection, .2f), 0);
        }

        //b) Wheels
        frontWheels.localEulerAngles = new Vector3(0, (horizontalMovementThisFrame * 15), frontWheels.localEulerAngles.z);
        frontWheels.localEulerAngles += new Vector3(0, 0, sphere.velocity.magnitude/2);
        backWheels.localEulerAngles += new Vector3(0, 0, sphere.velocity.magnitude/2);

        //c) Steering Wheel
        steeringWheel.localEulerAngles = new Vector3(-25, 90, ((horizontalMovementThisFrame * 45)));

        // ZAS: Clear command states after use
        shouldAccelerate = null;
        horizontalMovement = null;
        shouldJump = null;

        // ZAS: Store the jump state each frame for reference in the next frame to detect changes in state
        wasJumpingLastFrame = isJumping;
    }

    private void FixedUpdate()
    {
        //Forward Acceleration
        if(!drifting)
            sphere.AddForce(-kartModel.transform.right * currentSpeed, ForceMode.Acceleration);
        else
            sphere.AddForce(transform.forward * currentSpeed, ForceMode.Acceleration);

        //Gravity
        sphere.AddForce(Vector3.down * gravity, ForceMode.Acceleration);

        //Steering
        transform.eulerAngles = Vector3.Lerp(transform.eulerAngles, new Vector3(0, transform.eulerAngles.y + currentRotate, 0), Time.deltaTime * 5f);

        RaycastHit hitOn;
        RaycastHit hitNear;

        Physics.Raycast(transform.position + (transform.up*.1f), Vector3.down, out hitOn, 1.1f,layerMask);
        Physics.Raycast(transform.position + (transform.up * .1f)   , Vector3.down, out hitNear, 2.0f, layerMask);

        //Normal Rotation
        kartNormal.up = Vector3.Lerp(kartNormal.up, hitNear.normal, Time.deltaTime * 8.0f);
        kartNormal.Rotate(0, transform.eulerAngles.y, 0);
    }

    public void Boost()
    {
        drifting = false;

        if (driftMode > 0)
        {
            DOVirtual.Float(currentSpeed * 3, currentSpeed, .3f * driftMode, Speed);
            DOVirtual.Float(0, 1, .5f, ChromaticAmount).OnComplete(() => DOVirtual.Float(1, 0, .5f, ChromaticAmount));
            kartModel.Find("Tube001").GetComponentInChildren<ParticleSystem>().Play();
            kartModel.Find("Tube002").GetComponentInChildren<ParticleSystem>().Play();
        }

        driftPower = 0;
        driftMode = 0;
        first = false; second = false; third = false;

        foreach (ParticleSystem p in primaryParticles)
        {
            p.startColor = Color.clear;
            p.Stop();
        }

        kartModel.parent.DOLocalRotate(Vector3.zero, .5f).SetEase(Ease.OutBack);

    }

    public void Steer(int direction, float amount)
    {
        rotate = (steering * direction) * amount;
    }

    public void ColorDrift()
    {
        if(!first)
            c = Color.clear;

        if (driftPower > 50 && driftPower < 100-1 && !first)
        {
            first = true;
            c = turboColors[0];
            driftMode = 1;

            PlayFlashParticle(c);
        }

        if (driftPower > 100 && driftPower < 150- 1 && !second)
        {
            second = true;
            c = turboColors[1];
            driftMode = 2;

            PlayFlashParticle(c);
        }

        if (driftPower > 150 && !third)
        {
            third = true;
            c = turboColors[2];
            driftMode = 3;

            PlayFlashParticle(c);
        }

        foreach (ParticleSystem p in primaryParticles)
        {
            var pmain = p.main;
            pmain.startColor = c;
        }

        foreach(ParticleSystem p in secondaryParticles)
        {
            var pmain = p.main;
            pmain.startColor = c;
        }
    }

    void PlayFlashParticle(Color c)
    {
        GameObject.Find("CM vcam1").GetComponent<CinemachineImpulseSource>().GenerateImpulse();

        foreach (ParticleSystem p in secondaryParticles)
        {
            var pmain = p.main;
            pmain.startColor = c;
            p.Play();
        }
    }

    private void Speed(float x)
    {
        currentSpeed = x;
    }

    void ChromaticAmount(float x)
    {
        postProfile.GetSetting<ChromaticAberration>().intensity.value = x;
    }
}

Now the Kart Controller no longer uses inputs directly. It operates by having Accelerate, Jump, or Steer called by any external user… which can be a player driven by inputs or an AI driven by logic! Exciting!

Now we need to create a new MonoBehaviour named “PlayerInputProvider.cs” and have it detect inputs and then call commands on our newly refactored KartController. Here is what I came up with:

using UnityEngine;

public class PlayerInputProvider : MonoBehaviour
{

    [SerializeField]
    private KartController kart = null;

    private void Update()
    {
        if (kart == null)
        {
            return;
        }

        // ZAS: If we are accelerating, tell the kart to accelerate
        if (Input.GetButton("Fire1"))
            kart.Accelerate();

        if (Input.GetButton("Jump"))
            kart.Jump();

        // ZAS: Tell the kart how to steer each update
        float horizontalMovement = Input.GetAxis("Horizontal");
        kart.Steer(horizontalMovement);
    }

}

All PlayerInputProvider does is detect keyboard or game-pad inputs and call actions on our kart controller. Make sure to add and configure the PlayerInputProvider to the player kart object, otherwise it will no longer move!

kartsettings

Step 2 – Program The AI

This is where things get interesting. As stated above, we really want fair gameplay between AI and the player, so we don’t want this AI to be perfect and unbeatable. For this very reason, I decided to teach my AI how to make live assertions on the environment and use those assertions to navigate in real time like a player. I didn’t want the AI knowing too much information ahead of time, giving it a potential advantage over a real player. If you think about it… the players are really just watching pixels move on the screen and reacting to them in real time.. that’s what I want!!!

The first thing I need to do is teach my AI how to drive to a target location. We begin with rotation. I wanted my AI to understand if it should rotate left or right towards a target. To accomplish this, I used the Cross Product. Basically, if we know two directional vectors, we can use the cross product of them to give us a perpendicular direction. The perpendicular line changes directions based on the angle between the two source directions.

In the example above, notice the relationship between the lines. The blue line (cross product) will be pointing up when the target position is anywhere on the left side of the yellow line. The moment the target passes over to the right side of the yellow line the blue line will become positive. All these lines are directional axis aligned, forcing the y value of the endpoint of the blue line to have a range of [-1,1]. This value can then be used to determine whether or not the AI should turn left or right to reach it’s target, and will even modulate up to the max values to tell us how hard we should turn in that direction. The arrows in the example above indicate whether the kart in the center of the video should rotate left or right to face the moving target.

Time to program it in!

Create a new script and name it “AIInputProvider.cs”. Update the new script to look like this:

using UnityEngine;

public class AIInputProvider : MonoBehaviour
{

    [SerializeField]
    private Transform Target = null;

    [SerializeField]
    private KartController Kart = null;

    private void Update()
    {
        if (Kart == null || Target == null)
        {
            return;
        }

        Vector3 directionToTarget = (Target.position - Kart.transform.position).normalized;
        Vector3 targetInLocalSpace = Vector3.Cross(Kart.transform.forward, directionToTarget);

        Kart.Steer(targetInLocalSpace.y * 5f);
    }

}

This is pretty simple. We find the direction to the given target, then we take the kart forward and the direction to the target and run it through the Cross function to perform a cross product. We take the resulting vector and use it’s y value (the blue line from the earlier demonstration) and multiply it times a constant (5f) to give it more effect before we pass it along to our kart. If we drag and drop the player kart onto the AIInputProvider’s Target field and run the game, we get this:

It’s working! The AI kart seems to rotate to face it’s target! Now we just need the kart to move. Simply call Kart.Accelerate() after Kart.Steer() and re-run. Now we get this:

Notice how the AI kart seems to never stop ramming into the player (or driving on top of them)? Turns out, we should probably code in some logic to have the AI stop accelerating when it gets close to the target.

To accomplish artificial braking, we will have a distance from the target that, when reached, will make the kart stop accelerating. Then, we will add some additional code to ensure that acceleration only happens when the target is in front of the kart.

To add the stop threshold, simply drop in this bit of code before the turning logic:

float distanceFromTarget = Vector3.Distance(Target.position, Kart.transform.position);
if (distanceFromTarget < 5f)
{
        return;
}

Our AI will break when they are close enough to the target, but they accelerate a little too aggressively up until that point. To give them more accuracy, we should only have them accelerate when the target is in front of them. To accomplish this we can use the Dot Product. Basically, if we feed two normalized direction vectors to the Dot function, a value will come out that tells us how close the two directions are to facing each other. if the value is 1 then they are pointing the same direction. If the value is -1 then they are pointing in opposite directions. If the value is 0 then they are perpendicular (on either side). I made another demonstration of how this works for our example:

If we use a factor like .5 and say that the dot product must be greater than the given value, then you can think of that function as being a cone. This allows us to detect when things are within our cone of visibility and begin accelerating. Here is what that code might look like:

float forwardDot = Vector3.Dot(Kart.transform.forward, directionToTarget);
if (forwardDot > 0f)
  Kart.Accelerate();

With all changes together, this is what AIInputProvider.cs should look like

using UnityEngine;

public class AIInputProvider : MonoBehaviour
{

    private const float MINIMUM_DISTANCE_TO_POINT = 5f;

    [SerializeField]
    private Transform Target = null;

    [SerializeField]
    private KartController Kart = null;

    private void Update()
    {
        if (Kart == null || Target == null)
        {
            return;
        }

        float distanceFromTarget = Vector3.Distance(Target.position, Kart.transform.position);
        if (distanceFromTarget < MINIMUM_DISTANCE_TO_POINT)
        {
            return;
        }

        Vector3 directionToTarget = (Target.position - Kart.transform.position).normalized;
        Vector3 targetInLocalSpace = Vector3.Cross(Kart.transform.forward, directionToTarget);

        Kart.Steer(targetInLocalSpace.y * 5f);

        float forwardDot = Vector3.Dot(Kart.transform.forward, directionToTarget);
        if (Mathf.Abs(targetInLocalSpace.y) < .8f && forwardDot > 0f)
            Kart.Accelerate();
    }

}

Now our AI is fully capable of navigating towards a point in space! Onward to the next step, creating data for the AI to follow.

Step 3 – Generate Data

We are so close! Our AI now understands how to navigate to a point in 3D space, so all that remains to do is build a system of waypoints that the AI needs to follow and then they will be able to roam around the track! I took a pretty creative approach to this (there are so many ways to do it) where you actually race the track yourself and record data along the way. That data becomes the AI waypoint data. This is nice because in the future you could record an expert version of the data where you take the best paths and this will grant you the ability to change the difficulty!

First off, let’s define our data. All we need is an array of Vector3s that the AI can linearly work their way through. They will chase the current target until they are within stopping distance and then switch to the next target and do the process all over again. Because we will be recording live gameplay, I chose to use ScriptableObjects since they will not revert their changes after we stop playing. Create a script named “TrackData.cs”. Instead of inheriting from MonoBehaviour, you will instead inherit from ScriptableObject. Now we just define a serializable array of Vector3 values, and some methods for getting positions by index. Here is what mine ended up looking like:

using UnityEngine;

public class TrackData : ScriptableObject
{
    [SerializeField]
    private Vector3[] m_trackNodes = null;

    public int GetNodeLength()
    {
        return m_trackNodes.Length;
    }

    public Vector3? GetTargetNode(int index)
    {
        if (index < 0 || index >= m_trackNodes.Length)
        {
            return null;
        }

        return m_trackNodes[index];
    }

    public void SetPoints(Vector3[] poits)
    {
        m_trackNodes = poits;
    }
}

We will come back to this later.

We need to create a component to represent our track and data. Create a new component named “TrackDataController.cs”. All this needs to do is serialize a TrackData instance and provide access to some of those functions. Here’s mine:

using UnityEngine;

public class TrackDataController : MonoBehaviour
{

    [SerializeField]
    public TrackData TrackNodes = null;

    private void OnDrawGizmos()
    {
        if (TrackNodes == null)
        {
            return;
        }

        var length = TrackNodes.GetNodeLength();
        for (int i = 0; i < length; i++)
        {
            var node = TrackNodes.GetTargetNode(i);
            if (!node.HasValue)
            {
                continue;
            }

            Gizmos.DrawSphere(node.Value, .5f);
        }
    }

    public int GetNodeLength()
    {
        if (TrackNodes == null)
        {
            return 0;
        }

        return TrackNodes.GetNodeLength();
    }

    public Vector3? GetTargetNode(int nodeIndex)
    {
        if (TrackNodes == null)
        {
            return null;
        }

        return TrackNodes.GetTargetNode(nodeIndex);
    }
}

Place a TrackDataController on an object in the scene and then drag your recorded track data into it. If you have no data yet, don’t worry! You can just record some data and it will automatically be put into the controller for you.

Now we need a way to record the player movements. We need to do this without losing focus, so a custom inspector editor was out of the question. I decided to go with an editor window instead. All the window does is start and stop recording. When recording stops, it creates a new TrackData asset file and stores all the points in it. Here is what I ended up with:

using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

public class TrackDataWindow : EditorWindow
{

    private float DISTANCE_PER_POINT = 5f;

    private bool recordData = false;
    private PlayerInputProvider inputProvider = null;
    private List<Vector3> positionStack = new List<Vector3>();

    [MenuItem("MixAndJam/Windows/Track Data Editor")]
    public static void OpenTrackDataWindow()
    {
        GetWindow<TrackDataWindow>().Show();
    }

    private void OnEnable()
    {
        SceneView.duringSceneGui += OnSceneGUI;
    }

    private void OnDisable()
    {
        SceneView.duringSceneGui -= OnSceneGUI;
    }

    private void OnSceneGUI(SceneView sceneView)
    {
        Handles.BeginGUI();

        foreach (var point in positionStack)
        {
            Handles.SphereHandleCap(0, point, Quaternion.identity, .5f, EventType.Repaint);
        }

        Handles.EndGUI();
    }

    private void OnGUI()
    {
        RecordPath();
        RenderRecordButton();

        if (recordData)
            Repaint();
    }

    private void RecordPath()
    {
        if (inputProvider == null)
        {
            return;
        }

        if (positionStack.Count == 0)
        {
            return;
        }

        var currentPosition = inputProvider.transform.position;
        var lastPosition = positionStack[positionStack.Count - 1];
        var distanceFromLast = Vector3.Distance(currentPosition, lastPosition);
        if (distanceFromLast > DISTANCE_PER_POINT)
        {
            var delta = distanceFromLast - DISTANCE_PER_POINT;
            if (delta > 0f)
            {
                var directionToCurrent = (currentPosition - lastPosition).normalized;
                var adjustedPoint = lastPosition + (directionToCurrent * DISTANCE_PER_POINT);
                positionStack.Add(adjustedPoint);
            }
            else
            {
                positionStack.Add(currentPosition);
            }
        }
    }

    private void RenderRecordButton()
    {
        if (GUILayout.Button(recordData ? "Stop" : "Record"))
        {
            recordData = !recordData;

            if (recordData)
            {
                PlayerInputProvider playerInputProvider = Component.FindObjectOfType<PlayerInputProvider>();
                if (playerInputProvider == null)
                {
                    recordData = false;
                    return;
                }

                inputProvider = playerInputProvider;
                positionStack.Add(playerInputProvider.transform.position);
            }
            else
            {
                var newInstance = ScriptableObject.CreateInstance<TrackData>();
                newInstance.SetPoints(positionStack.ToArray());
                EditorUtility.SetDirty(newInstance);
                AssetDatabase.CreateAsset(newInstance, $"Assets/TrackData{DateTime.UtcNow.Ticks}.asset");

                TrackDataController trackDataController = Component.FindObjectOfType<TrackDataController>();
                if (trackDataController != null)
                {
                    trackDataController.TrackNodes = newInstance;
                    EditorUtility.SetDirty(trackDataController);
                }

                AssetDatabase.SaveAssets();

                inputProvider = null;
                positionStack.Clear();
            }
        }
    }

}

Now we can press play and open the window by clicking MixAndJam/Windows/Track Data Editor in the unity menu. You can press record at any time and move slowly around the track since it is based on distance. Files with the name “TrackDataXXXXXXX.asset” will begin appearing when you stop recording. These are the data files that we will use for our track. Since they are saved in separate files, they are interchangeable.

Now our AI needs to use the TrackDataController to navigate around instead of using a serialized target. Here’s the updated version:

using UnityEngine;

public class AIInputProvider : MonoBehaviour
{

    private const float MINIMUM_DISTANCE_TO_POINT = 5f;

    private Vector3? target = null;

    [SerializeField]
    private KartController Kart = null;

    [SerializeField]
    private TrackDataController TrackDataController = null;

    private int nodeIndex = 0;

    private void OnDrawGizmos()
    {
        if (target.HasValue)
        {
            Gizmos.DrawLine(Kart.transform.position, target.Value);
            Gizmos.DrawWireSphere(target.Value, MINIMUM_DISTANCE_TO_POINT);
        }
    }

    private void Update()
    {
        if (Kart == null || TrackDataController == null)
        {
            return;
        }

        if (target.HasValue)
        {
            float distanceFromTarget = Vector3.Distance(target.Value, Kart.transform.position);
            if (distanceFromTarget < MINIMUM_DISTANCE_TO_POINT)
            {
                nodeIndex++;
                if (nodeIndex > TrackDataController.GetNodeLength())
                {
                    nodeIndex = 0;
                }
            }
        }

        target = TrackDataController.GetTargetNode(nodeIndex);

        if (target != null)
        {
            Vector3 directionToTarget = (target.Value - Kart.transform.position).normalized;
            Vector3 targetInLocalSpace = Vector3.Cross(Kart.transform.forward, directionToTarget);

            Kart.Steer(targetInLocalSpace.y * 5f);

            float forwardDot = Vector3.Dot(Kart.transform.forward, directionToTarget);
            if (Mathf.Abs(targetInLocalSpace.y) < .8f && forwardDot > 0f)
                Kart.Accelerate();
        }
    }

}

Now our AI can navigate a recorded path! Here’s what it should look like (I am recording my driving data and then the AI are using it at the end):

Now you can record your own custom data and have the AI run through it :). Here is my final outcome again for reference:

Conclusion

I hope this is insightful and helpful to those wanting to experiment with racing AI. It was my first time building racing AI and it shows! What I did here is super basic and has many flaws. Let’s list them!

  1. No obstacle avoidance. If another racer is in front of a racer, they don’t attempt to move out of the way. They are all racing the same path and have a high likelihood of hitting each other.
  2. No rubber banding. In my small amount of research on racing AI, I found that there is a LOT of debate around fairness in racing games. A lot of companies use a technique called rubber banding, which is essentially the AI speeding up or slowing down to stay closer to the player and keep things interesting. The discussions seem to always be about the best way to make the player feel engaged the entire race and feel like they are barely winning by a small amount. The game would be no fun if you always lapped every AI all the time!
  3. No path-finding. This level has an elevated ramp on the edge somewhere. The AI will ride the ramp if everything lines up, but occasionally you can bump an AI off and they will instantly start moving towards the wall accelerating in attempt to get back to that node. The AI have no actual navigational abilities. I didn’t see the need for a navmesh on the race track, but maybe it could be used when the karts go out of bounds. It could help them get back home.
  4. No logic. At one point in the development of this AI, I noticed the way I play as a human. I took note of the way that my style of movement and braking/acceleration changed when I was on different types of turns. I entertained the idea of a state machine with a few states on it.. including a “straight” and “curve” state where each state handles the controls a little different. I think I could implement something like this and then mark groups of waypoints to tag as certain states so that while the AI chases those nodes, they will switch to the new state and drive accordingly.
  5. Imprecise steering. This one is obvious in the videos. The AI sort of “wiggle” back and forth as they level out. This isn’t too bad, but is obviously not production quality. I think with a little more work I could have sorted out that little issue.

Thank you for reading up on this, and I look forward to the next enhancements I will add to Andre’s projects :). If you would like to check out the code I wrote, you can check it out here:
https://github.com/mixandjam/MarioKart-Drift/tree/ai

Zachary is a programmer, designer, and composer. He has been working in the video game industry for 6 years, doing UI/UX for 5 years, and has been programming for almost 11 years. Zack loves making music and video games and loves helping others reach their dreams!

Soundcloud: soundcloud.com/zack-sheppard

Twitter: @GingerLoaf

Leave a Reply

Your email address will not be published. Required fields are marked *