Introduction

Welcome to the unofficial Cities: Skylines 2 modding wiki.

Here you'll find an assorted collection of information, references, guides and more, all related to modding and content creation for Cities: Skylines 2.

Getting Started

If you're new to modding in general, take a look at Longer Start which explains the full process better.

If you're not new to modding and just need a refreshener or looking for easy to find links, go to Quickstart.

Quickstart

This guide is specifically meant for people experienced with modding. If you're looking for document suitable for beginners, try the Longer Start guide instead.

Prerequisites

  • Cities: Skylines 2 acquired via Steam or Game Pass. No other versions are supported at the moment.

Installing BepInEx

  • Download BepInEx 5.4.22
  • Extract directory
  • Copy extracted directory to your Cities: Skylines 2 installation directory
    • You should end up with a BepInEx directory and the doorstop_config.ini next to the Cities2.exe binary

Installing Mods with Thunderstore Mod Manager

  • Install the Thunderstore Mod Manager
  • Select Cities II when running the mod manager
  • Now every mod you install via the mod manager should be automatically installed into the right directory
  • Make sure you launch the game via the Modded button in the top right, as otherwise Cities: Skylines 2 won't load any mods

Installing Mods manually

  • Each mod you want to install should be in it's own directory in BepInEx/plugins.
  • If you install a mod called CoolMod, you should end up with the following structure: Cities Skylines II/BepInEx/plugins/CoolMod/CoolMod.dll

Longer start

This guide is specifically meant for people new to modding. If you're looking for slightly more advanced material that isn't as verbose, try the Quickstart guide instead.

Guides

Map Editor - MOOB

Author: @dtoxftw

Hello! I just wanted to make a quick n dirty write-up about how to use the editor in its current (very early) state. Keep in mind that since this is not the official release, it can be a bit finicky at times. But it’s fun to play around with for now. So ENJOY!! I've also included the heightmap I'm using so you can play around with it as well.

If you want to work with heightmaps you will need to install BepInEx and the MOOB mod via thunderstore: https://thunderstore.io/c/cities-skylines-ii/

To get started open up the devui and go to UI Bindings, scroll down to menu and select startEditor.

If your terrain is brown and water is filled with poo, go into Simulation in the devui and hit Load game.

This essentially resets everything and your terrain/water should now look normal.

Prepping heightmaps: your heightmap should be grayscale 4096 x 4096 16bit in .RAW format. (.PNG support is coming soon) Note: the importer flips and rotates the heightmap. Before you export your map, be sure to flip and rotate 90 degrees.


Also, if using Photoshop to export RAW, be sure to check IBM instead of Macintosh.

Here is the .RAW file I’m using. It's from the Snowfall DLC in CS1 (Frozenshire)

Download "Frozenshire_heightmap.raw" - 32MB

Click on the shovel icon in the lower-mid of the screen. On the bottom-right you will see the button for Importing Heightmaps. Simply navigate to your file and select it. Your map should now look like this:

This is where the fun begins (not really.) WATER!!! The water is incredibly slow so to help it along we need to go into Simulation - water and change the water sim speed to 64x (or max.)

On the top left is the Workspace tab (close devui if you have it open.) Click on Water and you’ll see options pop up on the top right. Add a Border Sea Water Source and drop down its menu for settings. Make the source something large like a Radius of 3000 and then move it to the corner of the map. (Position -7000, 7000)

Once the placement is correct you can then define the Height. 50 seems to work well on this map. (If you are using a different map, you will have to play around to find what’s best for you.) Once you type in 50, the map will begin to flood. THIS WILL TAKE A LONG TIME. Even with the sim speed turned up. It just depends on your hardware. For example: on my machine (13700k) this map took 30 min to finally fill the rivers. Grab a snack lol

Make sure you are happy with the water height before you get too deep into terraforming or adding trees/roads/etc. If you are happy with it, go ham! Play around with the brushes, there are even a couple mountain brushes.

Saving: again go to the Workspace panel (top left) and click on Map. On the right you will see options that define your map. Give it a name and, at the bottom, hit Save Map. Now give your map a File Name and hit Save.

Your map will now be available when you start a new game along with the vanilla maps. The actual map location is C:\Users\USERNAME\AppData\LocalLow\Colossal Order\Cities Skylines II\Maps

I am still pretty new to the editor but it took us a while to figure these simple things out, so I hope this helps. If you have any info to add, by all means share!

ENJOY!

ECS

Introduction to ECS, and how Cities: Skylines 2 uses ECS as a corner-stone of their architecture

First, a short list of definitions:

  • ECS - Entity Component System - Software architecture for decoupled design, better memory access patterns and making parallelism easy
  • Entity - An ID representing something in a game, like the player's in game character
  • Component - A carrier of data, attached to the Entity. Like the character's health, or sprite position.
  • System - Queries for Components, and reads/mutates them. Behaviour like "When poisoned, drain health".

ECS not a new architecture, but have become more prominent as of late as it scales very well when you have large amount data and whoever is running the game, have multiple cores in the their CPU.

It gives you a decoupled design as you're acting on components rather than a composition of components, so you can for example easily separate code related to the position of a Entity, and the code related to how much health the Entity has.

Entity Component System - Basic Example

A quick demonstration of how ECS could be done (pseudo-code):

We first define three Components:

class Position extends Component {
    int x = 0
    int y = 0
}

class Health extends Component {
    int current_health = 100
}

class Poisoned extends Component {}

And then our Systems that act on those Components:

function UpdatePosition(query: List<Position>) {
    for position in query {
        position.x = position.x + 0.1
    }
}

function DrawCharacter(query: List<Position>) {
    for position in query {
        // Code for drawing a character of some kind, at the position
    }
}

function UpdateHealth(query: List<(Poisoned, Health)>) {
    for (poison, health) in query {
        health.current_health = health.current_health - 1
    }
}

