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.

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 are cleared on disconnect.

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; }

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.

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;
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:

  • 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 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.

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, 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 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>()) ...

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.

// 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 and Item now mirror LabAPI’s server-side Pickup.Get(ushort) / Item.Get(ushort) shape on the client.

// 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 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.

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.