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.
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 are cleared on disconnect.
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”Six new event handlers under Anomaly.Client.Api.Events.Handlers (alongside the existing RoundEvents, PlayerEvents, etc.). Wired via the framework’s TryHookStaticEvent helper, which logs and skips a handler that fails to bind so a single failure is non-fatal.
| 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;
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 => CameraShake.Apply(new RecoilShake(0.4f, 0.2f));
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 also gains stamina and Hume-shield events at game-tick resolution:
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 PlayerRoles.Voice;
foreach (var w in Announcer.GetByCategory(CassieClipCategory.Number)) 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 is preferred for new code; AnnouncerWord.Type is the older alias. Both return the same CassieClipCategory.
Announcer.AddWord registers a custom local word with developer-provided duration. For server-synchronised vocabulary used by every connected client, see Asset Distribution — Custom announcer words.
Item / Pickup / Door subtype dispatch
Section titled “Item / Pickup / Door subtype dispatch”Item, Pickup, and Door now dispatch to typed subclass wrappers based on the underlying IL2CPP runtime type. Mods stop having to cast to ItemBase / ItemPickupBase / DoorVariant and walk raw IL2CPP fields for common operations.
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>()) ...Item, Pickup, and Door all expose:
public T As<T>() where T : <BaseType>;public bool Is<T>() where T : <BaseType>;The Get(rawType) factories now 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. A future, more-specific wrapper (e.g. RevolverFirearmItem) wins over the base subtype because dictionary lookup is exact-type-first, hierarchy walk is fallback.
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 and Item now mirror LabAPI’s server-side Pickup.Get(ushort) / Item.Get(ushort) shape on the client.
// 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 is now backed by the cache instead of a per-call FindObjectsOfType<ItemPickupBase>() walk.
Pickup, Item, and Projectile caches are cleared on ClientEvents.Disconnected. Don’t hold wrapper references across a session boundary.
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”A single corner-anchored panel that combines warhead countdown, generator engagement count, tesla activity, and decontamination state.
using Anomaly.Client.Api.Events.Handlers;using Anomaly.Client.Api.Features;
public class FacilityStatusHud{ public void Subscribe() { WarheadEvents.ProgressChanged += _ => Refresh(); WarheadEvents.Detonated += _ => Refresh(); GeneratorEvents.Engaged += _ => Refresh(); TeslaEvents.Bursted += _ => Refresh(); AnnouncerEvents.LineStarted += _ => 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" : "—")}"); }}World Objects for local manipulation of arbitrary scene GameObjects.