First we define a System that will continiously query for Position Components, and when it finds any, update the position to go slightly to the left.

Second we define a System that draws our actual sprite to the display, to indicate where our player is. This will slowly change position as the position is updated in another system.

Last, we define a System that finds any Entities that have both a Poisoned and a Health component, and if it finds any, iterates over them to decrement the Health.

Finally we can instantiate our game:

var app = create_game();
app.AddSystem(UpdatePosition, phase.Update)
   .AddSystem(DrawCharacter,  phase.Update)
   .AddSystem(UpdateHealth,   phase.Update)

We not only create the game, but declare in what phases systems will run in. Currently, we only have one phase, but larger games will have multiple. If you want, you can specify Systems to run before/after other specific Systems, in case they have to run in a particular order.

With this architecture, we can simply add/remove the Poisoned component from a Entity at runtime, to let the system for removing health to run. Typically, ECS allows you to Querying for Components like our example above, so while the system is run continously, the query won't always result in any hits, and then the health won't be affected. But once a Entity has the Poisoned and Health component on it, then the System will continue to drain health.

Creating a new thing in the game world typically looks something like this:


var player_character = app.new_entity()
    .with_component(Position)
    .with_component(Health)

Then at some later stage, components can be added and removed:

function OnHit(query: List<PhysicsHit>) {
    for hit in query {
        hit.entity.add_component(Poisoned)
    }
}

ECS In Cities: Skylines 2

ECS in Cities: Skylines 2 begin at the place where all systems are put in one big list, with specific declaration at what "Phase" it'll be executed at.

Basically everything about how the game behaves or data it reads/mutates, is done via Systems and Components, for example: LoadGameSystem, AutoSaveSystem, BoardingVehicleSystem, BulldozeToolSystem and TransportInfoviewUISystem are implemented as any other ECS System in the game, and put in the giant list of what executes before/after what.

Each system is defined as to where in the list it should be executed, and at what "Phase" as well.

The Phases are defined as a public enum, Game.SystemUpdatePhase

Example:

...
Rendering = 14
PreTool = 15
PostTool = 16
ToolUpdate = 17
ClearTool = 18
...

The most basic API method is UpdateAt which defines at what Phase a System should be run at:

updateSystem.UpdateAt<ApplyObjectsSystem>(SystemUpdatePhase.ApplyTool);

The example above would run the ApplyObjectsSystem System when the active Phase is ApplyTool.

Sometimes you want to run a particular System before/after another, then UpdateBefore and UpdateAfter comes in handy. Example:

updateSystem.UpdateAt<ResourceAvailabilitySystem>(SystemUpdatePhase.GameSimulation);
updateSystem.UpdateAfter<EarlyGameOutsideConnectionTriggerSystem, ResourceAvailabilitySystem>(SystemUpdatePhase.GameSimulation);

This would make the EarlyGameOutsideConnectionTriggerSystem System run after the ResourceAvailabilitySystem system, while in the GameSimulation Phase.

Going further in, let's look at a specific System in CS2, the BrandPopularitySystem System in Game.City.

It sets up Queries for Components like we did in our pseudo-ECS game above:

this.m_ModifiedQuery = this.GetEntityQuery(new EntityQueryDesc() {
  All = new ComponentType[1] { ComponentType.ReadOnly<CompanyData>() },
  Any = new ComponentType[2] { ComponentType.ReadOnly<Created>(), ComponentType.ReadOnly<Deleted>() },
  None = new ComponentType[1] { ComponentType.ReadOnly<Temp>() }
});

This Query tries to find something that matches the following conditions:

  • Has all of the Components: CompanyData
  • Has any of the Components: Created or Deleted
  • Doesn't have any of the Components: Temp

Reverse Engineering

Radio

Idea Discussion on Discord: https://discord.com/channels/1169011184557637825/1174449459762057257

Music Loader example: https://github.com/optimus-code/Cities2Modding/blob/main/ExampleMod/MonoBehaviours/MusicLoader.cs

A CS2 radio test repository: https://github.com/dragonofmercy/cs2-customradio

Radio Station

Example Urban City Radio:

{
    "name": "Urban City Radio",
    "nameId": "Radio.NETWORK_TITLE[Urban City Radio]",
    "description": "Commercial radio stations",
    "descriptionId": "Radio.NETWORK_DESCRIPTION[Urban City Radio]",
    "icon": "Media/Radio/Networks/Commercial.svg",
    "uiPriority": 1,
    "allowAds": true
}

Urban City Radio.coc in Audio~/Radio

What is a RadioNetwork vs a RadioChannel?

RadioChannel belongs to a RadioNetwork, references it by name via network

Loading Audio Files

Audio files gets loaded with metatags

this.AddMetaTag(AudioAsset.Metatag.Title, track.Title);
this.AddMetaTag(AudioAsset.Metatag.Album, track.Album);
this.AddMetaTag(AudioAsset.Metatag.Artist, track.Artist);
this.AddMetaTag(AudioAsset.Metatag.Type, track, "TYPE");
this.AddMetaTag(AudioAsset.Metatag.Brand, track, "BRAND");
this.AddMetaTag(AudioAsset.Metatag.RadioStation, track, "RADIO STATION");
this.AddMetaTag(AudioAsset.Metatag.RadioChannel, track, "RADIO CHANNEL");
this.AddMetaTag(AudioAsset.Metatag.PSAType, track, "PSA TYPE");
this.AddMetaTag(AudioAsset.Metatag.AlertType, track, "ALERT TYPE");
this.AddMetaTag(AudioAsset.Metatag.NewsType, track, "NEWS TYPE");
this.AddMetaTag(AudioAsset.Metatag.WeatherType, track, "WEATHER TYPE");

Radio Channel declaration:

