Skip to content

ThingColorMessage

Vanilla ProcessedMessage<ThingColorMessage> at Assets.Scripts.Networking.ThingColorMessage (decompile line 283375). A client-to-server request carrying a Thing's new colour index: OnServer.SetCustomColor sends it only when it runs on a remote client, and its Process runs on the server. It is not how colour reaches clients; the server sends colour to clients in the flag 32 block of the per-tick delta state and in the per-Thing join and new-Thing record (see "Server-to-client colour sync" below). SprayPaintPlus piggybacks a second channel on Thing.NetworkUpdateFlags bit 12 for its own per-can color-index sync rather than extending this message.

Line numbers on this page are from the 0.2.6428.27798 decompile unless a passage says otherwise.

Wire format

public class ThingColorMessage : ProcessedMessage<ThingColorMessage>
{
    public long ThingId;     // 8 bytes
    public int ColorIndex;   // 4 bytes
}

Class declaration at decompile line 283375 (259874 at 0.2.6228.27061). Serialization body at lines 283386-283396 (259884-259891 at 0.2.6228.27061):

Deserialize: ThingId = reader.ReadInt64(); ColorIndex = reader.ReadInt32();
Serialize:   writer.WriteInt64(ThingId); writer.WriteInt32(ColorIndex);

12 bytes plus the message header. No bit packing, no reserved bytes, no unused slots.

Sender

OnServer.SetCustomColor(Thing thing, int colorIndex) (39796-39810) is the only code that constructs a ThingColorMessage (39803), and it sends one only when it runs on a remote client:

public static void SetCustomColor(Thing thing, int colorIndex)
{
    if (!thing.HasColorState)
    {
        thing.SetCustomColor(colorIndex);
        if (Assets.Scripts.Networking.NetworkManager.IsClient)
        {
            NetworkClient.SendToServer(new ThingColorMessage
            {
                ThingId = thing.netId,
                ColorIndex = thing.CustomColor.Index
            });
        }
    }
}

NetworkManager.IsClient is NetworkRole == NetworkRole.Client (278324), so on a host or in single-player nothing is sent: the colour is applied locally and reaches clients by the paths in "Server-to-client colour sync". On a remote client the colour is applied locally first, and the index the client then holds goes to the server through NetworkClient.SendToServer (218657). A Thing with its own colour interactable (HasColorState) is skipped entirely. Which callers of OnServer.SetCustomColor run on a remote client is not traced on this page.

Inside Thing.SetCustomColor(int index, bool emissive = false) (327900-327952), vanilla:

  • Validates via GameManager.IsValidColor(index) (327902-327905), which is 0 <= index < CustomColors.Count (209714-209721).
  • On valid, sets CustomColor = GameManager.GetColorSwatch(index) (327906) and, only when GameManager.RunSimulation, raises NetworkUpdateFlags |= 32 (bit 5) (327907-327910).
  • On invalid, returns early without setting the color and without raising an error (silent no-op).

Receiver

ThingColorMessage.Process(long hostId) (283381-283384) runs where the message arrives, which is the server. It calls:

Thing.Find<Thing>(ThingId).SetCustomColor(ColorIndex);

Same Thing.SetCustomColor entry as any other colour write, so the server's own call raises flag 32 and the new colour reaches every client, the sender included, through the delta state. Process checks nothing else: there is no null test on the Find result, no HasColorState or IsPaintable test, and no entitlement test (see DLCGating). The silent-no-op-on-invalid-index behavior applies on receive as well: attempts to piggyback extra bits inside ColorIndex are dropped by IsValidColor. The dispatcher NetworkBase.DeserializeReceivedData (39291-39311) calls Process on whichever peer receives a message, and prints Messages should only be processed on the server when a non-server processes one outside a short allowlist; ThingColorMessage is not on that allowlist (39306-39309).

Server-to-client colour sync

