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
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 thedoorstop_config.ini
next to theCities2.exe
binary
- You should end up with a
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.
Demonstrated concepts:
IMod
- The official modding API for declaring modsDeltaTimePrintSystem : GameSystemBase
- Basic usage on how to setup your own SystemPrintPopulationSystem : 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
triggersCountPopulationJob
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
- The Query made into a temporary
- Finally the job is scheduled with
Dependency = popJob.Schedule()
- Unsure what
CompleteDependency()
does but probably related to dependencies between jobs?
- The data passed to the job is:
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
andComponentTypeHandle
- Also does lookups with
ComponentLookup
forCitizen
andHealthProblem
- Keeps state via
m_Result
which is aNativeArray
ofint
- Keeps track of Chunks via a
TestModSystem : GameSystemBase
is a System for triggeringTestJob
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.