Curitor
Gameplay Systems · Topic Guide

Bot Players

Bots fill the empty slots in your round. They run your course through the same character controller your players use, navigate a waypoint graph you author, and lose jumps the way people actually lose jumps.

Overview

A parkour round with nobody in it is not a race. Bots give a single-player session an opponent and fill the slots a lobby didn't.

The important design decision is what a bot is: an input provider, not a movement system. A bot computes a move vector and a jump intent and feeds them to the same public methods on the character that a gamepad feeds. It never writes a transform and never sets a velocity.

The navigation graph

Bots navigate a typed waypoint graph that lives in your level scene: a NavGraph object with NavNode children, joined by directed edges that say how to get from one node to the next.

FieldTypeWhat it does
WalkEdgeHold the stick toward the node. The default for flat ground.
JumpEdgeRun at the gap and jump. The bot launches when the ground runs out — at the lip — not at the node, so it survives sloppy node placement.
DropEdgeWalk off and fall, where the landing is within a safe height.
InteractEdgeOperate the button at this node — then walk on. For a wall that only opens once something is pressed.

Edges are directed on purpose — a drop off a ledge is traversable one way only, and pretending otherwise is how a bot ends up trying to walk up a cliff.

  1. Add a NavGraph

    An empty GameObject in your level scene with the NavGraph component.
  2. Place nodes along the route

    Use Append Node At Scene View Pivot — it raycasts onto the collider under your view, so nodes land on the surface a bot will actually stand on, not on the mesh you can see.
  3. Mark the finish

    Tick Is Finish on the node(s) inside your end zone. Without one, bots have nothing to route to.
  4. Chain and type the edges

    Chain Nodes With Walk Edges gives you a first pass; upgrade the gaps to Jump and the button-wall to Interact. A course that needs a movement mode the kit doesn't ship should route around it with a Walk or Drop edge instead — NavEdgeTypehas no edge for a mode that doesn't exist.
  5. Snapshot the template

    Setup Window → Update Template From Level Scene. Skip this and your next Install overwrites the graph with the old template.
A NavGraph drawn over a generated course in the Scene view, with the Nav Graph inspector beside it listing every edge type and its colour
The graph draws itself in the Scene view, and the inspector beside it names what each colour means — walk, jump, drop, interact. A wrong edge is invisible in a list and obvious as a line running off a platform.

Authoring the graph from code

The graph can also be built programmatically: create NavNode children under a NavGraph, wire edges between them with TrySetInferredEdge(it adds the edge when none exists and retypes an unlocked one), thenCollectChildNodes() and RegisterNow() once the graph is complete.

Building a graph from a script
public class GenerateNavGraph : MonoBehaviour
{
    public void BuildTheGraph()
    {
        NavGraph graph = GetComponent<NavGraph>();

        // Create three nodes
        GameObject node1Obj = new GameObject("Node1");
        node1Obj.transform.SetParent(graph.transform);
        node1Obj.transform.position = new Vector3(0, 0, 0);
        NavNode node1 = node1Obj.AddComponent<NavNode>();

        GameObject node2Obj = new GameObject("Node2");
        node2Obj.transform.SetParent(graph.transform);
        node2Obj.transform.position = new Vector3(5, 0, 0);
        NavNode node2 = node2Obj.AddComponent<NavNode>();

        GameObject node3Obj = new GameObject("Node3");
        node3Obj.transform.SetParent(graph.transform);
        node3Obj.transform.position = new Vector3(5, 0, 5);
        NavNode node3 = node3Obj.AddComponent<NavNode>();

        // Link them: Walk to node2, Jump from node2 to node3
        node1.TrySetInferredEdge(node2, NavEdgeType.Walk);
        node2.TrySetInferredEdge(node3, NavEdgeType.Jump);
        node3.ConfigureGenerated(true);  // Mark as finish

        // A graph collects its child nodes once, when it is enabled; new
        // children need an explicit re-collect before registering.
        graph.CollectChildNodes();
        graph.RegisterNow();
    }
}

Difficulty: KPI profiles

A bot that runs a perfect line every time is a robot, and beating it means nothing. Human-likeness lives in the decision layer: a bot picks worse, hesitates, and sometimes simply fails to press jump at the lip — and then takes the real fall and the real respawn.

Those dials are a BotProfile asset. Every field is a range, rolled once when the bot spawns and then fixed for its life — so four bots from one profile are siblings, not clones.

FieldTypeDefaultWhat it does
Speed FactorVector2 (min, max)0.97 – 1.00Fraction of full speed. Keep this band narrow — see the callout below.
Drop ChanceVector2 (min, max)0.02 – 0.06Chance per jump that the bot runs off the lip WITHOUT jumping. A real fall, a real respawn, a real fail on its result row.
Wrong Turn ChanceVector2 (min, max)0.03 – 0.08Chance per edge of briefly steering off-line. On a narrow platform this genuinely walks the bot into the drink.
Wrong Turn Seconds / DegreesVector2 (min, max)0.3–0.8s / 25–70°How long a wrong turn lasts and how far off-line it goes.
Hesitation SecondsVector2 (min, max)0 – 0.35A pause at the take-off node before committing to a jump.

Three presets ship — Casual, Average and Skilled — and they are ordinary assets: retune them, or author your own, without touching code. Install never overwrites a profile you have edited.

Adding bots

In game, the host opens the Escape menu and clicks Add Bot. The button is hidden for clients (a bot is a server-owned object, so only the host can field one) and disabled once the round is under way, with the reason in the label.

From code, it is one call:

FillTheLobby.cs
using Curitor.PartyCoreKit.AI;
using UnityEngine;

// The same seam the Escape menu's "Add Bot" button uses.
// Host-side only -- a bot is a server-owned object.
public class FillTheLobby : MonoBehaviour
{
    [SerializeField] private BotProfile profile;   // null = a random stock preset

    public void FillRemainingSlots()
    {
        BotSpawner spawner = BotSpawner.Instance;
        if (spawner == null) return;

        // Capacity math already accounts for humans AND bots, so this
        // can never over-fill the level's spawn grid.
        while (spawner.CanAddBot())
            spawner.AddBot(profile);
    }
}

When a bot gets stuck

It will, while your graph is young. Bots are built to say so precisely, and to recover on their own.

A bot that stops making progress re-plansfrom where it actually is — and if it still can't get past, it takes the ordinary fail and respawn a human takes. Never a bot-specific teleport. The worst case is a bot that keeps failing and keeps respawning, which is exactly what a struggling player does, and the round timer ends it either way. A bot cannot soft-lock a round.

Everything is named in the console and in the Inspector: which node it was heading to, over which edge type, and how close it got. Select a live bot to see its rolled KPIs, its current target — and a warning when the jump it is about to attempt was rolled as a deliberate botch. That last one matters: without it, a bot that fell because it is a 15%-drop Casual and a bot that fell because your jump edge is wrong look exactly the same.

Reproducing a run
// Every bot's KPI roll derives from the session seed plus its bot index.
// Fix the seed and you replay the exact same field of runners -- which is
// what makes a bot regression reproducible instead of anecdotal.
BotSpawner.Instance.SessionSeed = 20260714;