Clients get colour from the server by two paths, and neither is this message:

  • The per-tick delta state, flag 32. The game names the bit NetworkUpdateType.Thing.CustomColour (286626). Thing.BuildUpdate writes GameManager.GetColorIndex(CustomColor) as an Int32 and then CustomName under it (328305-328310), and a client's Thing.ProcessUpdate reads them back and calls SetCustomColor(index) and RenameThing(newName) (328391-328397). The bit is raised by Thing.SetCustomColor(int, bool) when GameManager.RunSimulation (327907-327910) and by the CustomName setter on the server (323578-323581). It is not colour-only: Plant raises it as well, in its PlanterName setter (363678-363681) and on stack changes, and Plant.BuildUpdate / Plant.ProcessUpdate append PlanterName under the same bit (364043-364046, 364117-364120). The server sends the delta state from NetworkServer.NetworkUpdate (219609-219647) through FragmentHandler.Send. Bit map: NetworkUpdateFlags.
  • The per-Thing record. A joining client, or a client that sees a Thing created while it is connected, gets the colour from the record Thing.SerializeOnJoin writes and Thing.DeserializeOnJoin applies (328071-328141). It carries -1 for a Thing still in its prefab colour and holds the index in one signed byte; details on Thing, "The same sentinel crosses the network".

Backing field on Thing

Thing.CustomColor (322976; 297959 at 0.2.6228.27061) is typed ColorSwatch (object reference). The canonical int identifier is CustomColor.Index. ColorSwatch exposes Normal (Material), Emissive (Material), Index (int), Light (Color), DisplayName (string); the full member list is on ColorSwatch.

Save-side: ThingSaveData.CustomColorIndex (329560-329561; 304515 at 0.2.6228.27061) is an [XmlElement] int initialized to -1. -1 means "no custom color; use prefab default". Thing.DeserializeSave calls SetCustomColor(saveData.CustomColorIndex) only when the value is >= 0 (327298-327301). The 0.2.6228.27061 reading named the load method Thing.LoadSimData (decompile line 302262-302287); no method of that name exists at 0.2.6428.27798.

Emissive parameter and its transience

Thing.SetCustomColor(int index, bool emissive = false) accepts an optional emissive flag that, when true, swaps the renderer's material to ColorSwatch.Emissive (if non-null) via CustomColorMapping.SetEmissive. It also sets Thing.EmissionColor to Color.white and hands that to every ThingRenderer as _EmissionColor, but the value reaches no material, so the visible glow is the material swap alone (see ColorSwatch, "Normal vs Emissive selection"). A second overload, SetCustomColor(bool emissive) (327954-327974), does only the material swap. The full body is documented in ../GameClasses/ColorSwatch.md.

This wire format does NOT carry the emissive flag, and no other colour path does either. At 0.2.6428.27798 every path calls SetCustomColor with the default emissive: false:

  • OnServer.SetCustomColor (39796-39810) applies the color via thing.SetCustomColor(colorIndex) without passing an emissive value and then, on a remote client only, sends ThingColorMessage { ThingId, ColorIndex } to the server.
  • ThingColorMessage.Process (283381-283384) calls Thing.Find<Thing>(ThingId).SetCustomColor(ColorIndex) on the server, also without an emissive value.
  • A client applies the server's colour through Thing.ProcessUpdate (flag 32, SetCustomColor(index) at 328394) and through Thing.DeserializeOnJoin (SetCustomColor(b) at 328124), neither with an emissive value.
  • ThingSaveData persists only CustomColorIndex; on load, Thing.DeserializeSave calls SetCustomColor(saveData.CustomColorIndex) when the index is >= 0 (327298-327301), defaulting to emissive: false. The 0.2.6228.27061 reading named this method Thing.LoadSimData (line 302262-302287), which no longer exists.

Each of these runs the base method with emissive: false: every mapped renderer not tagged NotPaintable goes back through CustomColorMapping.SetColor, and EmissionColor becomes UnityEngine.Color.white * 0f, all four channels zero rather than Color.black (327915-327951). That clears the emission unless the Thing's class puts it back. WallLight (333693-333701), Beacon (346853-346861), PortableLight (359277-359285), PortablePortal (359346-359354) and StackableLight (360277-360286, so also ChemLight and RoadFlare) override SetCustomColor(int, bool) to call SetCustomColor(OnOff && Powered) after the base call; LandingPadModular (188461-188468) passes OnOff && _flash to the base instead of the argument; GrowLight (403764-403767) passes false to the base and then calls SetBulbMaterial(OnOff && Powered). Subclasses that do not override again inherit these.

