Backup Dispatcher — BackupDispatcher
Wave-based backup orchestration with honest unit tracking: the dispatcher knows which vehicles are actually yours, when they exist, and when they arrive on scene. It exists because the naive approaches both fail:
Functions.RequestBackupalone gives you a vehicle handle but no arrival signal, and spawns can silently fail.Events.OnBackupUnitCreatedfires for all backup in the game — including the player's own backup menu and other plugins. Blindly adopting every event would hijack units that aren't yours.
BackupDispatcher does both and correlates them: every RequestBackup return value is
kept, and unit-created events are matched by request position (30 m) and time window
(45 s). Duplicates (a vehicle seen via both paths) are folded together.
Core model
| Type | Role |
|---|---|
Wave | A labeled batch of units with a delay after the previous wave |
UnitOrder | One unit in a wave: EBackupUnitType + EBackupResponseType (default Code3) |
DispatchOptions | Session knobs: position provider, escalation gate, blips, stagger, arrival radius |
TrackedUnit | A followed unit: Vehicle, Blip, Type, CreatedUtc, HasArrived |
Usage
var dispatcher = new BackupDispatcher(log);
dispatcher.UnitTracked += u => log.Info("tracking " + u.Type);
dispatcher.UnitArrived += u => notifier.Show(Palette.Police(u.Type.ToString()) + " on scene");
// 1. Open a session (one per incident):
dispatcher.BeginSession(fibers, dutyFlag, new DispatchOptions
{
PositionProvider = () => Game.LocalPlayer.Character.Position, // re-read live
ContinueEscalation = () => StillInDanger(), // gate between waves; false = stop escalating
AttachBlips = true, // police light blue; EMS white; fire orange
ArrivalRadius = 35f,
StaggerMs = 300, // between unit requests (real time)
SpreadRadius = 18f, // 0 = off; see "Spread response" below
});
// 2. Build the timeline:
var waves = new List<Wave>
{
new Wave { Label = "patrol", DelaySeconds = 0,
Units = { new UnitOrder { Type = EBackupUnitType.LocalUnit },
new UnitOrder { Type = EBackupUnitType.LocalUnit } } },
new Wave { Label = "tactical", DelaySeconds = 45,
Units = { new UnitOrder { Type = EBackupUnitType.SwatTeam } } },
};
// 3. Run it (blocks the calling fiber for the whole timeline — run on your own fiber):
int created = dispatcher.DispatchWaves(waves); // honest count of spawned units
// 4. Stand down:
dispatcher.CancelDispatch(); // synchronous, never yields — safe as the FIRST
// step of a stand-down, kills the timeline instantly
dispatcher.EndSession(fadeBlips: true);
Live state while the session runs: TrackedCount, ArrivedCount, TotalTrackedCount
(cumulative, never decremented) and Snapshot() (safe to iterate; validate entities
before use).
Semantics that matter
Incident-scoped cancellation
BeginSession creates an internal incident flag alongside your duty-wide flag.
CancelDispatch()/EndSession cancel the incident: the wave timeline, event adoption
and tracker fiber all stop — without touching your duty-wide flag or other services. A
sleeping timeline observes the flag it was born with, so a rapid EndSession →
BeginSession cannot resurrect the old timeline into the new incident ("zombie resume").
Spread response (SpreadRadius)
With a spread radius set, successive unit requests target golden-angle bearings on rings
around the incident position instead of the exact same point, so a big response surrounds
the scene rather than stacking into a wall of vehicles on one road node (which physically
blocks late arrivals — the classic "ambulance can't reach the downed officer" jam). LSPDFR
snaps targets to road nodes, so offsets only diversify approaches. Keep the spread smaller
than ArrivalRadius or arrival detection will undercount. Correlation uses the offset
positions consistently.
Unit blip colors
With AttachBlips, tracked units get community-standard colors automatically: police
light blue, ambulances white (named "EMS"), firetrucks orange — so medical units
read at a glance on a busy minimap.
Escalation gating
Between waves (never before the first), ContinueEscalation() is consulted; returning
false skips all remaining waves — the threat is cleared, stop sending armies. A throwing
gate is treated as true: when in doubt, keep help coming.
Arrival detection
The tracker checks distance to the live incident position every 500 ms. Ground units
arrive within ArrivalRadius (3D). Air units are special-cased: helicopters hold
altitude and orbit, so a 3D check never passes — they are judged by ground-plane (2D)
distance with a widened ring (≥ 80 m), i.e. "on scene" while overhead.
Ownership
Tracked vehicles remain LSPDFR-owned. The dispatcher never makes them persistent and
never deletes them; EndSession removes its blips and forgets the units, letting LSPDFR's
own cleanup work. Do not put dispatcher units in your EntityBag.
Blip fade on stand-down
EndSession(fadeBlips: true) fades unit blips out (~1.6 s) on a detached fiber,
deliberately not a registry fiber: teardown paths often run on LSPDFR's callout fiber
(must not block) and registries are typically about to AbortAll (which would kill the
fade mid-way and leak the blips).
Agency override
DispatchOptions.AgencyScriptName forces a specific agency; leave it null/empty to let
LSPDFR and backup.xml decide — which is what respects users' region packs. When
overriding, ExactLocation and NoResponseTask are passed through to LSPDFR.
Rules
DispatchWavesandEndSessionmust run on a GameFiber.PositionProvideris called from fibers repeatedly — keep it cheap and side-effect free.- Event handlers (
UnitTracked,UnitArrived) fire on dispatcher fibers: return fast, hand real work off. See the Crowd engine'sEscalationLadderfor a full consumer that re-tasks arriving units. - One session per incident:
BeginSessionimplicitly ends any previous session.