Forked C# Product — Content Architecture Design
How templates, data, code, and modding fit together.
The Paradox Insight
In Paradox games, a building definition is 90% data, 10% logic:
# CK3 building definition
castle_walls_01 = {
construction_time = 730 # data
cost = { gold = 100 prestige = 50 } # data
county_modifier = { # data (modifier block)
levy_size = 0.1
fort_level = 1
}
next_building = castle_walls_02 # data (cross-reference)
can_construct = { # logic (trigger)
culture = { has_innovation = innovation_battlements }
}
ai_value = { # hybrid (data + logic)
base = 10
modifier = { add = 20 is_at_war = yes }
}
}
Modders mostly change the data (costs, modifiers, references). Occasionally they change the triggers/effects. The logic they write is composed from a fixed vocabulary of engine-provided keywords (has_innovation, is_at_war, add_gold). They never write raw C++ code.
This is why Paradox modding communities are huge — the barrier is low. You edit numbers and compose from building blocks.
The Design Question
For a forked C# product, the question is: how much of a template is data vs how much is code, and what file types hold what?
Two extremes:
| Extreme | Template Is... | Modding Is... | Problem |
|---|---|---|---|
| Full C# (current Rust approach) | A class with methods and constants | Write C# to override anything | High barrier — modders must know C# |
| Pure declarative (Paradox approach) | Key-value data + composed triggers/effects | Edit text values, pick from keyword menu | Low expressiveness — need engine keyword for everything |
The sweet spot is somewhere in between. And the file type separation is how you create two tiers of modding.
Proposed Architecture: Two File Types
.cs files — Game Developer Code (NOT moddable)
Regular C# compiled by Unity's normal pipeline. Contains:
- Views (data shapes)
- Schemas (behavior contracts)
- Game systems (helper functions, utilities)
- Host orchestration
// views/Location.cs — regular C#, NOT moddable
public view Location {
public double Population;
public double Food;
public double Water;
public double Gold;
}
// schemas/Building.cs — regular C#, NOT moddable
public schema building {
this: Location;
bool CanBuild();
void OnBuilt();
double AiWeight();
}
Views and schemas define the contract. They set the rules. Mods cannot change them — changing a view breaks all other mods, changing a schema breaks all templates.
Game developers also write helper functions in .cs:
// helpers/BuildingHelpers.cs — regular C#, NOT moddable
public static class BuildingHelpers {
public static bool HasEnoughResources(ref Location loc, double cost) {
return loc.Gold >= cost;
}
public static void ApplyFoodBonus(ref Location loc, double amount) {
loc.Food += amount * (1.0 + loc.Water * 0.1);
}
}
.secs files — Moddable Content (compiled by forked Roslyn)
Templates live here. This is what modders override. The forked Roslyn compiles these.
// buildings/farm.secs — MODDABLE content
template<building> Farm {
// ─── DATA (constants) ─── easy to override, just change values
const string DisplayName = "Farm";
const double BuildCost = 50.0;
const int MaxLevel = 3;
const string Category = "food";
const string Icon = "icons/farm";
// ─── MODIFIERS ─── declarative, engine-applied
modifier FoodProduction = 10.0;
modifier TaxIncome = 2.0;
// ─── LOGIC ─── methods, overridable
bool CanBuild() {
return BuildingHelpers.HasEnoughResources(ref @this, BuildCost)
&& @this.Population >= 10;
}
void OnBuilt() {
BuildingHelpers.ApplyFoodBonus(ref @this, 10.0);
}
double AiWeight() {
double w = 10.0;
if (@this.Food < 50.0) w += 20.0;
if (@this.Water > 0) w += 5.0;
return w;
}
}
Key: .secs files can call functions from .cs files (BuildingHelpers.HasEnoughResources), but .cs files cannot be overridden by mods. The game developer exposes what they want modders to use.
How Modding Works
Tier 1: Data-Only Modding (90% of modders)
Override just the constants. No code needed. Almost declarative.
// mods/my-balance/buildings/farm.secs
override template<building> Farm {
override const double BuildCost = 75.0; // rebalanced
override const int MaxLevel = 5; // more levels
override modifier FoodProduction = 15.0; // buffed food
}
This is the Paradox-like experience. Change numbers, change modifiers. The engine and existing logic handle the rest. A modder who can edit JSON can do this.
Tier 2: Logic Override (power modders)
Override methods to change behavior. Requires basic C#.
// mods/irrigation/buildings/farm.secs
override template<building> Farm {
override const double BuildCost = 60.0;
override modifier FoodProduction = 8.0; // lower base, but...
// inject new method
inject double IrrigationBonus() {
return @this.Water > 0 ? @this.Water * 2.0 : 0.0;
}
// override existing method
override void OnBuilt() {
double bonus = 8.0 + IrrigationBonus();
BuildingHelpers.ApplyFoodBonus(ref @this, bonus);
}
}
Tier 3: New Content (modders adding things)
Add entirely new templates implementing existing schemas. Full C# in .secs.
// mods/windmill/buildings/windmill.secs
template<building> Windmill {
const string DisplayName = "Windmill";
const double BuildCost = 120.0;
const int MaxLevel = 2;
const string Category = "food";
modifier FoodProduction = 5.0;
modifier GoldProduction = 3.0;
bool CanBuild() {
return @this.Population >= 50 && @this.Gold >= BuildCost;
}
void OnBuilt() {
@this.Food += 5.0;
@this.Gold += 3.0;
}
double AiWeight() => 15.0;
}
What .secs Files CAN and CANNOT Do
CAN (allowed in .secs)
- Define templates with constants, modifiers, and methods
- Use
@this/@from/@rootscope access - Call game-std APIs (stats, modifiers, variables, events, decisions, etc.)
- Call game developer's exposed helper functions from
.csfiles - Use basic C# features: if/else, loops, math, string operations
- Define local helper methods within templates
- Use Rust-like pattern matching (if forked into the C# syntax)
CANNOT (restricted in .secs)
- Define new views or schemas (those are in
.cs, not moddable) - Access filesystem, network, reflection, or unsafe code
- Reference arbitrary NuGet packages
- Use threading/async
- Access Unity APIs directly
The forked Roslyn enforces these restrictions at compile time. A mod that tries to do File.Delete("C:\\Windows") gets a compiler error, not a runtime crash.
Modifier Declaration
The modifier keyword is new syntax in the C# fork. It desugars to something the engine processes:
// What the modder writes:
template<building> Farm {
modifier FoodProduction = 10.0;
modifier TaxIncome = 2.0;
}
// What the forked Roslyn desugars it to:
public class Farm : IBuilding {
[SersModifier("FoodProduction")]
public const double Modifier_FoodProduction = 10.0;
[SersModifier("TaxIncome")]
public const double Modifier_TaxIncome = 2.0;
}
The runtime discovers modifiers the same way it discovers constants — by scanning compiled types for [SersModifier] attributes. When a building is "active," the runtime calls sync_modifiers with these values.
Overriding a modifier is just overriding a constant:
override template<building> Farm {
override modifier FoodProduction = 15.0; // just changes the number
}
No code required. The engine applies it.
How .secs Calls .cs
The game developer compiles their .cs code into a contracts assembly that both Unity and the forked Roslyn reference:
Game.Contracts.dll ← shared assembly
├── Views (Location, Character, etc.)
├── Schemas (IBuilding, IEvent, etc.)
└── Helpers (BuildingHelpers, CombatHelpers, etc.)
scripts.dll ← compiled from .secs files by forked Roslyn
├── Farm : IBuilding
├── Mine : IBuilding
└── (references Game.Contracts.dll)
Unity project ← references Game.Contracts.dll, loads scripts.dll
├── Host code
├── Game systems
└── UI
The contracts assembly is the bridge. The game developer decides what helpers to put there — that's their exposed API for modders.
File Extension Options
| Option | Extension | Meaning |
|---|---|---|
| A | .secs | SERS C Script |
| B | .sers | Same as current Rust, reused for C# |
| C | .gcs | Game C# Script |
| D | .mod.cs | Makes clear it's "modded C#" |
.secs is clean. It signals "this is SERS content in C# syntax" and differentiates from regular .cs files. IDEs can be configured to use the forked analyzer for .secs files and standard C# for .cs files.
Or keep .sers for both products — Rust fork compiles .sers with Rust syntax, C# fork compiles .sers with C# syntax. Same extension, different base language. This might be confusing though.
Override System
Same playset model as the Rust product:
game.toml — game identity, version
playset.toml — ordered list of active mods
game/src/buildings/ — base game .secs files
mods/
balance-patch/
mod.toml — version, dependencies
src/buildings/farm.secs — overrides
irrigation-mod/
mod.toml
src/buildings/farm.secs — more overrides
src/buildings/windmill.secs — new content
The forked Roslyn merges at AST level, same as the Rust fork:
- Parse all
.secsfiles from all layers - Group templates by schema + name
- Apply overrides/injects in layer order (later wins)
- Type-check the merged result
- Emit the final
.dll
If two mods override the same constant, later wins. If two mods override the same method, later wins. If a mod injects a new method, it's added. Compile-time errors catch type mismatches.
Compared to Other Engines
| Aspect | Paradox (CK3) | Factorio | Minecraft | This Design |
|---|---|---|---|---|
| Content files | .txt (Clausewitz) | data.lua | JSON data packs | .secs (C# syntax) |
| Code files | Same .txt | control.lua | Java mods | .cs (regular C#) |
| Separation | None (mixed) | Hard (different Lua states) | Complete (JSON vs Java) | Compile boundary (different compilers) |
| Override granularity | File or definition level | Field-level via data.raw | File-level | Member-level (override/inject) |
| Type checking | None (runtime errors) | Basic (prototype validation) | JSON schema | Full compile-time |
| Modder can write logic? | Compose from keywords only | Full Lua | No (data packs) / Full Java (mods) | Full C# subset (restricted) |
| Modder learning curve | Low (text editing) | Medium (Lua) | Low (JSON) / High (Java) | Low (data) / Medium (C# methods) |
This design gives you Paradox's data-oriented modding (override constants/modifiers) AND Factorio's full-code modding (write C# methods) in the same file format, with Minecraft's two-tier accessibility (Tier 1: data-only, Tier 2: code).
What the Forked Roslyn Actually Changes
The fork adds these to the C# lexer/parser/binder:
| Keyword | Desugars To | Purpose |
|---|---|---|
view | struct with [SersView] attribute | Data shape definition |
schema | interface with [SersSchema] attribute | Behavior contract |
template<S> Name | class Name : ISchema with [SersTemplate] attribute | Content implementation |
override template | Same class, AST merge replaces members | Mod override |
inject | Add new member to existing template | Mod extension |
modifier | const double with [SersModifier] attribute | Declarative stat modifier |
@this / @from / @root | ref parameter access | Scope system |
link | Navigation property with [SersLink] attribute | Entity relationship |
Everything else is standard C#. The fork is a thin syntax layer, same philosophy as the Rust fork.
Example: Full CK3-Style Event in .secs
// events/learning.secs
template<character_event> ScholarDiscovery {
const string EventKey = "learning.0001";
const string Title = "loc:learning.0001.title";
const string Description = "loc:learning.0001.desc";
const string Theme = "learning";
const bool FireOnlyOnce = false;
const int CooldownTicks = 365;
// trigger — who can this fire for?
bool Trigger() {
return @this.Age >= 16
&& @this.Learning >= 12
&& !Variables.HasFlag(@this.Id, "had_scholar_event");
}
// MTTH — mean time to happen (in ticks)
double MeanTimeToHappen() => 730.0;
// immediate effect — runs before player sees the event
void Immediate() {
Variables.SetFlag(@this.Id, "had_scholar_event", duration: 3650);
}
// option A
const string Option0Name = "loc:learning.0001.option_a";
void Option0Effect() {
Stats.AddModifier(@this.Id, "prestige", 100.0);
}
double Option0AiWeight() => @this.Learning >= 15 ? 80.0 : 40.0;
// option B
const string Option1Name = "loc:learning.0001.option_b";
void Option1Effect() {
Stats.AddModifier(@this.Id, "gold", 50.0);
}
double Option1AiWeight() => 50.0;
}
A modder who wants to rebalance this just overrides the constants and AI weights:
// mods/rebalance/events/learning.secs
override template<character_event> ScholarDiscovery {
override const int CooldownTicks = 180; // fires more often
override double MeanTimeToHappen() => 365.0; // happens faster
override double Option0AiWeight() => 95.0; // AI strongly prefers option A
}
No need to understand the full event system. Just change the numbers.