Vanilla classes that glow work around the clearing by re-applying emission from their own state, either in a SetCustomColor(int, bool) override (above) or from their interaction and animation handlers. ChemLight.OnInteractableUpdated (348410) and RoadFlare.OnInteractableUpdated (360342), at decompile lines 322433 and 334170 in 0.2.6228.27061, call SetCustomColor(emissive: true) when the light is switched on. Every site that can pass emissive: true is listed on ColorSwatch, "Vanilla callers of SetCustomColor with emissive: true".

No painted structure (wall, pipe, cable, frame) ever receives emissive: true in vanilla. Mods that want glow to persist and sync must store their own IsGlowing flag per Thing and re-apply emissive after every SetCustomColor entry. See ../GameSystems/RenderingPipelineAndGlow.md for the full rendering approach and ../GameClasses/ColorSwatch.md for swatches whose Emissive material is null.

Side-channel options for extra paint metadata

This message cannot carry anything to clients, since it only travels client to server, and the paths that do reach clients have no slack either: the flag 32 block carries the index as an Int32 next to CustomName, the per-Thing record carries it in one signed byte, and the base Thing.SetCustomColor drops an invalid index on every receiver. Mods that need to carry per-paint metadata (for example, a "glow" flag) to clients therefore cannot piggyback inside a colour index or extend a vanilla colour path without breaking version compatibility. Two documented alternatives:

  1. Claim a free bit in Thing.NetworkUpdateFlags (see ../GameSystems/NetworkUpdateFlags.md) and append a field via postfix patches on Thing.BuildUpdate / Thing.ProcessUpdate / Thing.SerializeOnJoin / Thing.DeserializeOnJoin. Spray Paint Plus does this twice: for spray can colour on Consumable (ConsumableSyncPatch.cs, bit 0x1000) and for glow on every Thing (ThingGlowSyncPatches.cs, bit 0x2000), so its glow field is carried by painted structures (walls, pipes, cables) as well as by spray cans. See ../Patterns/BinaryStreamSafety.md for the no-try-catch constraint.
  2. Ship a dedicated LaunchPadBooster INetworkMessage (see ./LaunchPadBoosterNetworking.md) that carries the metadata independently. Costs one extra message per paint event.