{
    "network": "Urban City Radio",
    "name": "The Vibe",
    "description": "The Vibe Channel",
    "icon": "Media/Radio/Stations/TheVibe.svg",
    "uiPriority": -1,
    "programs": [
        {
            "name": "Music non stop",
            "description": "Dance all day, dance all night",
            "icon": "coui://UIResources/Media/Radio/TheVibe.svg",
            "type": "Playlist",
            "startTime": "00:00",
            "endTime": "00:00",
            "loopProgram": true,
            "segments": [
                {
                    "type": "Playlist",
                    "tags": [
                        "type:Music",
                        "radio channel:The Vibe"
                    ],
                    "clipsCap": 3
                },
                {
                    "type": "Commercial",
                    "tags": [
                        "type:Commercial"
                    ],
                    "clipsCap": 2
                }
            ]
        }
    ]
}

the hard part is to load files into the existing queues you would need to recreate this to get valid entities But I think Colossal.IO.AssetDatabase.AudioAsset

Okay, so all audio files are loaded into an AudioSourcePool So we need to add to thise it has different groups

it's a lot to research since the radio is very complex we would need to add our own files as AudioSource and AudioAsset into the game. The game loads the .ogg files as AudioAssets on start into ram and is handling it as AudioSource which is basically a default unity object handling audio there are so many components that it's really hard to untackle it to fully understand how it works

Game UI

UI Bindings

Symbols used in the API

  • AddBinding: A method to add a new binding to the UI system. It links a specific UI element or event to a corresponding action or state in the game.
  • TriggerBinding: A binding type that associates a UI trigger (like a button click or a toggle) with a specific method or action.
  • InputManager.instance.FindAction: Used to find and set up proxy actions for gathering user inputs, like camera controls or menu navigation.
  • AddUpdateBinding: A method to add a binding that updates the UI based on changes in the game's state.
  • GetterValueBinding: A type of binding that retrieves a value from the game state and updates the UI accordingly.

Event Triggers and Handlers

Event triggers and handlers respond to user interactions with the UI, executing specific actions in response.

Code Example

// Adding an event trigger for a button click
this.AddBinding(new TriggerBinding("Menu", "PlayButton", 
    () => StartGame()));

Proxy Actions

Proxy Actions translate user inputs into game actions, allowing for flexible input configurations.

Code Example

// Setting up a proxy action for player movement
this.m_MoveAction = InputManager.instance.FindAction("Player", "Move");

Dynamic UI Updates

Dynamic UI updates ensure that UI elements reflect real-time changes in the game's state.

Code Example

// Updating a health bar dynamically
this.AddUpdateBinding(new GetterValueBinding<float>("Player", "Health", 
    () => player.health));

GetterValueBinding

GetterValueBinding is used for displaying specific data from the game in the UI.

Code Example

// Displaying the player's score
this.AddBinding(new GetterValueBinding<int>("Player", "Score", 
    () => player.score));

AddUpdateBinding vs AddBinding

  • AddUpdateBinding

    • Purpose: It's used to create a binding that will regularly update based on changes in the game's state or logic.
    • Functionality: Suitable for dynamic UI elements that need to reflect the current state of the game, like a score display or a health bar.
  • AddBinding:

    • Purpose: A general method to create a binding between a UI element and a game logic element. It can be used for both static and dynamic interactions.
    • Functionality: It's more general-purpose than AddUpdateBinding and can be used for a wide range of UI binding needs, including event triggers.

Example UI ECS System

A UI ECS System should extend from UISystemBase, which helps to manage the bindings a bit more.

UI Systems should be included at the UIUpdate Phase.

Make sure to call base.OnCreate(); as otherwise your Bindings might not work

Example:

class MyUISystem : UISystemBase {
    private int people_i_want_to_hug = 100;
    private string kGroup = "myowncoolmod_namespace";
    protected override void OnCreate() {
        base.OnCreate();

        // Update the UI when people_i_want_to_hug changes
        this.AddUpdateBinding(new GetterValueBinding<int>(this.kGroup, "people_i_want_to_hug", () => {
            return this.people_i_want_to_hug;
        }));

        AddHumanPeriodically();
    }
    private async void AddHumanPeriodically() {
        while (true) {
            UnityEngine.Debug.Log("Adding!");
            await Task.Delay(5000);
            people_i_want_to_hug += 1;
        }
    }
}

Adding the system to the updateSystem:

updateSystem.UpdateAt<MyOwnCoolUI>(SystemUpdatePhase.UIUpdate);

With BepInEx and Harmony:

[HarmonyPatch(typeof(SystemOrder))]
public static class SystemOrderPatch {
    [HarmonyPatch("Initialize")]
    [HarmonyPostfix]
    public static void Postfix(UpdateSystem updateSystem) {
        updateSystem.UpdateAt<MyUISystem>(SystemUpdatePhase.UIUpdate);
    }
}

A Typical System

using Unity.Entities;
using Unity.Jobs;
using Unity.Collections;

public class ExampleSystem : SystemBase {
    private EntityQuery query;
    private NativeList<ExampleData> dataList;
    private JobHandle jobHandle;

    protected override void OnCreate() {
        query = GetEntityQuery(typeof(ExampleComponent));
        dataList = new NativeList<ExampleData>(Allocator.Persistent);
    }

    protected override void OnDestroy() {
        dataList.Dispose();
    }

    protected override void OnUpdate() {
        if (/* some condition */) {
            var chunks = query.ToArchetypeChunkArray(Allocator.TempJob, out JobHandle handle);
            var componentTypeHandle = GetComponentTypeHandle<ExampleComponent>(true);

            JobHandle job = new ProcessDataJob {
                chunks = chunks,
                componentTypeHandle = componentTypeHandle,
                dataList = dataList
            }.Schedule(handle);

            jobHandle = JobHandle.CombineDependencies(jobHandle, job);
            job.Complete();

            chunks.Dispose();
        }
    }

