This page covers the kind of mod that adds behaviour to PlateUp!, a piece of code that runs
every frame inside the game's simulation and changes what the restaurant does.
It assumes you have already worked through:
PlateUp! is built on Unity's DOTS stack (Unity.Entities). The restaurant is not a tree of
GameObjects with MonoBehaviours on them, it's a flat database. Every hob, every plate, every
customer, and every fire is a numbered entity, and all of its state lives in small structs
attached to it.
That has one very convenient consequence for modders: the game's own logic has no special
access. A vanilla system reads and writes the same entity data your mod does, through the same
API. If you add a system that puts a hob on fire, it is indistinguishable from the game putting a
hob on fire.
An entity is just an ID. It has no data and no behaviour of its own. Entity.Null is the
"nothing" value, and you will compare against it constantly (cItemHolder.HeldItem != Entity.Null).
A component is a struct implementing IComponentData. It is only data, no methods, no logic.
Many components have no fields at all. Those are tag components: their presence on an entity is
the entire meaning. Kitchen.Common/Kitchen/CIsOnFire.cs is the perfect example:
public struct CIsOnFire : IComponentData
{
}
It is also worth nothing, when creating any new components they should also extend
IModComponentso the game can find it.
Nothing in that struct sets anything alight. Adding it to an entity is what sets the entity on fire,
because a dozen other systems query for it.
Components you will meet early:
| Component | Source | Meaning |
|---|---|---|
CPosition |
Kitchen.Common |
Where the entity is, and which way it faces |
CAppliance |
Kitchen.Common |
This entity is an appliance; holds its ID and Layer |
CIsInteractive |
Kitchen.Common |
Placed in the world and interactable, rather than held or a proxy |
CItemHolder |
Kitchen.Common |
Can hold an item; HeldItem is the entity it holds |
CIsOnFire |
Kitchen.Common |
Tag — this entity is burning |
CFireImmune |
KitchenMode |
Tag — this entity opts out of burning |
CPlayer |
Kitchen.Common |
A chef |
Some components double as appliance properties (IApplianceProperty). Those are authored on the
appliance asset in GameData and copied onto the entity when it spawns — that is how CIsInteractive
and CFireImmune get there without any system adding them.
A system is a class with an OnUpdate() that the game calls once per frame. It queries for entities
matching a shape, and reads or writes their components. That is the whole model.
public class MySystem : GameSystemBase, IModSystem
{
protected override void Initialise() { /* build queries here */ }
protected override void OnUpdate() { /* runs every frame */ }
}
You never register anything. When your DLL is loaded, KitchenMods/AssemblyModPack.cs reflects over
every type in it, checking for any which extend IModSystem:
// AssemblyModPack.PostActivate()
if (typeof(IModSystem).IsAssignableFrom(type))
{
this.Systems.Add(type);
}
and then, in AssemblyModPack.Inject(), instantiates each one into the game's ECS World and adds
it to a system group:
UpdateInGroupAttribute customAttribute = type.GetCustomAttribute<UpdateInGroupAttribute>();
Type type2 = ((customAttribute == null) ? typeof(SimulationSystemGroup) : customAttribute.GroupType);
Three practical rules fall out of that:
IModSystem is the switch. It is an empty marker interface (KitchenMods/IModSystem.cs).[UpdateInGroup(typeof(SomeGroup))] controls when in the frame you run. Without it you landSimulationSystemGroup at an unspecified point. Groups worth knowing:InteractionGroup, ItemTransferGroup, CreationGroup, DestructionGroup,UpdateCustomerStatesGroup, EndOfFrameGroup.Two sibling interfaces exist in the same file set:
IModInitializer — gives you PostActivate/PreInject/PostInject hooks, used for registeringGameData is built.IModComponent — mark your own components with it (public struct CMyThing : IModComponent)Deriving from the right base class is how you say when your system should run. Each one adds a
requirement on top of its parent; if the requirement is not met, OnUpdate() is simply not called.
| Base class | Source | Runs when |
|---|---|---|
GenericSystemBase |
Kitchen.Common |
Always. The root: gives you EntityManager, Data, TileManager, and the helper methods below |
GameSystemBase |
KitchenMode |
Always. Adds restaurant helpers — HasStatus, SetTheme, GetWeather, unlock helpers |
RestaurantSystem |
Kitchen.RestaurantMode |
A kitchen exists (SKitchenMarker) |
DaySystem |
Kitchen.RestaurantMode |
It is daytime — service is running (SIsDayTime) |
StartOfDaySystem |
Kitchen.RestaurantMode |
The first frame of the day (SIsDayFirstUpdate) |
NightSystem |
Kitchen.RestaurantMode |
It is night — the planning phase (SIsNightTime) |
StartOfNightSystem |
Kitchen.RestaurantMode |
The first frame of the night (SIsNightFirstUpdate) |
Pick the narrowest one that fits. A DaySystem needs no "is it daytime?" check in its body, and it
costs nothing on the frames it does not run.
A query is a persistent description of an entity shape. Build it once in Initialise() — never in
OnUpdate().
private EntityQuery _hobs;
protected override void Initialise()
{
base.Initialise();
_hobs = GetEntityQuery(new QueryHelper()
.All(typeof(CAppliance), typeof(CItemHolder))
.None(typeof(CIsOnFire)));
}
QueryHelper (Kitchen.Common/Kitchen/QueryHelper.cs) is the game's readable wrapper over
EntityQueryDesc:
.All(...) — the entity must have every listed component.Any(...) — the entity must have at least one.None(...) — the entity must have none of themAlways call base.Initialise() first. The base classes build their own singleton requirements in
there, and skipping it silently removes the day/night gating you chose your base class for.
RequireForUpdate(_hobs); // skip OnUpdate when the query is empty
RequireSingletonForUpdate<SMyFlag>(); // skip unless a singleton component exists
The common pattern is to copy the matches into a temporary array:
using NativeArray<Entity> hobs = _hobs.ToEntityArray(Allocator.Temp);
foreach (Entity hob in hobs)
{
if (!Require(hob, out CItemHolder holder)) continue;
if (holder.HeldItem == Entity.Null) continue;
// ...
}
Allocator.Temp memory lasts one frame, but the array is still a native allocation — dispose it with
using (or using (…) { } on older C# style) every time.
GenericSystemBase gives you short helpers so you rarely touch EntityManager directly:
| Helper | Does |
|---|---|
Has<T>(entity) |
Does this entity have the component? |
Require<T>(entity, out T comp) |
Read it, returning false if absent — the workhorse |
Set<T>(entity, T comp) |
Write it (adds the component if missing) |
Unset<T>(entity) |
Remove it |
GetOrCreate<T>() |
Read a singleton, creating it if it does not exist |
New<T>(val) |
Create a new entity carrying one component |
Adding or removing a component, creating an entity, or destroying one is a structural change. It
moves the entity between chunks in memory and invalidates anything currently iterating. Two safe ways
to do it:
Batch it through EntityManager, which is what vanilla does when a whole query needs the same
component — see KitchenMode/DetermineApplianceUsability.cs:
EntityManager.AddComponent<CPreventUse>(ShouldBreak);
EntityManager.RemoveComponent<CPreventUse>(ShouldFix);
Or defer it into a command buffer, which plays back at a defined point in the frame:
EntityCommandBuffer ecb = GetCommandBuffer(ECB.End);
ecb.AddComponent<CIsOnFire>(appliance);
The ECB enum (Kitchen.Common/Kitchen/ECB.cs) picks the playback point: End, PostInteraction,
StateChanges, DestructionGroup, PostCreation, ViewSystems.
Copying an EntityQuery's matches into a NativeArray first, then mutating, is also safe — the array
is a snapshot.
Can:
CCreateAppliance) and itemsCannot, on their own:
GameData registration, whichThe goal: every appliance in the kitchen is on fire, all service long.
Before writing anything, search the decompiled source for the concept. CIsOnFire turns up in about
twenty files, which split cleanly into two groups.
Systems that add it — i.e. things that start fires:
| Source | When |
|---|---|
KitchenMode/SpreadFire.cs |
A burning tile rolls a chance to ignite each nearby appliance |
KitchenMode/CatchFireDuringProcess.cs |
Cooking too long |
Kitchen.RestaurantMode/ConstantFires.cs |
The Halloween "random fires" restaurant status |
Kitchen.RestaurantMode/CustomersWithLowPatienceSetFires.cs |
Angry customers torching their table |
Systems that react to it — i.e. everything that makes a fire feel like a fire:
| Source | Effect |
|---|---|
KitchenMode/CreateFireSubEntity.cs |
Spawns the flame entity and its visuals, and tags the burning entity with CHasFireSubEntity |
KitchenMode/DetermineApplianceUsability.cs |
Adds CPreventUse, so the appliance cannot be used |
KitchenMode/SpreadFire.cs |
Spreads to neighbours |
KitchenMode/CustomersOnFireLosePatience.cs |
Customers panic |
Kitchen.Common/Kitchen/UpdateApplianceView.cs |
Sends IsOnFire to the renderer |
KitchenMode/CleanFires.cs |
Tears the flame entity down once CIsOnFire goes away |
This is the whole insight. Fire is not a behaviour you have to implement, it is a tag, and the
game already has six systems watching for it. The mod's entire job is to add one empty struct to the
right entities.
The game already answers "which appliances are allowed to catch fire?" in ConstantFires:
FlammableAppliances = GetEntityQuery(new EntityQueryDesc[] { new QueryHelper()
.All(typeof(CAppliance), typeof(CIsInteractive))
.None(typeof(CFireImmune), typeof(CApplianceTable), typeof(CApplianceChair), typeof(CIsOnFire)) });
Reading each filter tells you why it is there:
CAppliance — appliances, not items, customers, or players.
CIsInteractive — placed in the world. Without it you also match held appliances, blueprint
previews, and other proxy entities that should never be on fire.
None(CIsOnFire) — do not re-add what is already there. Structural changes are not free.
None(CFireImmune) — this one matters more than it looks. CleanFires contains:
if (HasComponent<CFireImmune>(e) || !HasComponent<CFire>(fire))
{
Remove<CIsOnFire>(e);
Remove<CHasFireSubEntity>(e);
}
Ignite a fire-immune entity and the game rips the fire back off it on the next frame. Your system
re-adds it, CleanFires removes it again, and you have built a structural-change loop that runs
forever for no visible result.
ConstantFires also excludes tables and chairs, because it is a background hazard and burning the
dining room is unfair. Everything's On Fire is not trying to be fair, so those two exclusions get
dropped — the game itself sets table parts alight in CustomersWithLowPatienceSetFires, so this is
well-trodden ground.
Systems/SetAppliancesOnFire.cs:
using Kitchen;
using KitchenMods;
using Unity.Entities;
namespace EverythingsOnFire.Systems
{
/// <summary>
/// Sets every flammable appliance in the kitchen on fire, for as long as the day is running.
/// Extinguished appliances are re-lit on the next frame.
/// </summary>
public class SetAppliancesOnFire : DaySystem, IModSystem
{
private EntityQuery _flammableAppliances;
protected override void Initialise()
{
base.Initialise();
// Mirrors the vanilla query in Kitchen.RestaurantMode/ConstantFires.cs: CIsInteractive keeps
// this to placed appliances, and CFireImmune is what messes and cleaning robots use to opt out.
_flammableAppliances = GetEntityQuery(new QueryHelper()
.All(typeof(CAppliance), typeof(CIsInteractive))
.None(typeof(CIsOnFire), typeof(CFireImmune)));
RequireForUpdate(_flammableAppliances);
}
protected override void OnUpdate()
{
// One batched structural change for the whole query. The game's own CreateFireSubEntity
// picks the appliances up from here and spawns the flame entities and visuals.
EntityManager.AddComponent<CIsOnFire>(_flammableAppliances);
}
}
}
That is the entire mod. Twenty lines of real code, no Harmony, no asset bundle.
: DaySystem, IModSystem, DaySystem restricts it to active service, so nothing burns while
you are planning the kitchen at night. IModSystem is what gets it loaded at all.
base.Initialise(), lets DaySystem install its SIsDayTime requirement. Skip this line and
the mod burns the restaurant during the night phase too.
The query, as derived above. Note None(CIsOnFire): without it the query would match burning
appliances every frame and AddComponent would churn pointlessly.
RequireForUpdate(_flammableAppliances), once everything is alight the query is empty, so
OnUpdate() stops being called entirely. It resumes the instant a player extinguishes something.
EntityManager.AddComponent<CIsOnFire>(_flammableAppliances), the query overload adds the
component to every match in one batched structural change, instead of one per entity. It is a no-op
on an empty query, and it is exactly the idiom DetermineApplianceUsability uses.
Then the game does the rest: CreateFireSubEntity spawns flames, DetermineApplianceUsability
adds CPreventUse, UpdateApplianceView renders it, SpreadFire spreads to anything the query
somehow missed.
Build the project. Yariazen.PlateUp.ModBuildUtilities deploys the DLL to
<PlateUp install>/Mods/EverythingsOnFire/content/ automatically, so you only need to restart the
game.
Mod.cs logs on load:
[Everything's On Fire] com.starfluxgames.everythingsonfire v0.1.0 in use!
If you do not see that in the Unity player log (…/AppData/LocalLow/<Company>/<Product>/Player.log),
the DLL never loaded and nothing else you do will matter.
Start a day. Everything should light up within a frame or two.
Once the shape is clear, the mod is easy to retune. Each of these is a one-line change:
Light everything once, then let players fight it. Swap the base class:
public class SetAppliancesOnFire : StartOfDaySystem, IModSystem
StartOfDaySystem only runs on the day's first frame, so extinguishers become useful again and the
day turns into a genuine rescue attempt. This is the more playable version.
Only the kitchen, not the dining room. Put back the vanilla exclusions:
.None(typeof(CIsOnFire), typeof(CFireImmune), typeof(CApplianceTable), typeof(CApplianceChair))
Only one thing at a time. Copy ConstantFires wholesale, take the matches into a
NativeArray, ShuffleInPlace(), and ignite the first one on a timer.
Only appliances that hold something. Add the component you care about to .All(...):
.All(typeof(CAppliance), typeof(CIsInteractive), typeof(CItemHolder))
Only one named appliance. CAppliance.ID is the KitchenData.Appliance asset ID, so filter on
it per entity. A handful of IDs have constants in Kitchen.GameData/KitchenData/AssetReference.cs
(AssetReference.DangerHob, AssetReference.Fire, …); for the rest, look the ID up through
GameData.Main.
In rough order of how often it is the cause:
IModSystem. No error, no log line, no system. Check this first, every time.RequireForUpdate on a query that matches nothing, or abase.Initialise() not called. Your query is fine; the inherited gating is gone._query.CalculateEntityCount() until entities appear.CAppliance.CIsInteractive is usually the filter that separates "in the kitchen" from "not really there".Require<T> hands you a copy of a struct. Mutating it changes nothingSet<T>(entity, comp) it back.Log freely, the template already gives you Mod.LogInfo / LogWarning / LogError, which prefix
every line with the mod name so you can filter the player log.
Files worth reading in full, in the order they will make sense:
| Source | Why |
|---|---|
KitchenMods/IModSystem.cs, KitchenMods/AssemblyModPack.cs |
How your code gets loaded and injected |
Kitchen.Common/Kitchen/GenericSystemBase.cs |
Every helper method available to your systems |
Kitchen.Common/Kitchen/QueryHelper.cs |
Query construction |
Kitchen.RestaurantMode/DaySystem.cs and siblings |
The day/night base classes |
Kitchen.RestaurantMode/ConstantFires.cs |
A short, complete vanilla system, and the model for this page's example |
KitchenMode/SpreadFire.cs |
A vanilla system reading tiles and neighbours — dense, but shows the real API surface |
Decompiled systems can be messy, and full of compiler-generated noise.
The parts worth reading are theInitialise()andOnUpdate()methods.
If a system usesEntities.ForEach, a lambda is generated (A nameless method), you'll likely see things likeEntityQueryDescbuilders,OriginalLambdaBody,<>c__DisplayClass. Some decompilers struggle with these, so it's worth using various different ones until one can read the code as best as it can!