Crowd Engine — civil-disorder scenes
Burnsie.Common.Lspdfr.Crowds is a toolkit for building crowd scenes — protests, brawls,
riots — that stay performant, stay in tone, and clean up after themselves. It composes
with the rest of the library rather than replacing it: entities go in your EntityBag,
dispatch goes through BackupDispatcher, natives through the fenced CrowdNatives.
The pieces
| Type | Role |
|---|---|
CrowdEngineOptions | Performance budgets + tone guardrails, populated from your ConfigSchema |
CrowdSpawner | Model-agnostic ped/vehicle spawning: load-with-timeout, bad-model memory, ground-snap |
FactionManager | Scene-scoped relationship groups so factions fight each other autonomously |
Crowd | A managed crowd: members, per-ped behavior driven by hostility, dispersal state machine |
HostilityMeter | 0–100 hostility model with named bands, event deltas and passive decay |
CrowdArsenal | Verified weapon pools with tone-gated arming (melee / incendiary / firearm) |
EscalationLadder | Police response waves over BackupDispatcher, re-tasking arrivals to scene posts |
RoadClosure | Road-node shutdown + lit police blockers around a road-blocking scene |
A scene, end to end
// Setup (inside a BurnsieCallout.OnAccepted, typically):
var options = new CrowdEngineOptions(); // or populated from your config
var spawner = new CrowdSpawner(Log);
var factions = new FactionManager(Log, CalloutName); // scope prevents cross-callout collisions
var meter = new HostilityMeter(Log, initial: 30f);
var crowd = new Crowd(Log, Scene, spawner, factions, rng) { MaxMembers = options.MaxPeds };
// Factions that hate each other, but not the police (until provoked):
RelationshipGroup mob = factions.Faction("Mob");
RelationshipGroup rival = factions.Faction("Rival");
FactionManager.MakeMutualEnemies(mob, rival);
// Populate in budget-friendly batches (yields between batches — no frame spikes):
crowd.Populate(mobModels, Anchor, 3f, 14f, 12, mob, canFight: true,
options.SpawnBatchSize, options.SpawnBatchDelayMs);
crowd.Populate(rivalModels, Anchor, 3f, 14f, 12, rival, canFight: true,
options.SpawnBatchSize, options.SpawnBatchDelayMs);
crowd.MarkRingleaders(2); // red-blipped anchors of the scene
// Tick (from your callout loop):
meter.Tick(); // passive decay (real time — dilation-proof)
crowd.Tick(meter.Band); // drives per-ped behavior from the current band
// Scenario events feed the meter:
meter.Add(+15f); // bottle thrown
meter.Add(-20f); // ringleader arrested
HostilityMeter — the scene's mood
A 0–100 value with named bands that drive everything else:
| Band | Range | Crowd reads as |
|---|---|---|
Calm | 0–24 | containable by presence alone |
Agitated | 25–49 | tension building; members wander restlessly |
Volatile | 50–74 | sporadic violence; fighters engage |
Rioting | 75–100 | full crowd violence |
- Scenarios feed discrete events via
Add(delta)(player wades in, gas deployed, ringleader arrested);Tick()applies passive decay (default 0.6/s, real time). BandChangedfires synchronously on band transitions — bind HUD updates or escalation triggers to it.Fraction(0–1) feeds aTimerBarHudprogress bar directly;BandLabel()gives a HUD-ready string ("VOLATILE").- Not internally locked: drive it from one fiber (your callout tick), which is the intended usage.
Crowd — members and behavior
Populate(models, anchor, minRadius, maxRadius, count, faction, canFight, …)spawns in batches with a hard cap (MaxMembers); returns the honest created count. Spawned peds are registered in the sceneEntityBagand assigned to their faction automatically.MarkRingleaders(n)promotes random fighters to red-blipped ringleaders — arrest targets that anchor the scene.Tick(band)prunes dead/missing members and drives the state machine:Idle → Agitated → Fighting → Fleeing/Cowering → Gone. Fighters engage their nearest hated target (via faction relationships) inVolatile/Rioting; everyone wanders restlessly inAgitated.DisperseAll()sends everyone fleeing (stand-down);CowerNear(position, radius)makes members near a point cower (gas deployment).ActiveCount/RingleadersRemainingfeed objectives ("disperse the crowd", "arrest the ringleaders").
FactionManager — who hates whom
Faction(name)creates (or reuses) a relationship group namedBC_<scope>_<name>. Groups persist in the game's registry for the session (there is no managed delete API), so names are deliberately bounded and reused rather than leaked per callout.MakeMutualEnemies(a, b),MakeMutual(a, b, level),SetRelationship(a, b, level)— declare the hate matrix once.MakeHostileToCops(faction)turns a faction on police and player — use sparingly; most crowd scenes should stay faction-vs-faction until provoked.CalmTowardCopsreverses it (de-escalation).FactionManager.Cops/PlayerGroupexpose the stock groups.
CrowdArsenal — tone-gated arming
Named weapon pools (all verified WeaponHash members) instead of raw hashes:
CrowdArsenal.GiveMelee(ped, rng); // bat/crowbar/hammer/club/knife
CrowdArsenal.GiveIncendiary(ped, rng, options); // molotov — IF tone gates allow
bool armed = CrowdArsenal.GiveEscalationFirearm(ped, rng, options); // pistol — IF allowed
The gates live in CrowdEngineOptions: with HeaviestScenariosEnabled off (or the
specific AllowIncendiaryWeapons / AllowFirearmEscalation sub-flags), those requests
silently degrade to common melee — scenario code never needs to check the config
itself. GiveEscalationFirearm returns whether a firearm was actually given, so the
scenario can adjust its dispatch response.
EscalationLadder — the police response
A thin composition over BackupDispatcher (dispatch, tracking,
correlation and cancellation stay there) that adds two things:
- The civil-disorder wave ladder:
DefaultLadder(includeFire, includeEms)builds L1 patrol → L2 state+support → L3 SWAT → L4 NOOSE+air. The heaviest waves are omitted whenHeaviestScenariosEnabledis false. - Deployment: arriving units are re-tasked to scene posts — occupants leave the vehicle, walk to the post, then hold or engage.
var ladder = new EscalationLadder(Log, dispatcher, options);
ladder.Run(Fibers, Flag,
ladder.DefaultLadder(includeFire: true, includeEms: true),
incidentPosition: () => Anchor,
postProvider: unit => NextPost(unit), // null = leave unit to LSPDFR's own AI
continueEscalation: () => meter.Band >= HostilityBand.Volatile);
// posts:
new DeployPost { Position = p, Heading = h, Role = DeployRole.SkirmishLine }; // engage hated factions
new DeployPost { Position = p, Heading = h, Role = DeployRole.Cordon }; // hold facing outward
new DeployPost { Position = p, Heading = h, Role = DeployRole.Overwatch }; // observe; drivers stay in cars
// stand-down (must run on a GameFiber):
ladder.StandDown(fadeBlips: true);
StandDown un-busies every cop the ladder tasked — busy flags and task-control flags
(SetCopAsBusy, SetCopIgnoreAmbientCombatControl, BlockPermanentEvents, KeepTasks)
— then ends the dispatch session. Restore-what-you-set applies to cops too: miss this and
LSPDFR's response AI never gets its officers back. PostsManned reports deployed units.
RoadClosure — sealing the streets
For road-blocking scenes: disables the vehicle road nodes in a box (ambient traffic reroutes instead of driving through your event) and parks lit police blockers across the approaches.
var closure = new RoadClosure(Log);
closure.Close(Scene, Anchor, radius: 30f, cruisers: 3, rng);
// heavier public-order look — riot vans instead of cruisers:
closure.Close(Scene, Anchor, 30f, 2, rng, RoadClosure.RiotVanModels);
// on EVERY exit path (OnCleanup): idempotent
closure.Restore();
Details handled for you: blocker spots are probed in 8 compass directions across several
distance rings (overpasses and odd geometry only resolve street nodes at some probes),
deduped and spread apart; blockers get lights-no-siren and a guarding officer each;
spawned vehicles/officers go in your EntityBag. Restore() is mandatory — closed
road nodes outlive your scene otherwise.
CrowdEngineOptions — budgets and guardrails
Populated by your plugin from its ConfigSchema so users tune everything in one INI.
Performance: MaxPeds (30), MaxVehicles (8), PedBudgetFraction (0.5 of
World.PedCapacity), SpawnBatchSize (4) / SpawnBatchDelayMs (60), DismissRadius
(120 m), SuppressAmbients + DensitySuppression (0.10 — remember density natives are
per-frame; see Safe natives).
Tone: HeaviestScenariosEnabled (master gate for riot fires, molotovs, lethal
escalation), AllowIncendiaryWeapons, AllowFirearmEscalation (off by default).
Rules
- Everything here runs on GameFibers.
- The crowd's peds belong to your scene
EntityBag; teardown is the bag plusRoadClosure.Restore()plusladder.StandDown()— wire all three into your callout's cleanup path. - Respect the budgets: the caps exist because 60 script peds with combat AI is a slideshow on a mid-range machine.