    [BurstCompile]
    struct ProcessDataJob : IJob {
        [ReadOnly] public NativeArray<ArchetypeChunk> chunks;
        [ReadOnly] public ComponentTypeHandle<ExampleComponent> componentTypeHandle;
        public NativeList<ExampleData> dataList;

        public void Execute() {
            foreach (var chunk in chunks) {
                var components = chunk.GetNativeArray(componentTypeHandle);
                foreach (var component in components) {
                    // Process component and update dataList
                }
            }
        }
    }

    struct ExampleData { /* ... */ }
    struct ExampleComponent : IComponentData { /* ... */ }
}

Official Mod Example

Extracted from ModPostProcessor found in official game release build.

This will give us an idea of how mods are meant to be written for CS2.

This is of course subject to change once the official modding platform arrives. It's only here to give a start and some idea of what to expect.

Demonstrated concepts:

  • IMod - The official modding API for declaring mods
    • OnCreateWorld - The way for mod authors to add their own custom System to the World
    • OnDispose - The way for mods to unload themselves
    • OnLoad - Maybe called when the mod is included into the runtime, but before World has been created
  • DeltaTimePrintSystem : GameSystemBase - Basic usage on how to setup your own System
    • This system just prints Delta Time on each tick
    • OnCreate - Called when the System is initially created
    • OnUpdate - Called for each tick in World
  • PrintPopulationSystem : GameSystemBase - A more advanced demonstration System
    • This System includes running a Job, doing Queries and Burst compilation
    • Gets reference to another System via World.GetOrCreateSystemManaged
    • Setups a Query for future use via GetEntityQuery
    • Creates a new NativeArray with the results from executing the Job
    • OnUpdate triggers CountPopulationJob once every 128 frames
      • The data passed to the job is:
        • The Query made into a temporary NativeArray<ArchetypeChunk>'
        • Unsure what GetBufferTypeHandle<HouseholdCitizen>(true) actually does but it seems to find the Type of a HouseholdCitizen
        • Same for the GetComponentTypeHandle
        • GetComponentLookup<HealthProblem>(true) seems to be getting a specific Component belonging to the current Entity
      • Finally the job is scheduled with Dependency = popJob.Schedule()
      • Unsure what CompleteDependency() does but probably related to dependencies between jobs?
  • CountPopulationJob : IJob - A Unity Job that gets runs in parallel to other jobs, and in a worker thread
    • Keeps track of Chunks via a NativeArray<ArchetypeChunk> which is readonly
    • Has more of the BufferTypeHandle and ComponentTypeHandle
    • Also does lookups with ComponentLookup for Citizen and HealthProblem
    • Keeps state via m_Result which is a NativeArray of int
  • TestModSystem : GameSystemBase is a System for triggering TestJob
  • TestJob : IJob
    • Seems to just increment the items by their index

ModPostProcessor.Resources.Mod.cs

#define BURST
//#define VERBOSE

using Game;
using Game.Citizens;
using Game.Modding;
using Game.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Colossal.Logging;

namespace ModSample
{
    public class TestMod : IMod
    {
        public static ILog log = LogManager.GetLogger(nameof(TestMod), false);

        public void OnCreateWorld(UpdateSystem updateSystem)
        {
            log.Info(nameof(OnCreateWorld));
            updateSystem.UpdateAt<PrintPopulationSystem>(SystemUpdatePhase.GameSimulation);
            updateSystem.UpdateAt<DeltaTimePrintSystem>(SystemUpdatePhase.GameSimulation);
            updateSystem.UpdateAt<TestModSystem>(SystemUpdatePhase.GameSimulation);
        }

        public void OnDispose()
        {
            log.Info(nameof(OnDispose));
        }

        public void OnLoad()
        {
            log.Info(nameof(OnLoad));
        }
    }

    public partial class DeltaTimePrintSystem : GameSystemBase
    {
        protected override void OnCreate()
        {
            base.OnCreate();

            TestMod.log.Info($"[{nameof(DeltaTimePrintSystem)}] {nameof(OnCreate)}");
        }
        protected override void OnUpdate()
        {
            var deltaTime = SystemAPI.Time.DeltaTime;
            TestMod.log.Info($"[{nameof(DeltaTimePrintSystem)}] DeltaTime: {deltaTime}");
        }
    }

    public partial class PrintPopulationSystem : GameSystemBase
    {
        private SimulationSystem m_SimulationSystem;
        private EntityQuery m_HouseholdQuery;

