Devlog: Building an IFTTT Engine for RimWorld
I play RimWorld the way I write software: I want systems, not chores. When food drops below ten days, I want to know before I’m watching colonists starve. When raiders show up, I want my shooters drafted without my intervention. I wanted IFTTT for RimWorld.
The mod didn’t exist. So I built it. What started as a weekend project became something closer to a proper automation framework — and the journey from “simple trigger/action pairs” to “stateful multi-settlement rule engine” taught me more about software architecture than most of my professional work.
This is a retrospective on that growth.
The First Decision: Polling vs. Events
RimWorld modding offers two paths for reacting to game state: Harmony patches (intercept specific method calls, event-driven) or GameComponent ticking (poll the world every N ticks).
Event-driven sounds cleaner. But for a general automation framework — where users define arbitrary conditions — it’s a trap. You’d need a Harmony patch for every possible condition: one for food consumption, one for power grid recalculations, one for raid spawning, one for mood changes. The surface area is unbounded, and every patch is a potential conflict with other mods touching the same methods.
Polling sidesteps this entirely. Every 250 ticks (~4 real seconds), the engine reads the entire game state. Pawn needs, map weather, item counts — it’s all queryable at tick-time. The framework stays stateless between evaluations, which makes reasoning about correctness vastly simpler.
flowchart LR
A[Game Tick] -->|every 250 ticks| B[Poll All Rules]
B --> C{Triggers Met?}
C -->|Yes| D[Execute Actions]
D --> E[Log + Cooldown]
C -->|No| F[Check ELSE Branch]
F --> G[Log]
The tradeoff is latency: a raid starting at tick 1 isn’t detected until tick 250. In practice, four seconds is imperceptible — players don’t manually react faster than that anyway.
Key finding: Polling was the right call. Six months in, it hasn’t caused a single timing-related bug, and adding new triggers requires zero awareness of RimWorld’s internal event plumbing.
v1.0 — One Trigger, One Action, One Problem
The first version was dead simple. Hardcoded trigger classes (Trigger_FoodLow, Trigger_UnderAttack, Trigger_EnemyCount) paired with hardcoded action classes (Action_DraftShooters, Action_SetResearchProject). One trigger, one action per rule.
It worked. But it didn’t scale. Every new condition required a new class with its own UI, persistence, and configuration. Want “trigger when a prisoner is present”? Write Trigger_PrisonerPresent from scratch. Want “trigger when steel drops below 200”? New class. Want “trigger when mood is low”? New class.
The real breaking point was compound conditions. “Draft shooters when under attack AND enemy count is above 5.” The flat model had no answer for AND or OR logic.
The specific-trigger approach was accumulating about 30 nearly identical classes, each differing only in what they queried. The pattern was obvious: they all read a game property, compare it to a threshold, and return a boolean. Only the property varied.
v2.0 — The Generic Pivot
This was the architectural turning point. Instead of one class per condition, I introduced four universal triggers:
- ThingCount — any item, any comparator, any threshold
- PawnState — any pawn property: hediff, need, skill, trait, capacity, bio data
- MapState — weather, temperature, season, time, fire count, wealth
- PawnProperty — runtime reflection over any scalar property on any pawn component
The UI pattern is uniform: a dropdown populated from RimWorld’s definition database at runtime. Pick what you want to monitor, set a threshold, done. One trigger class replaces dozens.
The same principle applied to actions. Three generic actions — Use Item On Pawn, Cast Ability, Set Forbidden — replaced a dozen specific ones by accepting any definition as configuration.
Requirement change: I originally assumed players would need specific, curated triggers. Wrong. Players wanted to query anything. The generic approach with dropdown menus turned out to be both more flexible and less code to maintain.
The reflection trigger deserves special mention. PawnProperty does a one-time scan of all loaded assemblies, discovers every public float/bool/int property on every pawn sub-tracker and ThingComp, and builds compiled getter lambdas. The result: a dropdown that automatically includes properties from any mod the player has installed. Install a mod that adds a custom StressLevel property? It shows up in the dropdown with zero integration work. This is the most “mod-agnostic” thing I’ve built.
v2.1 — Boolean Logic That Actually Works
The compound condition problem needed a real solution, not a hack.
The naive approaches (AND everything, OR everything) are both wrong. Real automation needs expressions like: “Draft shooters when (under attack OR raid incoming) AND (enemy count above 5).”
The solution: TriggerGroups. Each rule holds a list of groups. Each group has its own AND/OR mode. Groups are always AND-ed at the top level.
flowchart TD
R[Rule Fires When] --> G1
R --> G2
G1["Group 1 — ANY mode"]
G1 --> T1[Under Attack]
G1 --> T2[Raid Incoming]
G2["Group 2 — ALL mode"]
G2 --> T3["Enemy Count >= 5"]
G2 --> T4["NOT Pacifist Colony"]
G1 -.- AND{AND} -.- G2
This covers the vast majority of real automation needs without requiring a full boolean expression parser. Per-trigger negation (NOT) adds further flexibility. The architecture is simple enough to serialize, simple enough to render in a UI, and powerful enough for real use.
Decision: I considered a full boolean expression tree. Rejected it — the UI complexity would have been prohibitive, and grouped AND/OR with negation covers every real scenario I could imagine.
v2.2-2.3 — When Simple Rules Aren’t Enough
Two features emerged from actual gameplay needs:
Per-action guards let individual actions within a rule carry their own trigger condition. A single rule fires when colonists are idle, but Action A only runs on pawns with mood below 30%, while Action B runs on all others. Without guards, this required two separate rules with partially overlapping conditions — clunky and error-prone.
ELSE branches added a second action list that executes when the trigger condition is not met. Combined with the state machine (below), this enables genuine conditional logic: if this state, do A; otherwise, do B. No code required.
Lesson learned: Guards introduced unexpected complexity in the serialization layer. Guards are stored as a parallel list to actions — they must stay in sync when actions are reordered in the UI. A tuple-based (action, guard) design would have been cleaner. It works, but it’s the one piece of architecture I’d redesign if starting over.
v2.4 — The State Machine: From Stateless to Stateful
This changed the character of the entire framework.
Previously, every rule evaluation was independent — no memory between ticks. But real automation workflows have phases. A mining expedition has stages: idle, active, returning, unloading. You can’t express “do X only during phase 2” without persistent state.
The solution is a global variable store: named numeric values persisted in the save file, readable by any trigger, writable by any action.
stateDiagram-v2
[*] --> Idle : mission_phase = 0
Idle --> Alert : ore_stockpile < 100
Alert --> Active : shuttle docked
Active --> Complete : shuttle gone
Complete --> Idle : reset phase
state Idle {
[*] : Monitoring stockpile
}
state Alert {
[*] : Notify player, phase = 1
}
state Active {
[*] : Restrict pawns, phase = 2
}
state Complete {
[*] : Resume operations, phase = 0
}
This isn’t specific to mining — it’s completely generic. Variables can model:
- Phase integers: mission states, seasonal cycles
- Counters: raid count, harvests since restock
- Flags: on/off toggles coordinating independent rules
- Accumulators: resources earned, colonists lost
The key architectural decision was global scope. Variables are shared across all maps, all rules. I considered per-map variables but decided against it — global state is simpler to reason about and sufficient for 99% of use cases. Per-map can be added later without breaking the model.
Multi-Settlement: The Scope Problem
RimWorld lets you run multiple colonies simultaneously. Each is a separate Map object. The automation engine is a GameComponent — one per save file. So when a rule says “food is low,” which map’s food?
This forced a scoping system. Each rule carries a RuleMapScope:
- AnyHomeMap — evaluate on the primary settlement (backward-compatible default)
- AllHomeMaps — evaluate independently on every settlement, with per-settlement cooldowns
- SpecificMap — pinned to one named settlement
The “independently on every settlement” part was subtle. If a rule fires on Colony A, its cooldown shouldn’t block it from firing on Colony B the same tick. So cooldown tracking moved from a single integer to a per-tile dictionary.
The deep-copy problem surfaced here too: duplicating a rule in the UI was silently sharing trigger and action instances between the original and the copy. Edit one, both change. The fix was a reflection-based Clone() method on all triggers and actions — field-by-field copy, skip anything marked non-serialized.
The Shuttle Discovery
I assumed shuttle automation would require the Vanilla Expanded Framework (VEF) — a popular mod framework I already depend on. Research proved this wrong on two counts:
-
VEF has zero shuttle/transport/vehicle APIs. None. Vehicle functionality lives entirely in SmashPhil’s separate Vehicle Framework.
-
Vanilla RimWorld 1.6 already exposes a complete shuttle API.
Find.TransportShipManager.AllTransportShipsgives you every shuttle. Each has queryable state: is it docked? Loading? Ready to launch? About to auto-depart? You can even programmatically launch shuttles viaForceJob(ShipJobDefOf.FlyAway).
This was a requirement assumption that nearly led me to build unnecessary mod integration. The lesson: verify the vanilla API before reaching for frameworks. RimWorld’s internal APIs are more complete than community documentation suggests.
The shuttle trigger now queries six states, all from vanilla code, zero external dependencies:
flowchart LR
A["No Shuttle"] -->|arrives| B["Docked & Waiting"]
B -->|cargo added| C["Loading"]
C -->|all loaded| D["Ready to Launch"]
D -->|timer| E["Departure Imminent"]
E -->|launches| A
B -.->|player holds| F["Waiting Indefinitely"]
F -->|released| D
The shuttle control action now covers four modes — launch, return-to-home-map, hold, and unload — all from vanilla API, zero external dependencies.
The Vehicle Framework Surprise
SmashPhil’s Vehicle Framework is the dominant mod for adding driveable vehicles to RimWorld. I initially assumed integrating with it would require dedicated trigger and action classes — a reflection-based scanner for vehicle-specific comp types, special pawn enumeration, maybe a custom VehiclePawn filter.
Then I read the source.
VehicleComp extends ThingComp. Not a custom base class, not a parallel hierarchy — standard ThingComp, stored in the standard AllComps list inherited from ThingWithComps. And VehiclePawn extends Pawn.
This means my existing PawnPropertyScanner — which already discovers all ThingComp subclasses from loaded mod assemblies — automatically discovers Vehicle Framework components with zero integration code. Properties like CompFueledTravel.FuelPercent, CompVehicleLauncher.inFlight, and CompFueledTravel.EmptyTank simply appear in the dropdown when Vehicle Framework is installed.
flowchart TD
S[PawnPropertyScanner] -->|scans ThingComp subclasses| VF[Vehicle Framework Assembly]
VF --> CFT[CompFueledTravel]
VF --> CVL[CompVehicleLauncher]
CFT --> P1["Fuel, FuelPercent, EmptyTank"]
CVL --> P2["inFlight, FlightSpeed, MaxLaunchDistance"]
S -->|scans other mod assemblies| OM["VPE, HAR, etc."]
OM --> P3["...auto-discovered properties"]
The nullable getter pattern makes this work seamlessly: GetFloat(pawn) returns null for regular colonists (no VehicleComp), returns the actual value for VehiclePawns. The trigger’s val.HasValue check naturally filters to only vehicles. No PawnKindFilter.Vehicle needed — Any filter with a vehicle-specific property targets vehicles exclusively.
Lesson: Design for composability, not integration. The reflection scanner wasn’t built for Vehicle Framework. It was built to discover any ThingComp property from any mod. Vehicle Framework happened to follow the standard pattern, so compatibility came free. The best integrations are the ones you never have to write.
RimWorld 1.6 API: The Minefield
RimWorld 1.6 (Odyssey) renamed and restructured many APIs. Documentation is sparse. Stack Overflow answers reference pre-1.6 method names. The assembly browser is your real friend.
Some highlights from my corrections log:
- Property setters that became methods (
currentProj = xbecameSetCurrentProject(x)) - Methods that changed return types (
TryStartUseJob()went from returning a Job to returning void) - Properties that were renamed to reflect multi-map scoping (
AreaRestrictionbecameAreaRestrictionInPawnCurrentMap) - Properties that simply don’t exist despite documentation claiming they do (
Ability.IsOnCooldown— nonexistent)
The Designation system was completely rewritten in 1.6. The old flat list was replaced with indexed lookups by def, cell, and thing — better performance, but different method signatures. Eight new designation types were added (vein mining, tree extraction, fuel ejection).
Lesson: Maintain a corrections reference. Every time I find a wrong API name, I document the wrong version and the correct version side by side. This saves hours on every subsequent class I write.
Where It Stands
flowchart LR
V1["v1.0\n1:1 Rules\n5 triggers\n5 actions"] --> V2["v2.0\nGeneric Triggers\n+ Actions\n30+ triggers"]
V2 --> V21["v2.1\nBoolean Groups\nAND/OR Logic"]
V21 --> V23["v2.2–2.3\nTHEN/ELSE\nPer-Action Guards"]
V23 --> V24["v2.4\nState Machine\nMulti-Map\nShuttles\n41 triggers\n36 actions"]
style V1 fill:#f99,stroke:#333
style V2 fill:#fc9,stroke:#333
style V21 fill:#ff9,stroke:#333
style V23 fill:#9f9,stroke:#333
style V24 fill:#9cf,stroke:#333
41 triggers, 36 actions, 36 unit tests. The framework handles everything from “food low, send alert” to multi-phase shuttle workflows with persistent state, with automatic soft-compatibility for mods like Vehicle Framework. The test suite runs without a RimWorld runtime — stub triggers and actions make the rule engine fully hermetic-testable.
What I’d keep: the polling architecture, the generic trigger approach, the variable system, and the principle of populating UI from the game’s own definition database rather than hardcoding options.
What I’d redesign: the per-action guard storage (parallel lists instead of tuples), and the PawnState mega-class (24 property types in one class is too many, even with the registry refactor).
The mod isn’t done. But the architecture has stabilized — adding new triggers and actions is a 30-minute task now, not a day-long project. That’s when a framework stops being a prototype and starts being a platform.