Skip to main content
The live game

Not translated yet — showing English.

Bots

A bot joins as an ordinary player. Only the brain lives elsewhere. Same session, same entry validation, same rules, same ACL. The room cannot tell the difference, by design, so bots exercise your real game rules and anti-cheat never needs a bot exception.

When to use it

  • Your lobbies need filling at off-peak hours: FillRoom tops matches to a quota, and bots yield seats as humans arrive.
  • Bots must play by the real rules (entry validation, ACL, visibility), so anti-cheat never needs a bot exception.
  • You bring an external brain, such as a learned policy or a service, that joins through ConnectAsBot like any player.
  • A disconnected player's entity must hand to a bot and back on reconnect, without the seat or prediction noticing.
  • Skip it when the character never decides. A dialogue NPC with no brain lives in World Objects.

This page covers connecting a bot: getting it into a room, filling a lobby to quota, handing a seat between a bot and a human. Writing the brain that decides is Writing a Brain.

Who does what

ActorOn this page
bot-brainconnects as a player; receives perception; sends commands
room-ownerdeclares profiles, fills rooms to quota, hands over bot/human

At a glance

The filler profile: difficulty numbers, a utility brain, and FillRoom to a quota
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);
@BotProfile('filler')
@Brain({ kind: 'utility' })
export class Filler {
  static difficulty = Difficulty.of({ reactionMs: 250, aimJitter: 0.08 });

  @Consider(Targeting.nearestEnemy) target: Behaviour;
  @Steer(Steering.seekAndStrafe) move: Behaviour;
  @UseAbilities(When.ready) fire: Behaviour;
}

PlayServ.bots.fillRoom('battle', { toQuota: 8, profile: 'filler', minHumans: 1 });
@bot_profile("filler")
@brain(kind="utility")
class Filler:
    difficulty = Difficulty.of(reaction_ms=250, aim_jitter=0.08)

    target = consider(Targeting.NEAREST_ENEMY)
    move = steer(Steering.SEEK_AND_STRAFE)
    fire = use_abilities(When.READY)

playserv.bots.fill_room("battle", to_quota=8, profile="filler", min_humans=1)
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

USTRUCT(PSBotProfile = "filler", PSBrain = (Kind = "Utility"))
struct FFiller
{
    GENERATED_BODY()

    UPROPERTY(PSDifficulty = (ReactionMs = 250, AimJitter = "0.08"))
    FPSDifficulty Difficulty;

    UPROPERTY(PSConsider = (Targeting = "NearestEnemy")) FPSBehaviour Target;
    UPROPERTY(PSSteer    = (Steering = "SeekAndStrafe")) FPSBehaviour Move;
    UPROPERTY(PSUseAbilities = (When = "Ready"))         FPSBehaviour Fire;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// a host tops up the room it serves
Client->Bots->FillRoom(PSKeys::Rooms::Battle,
    FPSFillRoomParams{ .ToQuota = 8, .Profile = TEXT("filler"), .MinHumans = 1 });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — the same attributes compile in Unity; FillRoom needs host rights, which a master-client build holds
[BotProfile("filler")]
[Brain(Kind.Utility)]
public static class Filler
{
    public static Difficulty Difficulty = Difficulty.Of(reactionMs: 250, aimJitter: 0.08f);