        private NativeArray<int> m_ResultArray;
        protected override void OnCreate()
        {
            base.OnCreate();

            TestMod.log.Info($"[{nameof(PrintPopulationSystem)}] {nameof(OnCreate)}");

            m_SimulationSystem = World.GetOrCreateSystemManaged<SimulationSystem>();

            m_HouseholdQuery = GetEntityQuery(
                ComponentType.ReadOnly<Household>(),
                ComponentType.Exclude<TouristHousehold>(),
                ComponentType.Exclude<CommuterHousehold>(),
                ComponentType.ReadOnly<Game.Buildings.PropertyRenter>(),
                ComponentType.Exclude<Game.Common.Deleted>(),
                ComponentType.Exclude<Game.Tools.Temp>()
                );

            m_ResultArray = new NativeArray<int>(1, Allocator.Persistent);
        }
        protected override void OnUpdate()
        {
            if (m_SimulationSystem.frameIndex % 128 == 75)
            {
                TestMod.log.Info($"[{nameof(PrintPopulationSystem)}] Population: {m_ResultArray[0]}");

                var popJob = new CountPopulationJob
                {
                    m_HouseholdChunks = m_HouseholdQuery.ToArchetypeChunkArray(Allocator.TempJob),
                    m_HouseholdCitizenType = GetBufferTypeHandle<HouseholdCitizen>(true),
                    m_CommuterType = GetComponentTypeHandle<CommuterHousehold>(true),
                    m_MovingAwayType = GetComponentTypeHandle<Game.Agents.MovingAway>(true),
                    m_TouristType = GetComponentTypeHandle<TouristHousehold>(true),
                    m_HouseholdType = GetComponentTypeHandle<Household>(true),

                    m_HealthProblems = GetComponentLookup<HealthProblem>(true),
                    m_Citizens = GetComponentLookup<Citizen>(true),

                    m_Result = m_ResultArray,
                };

                Dependency = popJob.Schedule();
                CompleteDependency();
            }
        }

#if BURST
        [BurstCompile]
#endif
        public struct CountPopulationJob : IJob
        {
            [DeallocateOnJobCompletion] [ReadOnly] public NativeArray<ArchetypeChunk> m_HouseholdChunks;
            [ReadOnly] public BufferTypeHandle<HouseholdCitizen> m_HouseholdCitizenType;
            [ReadOnly] public ComponentTypeHandle<TouristHousehold> m_TouristType;
            [ReadOnly] public ComponentTypeHandle<CommuterHousehold> m_CommuterType;
            [ReadOnly] public ComponentTypeHandle<Game.Agents.MovingAway> m_MovingAwayType;
            [ReadOnly] public ComponentTypeHandle<Household> m_HouseholdType;

            [ReadOnly] public ComponentLookup<Citizen> m_Citizens;
            [ReadOnly] public ComponentLookup<HealthProblem> m_HealthProblems;

            public NativeArray<int> m_Result;

            public void Execute()
            {
#if VERBOSE
                TestMod.Log.Debug($"Start executing {nameof(CountPopulationJob)}");
#endif
                m_Result[0] = 0;

                for (int i = 0; i < m_HouseholdChunks.Length; ++i)
                {
                    ArchetypeChunk chunk = m_HouseholdChunks[i];
                    BufferAccessor<HouseholdCitizen> citizenBuffers = chunk.GetBufferAccessor(ref m_HouseholdCitizenType);
                    NativeArray<Household> households = chunk.GetNativeArray(ref m_HouseholdType);

                    if (chunk.Has(ref m_TouristType) || chunk.Has(ref m_CommuterType) || chunk.Has(ref m_MovingAwayType))
                        continue;

                    for (int j = 0; j < chunk.Count; ++j)
                    {
                        if ((households[j].m_Flags & HouseholdFlags.MovedIn) == 0)
                            continue;

                        DynamicBuffer<HouseholdCitizen> citizens = citizenBuffers[j];
                        for (int k = 0; k < citizens.Length; ++k)
                        {
                            Entity citizen = citizens[k].m_Citizen;
                            if (m_Citizens.HasComponent(citizen) && !CitizenUtils.IsDead(citizen, ref m_HealthProblems))
                                m_Result[0] += 1;
                        }
                    }
                }
#if VERBOSE
                TestMod.Log.Debug($"Finish executing {nameof(CountPopulationJob)}");
#endif
            }
        }
    }

#if BURST
    [BurstCompile]
#endif
    public partial class TestModSystem : GameSystemBase
    {
        private SimulationSystem m_SimulationSystem;
        private NativeArray<int> m_Array;

        protected override void OnCreate()
        {
            m_SimulationSystem = World.GetOrCreateSystemManaged<SimulationSystem>();
            m_Array = new NativeArray<int>(5, Allocator.Persistent);
        }
        protected override void OnUpdate()
        {
            if (m_SimulationSystem.frameIndex % 128 == 75)
            {
                TestMod.log.Info(string.Join(", ", m_Array));

                var testJob = new TestJob
                {
                    m_Array = m_Array,
                };

                Dependency = testJob.Schedule();
            }
        }

#if BURST
        [BurstCompile]
#endif
        public struct TestJob : IJob
        {
            public NativeArray<int> m_Array;

            public void Execute()
            {
#if VERBOSE
                UnityEngine.Debug.Log($"Start executing {nameof(TestJob)}");
#endif
                for (int i = 0; i < m_Array.Length; i += 1)
                {
                    m_Array[i] = m_Array[i] + i;
                }
#if VERBOSE
                UnityEngine.Debug.Log($"Finish executing {nameof(TestJob)}");
#endif
            }

#if BURST
            [BurstCompile]
#endif
            public static void WorkTime(in long start, in long current, out long duration)
            {
                duration = current - start;
            }
        }
    }
}

Line-by-line explanation

Expand
public class TestMod : IMod { ... }

A class implementing the IMod interface, which is the entry point for a mod in Cities Skylines 2. It contains methods for lifecycle events like loading and disposing the mod.

public void OnCreateWorld(UpdateSystem updateSystem) { ... }

A method in TestMod that gets called during the creation of the game world. It registers various systems to update during the game simulation phase.

public partial class DeltaTimePrintSystem : GameSystemBase { ... }

A basic ECS (Entity Component System) system extending from GameSystemBase. It's responsible for printing the time delta between game updates.

public partial class PrintPopulationSystem : GameSystemBase { ... }

An ECS system that calculates and prints the population. It uses a custom job (CountPopulationJob) to count the population in an efficient, multi-threaded manner.

private SimulationSystem m_SimulationSystem;

A field in PrintPopulationSystem, referencing the game's simulation system to access game state information.

private EntityQuery m_HouseholdQuery;

An EntityQuery in PrintPopulationSystem used to query entities that represent households in the game.

[BurstCompile]

An attribute indicating that the following method or job should be compiled using Unity's Burst compiler for high-performance, highly optimized native code.

public struct CountPopulationJob : IJob { ... }

A struct implementing the IJob interface, representing a parallel job in Unity's ECS for counting the population. It utilizes Burst compilation for performance optimization.

