Thing¶
Vanilla game class at Assets.Scripts.Objects.Thing. The base class of every in-world game object. All prefab types derive from Thing either as DynamicThing (non-fixed-position) or Structure (player-built, fixed).
Object hierarchy¶
Source: F0223.
Assets.Scripts.Objects.Thing (base of ALL game objects)
|-- DynamicThing (non-fixed-position)
| |-- Item (inventory-storable)
| |-- Entity (living: Human, etc.)
|-- Structure (player-built, fixed)
|-- LargeStructure (2m grid: frames, walls)
|-- SmallGrid (0.5m grid: pipes, cables, devices)
|-- Device (powered machines)
CustomColor field and IsPaintable gate¶
Thing carries two color-related fields declared together at decompile line ~360 of Assets.Scripts.Objects.Thing:
[Header("Thing Colors")]
[Tooltip("If set, will allow any parts of the thing with this material to be spraypainted")]
public Material PaintableMaterial;
[ReadOnly]
public ColorSwatch CustomColor;
Key facts:
CustomColoris apublic ColorSwatchfield (not a property), reference type, nullable. Marked[ReadOnly]for inspector display only; runtime code freely reassigns it viaThing.SetCustomColor(int)(decompile line ~5265), which callsCustomColor = GameManager.GetColorSwatch(index).CustomColorCAN be null at runtime.SetCustomColor(int)early-returns ifCustomColor == nullafter the assignment (meaningGameManager.GetColorSwatchreturned null). Existing mod code checksif (__instance.CustomColor == null) return true;before readingCustomColor.Index(seeGlowPaintPatches.cs:123).- When
CustomColoris non-null,CustomColor.Indexreturns the 0-based index intoGameManager.Instance.CustomColors. TheIndexproperty is a lazyGameManager.GetColorIndex(this)lookup per../GameClasses/ColorSwatch.md. Thing.IsPaintable(decompile line ~1772) is:
public bool IsPaintable
{
get
{
if (!(PaintableMaterial != null))
{
return HasPaintableMaskMaterial;
}
return true;
}
}
A Thing is paintable when either PaintableMaterial is assigned (the normal case: pipes, cables, walls, large structures) or HasPaintableMaskMaterial is true (a virtual override used by mask-painted prefabs). Non-paintable Things (e.g. most small items, entities) have IsPaintable == false and no meaningful CustomColor value; callers that eyedrop must check IsPaintable before reading CustomColor.Index to avoid sampling a stale or default swatch on a prefab that does not participate in the color system.
-
On save,
ThingSaveData.CustomColorIndexpersists the color, but-1is used as the sentinel for "unpainted / using prefab default":savedData.CustomColorIndex = ((CustomColor.Normal == PaintableMaterial) ? (-1) : GameManager.GetColorIndex(CustomColor));(decompile line ~4692). At load,saveData.CustomColorIndex >= 0gates theSetCustomColorcall (line ~4667). The-1sentinel is save-format only: at runtime an unpainted paintable Thing still has a validCustomColorassigned (equal to the swatch whoseNormalmaterial matchesPaintableMaterial), not null. -
Two overloads of
SetCustomColorexist onThing: SetCustomColor(ColorSwatch colorSwatch)forwards toSetCustomColor(colorSwatch.Index).-
SetCustomColor(int index, bool emissive = false)is the authoritative entry point; silently no-ops on invalid indices viaGameManager.IsValidColor(index). -
Network replication:
SetCustomColor(int)setsNetworkUpdateFlags |= 32whenGameManager.RunSimulation(server-authoritative), triggering aThingColorMessagebroadcast. Remote clients receive the color update and apply it locally throughThing.SetCustomColor(index), soCustomColor.Indexis readable and correct on every peer that has the Thing loaded.
Reading color client-side for an eyedropper:
Thing target = CursorManager.CursorThing;
if (target == null) return;
if (!target.IsPaintable) return;
if (target.CustomColor == null) return;
int index = target.CustomColor.Index;
if (index < 0) return; // defensive: swatch not yet registered in GameManager.CustomColors
The client-local CustomColor reflects the last value applied by either a local paint action or an incoming ThingColorMessage; no server round-trip is needed to read it.
CustomColorMapping build rule (which renderers recolor)¶
IsPaintable (above) only gates whether the spray can will target a Thing. The set of renderers that actually change color when SetCustomColor runs is a separate list, _customMaterials (protected List<CustomColorMapping>), built once in Thing.Awake (decompile line 301261-301284):
_customMaterials = new List<CustomColorMapping>();
int colorIndex = GameManager.GetColorIndex(PaintableMaterial);
MeshRenderer[] componentsInChildren = GetComponentsInChildren<MeshRenderer>(includeInactive: true);
foreach (MeshRenderer meshRenderer in componentsInChildren)
{
if (!CanCacheRenderer(meshRenderer)) continue;
ThingRenderer thingRenderer = new ThingRenderer(this, meshRenderer);
for (int num2 = 0; num2 < meshRenderer.sharedMaterials.Length; num2++)
{
Material material = meshRenderer.sharedMaterials[num2];
if ((object)material != null && (material == PaintableMaterial || material?.mainTexture is Texture2DArray))
{
_customMaterials.Add(new CustomColorMapping(thingRenderer, num2, colorIndex));
}
}
Renderers.Add(thingRenderer);
}
A renderer material slot is added to the recolor set when either:
- the material reference-equals
PaintableMaterial, or - the material's
mainTextureis aTexture2DArray(the standard Stationeers paint shader uses a texture-array atlas; this is how the bulk of a paintable prefab's surfaces are caught).
SetCustomColor(int) later iterates _customMaterials and swaps each mapped material (skipping renderers whose GameObject is tagged "NotPaintable"); see the "CustomColor field and IsPaintable gate" section.
Consequences (relevant to making a normally-unpaintable Thing paintable):
- The two conditions are independent.
IsPaintableis gated solely byPaintableMaterial != null, but the recolor set is gated by the per-renderer rule above. A prefab can therefore have texture-array materials (so its renderers would recolor) yet be non-sprayable because itsPaintableMaterialslot was left null. Assigning any non-nullPaintableMaterialflipsIsPaintableto true, and the already-present texture-array renderers recolor with no further work. colorIndex(theCustomColorMappingdefault-color, third ctor arg) isGameManager.GetColorIndex(PaintableMaterial). WithPaintableMaterialnull this is-1; assigning the prefab its real base paint material gives the mapping the correct "unpainted default" index. Active painting (SetCustomColor(index)) applies the chosen index regardless, so a wrong default does not block painting, only the unpainted baseline.- Worked example (game 0.2.6228.27061): the Steel Frame kit (
ItemSteelFrames) builds four shapes.StructureFrameandStructureFrameCornerCutship withPaintableMaterialset (paintable);StructureFrameCornerandStructureFrameSideship with it null (non-sprayable in vanilla) even though their steel material is the same texture-array material. Copying the baseStructureFrame.PaintableMaterialonto the two null prefabs at load makes all four sprayable; cross-checked on the dedicated server that the two flip toIsPaintable == trueafter the assignment, and all four reportstructureRenderMode == StandardsoStructure.SetCustomColordoes not throw the batched-meshNotImplementedException(see./Structure.md).
Structures build the recolor set per build state¶
Structure.Awake (decompile line 295943-295980) runs its own copy of this inclusion test, once per BuildState: for each BuildState.InitialDrawData.materials slot it adds a CustomColorMapping when material == PaintableMaterial || material.mainTexture is Texture2DArray. That is why a structure recolors at every construction stage, not only when finished. Structure.SetCustomColor (line 295933) forwards to base.SetCustomColor (the Thing version above) when structureRenderMode == StructureRenderMode.Standard, and throws NotImplementedException when it is Batched (combined-mesh structures cannot be recolored per instance; see ./Structure.md).
Because the Texture2DArray branch does not depend on PaintableMaterial, this defines the removal-safe behaviour when a mod makes a structure paintable by assigning PaintableMaterial at runtime and is later uninstalled: the assignment is an in-memory prefab mutation that is never serialized, so on the next load PaintableMaterial is null again and the structure is no longer sprayable, yet its texture-array renderers are still gathered into _customMaterials, so a CustomColorIndex persisted in the ordinary vanilla save is reapplied and the structure keeps its color. Painting via the vanilla path adds no custom save-data type, so such a save still loads in the unmodded base game.
Initial CustomColor by spawn path¶
Every spawn route eventually calls Thing.Create<T>(prefab, worldPos, worldRot, refId) (decompile line ~2320) which calls UnityEngine.Object.Instantiate(prefab, ...). Instantiation runs Thing.Awake() (line 3619). Lines 3745-3748 of Awake are the single authoritative initializer of CustomColor during Thing instantiation:
GameManager.GetColorSwatch(Material) (decompile line 539-554) linearly scans CustomColors and returns the swatch whose Normal material reference-equals PaintableMaterial, or null on miss. Consequences:
- A paintable Thing's
CustomColoris non-null after Awake iffPaintableMaterialis a Material that appears as.Normalon some entry ofGameManager.CustomColors. For the 12 vanilla swatches, this holds for any vanilla paintable prefab (pipes, cables, frames, walls, devices): theirPaintableMaterialis one of the 12 registered.Normalmaterials. - Whatever
CustomColorwas inspector-assigned on the prefab asset is IGNORED: Awake unconditionally overwrites it whenPaintableMaterialis non-null. So the inspector-levelCustomColoron a prefab has no runtime effect. - Non-paintable Things (
PaintableMaterial == nullandHasPaintableMaskMaterial == false) leaveCustomColorat whatever the prefab asset had, which is typically null.IsPaintableis false for these and the eyedropper should refuse them.
Kit / Structure color asymmetry¶
Structures that are player-built (e.g. Ladder, Pipe, Cable, Wall) are never spawned as raw Structure prefabs via the console, creative menu, or fabricator flows. Those flows only accept DynamicThing prefabs. The in-world Structure is born out of a kit DynamicThing (a Constructor or MultiConstructor item, prefab name typically KitLadder, KitPipe, etc.) via the authoring/construction pipeline.
Two PaintableMaterial slots are therefore in play for any built Structure:
- The kit's
PaintableMaterial(set on theConstructor/MultiConstructorprefab asset). This determines the color the kit item appears in while held / stacked. - The target Structure's
PaintableMaterial(set on the Structure prefab asset). This determines the built-structure's Awake default.
These two Materials are independent and commonly differ. For the Ladder specifically the user-observed "yellow kit vs orange structure" asymmetry is evidence that KitLadder.PaintableMaterial and Ladder.PaintableMaterial are mapped to different CustomColors entries in the prefab asset YAML. The DLL does not store the Material references (they are Unity asset GUIDs on the prefab), but the code downstream proves the kit's color wins.
Per-spawn-path behavior¶
- Console
spawn <prefabName> [amount](Util.Commands.ThingCommand.Executecase"spawn", decompile line 139-170): callsOnServer.SpawnDynamicThingMaxStack(humanRefId, prefabName).OnServer.SpawnDynamicThingMaxStack(line 675-735) doesPrefab.Find<DynamicThing>(prefabName)andCreate<DynamicThing>; it silently fails ifprefabNameresolves to a non-DynamicThing prefab (e.g. typing/spawn Ladderfinds nothing becauseLadderis aStructure). When it succeeds it spawns the DynamicThing (e.g.KitLadder) withCustomColor == GameManager.GetColorSwatch(KitLadder.PaintableMaterial)and never layers aSetCustomColor. Result: kit-color, NOT structure-color. The user sees the kit in-hand with its printer default. - Creative menu (inventory
+button) (Assets.Scripts.UI.ImGuiUi.ImguiCreativeSpawnMenuline 196 →InventoryManager.SpawnDynamicThing(ICreativeSpawnable)line 937-947): the method checksprefab is DynamicThingbefore forwarding toSpawnDynamicThingMaxStack. OnlyDynamicThings are registered into the menu in the first place (Prefab.RegisterExistingandWorldManager.RegisterThingboth gate onis DynamicThingbefore callingImguiCreativeSpawnMenu.AddDynamicItem, decompile lines 116842 and 268759). Same outcome as console: kit is spawned with its own Awake default. - Creative menu (authoring placement, Authoring Tool wand) (
Assets.Scripts.Inventory.InventoryManager.UsePrimaryline 2334/2338 →OnServer.UseItemPrimaryAuthoring/UseItemPrimaryline 938-956): whenActiveHand.Slot.Occupant is AuthoringTool, the server substitutesPrefab.Find<Constructor>(spawnPrefab.SpawnId)for the in-hand tool and callsOnUsePrimary(..., authoringMode: true). This reachesConstructor.Construct(decompile line 23-34) which creates aCreateStructureInstance(BuildStructure, ..., steamId)with defaultCustomColor == -1, then IFPaintableMaterial != null && CustomColor.Normal != null(on the Constructor kit prefab; the kit has just been instantiated with Awake defaults and itsCustomColoris the kit's own swatch), overwritescreateStructureInstance.CustomColor = CustomColor.Index(the kit's color index).SpawnConstructcallsThing.Create<Structure>(BuildStructure, ...)(Awake sets the Structure's CustomColor to the Structure's Awake default, e.g. orange-ladder), thenStructure.SetStructureData(..., instance.CustomColor)(decompile line 2239-2248), which appliesSetCustomColor(kitColorIndex)only ifkitColorIndex >= 0 && PaintableMaterial != null && kitColorIndex != CustomColor.Index. Net result: the Structure's runtime CustomColor is the KIT's default color, not the Structure's default color, whenever the kit has aPaintableMaterial. This is why a Ladder placed via the creative menu (authoring click) comes out yellow, not orange. - Normal player build (kit in hand) (
Item.OnUsePrimaryon aConstructor/MultiConstructorduringauthoringMode == false): same path as creative authoring; the player's in-hand kit runsConstructor.Construct→SpawnConstruct. The built Structure inherits the kit'sCustomColor, which equals the kit prefab's Awake default if the kit was printed-and-untouched, or whatever paint has been applied to the kit in inventory since. - Fabricator output (
Assets.Scripts.Objects.Electrical.SimpleFabricatorBase.SpawnCreatedItems, line 894-908): callsThing.Create<DynamicThing>(_currentResult, ...)with no follow-upSetCustomColor. The fabricated DynamicThing carries its own Awake default. For a fabricator that prints a kit, this is the kit prefab's default. - MultiConstructor (
Assets.Scripts.Objects.MultiConstructor.Constructline 47-61): same shape asConstructor. UsesConstructables[optionIndex]as thePrefab, readsPaintableMaterial != null && CustomColor.Normal != nullon the MultiConstructor item itself, setscreateStructureInstance.CustomColor = CustomColor.Index, then routes throughConstructor.SpawnConstruct. Kit color wins for the same reason. - DynamicThingConstructor (
Assets.Scripts.Objects.Items.DynamicThingConstructor.OnUseItem, decompile around line 323731 of the game DLL): callsOnServer.CreateOld(ConstructedPrefab, ...)then ifPaintableMaterial != null && CustomColor.Normal != nullon the DynamicThingConstructor item, callsOnServer.SetCustomColor(thing, CustomColor.Index). The built DynamicThing ends up wearing the tool's current color. - Reverse lookup: kit-for-structure.
Prefab.OnLoad(decompile line 244-247) registers everyIConstructionKitintoAssets.Scripts.Objects.Items.ElectronicReader._constructKitLookupviaElectronicReader.AddToLookup(IConstructionKit creator)(line 529-539). Each kit'sGetConstructedPrefabs()(implemented byConstructorwith{ BuildStructure }andMultiConstructorwith allConstructables) enumerates the Structures it builds. The registration callsAddToLookup(IConstructionKit, Thing)(line 599-614) which inserts(created.PrefabHash -> List<IConstructionKit>). Read-back at runtime:ElectronicReader.GetAllConstructors(Thing)(line 642-646) returns every kit that builds a given Structure, ornullif none. This is the single canonical reverse-lookup from a placed Structure back to the kit(s) that build it. - Vanilla prefab default (the prefab asset itself, before instantiation):
PaintableMaterialis a serializedMaterialslot filled in the Unity inspector.CustomColoris a serializedColorSwatchslot also fillable in the inspector, but it is OVERWRITTEN duringAwakeon every instantiation wheneverPaintableMaterialis set. TheCustomColorslot on the prefab asset is therefore a no-op for runtime behavior. There is no separateDefaultColor/DefaultCustomColor/PrefabColor/InitialColorSwatchfield onThing,Structure,Item,Tool,Consumable,Pipe, orLargeStructure; grep over the full Thing decompile confirms no such field exists.
For any paintable Thing from any spawn path, the moment Awake returns the Thing has CustomColor == GameManager.GetColorSwatch(PaintableMaterial) with CustomColor.Normal == PaintableMaterial and CustomColor.Index == GameManager.GetColorIndex(PaintableMaterial). Constructor / MultiConstructor / DynamicThingConstructor paths then layer a SetCustomColor(kitColorIndex) on top before the Thing is handed off. There is no observable window in which CustomColor is null for a paintable Thing post-Awake.
The observed yellow-Ladder-vs-orange-Ladder rule. A Ladder placed by any of the three construct-via-kit paths (player build, creative Authoring Tool click, MultiConstructor) ends with CustomColor == kit.PaintableMaterial-derived-swatch. A Ladder Structure in isolation (never reachable from any public spawn path, but recoverable as Prefab.Find<Ladder>("Ladder")) would have CustomColor == Ladder.PaintableMaterial-derived-swatch. Those two PaintableMaterials are independent prefab-asset values; when they differ, so do the resulting in-world colors. The user's yellow-vs-orange observation is this asymmetry surfacing.
Printer-default color lookup¶
"The color an object would have if freshly built via the normal flow" has two different valid definitions:
- "As it rolls out of the printer" (kit-color). The DynamicThing (kit or item) that the fabricator emits into the export slot. For Items and non-built DynamicThings this equals the Thing's own
PaintableMaterial-derived swatch. For kits, this is the kit's own Awake default, which is NOT the same as the Structure the kit builds. - "As freshly placed in the world by the normal construct flow" (built-structure color, which is the kit's color applied to the placed Structure). This is what a player sees standing next to a just-built Ladder. Per the Kit / Structure color asymmetry section above, this equals the kit's
PaintableMaterial-derived swatch, NOT the Structure's ownPaintableMaterial-derived swatch.
These two definitions collapse to the same answer for Things that are not kit-built (pipes placed as items, tools, resources) but diverge for kit-built Structures.
Lookup for a DynamicThing / Item (no kit involved)¶
int PrinterDefaultColorIndex(Thing target)
{
if (!target.IsPaintable) return -1;
Material def = target.PaintableMaterial;
if (def == null) return -1; // HasPaintableMaskMaterial but no base material
return GameManager.GetColorIndex(def); // == -1 if not in CustomColors
}
Why this works for the non-kit case:
Thing.PaintableMaterialis a serialized field on the prefab asset and is never reassigned by any vanilla code path. Grep across the Thing decompile shows no writes toPaintableMaterialanywhere; only reads. Painting a Thing reassignsCustomColor, notPaintableMaterial.- On the live instance,
target.PaintableMaterialis the sameMaterialreference as on the source prefab (Unity prefab instantiation shares the serialized Material reference). GameManager.GetColorIndex(Material)(line 467) finds the matching swatch inCustomColorsand returns its index, or-1on miss.- For a Thing that has
HasPaintableMaskMaterial == truebutPaintableMaterial == null, there is no registered "printer default" in the swatch list; the helper should return-1and the feature falls back to a no-op.
Lookup for a placed Structure (kit was involved)¶
target.PaintableMaterial on a placed Structure is not the "as-built" color. The as-built color is the kit's PaintableMaterial. Recover it via the kit reverse lookup:
int AsBuiltColorIndex(Thing target)
{
if (!target.IsPaintable) return -1;
// Kit-built Structures: consult the reverse lookup first.
List<IConstructionKit> kits = ElectronicReader.GetAllConstructors(target);
if (kits != null && kits.Count > 0)
{
// Prefer a Constructor-kit (1:1 match) over a MultiConstructor (1:N).
IConstructionKit kit = kits[0];
Thing kitThing = kit as Thing;
if (kitThing != null && kitThing.PaintableMaterial != null)
return GameManager.GetColorIndex(kitThing.PaintableMaterial);
}
// Fallback for non-kit-built Things.
Material def = target.PaintableMaterial;
if (def == null) return -1;
return GameManager.GetColorIndex(def);
}
Caveats:
ElectronicReader.GetAllConstructors(Thing)keys byPrefabHash. It works on either a prefab or a live instance; both share the hash.- When a Structure has multiple constructor kits (modded parallel kits, or a
MultiConstructorthat builds the same Structure as one option among many), the list carries all of them. The kits'PaintableMaterials may disagree. The helper above picks[0]which is registration order; a user-visible eyedropper probably wants the firstConstructorover anyMultiConstructor, since a MultiConstructor's color represents the selector-kit itself, not any one of its N outputs. A production implementation should iterate, prefer aConstructormatch, and fall back to the firstMultiConstructorif that is all that exists. - For Structures that no kit builds (console-spawned-via-mod, hand-placed by a dev tool, or
SpawnDatacontent),GetAllConstructorsreturnsnulland the fallback totarget.PaintableMaterialis the best approximation.
Recommendation for the Ctrl+right-click eyedropper variant¶
The feature wants the "as it rolls out of the printer" color, which for a placed Ladder means the kit color (yellow), not the Structure color (orange). The old one-liner SprayPaintHelpers.GetPaintColorIndex(target.PaintableMaterial) returns the Structure color, which is wrong for any kit-built Structure. Replace with the AsBuiltColorIndex shape above.
Feasibility verdict: 100% faithful recovery is possible for any vanilla kit-built Structure via ElectronicReader.GetAllConstructors, because the registration is prefab-deterministic at load time. The only lossy case is a Structure placed by a non-kit path (vanilla code has no such placements; modded code could), where the helper falls back to target.PaintableMaterial. In that fallback case the feature is "as faithful as possible given no kit metadata exists."
PrefabName and PrefabHash visual-variant identity¶
Thing carries two fields under [Header("Thing")] that identify the source prefab of every spawned instance:
PrefabHash is the canonical per-prefab integer identity. Every Unity prefab variant has its own value: StructureWall, StructureWallFlat, StructureWallArched, StructureWallIron, StructureWallPadded, etc. all map to distinct hashes even though they share the same C# class (Wall). Same for the Frame family (open frame, web frame, girder), the Floor family (visual floor variants), and every other class with multiple visual prefabs.
PrefabName is the matching string identifier (also the value passed to Prefab.Find<T>(name)). It is a one-to-one alias of PrefabHash via Animator.StringToHash(name) at registration time; reads of either are equivalent for identity comparison, but PrefabHash is cheaper (int compare vs string compare).
Implication for type-keyed flood-fill or selection code: filtering candidates by s.GetType() == origin.GetType() collapses every visual variant of a given C# class into one bucket. To distinguish visual variants (e.g. paint only Flat Walls when sprayed on a Flat Wall, leaving Arched Walls untouched), use s.PrefabHash == origin.PrefabHash. The reverse direction also holds: when code intends to treat all visual variants of a class as one group, the type filter is correct and the prefab-hash filter would be too narrow.
PrefabHash is set during prefab registration and never reassigned at runtime; it is identical on the live instance and on the source prefab asset (Unity prefab instantiation copies the serialized field). It is server-authoritative in the sense that every connected peer has the same value for the same Thing (the value is part of the prefab itself, not part of any networked state).
Delete(Thing sourceItem) and the destruction entry points¶
Thing.Delete(Thing sourceItem) is virtual. It is the "a tool deleted me" path -- the creative Authoring Tool's destroy-click reaches it (via Thing.AttackWith / Structure.AttackWith when attack.IsDestroy), and the DestroyThingRequest network message re-enters it on the receiving side:
public virtual void Delete(Thing sourceItem)
{
if (sourceItem == null) return;
if (Slots.Count > 0)
foreach (Slot slot in Slots)
if ((bool)slot.Occupant && GameManager.RunSimulation)
slot.Occupant.Delete(sourceItem); // recursively deletes everything in slots
if (GameManager.RunSimulation)
{
if (ThreadedManager.IsThread) DestroyFromThread().Forget();
else OnServer.Destroy(this);
}
else
{
NetworkServer.SendToClients(new DestroyThingRequest { ThingId = netId, SourceItemId = sourceItem.NetworkId }, ...);
}
}
Notes:
- Requires a non-null
sourceItem(the Thing that "did it"). A mod can pass any Thing; anAuthoringToolinstance gives creative semantics. - Recursively
Deletes slot occupants (so contained items vanish too) rather than dropping them.DynamicThingoverridesDelete: if the source is anAuthoringToolit callsbase.Deleteand then moves slot occupants to the world viaOnServer.MoveToWorld(so a deleted container spills its contents to the floor).Structuredoes not overrideDelete; it uses this base version. - On the server (
RunSimulation): ends inOnServer.Destroy(this)(see./OnServer.md). On a pure client: sends aDestroyThingRequestto the server, which re-runsthing.Delete(source)server-side. - There is no type gate (works on any Thing including structures) and no living-player guard inside
Delete; the "can't delete a conscious Human" guard lives upstream inThing.AttackWith(if (this is Human h && h.OrganBrain) return null;).
The full set of ways a Thing leaves the world: OnServer.Destroy(Thing) (low-level GameObject destroy; see ./OnServer.md), Thing.Delete(Thing) (this method), Structure.OnDamageDestroyed() (the damage-maxed-out path with wreckage + construction event; see ./Structure.md), letting DamageState.Total reach MaxDamage (the async ThingDamageState.Destroy() chain; see ../GameSystems/DamageState.md and ./Structure.md), and the DestroyThingRequest network message (the wire format that funnels back into Thing.Delete). The deconstruct-by-tool flow walks Structure.CurrentBuildStateIndex down stage by stage and only removes the object when it deconstructs build state 0; there is no OnDeconstructPrimary / OnDeconstructSecondary method (those names do not exist in this version).
Enumerating Things near a point¶
Two canonical patterns:
- Collider-based spatial query (what
Explosionand rocket-landing damage use):Physics.OverlapSphereNonAlloc(pos, radius, buffer)thenThing.Find(collider)for each hit.Thing.Find(Collider)is a staticDictionary<Collider, Thing>lookup (Thing._colliderLookup, populated byThing.CacheColliders(), entries removed inThing.OnDestroy()); returns null for colliders not owned by a Thing (raw scenery, terrain). One Thing can have several colliders, so dedupe by Thing. Cheaper for small radii but limited to the buffer size and only catches Things with colliders on a queried layer. - Global pool iteration (what
WorldManager.DeleteOutOfBoundsObjectsuses):OcclusionManager.AllThingsis aConcurrentDensePool<Thing>holding every non-cursor Thing (registered inOcclusionManager.Register, removed inDeregister). Iterate viaAllThings.ForEach(t => ...)or snapshot withAllThings.ToList()(snapshot before iterating if you will destroy entries mid-loop). Filter byVector3.Distance(t.Position, center) <= radius. Catches collider-less Things and is not limited by a fixed buffer; iterates the whole pool.WorldManager.DeleteOutOfBoundsObjectsis the precedent for "iterate all Things,OnServer.Destroythe ones matching a predicate":
private static Action<Thing> _destroyOutOfBounds = delegate(Thing thing)
{
if (!(thing == null) && !thing.IsBeingDestroyed && thing.WorldGrid.OutOfBounds())
{
OnServer.Destroy(thing);
ConsoleWindow.PrintAction("Deleting " + thing.DisplayName + "...");
}
};
Interaction and hover surface: GetInteractable, InteractWith, GetContextualName, GetExtendedText¶
Thing declares the base click-and-hover surface every prefab inherits. The members below are what the HUD polls every frame while the cursor rests on a Thing; see ElectricalInputOutput for the full tooltip render path and its two display routes (ALT mouse-control vs crosshair), and Interactable for the Interaction struct and the OnServer.Interact state funnel these methods feed. All line references are the 0.2.6403.27689 decompile.
GetInteractable(Collider): strict dictionary lookup, no fallback¶
public Interactable GetInteractable(Collider selectedCollider) // line 320193
{
if (_interactableColliderLookup == null)
{
return null;
}
_interactableColliderLookup.TryGetValue(selectedCollider, out var value);
return value;
}
The collider-to-interactable map is a plain TryGetValue: a collider that is not a registered interactable collider (device body meshes, Connection open-end colliders, damage colliders) returns null, with no proximity or parent fallback. Both HUD display routes call this with the hit collider to decide "button hover" vs "body hover", so body hovers are exactly the hovers where this returns null. An overload GetInteractable(InteractableType action) (320203) resolves by axis instead (Device uses it in the switch-color plumbing).
InteractWith: the per-click handler doubles as the hover preview¶
Thing.InteractWith(Interactable, Interaction, bool doAction = true) is the base implementation subclasses override (Transformer buttons, LogicMirror, PipeIgniter, etc.). The HUD calls it with doAction: false on every hover frame to build the tooltip preview (Tooltip.SetValuesForInteractable, see ElectricalInputOutput); the click calls it with doAction: true. Full body (321491-321541):
public virtual DelayedActionInstance InteractWith(Interactable interactable, Interaction interaction, bool doAction = true) // line 321491
{
DelayedActionInstance delayedActionInstance = new DelayedActionInstance
{
Duration = 0f,
ActionMessage = interactable.ContextualName // line 321496
};
if (interactable.Slot != null)
{
if (interactable.Slot.Type != Slot.Class.None)
{
delayedActionInstance.ExtendedMessage = GameStrings.TypeOfSlot.AsString(Localization.GetName(interactable.Slot)) + "\n";
}
Slot sourceSlot = interaction.SourceSlot;
if (sourceSlot != null && sourceSlot.IsNotEmpty() && interaction.SourceSlot.Get().TryInteractWithSlotOccupant(interactable, out var actionInstance, doAction))
{
return actionInstance;
}
return HandleSwitch(interaction, interactable.Slot.SlotIndex, delayedActionInstance, doAction);
}
switch (interactable.Action)
{
case InteractableType.Open:
if (!doAction)
{
return delayedActionInstance.Succeed();
}
OnServer.Interact(interactable, (interactable.State != 1) ? 1 : 0); // line 321518
return delayedActionInstance.Succeed();
case InteractableType.OnOff:
if (!doAction)
{
if (Error == 1)
{
delayedActionInstance.AppendStateMessage(GameStrings.ThingCurrentlyFlashingError); // line 321525
}
return delayedActionInstance.Succeed();
}
OnServer.Interact(interactable, (!OnOff) ? 1 : 0); // line 321529
return delayedActionInstance.Succeed();
case InteractableType.Lock:
if (!doAction)
{
return delayedActionInstance.Succeed();
}
OnServer.Interact(interactable, (interactable.State != 1) ? 1 : 0); // line 321536
return delayedActionInstance.Succeed();
default:
return delayedActionInstance.Fail(); // line 321539
}
}
Load-bearing details:
ActionMessageis seeded frominteractable.ContextualName(321496), which isParent.GetContextualName(this); the leading word on every button hover comes from the method in the next subsection.- Slot-backed interactables short-circuit into slot-occupant interaction or
HandleSwitchbefore the type switch (321498-321510). - The
OnOffhover preview (doAction == false) appendsGameStrings.ThingCurrentlyFlashingErrorto the tooltip State line whenError == 1(321523-321526). This is the vanilla "device is error-flashing" hint on the switch hover. - The
OnOffclick writes the INVERSE of the current property:OnServer.Interact(interactable, (!OnOff) ? 1 : 0);(321529).OpenandLockinstead invert the interactable's ownState(321518, 321536). - Any other
InteractableTypereaching the base returnsdelayedActionInstance.Fail()(321539).
GetContextualName: the leading word is the available ACTION, not the current state¶
public virtual string GetContextualName(Interactable interactable) // line 319699
{
switch (interactable.Action)
{
case InteractableType.Open:
if (!IsOpen)
{
return ActionStrings.Open;
}
return ActionStrings.Close;
case InteractableType.OnOff: // line 319709
if (!OnOff)
{
return ActionStrings.On;
}
return ActionStrings.Off;
case InteractableType.Activate:
return (Activate == 0) ? GameStrings.Activate : GameStrings.Deactivate;
case InteractableType.Mode:
{
string arg = ((interactable.State == 0) ? ModeStrings[1] : ModeStrings[0]);
return GameStrings.InteractableAction.AsString(arg);
}
default:
return interactable.DisplayName;
}
}
For an on/off button the returned word is the action a click would perform, not the state: a switched-OFF device shows "On", a switched-ON device shows "Off". The same action-word convention holds for Open ("Open" when closed, "Close" when open) and Activate. Reading the word as the current state inverts reality; UI or mod text that wants the state word must map from OnOff itself.
ActionStrings (class 285747, namespace Assets.Scripts.Inventory opening at 285745) holds the bare localized words as static string properties: Off => Localization.GetAction(334568355) (285807), On => Localization.GetAction(-1674441366) (285809). State words also exist in the same class (Opened 285803, Closed 285805, Powered 285811, Unpowered 285813) but GetContextualName does not use them.
In-repo consumers: SprayPaintPlus relabels the word per class via a GetContextualName postfix (see SprayGun, which carries the 0.2.6228 excerpt of this same body); PowerGridPlus's TransformerHoverErrorPatches swaps the word for the switch-state word (OnOff ? ActionStrings.On : ActionStrings.Off) only while it is showing a lockout fault line, precisely because the vanilla word is the action.
GetExtendedText: the burning / damage / pickup lines¶
public virtual StringBuilder GetExtendedText() // line 320726
{
StringBuilder stringBuilder = new StringBuilder();
if (IsBurning)
{
stringBuilder.AppendLine(GameStrings.CurrentlyOnFire.DisplayString);
}
if (IsBroken)
{
stringBuilder.AppendLine(GameStrings.IsDestroyed.DisplayString);
}
else if (DamageState.TotalRounded > 0)
{
if (DamageState.DecayRounded == DamageState.TotalRounded)
{
stringBuilder.AppendLine(GameStrings.ThingDamageIsAt.AsString(GameStrings.DamageTypeDecay, DamageState.DecayRounded.ToStringPercent(GetDamageColor())));
}
else
{
AddDamageString(stringBuilder);
}
}
if (!CanPickup)
{
stringBuilder.AppendLine(GameStrings.CannotBePickedUpRightNow.DisplayString);
}
return stringBuilder;
}
Returns a StringBuilder (callers append to it or .ToString() it: Structure.GetPassiveTooltip and AreaPowerControl.GetPassiveTooltip both do Extended = GetExtendedText().ToString(), see ElectricalInputOutput). A healthy, pickup-able, non-burning Thing returns an empty builder. The damage percent is colored via GetDamageColor() (the helper directly above, returning color-name strings such as "yellow" / "green"); AddDamageString (320755) formats the mixed damage line.
Related state anchors at 0.2.6403.27689: the OnOff virtual property is at 318249 and the [ReadOnly] public bool HasOnOffState flag at 317241; the full state-property story (animator-backed getters, per-frame caches, CacheStates as the sole Has*State writer) lives on Device.
Verification history¶
- 2026-04-20: page created from the Research migration; verbatim content lifted from F0223. No conflicts.
- 2026-04-22: added "CustomColor field and IsPaintable gate" section. Additive only; no existing content changed. Sources: decompile of
Assets.Scripts.Objects.Thingfields at line ~360 (PaintableMaterial,CustomColor),IsPaintableat line ~1772,SetCustomColor(int, bool)at line ~5265, save round-trip at line ~4667 / ~4692, all in game version 0.2.6228.27061. - 2026-04-22: added "Initial CustomColor by spawn path" and "Printer-default color lookup" sections. Additive; no existing content changed. Sources:
Thing.Awakelines 3619-3748 (CustomColor initializer at 3745-3748),Thing.Create<T>line ~2320,GameManager.GetColorSwatch(Material)line 539-554,GameManager.GetColorIndex(Material)line 467,Util.Commands.ThingCommand.Execute"spawn"case,Assets.Scripts.UI.ImGuiUi.ImguiCreativeSpawnMenu.SpawnDynamicThingline 196,Assets.Scripts.Inventory.InventoryManager.SpawnDynamicThingline 937-952,OnServer.SpawnDynamicThingMaxStackline 675-735,Assets.Scripts.Objects.Electrical.SimpleFabricatorBase.SpawnCreatedItemsline 894-908,Assets.Scripts.Objects.Constructor.Construct/SpawnConstruct,Assets.Scripts.Objects.MultiConstructor.Construct,Assets.Scripts.Objects.Items.DynamicThingConstructor.OnUseItem,Assets.Scripts.Objects.Structure.SetStructureDataline 2239-2247. All in game version 0.2.6228.27061. No conflict with existing content. - 2026-04-28: added "PrefabName and PrefabHash visual-variant identity" section after a SprayPaintPlus bug report ("wall painting spills across visual wall variants"). Additive; no existing content contradicted. Sources:
Assets.Scripts.Objects.Thingfields at decompile line 297860-297865 ([Header("Thing")] [ReadOnly] public string PrefabName; [ReadOnly] public int PrefabHash;), in game version 0.2.6228.27061. - 2026-04-29: added "Delete(Thing sourceItem) and the destruction entry points" section (and "Enumerating Things near a point" subsection) from a research pass on the explosion / structure-destruction system. Additive; no existing content changed. Sources:
Thing.Delete,DynamicThing.Delete,Thing.AttackWith/Structure.AttackWith,DestroyThingRequest,Thing.Find(Collider)/Thing._colliderLookup,OcclusionManager.AllThings/Register/Deregister,WorldManager.DeleteOutOfBoundsObjects(all inAssembly-CSharp, game version 0.2.6228.27061). - 2026-04-22: refined "Initial CustomColor by spawn path" and rewrote "Printer-default color lookup" after user reported a yellow-kit-vs-orange-structure color asymmetry on a placed Ladder. Prior opening sentence "console/creative/fabricator/constructor all end at the same default" was misleading: it was true that Awake sets a default, but omitted that the Constructor path always re-overwrites that default with the KIT's color (not the target Structure's), and it failed to note that no vanilla path lets a raw Structure reach the world without going through a kit. Additions: (a) new subsection "Kit / Structure color asymmetry" explaining the two PaintableMaterial slots on a built structure, (b) new subsection "Per-spawn-path behavior" with the Authoring Tool / placement-click path (
OnServer.UseItemPrimaryAuthoringline 948-956 substitutesPrefab.Find<Constructor>(spawnPrefab.SpawnId)for the heldAuthoringTool), © documentation of the_constructKitLookupreverse-lookup registered inPrefab.OnLoadline 244-247 and read viaElectronicReader.GetAllConstructors(Thing)line 642-646, (d) rewrotePrinterDefaultColorIndexinto a kit-awareAsBuiltColorIndexhelper. No verified claim was removed -- theConstructorbullet at original line 120 already carried theinstance.CustomColordetail correctly; this pass promotes that detail to the top of the section and adds the reverse-lookup primitive. No fresh validator required: refinement/addition, not contradiction of previously-verified claims. Sources additionally consulted:Constructor.Constructline 23-34,MultiConstructor.Constructline 47-61,CreateStructureInstance(Structure, Grid3, Quaternion, ulong, int = -1)ctor line 35-43,Structure.SetStructureDataline 2239-2248,OnServer.UseItemPrimary/UseItemPrimaryAuthoringline 938-956,Assets.Scripts.UI.ImGuiUi.ImguiCreativeSpawnMenuline 58-64 + 166-200,InventoryManager.SpawnDynamicThing(ICreativeSpawnable)line 937-947,Prefab.OnLoadkit-registration line 244-247,ElectronicReader._constructKitLookupline 89 andGetAllConstructorsline 642-646,ElectronicReader.AddToLookup(IConstructionKit)line 529-539 and 599-614,DynamicThingConstructor.OnUseItemat game-DLL line ~323731. All in game version 0.2.6228.27061. - 2026-06-20: added "CustomColorMapping build rule (which renderers recolor)" section. Additive; no existing content changed (the "CustomColor field and IsPaintable gate" section described
IsPaintableandSetCustomColor's swap loop, but not the Awake-time rule that decides which renderers populate_customMaterials). Source:Thing.Awake_customMaterialsbuild at decompile line 301261-301284 (thematerial == PaintableMaterial || material.mainTexture is Texture2DArrayinclusion test), game version 0.2.6228.27061. Worked example (the steel-frame Corner/Side shapes made paintable by copyingPaintableMaterial) cross-checked live on the dedicated server via the ScenarioRunnerpaintable-prefab-dumpprobe:StructureFrameCornerandStructureFrameSideflip toPaintableMaterialSet=Trueafter the load-time assignment, all four steel-frame shapes reportrenderMode=Standard. No fresh validator required: additive, no contradiction of previously-verified claims. - 2026-06-20: extended the "CustomColorMapping build rule" section with a "Structures build the recolor set per build state" subsection while answering a SprayPaintPlus savegame-removal-safety question. Documents
Structure.Awake's per-BuildState_customMaterialsbuild (decompile 295943-295980, same== PaintableMaterial || Texture2DArrayrule) andStructure.SetCustomColor's Standard-forwards / Batched-throws dispatch (line 295933-295941), and spells out the removal-safe consequence (a runtimePaintableMaterialassignment is never serialized; a vanillaCustomColorIndexis reapplied via the Texture2DArray branch on load even without the mod). Additive; consistent with the existing./Structure.mdSetCustomColorcoverage. No fresh validator required. - 2026-07-14: added "Interaction and hover surface" section (game version 0.2.6403.27689, all bodies read verbatim from the 0.2.6403.27689 decompile):
GetInteractable(Collider)strictTryGetValuelookup with no fallback (320193-320201;InteractableTypeoverload 320203), the fullInteractWithbase body (321491-321541) including thedoAction: falsehover-preview contract, the OnOff error-flash state append (GameStrings.ThingCurrentlyFlashingError, 321523-321526) and the inverse-of-OnOffwrite (321529),GetContextualName(319699-319725) with the action-word (not state-word) semantics and theActionStringsanchors (class 285747 inAssets.Scripts.Inventory,Off285807,On285809, state wordsOpened/Closed/Powered/Unpowered285803-285813), andGetExtendedText(320726-320753,AddDamageString320755). Also recorded the 0.2.6403.27689 anchors forOnOff(318249) andHasOnOffState(317241), whose full semantics live on Device. Additive; consistent with the earlier 0.2.6228 excerpts on SprayGun (InteractWithold line 302420,GetContextualNameold line 300637; both bodies unchanged apart from line drift) and with Device's state-property section. Occasion: the PowerGridPlus tooltip-pipeline curation pass, alongside the display-route sections added to ElectricalInputOutput the same day. Bumped frontmatter verified_in / verified_at; added theuitag.
Open questions¶
None at creation.