    [Consider(Targeting.NearestEnemy)] public static Behaviour Target;
    [Steer(Steering.SeekAndStrafe)]    public static Behaviour Move;
    [UseAbilities(When.Ready)]         public static Behaviour Fire;
}

PlayServ.Bots.FillRoom("battle", toQuota: 8, profile: "filler", minHumans: 1);

An external brain (heavier AI, a learned policy, a service) connects like any player:

ConnectAsBot joins an external brain as a player: same deltas in, same inputs out
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
const bot = await PlayServ.connectAsBot(projectKey, { botId: 'trainer-07' });
const seat = await bot.matchmaking.find('battle');
const room = await bot.rooms.join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs
bot = await PlayServ.connect_as_bot(project_key, bot_id="trainer-07")
seat = await bot.matchmaking.find("battle")
room = await bot.rooms.join(seat)
# perception in ← the same deltas a player receives; commands out ← the same inputs
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

// An Unreal-based trainer client is a legitimate brain — it connects as a player.
FPlayServClient::ConnectAsBot(ProjectKey, TEXT("trainer-07"),
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Bot = Result.Value();
        Bot->Matchmaking->Of<FBattleQueue>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("battle") },
            TPSOnResult<FPSTicket*>::CreateLambda([Bot](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                TPSSubscription Placement = TicketResult.Value()->Subscribe(
                    [Bot](const FPSSeat& Seat) { Bot->Rooms->Join(Seat); });
            }));
    }));
// perception in ← the same deltas a player receives; commands out ← the same inputs
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — runs as-is in Unity against the generated types
var bot = await PlayServ.ConnectAsBot(projectKey, botId: "trainer-07");
var seat = await bot.Matchmaking.Find("battle");
var room = await bot.Rooms.Join(seat);
// perception in ← the same deltas a player receives; commands out ← the same inputs

The model

A bot introduces no notion of its own. It is an actor credential wearing what rooms, data and locomotion already declare.

What a bot's declaration carries.

DeclaresWhat it is
thinking tickhow often the brains are asked, and it is not the simulation tick: the brains run outside, and a network call every tick is unfeasible
direction of the brainswhere they execute — a cloud function, the studio's backend, its game server. Which one is not part of the contract, and moving between them is not a breaking change
actor presetthe bot's rights, as an ordinary actor preset
visibility of the bot markerwhether participants are told. The marker itself always exists and the platform always observes it; whether players see it is the room type's declaration, because in some markets disclosing an AI opponent is an obligation and in others it is a product choice
behaviour when the brains are unavailableone of three, with no default: do nothing · leave the room · fall back to built-in default behaviour
roster fillingdeclared by the room type — how many, under what condition, until what moment. Matchmaking knows nothing about bots: it matches actors, and does not decide whom to top up with

What is true of every bot.

AlwaysWhat it is
an actor, not a playerit holds an actor credential but has no login provider, no links and no sessions
economic ownershipis none: no entitlements, no purchases, no leaderboard entries
perceptionis a player's: the same visibility zone, the same predicate, the same object-count limit. A bot and a player in the same position receive the same set of objects, so a bot cannot wallhack any more than a player can
wider perceptionis an actor preset, not a bot property: a debug or "omniscient coach" mode is declared as a preset with a wider predicate
between thoughtsthe last command applies, and its fate is whatever the movement type already declares for stale input — a bot whose brain is thinking is the same case as a player whose network dropped
room capacitycounts a bot: it occupies a seat like anyone else

Errors

  • The room does not accept bots is a conflict, and repeating it will not help.
  • The bot limit is exhausted is a conflict, not forbidden — the permission to introduce one is held; the room is full. Worth repeating once the room frees up.
  • A command for a bot from an actor without the permission answers forbidden, and repeating is pointless.
  • The brains being unavailable is not an error — it is one of the three declared behaviours above.
  • Declared at deploy, refused at deploy: a bot named as the owner of a leaderboard entry, and a missing thinking tick, are both validation failures at deploy time rather than surprises in a live room.

Limits

The numbers arrive with the platform limits chapter; what happens at each ceiling is fixed now.

  • Bots in a room: introduction is refused as a conflict; existing bots are never removed to make room.
  • Bots per project: the same conflict.
  • The thinking tick from below: a declaration faster than the floor is refused at deploy time, because a network call per tick is impractical.
  • The rate of commands for one bot: a rate limit with a deadline.
  • The deadline for a response from the brains: once it expires, the declared unavailability behaviour applies.