ComponentLookup<T> m_Citizens;

A field in CountPopulationJob that provides access to ECS components of type T (here, Citizen), enabling efficient component data retrieval within a job.

public partial class TestModSystem : GameSystemBase { ... }

Another ECS system part of the mod, demonstrating custom logic within the ECS framework. It includes a custom job for demonstration purposes.

public struct TestJob : IJob { ... }

A struct implementing the IJob interface for a simple job example, modifying an array within the ECS framework.

NativeArray<int> m_Array;

A field in TestModSystem, representing a native array used in ECS for high-performance operations. It's used here to store and manipulate data in the custom job.

ModPostProcessor.Resources.Configuration.csproj

<Project>
	<PropertyGroup>
		<TargetFramework>net472</TargetFramework>
		<RunPostBuildEvent>OnOutputUpdated</RunPostBuildEvent>
		<Configurations>Editor;Steam</Configurations>

		<UnityVersion>2022.3.7f1</UnityVersion>
		<EntityVersion>1.0.14</EntityVersion>
		<InstallationPath>C:\Program Files (x86)\Steam\steamapps\common\Cities Skylines II</InstallationPath>
		<DataPath>$(LOCALAPPDATA)\..\LocalLow\Colossal Order\Cities Skylines II</DataPath>
		<RepoPath Condition="'$(RepoPath)'==''">$(ProjectDir)..\..\..\..</RepoPath>

		<UnityModProjectPath>$(DataPath)\.cache\Modding\UnityModsProject</UnityModProjectPath>

		<ModPostProcessorPath Condition="'$(Configuration)'=='Editor'">$(RepoPath)\BeverlyHills\Assets\StreamingAssets\~Tooling~\ModPostProcessor\ModPostProcessor.exe</ModPostProcessorPath>
		<ModPostProcessorPath Condition="'$(Configuration)'=='Steam'">$(InstallationPath)\Cities2_Data\StreamingAssets\~Tooling~\ModPostProcessor\ModPostProcessor.exe</ModPostProcessorPath>

		<EntityPackagePath>$(UnityModProjectPath)\Library\PackageCache\com.unity.entities@$(EntityVersion)\Unity.Entities\SourceGenerators</EntityPackagePath>

		<ManagedDLLPath Condition="'$(Configuration)'=='Editor'">$(RepoPath)\BeverlyHills\Library\ScriptAssemblies</ManagedDLLPath>
		<ManagedDLLPath Condition="'$(Configuration)'=='Steam'">$(InstallationPath)\Cities2_Data\Managed</ManagedDLLPath>

		<UnityEnginePath>C:\Program Files\Unity\Hub\Editor\$(UnityVersion)\Editor\Data\Managed\UnityEngine</UnityEnginePath>

		<AssemblySearchPaths Condition="'$(Configuration)'=='Editor'">
			$(AssemblySearchPaths);
			$(ManagedDLLPath);
			$(UnityEnginePath);
		</AssemblySearchPaths>
		<AssemblySearchPaths Condition="'$(Configuration)'=='Steam'">
			$(AssemblySearchPaths);
			$(ManagedDLLPath);
		</AssemblySearchPaths>
	
	</PropertyGroup>

	<ItemGroup>
		<Analyzer Include="$(EntityPackagePath)\Unity.Entities.SourceGen.SystemGenerator.SystemAPI.dll" />
		<Analyzer Include="$(EntityPackagePath)\Unity.Entities.SourceGen.SystemGenerator.dll" />
		<Analyzer Include="$(EntityPackagePath)\Unity.Entities.SourceGen.SystemGenerator.SystemAPI.QueryBuilder.dll" />
		<Analyzer Include="$(EntityPackagePath)\Unity.Entities.Analyzer.dll" />
		<Analyzer Include="$(EntityPackagePath)\Unity.Entities.SourceGen.SystemGenerator.LambdaJobs.dll" />
		<Analyzer Include="$(EntityPackagePath)\Unity.Entities.SourceGen.SystemGenerator.Common.dll" />
		<Analyzer Include="$(EntityPackagePath)\Unity.Entities.SourceGen.SystemGenerator.SystemAPI.Query.dll" />
		<Analyzer Include="$(EntityPackagePath)\Unity.Entities.Analyzer.CodeFixes.dll" />
		<Analyzer Include="$(EntityPackagePath)\Unity.Entities.SourceGen.AspectGenerator.dll" />
		<Analyzer Include="$(EntityPackagePath)\Unity.Entities.SourceGen.Common.dll" />
		<Analyzer Include="$(EntityPackagePath)\Unity.Entities.SourceGen.SystemGenerator.EntityQueryBulkOperations.dll" />
		<Analyzer Include="$(EntityPackagePath)\Unity.Entities.SourceGen.JobEntityGenerator.dll" />
	</ItemGroup>
	<ItemGroup>
		<None Remove="Logs\**" />
		<None Remove="Library\**" />
	</ItemGroup>
	
</Project>

ModPostProcessor.Resources.ModSample.csproj