Verification history

  • 2026-04-21: page created. Decompile findings sourced from Assembly-CSharp.dll (ThingColorMessage at line 259874-259891, OnServer.SetCustomColor sender at line 39453-39459, Thing.SetCustomColor receiver at line 302860 with NetworkUpdateFlags |= 32 at line 302873-302874, Thing.CustomColor field at line 297959, ThingSaveData.CustomColorIndex at line 304515, Thing.LoadSimData at line 302262-302287).
  • 2026-04-21: added "Emissive parameter and its transience" section documenting that the emissive flag on Thing.SetCustomColor is not stored, saved, or synced, and pointing at the two vanilla callers (ChemLight.OnInteractableUpdated at line 322433 and RoadFlare.OnInteractableUpdated at line 334170). Additive; no prior claim changed.
  • 2026-09-21 (game version 0.2.6428.27798): conflict on "which way this message travels". Previous claims: "Server-to-client broadcast of a Thing's custom-color change. Authoritative channel for custom-color sync in vanilla" (intro); "OnServer.SetCustomColor(Thing thing, int colorIndex) sends ThingColorMessage at decompile line 39453-39459 after applying the color change server-side. The message fans out to all connected clients." (Sender); and Process running "on the receiving client" (Emissive section). New finding: the message travels client to server only; OnServer.SetCustomColor sends it only when NetworkManager.IsClient, Process runs on the server, and the server reaches clients through flag 32 in the delta state and through the per-Thing record. Fresh validator verdict: B is correct, quoting the OnServer.SetCustomColor body (39796-39810) with its if (Assets.Scripts.Networking.NetworkManager.IsClient) send. It found the only construction at 39803, NetworkClient.SendToServer refusing to run on the server, no relay in ProcessedMessage<T> or on the receive path, the game's own name for the bit (NetworkUpdateType.Thing.CustomColour = 32, 286626) and Plant's use of the same bit for PlanterName. Result: intro, "Sender" and "Receiver" rewritten; new section "Server-to-client colour sync"; the side-channel premise rewritten for the real direction; the Emissive bullets corrected.
  • 2026-09-21 (game version 0.2.6428.27798): conflict on two identifiers. Previous: Assets.Scripts.Networking.Messages.ThingColorMessage (intro and sources) and Thing.LoadSimData as the save-load method ("Backing field on Thing" and the Emissive bullets). New finding: the class is Assets.Scripts.Networking.ThingColorMessage, no Networking.Messages namespace exists, no LoadSimData exists anywhere in the assembly, and the colour load is Thing.DeserializeSave (327298-327301). Fresh validator verdict: B is correct, quoting if (saveData.CustomColorIndex >= 0) { SetCustomColor(saveData.CustomColorIndex); } from Thing.DeserializeSave, confirmed against the IL of the installed DLL. It also found that OnServer is in the global namespace, so the sources pointer Assets.Scripts.OnServer.SetCustomColor is now OnServer.SetCustomColor. The 0.2.6228.27061 decompile is no longer on disk, so whether those names were right for that build was not checked; the old names are kept beside the new ones on the page.
  • 2026-09-21 (game version 0.2.6428.27798): conflict on "whether every colour re-entry clears the emissive state". Previous claim: "Every re-entry therefore clears the emissive state." New finding: the base method clears it and sets EmissionColor to Color.white * 0f (all four channels zero, not Color.black), but WallLight, Beacon, PortableLight, PortablePortal, StackableLight, LandingPadModular and GrowLight re-apply emission after the base call. Fresh validator verdict: B is correct, quoting WallLight.SetCustomColor (333693-333701). Result: the sentence replaced by the paragraph listing the overrides, under the same inline 0.2.6428.27798 stamp as the corrected bullets.
  • 2026-09-21: resolved the Open question "verify whether bit 5 is the exclusive trigger for a color-message send or whether it is a generic "dirty" marker consumed by multiple senders". Neither: bit 5 (value 32) is not connected to this message at all. It marks the colour-and-name block of the delta state, which Thing.BuildUpdate / ProcessUpdate read, and Plant adds PlanterName under it; covered by the direction verdict above and now in "Server-to-client colour sync". Restamps: intro, "Wire format", "Sender", "Receiver", "Backing field on Thing" and "Side-channel options" re-verified against 0.2.6428.27798 and restamped, their line numbers moved to that decompile with the 0.2.6228.27061 numbers kept where the passage cites them (class 259874 to 283375, serializers 259884-259891 to 283386-283396, OnServer.SetCustomColor 39453-39459 to 39796-39810, Thing.SetCustomColor 302860 to 327900 and its flag write 302873-302874 to 327907-327910, Process 259881 to 283381, Thing.CustomColor 297959 to 322976, ThingSaveData.CustomColorIndex 304515 to 329560-329561). The Sender now carries the verbatim OnServer.SetCustomColor body and the RunSimulation gate on the flag write, and the Receiver the checks Process does not make and the dispatcher's server-only warning. The side-channel example now names the two sync patches Spray Paint Plus ships (ConsumableSyncPatch.cs on bit 0x1000, ThingGlowSyncPatches.cs on bit 0x2000), and "Incurs a second round-trip per paint event" now reads "Costs one extra message per paint event", since the option adds one one-way message. "Emissive parameter and its transience" keeps its 0.2.6228.27061 stamp: its opening paragraph, its two-caller list and its closing paragraph were not re-verified. Frontmatter: verified_in / verified_at bumped, the two source pointers corrected, a decompile sources line and a Spray Paint Plus sources line added, ColorSwatch and DLCGating added to related. Occasion: correction pass after the 2026-09-21 Thing and OnServer conflicts.
  • 2026-09-21 (third pass, game version 0.2.6428.27798): "Emissive parameter and its transience" said SetCustomColor(int, bool) "writes Color.white to the _EmissionColor shader property on every ThingRenderer", and listed ChemLight and RoadFlare as the callers that re-apply emissive: true. Both went to fresh validators whose verdicts are recorded in full on ColorSwatch: the _EmissionColor value reaches no material at 0.2.6428.27798 (B is correct), and many classes can pass emissive: true through either overload (Neither, with a corrected census). Result: the opening paragraph now says the glow is the material swap alone and names the SetCustomColor(bool) overload, and the two-caller list became a pointer to the census with the current ChemLight and RoadFlare lines (348410, 360342). With those parts re-verified and the painted-structure sentence confirmed by the census, the whole section is restamped 0.2.6428.27798 @ 2026-09-21 and its inline stamp removed, so every section of this page now stands at 0.2.6428.27798. Added a sources line.

Open questions

None.