Map Features
This chapter covers the read-only wrappers around global facility state and per-instance map distributors, the matching event handlers, and the typed subtype dispatch on Item, Pickup, and Door.
All wrappers are observation-only by design. Mutation (knob change, force tesla burst, freeze ragdoll, open locker chamber) is server-authoritative — those actions belong in a server companion. The client wrappers exist for HUDs, overlays, and gameplay-aware feedback.
For server-side procedural placement, see Server Facility Authoring.
Singleton wrappers — Warhead, Scp914, Decontamination
Section titled “Singleton wrappers — Warhead, Scp914, Decontamination”Three thin static facades over engine singletons.
Warhead
Section titled “Warhead”using Anomaly.Client.Api.Features;
if (Warhead.IsInProgress) HudShowCountdown(Warhead.TimeUntilDetonation); // float.NaN when no countdown
if (Warhead.Detonated) PlayDetonationBoom();public static AlphaWarheadController Base { get; }public static bool AlreadyDetonated { get; }public static bool IsInProgress { get; }public static bool Detonated { get; }public static bool IsLocked { get; }public static float TimeUntilDetonation { get; } // seconds, or float.NaNpublic static int Kills { get; }public static ReferenceHub TriggeredBy { get; }public static Player TriggeredByPlayer { get; }Scp914
Section titled “Scp914”public static Scp914Controller Base { get; }public static bool IsAvailable { get; }public static Scp914KnobSetting KnobSetting { get; } // Rough/Coarse/OneToOne/Fine/VeryFinepublic static bool IsUpgrading { get; }public static float RemainingCooldown { get; }public static float TotalSequenceTime { get; }Decontamination
Section titled “Decontamination”public static DecontaminationController Base { get; }public static bool IsAvailable { get; }public static bool IsDecontaminating { get; }public static bool IsAnnouncementHearable { get; }Decontamination’s surface is intentionally minimal. The controller’s wider state is announcement-driven; mods that need phase-by-phase reactions should hook AnnouncerEvents.LineStarted filtered by API name.
All three return sensible defaults if the singleton isn’t initialised yet (Warhead.IsInProgress = false, Scp914.KnobSetting = OneToOne, etc.).
Multi-instance wrappers
Section titled “Multi-instance wrappers”The multi-instance wrappers follow a consistent pattern: each is a Get(rawType) factory backed by a private dictionary cache, plus a static List that enumerates the current scene’s instances. Caches clear on single-scene transitions, disconnect, and full runtime reset; do not retain wrappers across those boundaries.
Generator
Section titled “Generator”using Anomaly.Client.Api.Features;
foreach (var gen in Generator.List){ if (gen.IsActivating) Debug.Log($"{gen.Room?.Name} generator activating ({gen.TotalActivationTime}s total)");}public Scp079Generator Base { get; }public bool IsOpen { get; } // door panel openpublic bool IsUnlocked { get; } // cooldown elapsedpublic bool IsEngaged { get; } // activation completepublic bool IsActivating { get; } // countdown in progresspublic bool IsActivationReady { get; }public float TotalActivationTime { get; }public float TotalDeactivationTime { get; }public Room Room { get; }public Vector3 Position { get; }public GameObject GameObject { get; }
public static ReadOnlyCollection<Generator> List { get; }public static Generator Get(Scp079Generator generatorBase);IsEngaged and IsActivating derive from the Network_flags byte (mask against Scp079Generator.GeneratorFlags); the read is wrapped in try/catch because the IL2CPP enum cast can be fragile across game updates.
public TeslaGate Base { get; }public bool InProgress { get; } // shock animation runningpublic float InactiveTime { get; } // seconds since last burstpublic Room Room { get; }public Vector3 Position { get; }public GameObject GameObject { get; }
public static ReadOnlyCollection<Tesla> List { get; }public static Tesla Get(TeslaGate teslaBase);Elevator
Section titled “Elevator”public ElevatorChamber Base { get; }public bool IsReady { get; }public bool IsReadyForUserInput { get; }public int DestinationLevel { get; }public bool GoingUp { get; }public int NextLevel { get; }public int PreviousLevel { get; }public ElevatorChamber.ElevatorSequence CurrentSequence { get; } // Ready/Arrived/DoorOpening/Moving/DoorClosingpublic float SequenceElapsed { get; }public Room Room { get; }public Vector3 Position { get; }public GameObject GameObject { get; }
public static ReadOnlyCollection<Elevator> List { get; }public static Elevator Get(ElevatorChamber chamberBase);FacilityCamera
Section titled “FacilityCamera”public Scp079Camera Base { get; }public bool IsMain { get; }public string Label { get; }public bool IsActive { get; }public bool IsUsedByLocalPlayer { get; }public Vector3 CameraPosition { get; }public float VerticalRotation { get; }public float HorizontalRotation { get; }public float RollRotation { get; }public Transform Anchor { get; }public GameObject GameObject { get; }
public static ReadOnlyCollection<FacilityCamera> List { get; }public static ReadOnlyCollection<FacilityCamera> GetForRoom(RoomIdentifier room);public static FacilityCamera Get(Scp079Camera cameraBase);Named FacilityCamera, not Camera, to avoid collision with UnityEngine.Camera.
Ragdoll
Section titled “Ragdoll”public BasicRagdoll Base { get; }public RoleTypeId RoleType { get; } // role of the dead playerpublic string Nickname { get; }public ReferenceHub OwnerHub { get; }public Player Owner { get; } // null if hub gonepublic DamageHandlerBase DamageHandler { get; }public double CreationTime { get; }public float ExistenceTime { get; }public bool IsFrozen { get; }public Transform CenterPoint { get; }public Vector3 Position { get; }public GameObject GameObject { get; }
public static ReadOnlyCollection<Ragdoll> List { get; }public static Ragdoll Get(BasicRagdoll ragdollBase);Locker + LockerChamberWrapper
Section titled “Locker + LockerChamberWrapper”using Anomaly.Client.Api.Features;
foreach (var locker in Locker.List){ foreach (var chamber in locker.Chambers) { if (chamber.IsOpen) Debug.Log($"Chamber {chamber.Index} open with {chamber.Contents.Count} items"); }}public Il2CppMapGeneration.Distributors.Locker Base { get; }public ushort OpenedChambersMask { get; }public IReadOnlyList<LockerChamberWrapper> Chambers { get; }public Room Room { get; }public Vector3 Position { get; }public GameObject GameObject { get; }
public static ReadOnlyCollection<Locker> List { get; }public static Locker Get(Il2CppMapGeneration.Distributors.Locker lockerBase);LockerChamberWrapper:
public LockerChamber Base { get; }public byte Index { get; }public Locker Parent { get; }public bool IsOpen { get; }public bool WasEverOpened { get; }public DoorPermissionFlags RequiredPermissions { get; }public Il2CppStructArray<ItemType> AcceptableItems { get; }public Il2CppList<ItemPickupBase> Contents { get; }public Transform Spawnpoint { get; }Map-feature events
Section titled “Map-feature events”The public handlers live under Anomaly.Client.Api.Events.Handlers
alongside RoundEvents, PlayerEvents, and the other lifecycle surfaces. Their
runtime subscriptions are installed as one required transaction: if any required
hook cannot bind, Anomaly rolls back the subscriptions installed by that attempt,
marks runtime-hooks failed, and blocks Anomaly authentication instead of running
with a partial event surface.
| Handler | Fires when | Args |
|---|---|---|
WarheadEvents.ProgressChanged | Countdown starts or stops | WarheadProgressChangedEventArgs (carries bool InProgress) |
WarheadEvents.Detonated | Detonation completes | EventArgs.Empty |
GeneratorEvents.Engaged | A generator finishes activation | GeneratorEngagedEventArgs |
TeslaEvents.Added / Removed / Bursted | Lifecycle + shock burst | TeslaEventArgs |
ElevatorEvents.Spawned / Removed / Moved | Chamber lifecycle + motion | ElevatorEventArgs |
FacilityCameraEvents.Created / Removed / StateChanged | CCTV lifecycle + active toggle | FacilityCameraEventArgs |
RagdollEvents.Spawned / Removed | Ragdoll lifecycle | RagdollEventArgs |
AnnouncerEvents.LineScheduled / LineStarted | CASSIE pre-roll and audio start | AnnouncerLineScheduledEventArgs, AnnouncerLineStartedEventArgs |
using Anomaly.Client.Api.Events.Handlers;using Il2CppCameraShaking;
WarheadEvents.ProgressChanged += args =>{ if (args.InProgress) HudShowWarheadCountdown(); else HudHideWarheadCountdown();};
GeneratorEvents.Engaged += args => Debug.Log($"Generator engaged in room: {args.Generator.Room?.Name}");
TeslaEvents.Bursted += args =>{ var recoil = new RecoilShake( new RecoilSettings(0.2f, 0f, 0.4f, 0.4f, 0f)); CameraShake.Apply(recoil.Cast<IShakeEffect>());};
FacilityCameraEvents.StateChanged += args => Debug.Log($"Camera {args.Camera.Label} → {args.Camera.IsActive}");AnnouncerEvents is split into two events for a reason:
LineScheduledfires before audio playback begins, with the DSP time at which playback is scheduled (scheduledStartDspTime). Use it for lead-time computations againstUnityEngine.AudioSettings.dspTime— pre-loading subtitle text, swapping audio at scheduling time so the substitute is ready by start.LineStartedfires when audio becomes audible. Use it to flash subtitles in sync with the audible line.
Both args expose ApiName (e.g. "ALERT") directly so handlers can switch on the line without unwrapping the AnnouncerWord.
StatsEvents exposes stamina and Hume-shield events polled during the client update:
StatsEvents.StaminaChanged += args => UpdateStaminaBar(args.Current, args.Previous);StatsEvents.HumeShieldChanged += args => { if (args.Broke) ScreenEffects.Flash(Color.red); };Epsilons exist to avoid event spam on noisy stats — stamina regenerates continuously. StaminaChanged fires at ~0.5% normalised stamina, HumeShieldChanged at 0.5 raw HP plus a forced fire on the broken-zero transition. If you want every microscopic change, read Player.Stats.Stamina / HumeShield directly.
AnnouncerWord vocabulary
Section titled “AnnouncerWord vocabulary”Announcer.AllLines, Announcer.GetByCategory(...), and the AnnouncerWord wrapper let mods enumerate the available CASSIE vocabulary.
using Anomaly.Client.Api.Features;using Il2CppCassie;
foreach (var w in Announcer.GetByCategory(CassieClipCategory.Word)) Debug.Log($"{w.ApiName} — {w.Duration:F2}s");public static CassieLineDatabase LineDatabase { get; }public static ReadOnlyCollection<AnnouncerWord> AllLines { get; }public static string[] CollectionNames { get; }public static ReadOnlyCollection<AnnouncerWord> GetByCategory(CassieClipCategory category);public static bool TryGetDatabase(out CassieLineDatabase db);public static bool IsValid(string word);public static bool AddWord(string apiName, AudioClip clip, float durationSeconds, CassieClipCategory type = CassieClipCategory.Word);AnnouncerWord.Category and its Type alias both return the line’s CassieClipCategory.
Announcer.AddWord registers a custom local word with developer-provided duration. Remote OGG delivery is consent-bound and must stay within a supported server feature; it is not a general asset-spawn surface. See Assets and Localization.
Item / Pickup / Door subtype dispatch
Section titled “Item / Pickup / Door subtype dispatch”Item, Pickup, and Door dispatch to typed subclass wrappers based on the underlying IL2CPP runtime type. Use these wrappers to read subtype-specific state.
using Anomaly.Client.Api.Features;
// Typed downcast — returns null if the item isn't a firearmvar firearm = item.As<FirearmItem>();if (firearm != null) Debug.Log($"{firearm.AmmoInMagazine}/{firearm.MagazineCapacity}");
// Or use C# pattern-matchingif (item is FirearmItem fi) Debug.Log($"Aiming: {fi.IsAiming}");
// Type check without the castif (door.Is<CheckpointDoor>()) Debug.Log("Checkpoint door detected.");Item, Pickup, and Door all expose:
public T As<T>() where T : <BaseType>;public bool Is<T>() where T : <BaseType>;The Get(rawType) factories consult an internal dispatch table and walk the runtime type’s class hierarchy before falling back to the base wrapper. Engine subtypes like RevolverFirearm, Scp127Firearm, ShotgunFirearm all resolve to FirearmItem automatically — no explicit registration. The dispatch checks the exact runtime type before walking its base types.
Item subtypes
Section titled “Item subtypes”// FirearmItempublic Firearm FirearmBase { get; }public MagazineModule Magazine { get; }public int AmmoInMagazine { get; }public int MagazineCapacity { get; }public bool IsAiming { get; }public bool TryGetModule<T>(out T module) where T : ModuleBase;
// KeycardItempublic KeycardItemBase KeycardBase { get; }public DoorPermissionFlags Permissions { get; }public DoorPermissionFlags GetPermissionsFor(IDoorPermissionRequester requester);
// ThrowableItempublic ThrowableItemBase ThrowableBase { get; }public ProjectileSettings? WeakThrowSettings { get; }public ProjectileSettings? FullThrowSettings { get; }
// Scp330Itempublic Scp330Bag BagBase { get; }public static int MaxCandies { get; } // 6public ReadOnlyCollection<CandyKindID> Candies { get; }public int CandyCount { get; }public bool IsFull { get; }public bool IsCandySelected { get; }public int SelectedCandyId { get; }public CandyKindID? SelectedCandy { get; }ProjectileSettings? is intentionally nullable — ProjectileSettings is a value-type struct nested inside ThrowableItem, so the nullable bubble flows through naturally. Mods that know the throwable is alive can do .Value to unwrap.
Pickup subtypes
Section titled “Pickup subtypes”// FirearmPickuppublic FirearmPickupBase FirearmPickupBase { get; }public bool IsTemplateSet { get; } // attachments / modules resolved
// KeycardPickuppublic KeycardPickupBase KeycardPickupBase { get; }public bool OpensDoorsOnCollision { get; } // "thrown card opens door" flag
// TimedGrenadePickuppublic TimedGrenadePickupBase TimedGrenadePickupBase { get; }public bool IsAboutToReplace { get; } // single-frame "going live" flagpublic static float ChainActivationRange { get; }
// Scp330Pickuppublic Scp330PickupBase Scp330PickupBase { get; }public CandyKindID ExposedCandy { get; }public ReadOnlyCollection<CandyKindID> StoredCandies { get; }public int CandyCount { get; }The pickup subtypes are intentionally thin — most useful firearm / keycard state lives on the matching *Item wrapper while the item is in someone’s inventory. The pickup wrappers cover only the fields that are pickup-specific.
Door subtypes
Section titled “Door subtypes”// BreakableDoorpublic BreakableDoorBase BreakableBase { get; }public float Health { get; }public float MaxHealth { get; }public float HealthPercent { get; }public DoorDamageType IgnoredDamageSources { get; }public bool IsBroken { get; }
// CheckpointDoorpublic CheckpointDoorBase CheckpointBase { get; }public CheckpointDoorBase.SequenceState CurrentSequence { get; }public ReadOnlyCollection<Door> SubDoors { get; } // each dispatched to its own subtype if registered
// ElevatorDoorpublic ElevatorDoorBase ElevatorBase { get; }public ElevatorGroup? Group { get; }public Elevator Chamber { get; }CheckpointDoor.SubDoors returns ReadOnlyCollection<Door> rather than ReadOnlyCollection<CheckpointDoor> — the sub-doors are typically BasicDoors wrapped as base Door. If any sub-door has a registered subtype, it dispatches to that subtype.
Serial-based lookup
Section titled “Serial-based lookup”Pickup.Get(ushort) and Item.Get(ushort) resolve client-visible objects by serial.
// Pickuppublic static Pickup Get(ushort serial);public static bool TryGet(ushort serial, out Pickup pickup);public static ReadOnlyCollection<Pickup> GetAll(ItemType type);
// Itempublic static Item Get(ushort serial);public static bool TryGet(ushort serial, out Item item);public static ReadOnlyCollection<Item> GetAll(ItemType type);public static ReadOnlyCollection<Item> GetAll(ItemCategory category);Projectile exposes the same serial-based shape: Projectile.Get(ushort) / TryGet(ushort, out Projectile).
Pickup.List enumerates the client’s cached pickup wrappers.
Pickup, Item, and Projectile caches clear on single-scene transitions, disconnect, and full runtime reset. Do not hold wrapper references across those lifecycle boundaries.
The matching PlayerInventory properties (Count, Items, Ammo) are local-only too — TargetRefreshItems / TargetRefreshAmmo are TargetRpcs that only reach the owning client. CurrentItemIdentifier and CurrentItem are replicated via the [SyncVar] CurItem and so are valid for both local and remote players.
Worked example: facility status HUD
Section titled “Worked example: facility status HUD”This event-driven example combines warhead countdown, generator engagement count, tesla activity, and decontamination state. Call Subscribe when the feature enables and Dispose when it disables. Add a scheduled refresh if the countdown needs to update between events.
using Anomaly.Client.Api.Events.Handlers;using Anomaly.Client.Api.Features;using System.Linq;
public sealed class FacilityStatusHud : System.IDisposable{ private bool _subscribed;
public void Subscribe() { if (_subscribed) return; _subscribed = true; WarheadEvents.ProgressChanged += OnChanged; WarheadEvents.Detonated += OnChanged; GeneratorEvents.Engaged += OnChanged; TeslaEvents.Bursted += OnChanged; AnnouncerEvents.LineStarted += OnChanged; }
public void Dispose() { if (!_subscribed) return; _subscribed = false; WarheadEvents.ProgressChanged -= OnChanged; WarheadEvents.Detonated -= OnChanged; GeneratorEvents.Engaged -= OnChanged; TeslaEvents.Bursted -= OnChanged; AnnouncerEvents.LineStarted -= OnChanged; }
private void OnChanged(System.EventArgs _) => Refresh();
private void Refresh() { var engaged = Generator.List.Count(g => g.IsEngaged); var teslas = Tesla.List.Count(t => t.InProgress); var decon = Decontamination.IsAnnouncementHearable; var warhead = Warhead.IsInProgress ? $"{Warhead.TimeUntilDetonation:F1}s" : Warhead.Detonated ? "DETONATED" : "—";
UpdatePanel($"Warhead: {warhead}\nGenerators: {engaged}/3\nTeslas active: {teslas}\nDecon: {(decon ? "IMMINENT" : "—")}"); }
private static void UpdatePanel(string text) { // Update your UI surface here. }}World Objects for local manipulation of arbitrary scene GameObjects.