<Project Sdk="Microsoft.NET.Sdk">

	<Import Project="..\Configuration.csproj" />
	<Import Project="..\Targets.csproj" />

	<ItemGroup>
		<Reference Include="Game">
			<Private>false</Private>
		</Reference>
		<Reference Include="Colossal.Core">
			<Private>false</Private>
		</Reference>
		<Reference Include="Colossal.Logging">
			<Private>false</Private>
		</Reference>
		<Reference Include="UnityEngine.CoreModule">
			<Private>false</Private>
		</Reference>
		<Reference Include="Unity.Burst">
			<Private>false</Private>
		</Reference>
		<Reference Include="Unity.Collections">
			<Private>false</Private>
		</Reference>
		<Reference Include="Unity.Entities">
			<Private>false</Private>
		</Reference>
		<Reference Include="Unity.Mathematics">
			<Private>false</Private>
		</Reference>
	</ItemGroup>

	<ItemGroup>
		<Reference Update="System">
			<Private>false</Private>
		</Reference>
	</ItemGroup>
	<ItemGroup>
		<Reference Update="System.Core">
			<Private>false</Private>
		</Reference>
	</ItemGroup>
	<ItemGroup>
		<Reference Update="System.Data">
			<Private>false</Private>
		</Reference>
	</ItemGroup>
	<ItemGroup>
		<Reference Update="System.Drawing">
			<Private>false</Private>
		</Reference>
	</ItemGroup>
	<ItemGroup>
		<Reference Update="System.IO.Compression.FileSystem">
			<Private>false</Private>
		</Reference>
	</ItemGroup>
	<ItemGroup>
		<Reference Update="System.Numerics">
			<Private>false</Private>
		</Reference>
	</ItemGroup>
	<ItemGroup>
		<Reference Update="System.Runtime.Serialization">
			<Private>false</Private>
		</Reference>
	</ItemGroup>
	<ItemGroup>
		<Reference Update="System.Xml">
			<Private>false</Private>
		</Reference>
	</ItemGroup>
	<ItemGroup>
		<Reference Update="System.Xml.Linq">
			<Private>false</Private>
		</Reference>
	</ItemGroup>

</Project>

ModPostProcessor.Resources.Targets.csproj

<Project>
	
	<Target Name="ChechManagedDLLPath" BeforeTargets="PreBuildEvent">
		<Message Text="$(CommonLocation)\General.targets" Importance="high"/>
		<Error Condition="!Exists('$(ManagedDLLPath)')" Text="The Managed DLL path is wrong: $(ManagedDLLPath)" />
		<OnError ExecuteTargets="CheckInstallationPath" />
	</Target>
	
	<Target Name="CheckInstallationPath" AfterTargets="ChechManagedDLLPath">
		<Error Condition="'$(Configuration)'=='Steam' AND !Exists('$(InstallationPath)\Cities2.exe')" Text="The Game Installation path is wrong: $(InstallationPath)" />
		<OnError ExecuteTargets="CheckDataPath" />
	</Target>

	<Target Name="CheckDataPath" AfterTargets="CheckInstallationPath">
		<Error Condition="!Exists('$(DataPath)')" Text="The Game Data path is wrong: $(DataPath)" />
		<OnError ExecuteTargets="CheckUnityModProjectPath" />
	</Target>

	<Target Name="CheckUnityModProjectPath" AfterTargets="CheckDataPath">
		<Error Condition="!Exists('$(UnityModProjectPath)')" Text="The Unity Mod Project path is wrong: $(UnityModProjectPath)" />
		<OnError ExecuteTargets="CheckModPostProcessorPath" />
	</Target>

	<Target Name="CheckModPostProcessorPath" AfterTargets="CheckUnityModProjectPath">
		<Error Condition="!Exists('$(ModPostProcessorPath)')" Text="The Mod Post Processor path is wrong: $(ModPostProcessorPath)" />
		<OnError ExecuteTargets="CheckEntityPackagePath" />
	</Target>

	<Target Name="CheckEntityPackagePath" AfterTargets="CheckModPostProcessorPath">
		<Error Condition="!Exists('$(EntityPackagePath)')" Text="The Entity package path is wrong: $(EntityPackagePath)" />
	</Target>
	
	<Target Name="BeforeBuildAction" BeforeTargets="BeforeBuild">
		<RemoveDir Directories="$(OutDir)" />
	</Target>
	
	<Target Name="AfterBuildAction" AfterTargets="AfterBuild">
		<PropertyGroup>
			<ModPostProcessorArgs>"$(ModPostProcessorPath)" PostProcess "$(TargetPath)" -r "@(ReferencePath)" -u "$(UnityModProjectPath)" -t Windows -t macOS -t Linux -b $(Configuration) -d -v</ModPostProcessorArgs>
		</PropertyGroup>
		<Message Condition="Exists('$(ModPostProcessorPath)')" Text="Run post processor: $(ModPostProcessorArgs)" Importance="high" />
		<Message Condition="!Exists('$(ModPostProcessorPath)')" Text="Post processor was not found, please check the path: @(ModPostProcessorPath)" Importance="high" />
		<Exec Condition="Exists('$(ModPostProcessorPath)')" Command="$(ModPostProcessorArgs)"></Exec>
		<ItemGroup>
			<FilesToCopy Include="$(OutDir)\**\*.*" />
			<DeployDir Include="$(DataPath)\Mods\WorkInProgress\$(ProjectName)" />
		</ItemGroup>
		<Message Text="Copy output to deploy dir @(DeployDir)" Importance="high" />
		<RemoveDir Directories="@(DeployDir)" />
		<Copy SourceFiles="@(FilesToCopy)" DestinationFolder="@(DeployDir)" />
	</Target>
	
</Project>

Reference

Game UI

GetterValueBinding

Colossal.UI.Binding.GetterValueBinding<T>

Responsible for setting up bindings between the C# parts of the code, and the Game UI

Custom Writer

If you end up using types not natively supported by GetterValueBinding, you can implement your own type that add support. For example, here is a HashSetWriter that adds support to bind a HashSet:

internal class HashSetWriter<T> : IWriter<HashSet<T>> {
    [NotNull]
    private readonly IWriter<T> m_ItemWriter;

    public HashSetWriter(IWriter<T> itemWriter = null) {
        m_ItemWriter = itemWriter ?? ValueWriters.Create<T>();
    }

    public void Write(IJsonWriter writer, HashSet<T> value) {
        if (value != null) {
            writer.ArrayBegin(value.Count);
            foreach (T item in value) {
                m_ItemWriter.Write(writer, item);
            }

            writer.ArrayEnd();
            return;
        }

        writer.WriteNull();
        throw new ArgumentNullException("value", "Null passed to non-nullable hashset writer");
    }
}

