Controllers in depth
A controller is the possession point between a player and the world: it can grab and release pawns at runtime, it is private to its owner, and — done right — it is disposable, which is exactly what a clean reconnect flow relies on.
When to use this
- Runtime possession changes: respawning into a new pawn, entering a vehicle, switching to spectator.
- Disconnect/reconnect handling where the player's body must survive the connection.
When not to
- Bot logic — that is ai-controllers.md, same possession model without the network.
- Anything that must outlive the connection (score, inventory, match stats) — that state belongs on the pawn or a game entity, not on the controller (see the reconnect pattern below).
Minimal example
Server-side session handling that survives a dropped connection:
PlayerSessions.cs (server)
using LiteEntitySystem;
using LiteEntitySystem.Transport;
public class PlayerSessions
{
private readonly ServerEntityManager _manager;
public PlayerSessions(ServerEntityManager manager) => _manager = manager;
public void OnDisconnected(NetPlayer player)
{
// detach first — otherwise RemovePlayer destroys the pawn together
// with the controller via DestroyWithControlledEntity
_manager.GetPlayerController(player)?.StopControl();
_manager.RemovePlayer(player);
// the pawn stays in the world, reverted to server ownership
}
public NetPlayer OnReconnected(AbstractNetPeer peer, BasePlayer existingPawn)
{
var player = _manager.AddPlayer(peer);
_manager.AddController<BasePlayerController>(player, existingPawn);
return player;
}
}
How it works
The possession API
StartControl(pawn) binds a pawn to this controller and rewires the pawn's ownership to the controller's owner; it implicitly releases any previously controlled pawn. StopControl() detaches the pawn, which reverts to server ownership (or its parent's). DestroyWithControlledEntity() kills both at once — it is what RemovePlayer calls for the player's controller by default. All three are server-only and silently do nothing on the client.
The ordering guarantee
BeforeControlledUpdate() runs from the pawn's base.Update(), immediately before the pawn's own logic — a per-tick guarantee that input handling precedes movement. Do not rely on the relative Update() order of the controller and pawn entities instead: entity update order follows creation order, which is not a contract. Anything that must happen "just before my pawn moves" belongs in BeforeControlledUpdate.
Finding controllers and players
On the server: GetPlayerController(NetPlayer | byte | AbstractNetPeer) returns the player's HumanControllerLogic. On the client: GetPlayerController<T>() returns the local player's controller or null. In the other direction, a controller resolves its player with GetAssignedPlayer(), and GetControlledEntity<T>() / the typed ControlledEntity property resolve the pawn.
Reacting to possession changes
Override OnControlledEntityChanged(prevPawn) on the controller — it fires on the server at the change and on the owning client when the change syncs in. The argument is the previous pawn; the current one is already readable through ControlledEntity.
The reconnect pattern
A controller should be treated as connection-scoped: when a player drops, detach the pawn with StopControl, let RemovePlayer destroy the controller, and keep the pawn alive in the world. On reconnect, AddPlayer produces a fresh NetPlayer (with a new player id), a fresh controller is created, and AddController(player, existingPawn) re-possesses the old body — ownership rewires to the new id automatically.
The corollary is the design rule from the top of this page: nothing that must survive a reconnect may live in the controller. Its SyncVars start clean with every new controller instance; the pawn's state is what persists.
Behavior details
Possession is entirely server-driven. RemovePlayer without a prior StopControl destroys the controller and its pawn — the right default for permanent leavers, the wrong one for reconnectable games. A pawn without a controller keeps updating as a server-owned entity.
Warning
Common mistakes
- Calling
RemovePlayerwithoutStopControlwhen the pawn should survive — the default path destroys pawn and controller together. - Storing score, inventory or per-match stats in the controller — a reconnect creates a fresh controller and that state is gone; persistent state lives on the pawn or a game entity.
- Ordering logic by the controller entity's
Update()relative to the pawn's — creation-order scheduling is not a contract; useBeforeControlledUpdate. - Calling
StartControl/StopControlon the client and wondering why nothing happens — possession is server-only and the calls are silent no-ops.