Skip to content

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.

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.NaN
public static int Kills { get; }
public static ReferenceHub TriggeredBy { get; }
public static Player TriggeredByPlayer { get; }
public static Scp914Controller Base { get; }
public static bool IsAvailable { get; }
public static Scp914KnobSetting KnobSetting { get; } // Rough/Coarse/OneToOne/Fine/VeryFine
public static bool IsUpgrading { get; }
public static float RemainingCooldown { get; }
public static float TotalSequenceTime { get; }
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.).

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.

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 open
public bool IsUnlocked { get; } // cooldown elapsed
public bool IsEngaged { get; } // activation complete
public bool IsActivating { get; } // countdown in progress
public 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 running
public float InactiveTime { get; } // seconds since last burst
public Room Room { get; }
public Vector3 Position { get; }
public GameObject GameObject { get; }
public static ReadOnlyCollection<Tesla> List { get; }
public static Tesla Get(TeslaGate teslaBase);
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/DoorClosing
public 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);
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.

public BasicRagdoll Base { get; }
public RoleTypeId RoleType { get; } // role of the dead player
public string Nickname { get; }
public ReferenceHub OwnerHub { get; }
public Player Owner { get; } // null if hub gone
public 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);
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; }

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.

HandlerFires whenArgs
WarheadEvents.ProgressChangedCountdown starts or stopsWarheadProgressChangedEventArgs (carries bool InProgress)
WarheadEvents.DetonatedDetonation completesEventArgs.Empty
GeneratorEvents.EngagedA generator finishes activationGeneratorEngagedEventArgs
TeslaEvents.Added / Removed / BurstedLifecycle + shock burstTeslaEventArgs
ElevatorEvents.Spawned / Removed / MovedChamber lifecycle + motionElevatorEventArgs
FacilityCameraEvents.Created / Removed / StateChangedCCTV lifecycle + active toggleFacilityCameraEventArgs
RagdollEvents.Spawned / RemovedRagdoll lifecycleRagdollEventArgs
AnnouncerEvents.LineScheduled / LineStartedCASSIE pre-roll and audio startAnnouncerLineScheduledEventArgs, 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:

  • LineScheduled fires before audio playback begins, with the DSP time at which playback is scheduled (scheduledStartDspTime). Use it for lead-time computations against UnityEngine.AudioSettings.dspTime — pre-loading subtitle text, swapping audio at scheduling time so the substitute is ready by start.
  • LineStarted fires 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.

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, 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 firearm
var firearm = item.As<FirearmItem>();
if (firearm != null) Debug.Log($"{firearm.AmmoInMagazine}/{firearm.MagazineCapacity}");
// Or use C# pattern-matching
if (item is FirearmItem fi)
Debug.Log($"Aiming: {fi.IsAiming}");
// Type check without the cast
if (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.

// FirearmItem
public 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;
// KeycardItem
public KeycardItemBase KeycardBase { get; }
public DoorPermissionFlags Permissions { get; }
public DoorPermissionFlags GetPermissionsFor(IDoorPermissionRequester requester);
// ThrowableItem
public ThrowableItemBase ThrowableBase { get; }
public ProjectileSettings? WeakThrowSettings { get; }
public ProjectileSettings? FullThrowSettings { get; }
// Scp330Item
public Scp330Bag BagBase { get; }
public static int MaxCandies { get; } // 6
public 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.

// FirearmPickup
public FirearmPickupBase FirearmPickupBase { get; }
public bool IsTemplateSet { get; } // attachments / modules resolved
// KeycardPickup
public KeycardPickupBase KeycardPickupBase { get; }
public bool OpensDoorsOnCollision { get; } // "thrown card opens door" flag
// TimedGrenadePickup
public TimedGrenadePickupBase TimedGrenadePickupBase { get; }
public bool IsAboutToReplace { get; } // single-frame "going live" flag
public static float ChainActivationRange { get; }
// Scp330Pickup
public 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.

// BreakableDoor
public BreakableDoorBase BreakableBase { get; }
public float Health { get; }
public float MaxHealth { get; }
public float HealthPercent { get; }
public DoorDamageType IgnoredDamageSources { get; }
public bool IsBroken { get; }
// CheckpointDoor
public CheckpointDoorBase CheckpointBase { get; }
public CheckpointDoorBase.SequenceState CurrentSequence { get; }
public ReadOnlyCollection<Door> SubDoors { get; } // each dispatched to its own subtype if registered
// ElevatorDoor
public 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.

Pickup.Get(ushort) and Item.Get(ushort) resolve client-visible objects by serial.

// Pickup
public static Pickup Get(ushort serial);
public static bool TryGet(ushort serial, out Pickup pickup);
public static ReadOnlyCollection<Pickup> GetAll(ItemType type);
// Item
public 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.

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.