Then for using your new writer:

this.AddUpdateBinding(new GetterValueBinding<HashSet<string>>("namespace", "available_extensions", () => {
    return this.availableExtensions;
}, new HashSetWriter<string>()));

UISystemBase

Base class for extending with your own UIs

Used for setting up Bindings between the C# <> Game UI parts of the game. The communication is bi-directional, so you can Trigger events from the Game UI to make things happen in your mod, but you can also send data from the mod to the Game UI.

ECS

For a beginners introduction to ECS and how Cities: Skylines 2 uses ECS, please check out "Guide - ECS" that goes into more depth.

Basic architecture:

One Entity, Has one or more Components.

Systems run independently and are part of the Game Loop. Systems usually Query for Components, then act based on the data they retrieve, possibly mutates data inside the Components too.

Entity

A Entity in ECS is just a collection of Components, grouped together to belong to one Entity. Usually represented as some sort of ID.

Component

A Component is a carrier of data, used by Systems and can be Queried.

A Component is any class that implements the IComponentData interface.

System

Example basic System that figures out how many active vehicles are spawned in the game:

public class VehicleCounterSystem : GameSystemBase {
    // Define a EntityQuery we can create in OnCreate and use in OnUpdate
    private EntityQuery m_VehicleQuery;
    // Field we can use to keep track of how many vehicles we've found from our query.
    public int current_vehicle_count = 0;

    protected override void OnCreate() {
        base.OnCreate();
        // Since we extend from GameSystemBase we have access to GetEntityQuery in order
        // to query the game world for entities with different components
        this.m_VehicleQuery = this.GetEntityQuery(new EntityQueryDesc() {
            // `All` selects all entities that have all of the components mentioned, in
            // this case `Vehicle`. We also mark it as `ReadOnly` as we don't need to
            // mutate the data, others can access it at the same time
            All = new ComponentType[1] {
                ComponentType.ReadOnly<Vehicle>()
            },
            // `None` deselects the entities that has any of the mentioned components
            None = new ComponentType[2] {
                ComponentType.ReadOnly<Deleted>(),
                ComponentType.ReadOnly<Temp>()
            }
        });
        // We do a initial count of how many entities we found that matches the query
        // we wrote above
        var count = this.m_VehicleQuery.CalculateEntityCount();

        // For debugging purposes, we output the current count to the console
        UnityEngine.Debug.Log($"OnCreate - VehicleCounter - Vehicle Count: {count}");
        this.PrintPeriodically();
    }

    protected override void OnUpdate() {
        // Here we re-calculate the current vehicles count based on how many entities
        // the query now returns.
        var count = this.m_VehicleQuery.CalculateEntityCount();
        this.current_vehicle_count = count;
    }

    private async void PrintPeriodically() {
        while (true) {
            UnityEngine.Debug.Log($"OnUpdate - VehicleCounter - Vehicle Count: {this.current_vehicle_count}");
            await Task.Delay(1000 * 10);
        }
    }
}

Inject your System with Harmony to run at the PostSimulation Phase, to make it enabled in the game:

[HarmonyPatch(typeof(SystemOrder))]
public static class SystemOrderPatch {
    [HarmonyPatch("Initialize")]
    [HarmonyPostfix]
    public static void Postfix(UpdateSystem updateSystem) {
        // Add our defined VehicleCounterSystem to the update system which makes it part of
        // the game loop. Make sure it runs at the Phase `PostSimulation`
        updateSystem.UpdateAt<VehicleCounterSystem>(SystemUpdatePhase.PostSimulation);
    }
}

Query

Small example for getting all Vehicle Components that doesn't have the Deleted or Temp Components (all active vehicles) inside a GameSystemBase which your System should extend from:

this.m_VehicleQuery = this.GetEntityQuery(new EntityQueryDesc() {
    All = new ComponentType[1] {
        ComponentType.ReadOnly<Vehicle>()
    },
    None = new ComponentType[2] {
        ComponentType.ReadOnly<Deleted>(),
        ComponentType.ReadOnly<Temp>()
    }
});

Then you can do things like count the number of found Entities with that matching set:

var count = this.m_VehicleQuery.CalculateEntityCount();

Phase

Phases help you decide when your System should run, and optionally lock it to run before/after another specific System

The Phases are defined as a public enum, Game.SystemUpdatePhase

Example:

...
Rendering = 14
PreTool = 15
PostTool = 16
ToolUpdate = 17
ClearTool = 18
...

The most basic API method is UpdateAt which defines at what Phase a System should be run at:

updateSystem.UpdateAt<ApplyObjectsSystem>(SystemUpdatePhase.ApplyTool);

The example above would run the ApplyObjectsSystem System when the active Phase is ApplyTool.

Sometimes you want to run a particular System before/after another, then UpdateBefore and UpdateAfter comes in handy. Example:

updateSystem.UpdateAt<ResourceAvailabilitySystem>(SystemUpdatePhase.GameSimulation);
updateSystem.UpdateAfter<EarlyGameOutsideConnectionTriggerSystem, ResourceAvailabilitySystem>(SystemUpdatePhase.GameSimulation);

This would make the EarlyGameOutsideConnectionTriggerSystem System run after the ResourceAvailabilitySystem system, while in the GameSimulation Phase.

World

The ECS World is represented by Unity.Entities.World.

A World contains basically all the Entities in the game and has the relevant Systems attached to it. Systems can only access Entities belonging to the same World as themselves.

Archetype

Archetypes allows you to categorizes entities based on their component types, allowing efficient querying and improved performance by enabling the retrieval of entities with specific combinations of components, such as types A and B, from a stable set of archetypes.