- Project Summary
- Runtime Lifecycle
- Event System
- Settings System
- Physics System
- GUI System
- Module Registration Protocol
- How to Add a New Event
- How to Add a New Event Category
- Known Bugs & Limitations
- Multiplayer Considerations
- Build & Deploy
FS25_RandomWorldEvents adds:
- A probabilistic event engine that fires timed world events (economic, vehicle, field, special) with configurable frequency, intensity, and cooldown.
- A real vehicle-physics layer (
RWEVehiclePhysics, a vehicle specialization) that drives speed, top speed, acceleration and steering through the engine's own fields, plus a loose-ground traction governor. See §5. - An in-game settings screen (tabbed GUI) for toggling categories and tuning all parameters without restarting the game.
- Per-savegame persistence so each farm's settings survive restarts.
Mod version: 2.0.0.0 (note: modDesc.xml incorrectly shows 1.0.0.0).
FS25 engine boots
│
├─ Loads all extraSourceFiles in order (modDesc.xml):
│ RandomWorldEvents.lua ← defines class + hooks lifecycle
│ gui/RandomWorldEventsScreen.lua
│ gui/RandomWorldEventsFrame.lua
│ gui/RandomWorldDebugFrame.lua
│ utils/economicEvents.lua ← queues pendingRegistrations
│ utils/vehicleEvents.lua ← queues pendingRegistrations
│ utils/fieldEvents.lua ← queues pendingRegistrations
│ utils/animalEvents.lua ← queues pendingRegistrations (see Bug #1)
│ utils/specialEvents.lua ← queues pendingRegistrations
│ utils/VehiclePhysics.lua ← installs TypeManager.finalizeTypes hook on load
│ utils/PhysicsUtils.lua ← debug telemetry only (queues pendingRegistrations)
│
├─ Mission00.load fires
│ ├─ RandomWorldEvents:new(mission)
│ │ ├─ createSettingsManager()
│ │ ├─ loadSettings() ← reads savegame XML
│ │ └─ registerConsoleCommands()
│ ├─ g_RandomWorldEvents = rweManager ← global exposed HERE
│ ├─ loadEventModules() ← drains pendingRegistrations
│ ├─ loadGUI() ← sources + registers screen/frame classes
│ └─ isInitialized = true
│
├─ FSBaseMission.update fires every frame
│ └─ rweManager:update(dt)
│ ├─ Event timer check → triggerRandomEvent() if chance fires
│ ├─ Active event tick → applyActiveEventEffects()
│ ├─ Event expiry check → event.onEnd()
│ └─ (optional) debug readout → PhysicsUtils:showPhysicsInfo(vehicle)
│ Real physics runs per-vehicle in the RWEVehiclePhysics spec, not here.
│
└─ FSBaseMission.delete fires on exit/unload
├─ saveSettings()
└─ g_RandomWorldEvents = nil
Every registered event is a Lua table with the following fields:
{
name = "my_event_name", -- string: unique key in EVENTS table
category = "economic", -- string: must match a <category>Events key in settings
weight = 1, -- number: relative selection weight (currently unused; all events have equal chance)
duration = { min = 15, -- table: duration range in in-game minutes
max = 60 }, -- converted to ms: value * 60000
minIntensity = 1, -- number 1-5: minimum intensity level required
canTrigger = function() -- function() → bool: runtime eligibility check
return g_currentMission ~= nil
end,
onStart = function(intensity) -- function(intensity) → string|nil
-- Apply event effects here.
-- Return a notification string, or nil to suppress.
return "Event started!"
end,
onEnd = function() -- function() → string|nil
-- Clean up all effects applied by onStart.
-- Return a notification string, or nil to suppress.
return "Event ended."
end
}Important: onEnd is shared across all events registered in a single module. It
must clear every possible EVENT_STATE key that any event in that module could set.
Failing to do so causes stale state from a previous event to persist.
The g_RandomWorldEvents.EVENT_STATE table holds all transient effect data:
| Key | Set by | Meaning |
|---|---|---|
activeEvent |
core | Name of the currently running event, or nil |
eventStartTime |
core | g_currentMission.time when event started |
eventDuration |
core | Duration in ms |
cooldownUntil |
core | g_currentMission.time after which next event may fire |
marketBonus |
economicEvents | Sell price multiplier bonus (fraction, e.g. 0.15) |
marketMalus |
economicEvents | Sell price multiplier penalty |
seedDiscount |
economicEvents | Seed cost reduction fraction |
fertilizerDiscount |
economicEvents | Fertilizer cost reduction fraction |
fuelDiscount |
economicEvents | Fuel cost reduction fraction |
equipmentDiscount |
economicEvents | Equipment cost reduction fraction |
priceFixing |
economicEvents | Fixed sell price bonus |
priceFixingDuration |
economicEvents | Minutes remaining on price fixing |
exportBonus |
economicEvents | Export price bonus fraction |
exportDuration |
economicEvents | Minutes remaining on export bonus |
economicCrisis |
economicEvents | Table {marketMalus, loanPenalty, duration} |
yieldBonus |
fieldEvents | Crop yield multiplier bonus |
yieldMalus |
fieldEvents | Crop yield multiplier penalty |
fertilizerBonus |
fieldEvents | Fertilizer effectiveness flag |
fertilizerMalus |
fieldEvents | Fertilizer effectiveness flag |
seedBonus |
fieldEvents | Seed growth speed flag |
seedMalus |
fieldEvents | Seed growth speed flag |
harvestBonus |
fieldEvents | Harvest amount flag |
harvestMalus |
fieldEvents | Harvest amount flag |
fieldSaleBonus |
fieldEvents | Field crop sale price bonus fraction |
fieldSaleMalus |
fieldEvents | Field crop sale price penalty fraction |
vehiclePhysics |
vehicleEvents | {vehicle} - vehicle with active RWEVehiclePhysics modifiers (speed/engine/steering); restored via RWEVehiclePhysics.clearEventMods |
vehicleAccident |
vehicleEvents | {vehicle, damagePercent} table |
vehicleUpgrade |
vehicleEvents | {vehicle} table (color tint state) |
originalTimeScale |
specialEvents | Saved missionInfo.timeScale before time warp |
xpBonus |
specialEvents | XP gain multiplier bonus |
xpMalus |
specialEvents | XP gain multiplier penalty |
moneyBonus |
specialEvents | Money gain multiplier bonus |
moneyMalus |
specialEvents | Money gain multiplier penalty |
durabilityBoost |
specialEvents | Equipment durability flag |
durabilityMalus |
specialEvents | Equipment durability flag |
tradeBonus |
specialEvents | Trade price flag |
Note: Most
EVENT_STATEflags (e.g.yieldBonus,xpBonus) are set but never actually read by any game hook. They function as observable state indicators only - the actual gameplay integration (hooking FS25 crop yield or XP grant callbacks) is not yet implemented.
Each frame in update(dt):
- If
events.enabledandg_currentMission.time > cooldownUntil:- Roll
math.random() <= frequency * 0.001. At frequency=5 this is a 0.5% chance per game tick (typically ~60 Hz), making events fire very frequently. - If roll passes: call
triggerRandomEvent(), then set cooldown tocooldown * 60000 * ((11 - frequency) / 10)ms.
- Roll
- If an event is active: call
applyActiveEventEffects()(currently a no-op stub). - Check if
eventStartTime + eventDurationhas elapsed → callevent.onEnd().
Settings are split into three sub-tables on the RandomWorldEvents instance:
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
enabled |
bool | true |
- | Master on/off switch |
frequency |
int | 5 |
1-10 | Trigger chance multiplier |
intensity |
int | 2 |
1-5 | Event magnitude |
showNotifications |
bool | true |
- | In-game HUD notices |
showWarnings |
bool | true |
- | Warning notifications |
cooldown |
int | 30 |
1-240 | Minutes between events |
weatherEvents |
bool | false |
- | Weather category (stub) |
economicEvents |
bool | true |
- | Economic category |
vehicleEvents |
bool | true |
- | Vehicle category |
fieldEvents |
bool | true |
- | Field category |
wildlifeEvents |
bool | true |
- | Wildlife/animal category |
specialEvents |
bool | true |
- | Special category |
debugLevel |
int | 1 |
- | Verbosity (unused in core) |
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
enabled |
bool | true |
- | Physics layer master switch (traction governor) |
wheelGripMultiplier |
float | 1.0 |
0.5-2.0 | Loose-ground traction: higher = more grip, less slowdown |
showPhysicsInfo |
bool | false |
- | Log the honest physics readout each frame |
debugMode |
bool | false |
- | Extra verbose physics logging |
articulationDamping |
float | 0.5 |
- | Legacy/unused - kept only so old save XML still loads |
comStrength |
float | 1.0 |
- | Legacy/unused - kept only so old save XML still loads |
suspensionStiffness |
float | 1.0 |
- | Legacy/unused - no script lever exists for it in FS25 |
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Debug mode flag |
debugLevel |
int | 1 |
Verbosity level |
showDebugInfo |
bool | false |
Show HUD debug info |
<g_currentMission.missionInfo.savegameDirectory>/FS25_RandomWorldEvents.xml
Root XML tag: RandomWorldEvents. Sub-paths mirror the table hierarchy
(e.g. RandomWorldEvents.events.frequency).
The physics layer was rebuilt to use only fields the GIANTS engine actually reads. The
old version (PhysicsUtils:applyAdvancedPhysics + RandomWorldEvents:updatePhysics)
wrote to wheel.physics.frictionScale, wheel.suspension.springForce and
wheel.contact.groundTypeName - none of which exist - so it never affected the
game. That dead code is gone.
A vehicle specialization injected into every drivable + motorized + wheels
type via a TypeManager.finalizeTypes hook (matched against g_vehicleTypeManager).
It owns all real vehicle-physics changes and restores them cleanly.
Real, engine-respected levers:
| Lever | Field / call | Used for |
|---|---|---|
| Speed cap | vehicle.speedLimit (km/h, read by Vehicle:getRawSpeedLimit) |
speed boost / slow-down |
| Top speed | motor.maxForwardSpeed (restore from motor.maxForwardSpeedOrigin) |
true turbo above gear cap |
| Acceleration | motor:setAccelerationLimit() |
engine sluggishness / limp home |
| Steering | spec_drivable.lastInputValues.axisSteer (per frame) |
steering pull |
| Surface (read) | wheel.physics:getSurfaceSoundAttributes() |
traction governor |
Per-vehicle state lives on vehicle._rwePhysics (event scales + captured baselines), so
the event API can reach it even on a vehicle that did not receive the spec. Baselines are
captured lazily on first update. Enforcement runs in onUpdate/onPreUpdate on the
server, and only writes when a value actually deviates, so untouched parked machines are
never modified. Restore happens on clearEventMods, onLeaveVehicle and onDelete.
RWEVehiclePhysics.applyEventMods(vehicle, {
speedScale = 1.4, -- km/h cap multiplier
topScale = 1.4, -- physical top-speed multiplier
accelScale = 0.5, -- acceleration multiplier (engine feel)
steerPull = 0.10, -- -1..1 continuous steering bias
})
RWEVehiclePhysics.clearEventMods(vehicle) -- restore baselinesWhen physics.enabled is on, the spec eases speed/acceleration for the controlled
vehicle on loose ground (field, mud, snow), scaled by the wheelGripMultiplier setting.
Surfaces come from the real getSurfaceSoundAttributes() data, never a faked field.
Reduced to honest debug telemetry. showPhysicsInfo(vehicle) logs the real speed
(getLastSpeed(), km/h - the old lastSpeedReal * 3.6 was 1000x too small), the surface
under the wheels, and any active modifiers.
The steering technique - writing into
spec_drivable.lastInputValues.axisSteereach frame so the game steers as if the player were holding the wheel - and the approach of injecting a specialization into existing vehicle types viaTypeManager.finalizeTypesare adapted from "RealPhysics Steering" by Tubez47. The source header ofutils/VehiclePhysics.luarecords this inline. Thank you, Tubez47.
The GUI is a standard FS25 TabbedMenuWithDetails with two frames:
| Tab | Frame Class | XML | Controls |
|---|---|---|---|
| Events/Settings | RandomWorldEventsFrame |
xml/RandomWorldEventsFrame.xml |
Toggle switches + text inputs for all event settings |
| Physics/Debug | RandomWorldEventsDebugFrame |
xml/RandomWorldDebugFrame.xml |
Toggle switches + text inputs for all physics settings |
The settings screen is not yet wired to a menu button or keyboard shortcut -
F3 is stubbed in the keyEvent handler but calls no screen-open logic.
To open it programmatically:
g_gui:showGui("RandomWorldEventsScreen")Each control's id in the XML maps directly to a key in g_RandomWorldEvents.events
or g_RandomWorldEvents.physics. The frame handlers use element.id to write back
to the correct sub-table:
-- In RandomWorldEventsFrame:
g_RandomWorldEvents.events[element.id] = value
-- In RandomWorldEventsDebugFrame:
g_RandomWorldEvents.physics[element.id] = valueThis means XML element IDs must exactly match the settings key names.
RandomWorldEventsFrame includes a triggerEventButtonWrapper control. Clicking it
calls g_RandomWorldEvents:triggerRandomEvent() directly - useful for testing without
console commands.
Each event module follows the same pattern to safely register with the core:
local function registerXxxEvents()
if not g_RandomWorldEvents or not g_RandomWorldEvents.registerEvent then
Logging.warning("[XxxEvents] g_RandomWorldEvents not available yet")
return false
end
-- call g_RandomWorldEvents:registerEvent({...}) for each event
return true
end
-- At module load time:
if g_RandomWorldEvents and g_RandomWorldEvents.registerEvent then
registerXxxEvents()
else
-- Defer to after core is initialized
if not RandomWorldEvents then RandomWorldEvents = {} end
if not RandomWorldEvents.pendingRegistrations then
RandomWorldEvents.pendingRegistrations = {}
end
table.insert(RandomWorldEvents.pendingRegistrations, function()
registerXxxEvents()
end)
endThe core drains pendingRegistrations in loadEventModules() after the singleton
is assigned to g_RandomWorldEvents.
- Open the appropriate utils file (e.g.
utils/economicEvents.lua) or create a new module file (see §9). - Add an entry to the module's
eventList:
{
name = "crop_insurance_payout", -- must be globally unique
minI = 2, -- minimum intensity (1-5)
func = function(intensity)
local amount = 1000 * intensity
if g_currentMission and g_currentMission.addMoney then
g_currentMission:addMoney(
amount,
economicEvents.getFarmId(),
MoneyType.OTHER,
true
)
end
return string.format("Crop insurance payout! +€%d", amount)
end
}- The
registerXxxEvents()loop will pick it up automatically on the next load.onEndfor the whole module clears allEVENT_STATEkeys used by any event in the module - add any new keys your event sets to that cleanup block. - If the event sets a state flag that needs per-tick logic, implement it in the
module's
updateoverride (see §10, Bug 4 for the chaining fragility warning before doing this).
- Create
utils/myNewEvents.luafollowing the registration protocol in §7. - Use a unique
categorystring in every event (e.g."community"). - Add
self.events.communityEvents = trueto the default config inRandomWorldEvents:createSettingsManager()(both thedefaultConfigblock and the load/save XML paths). - Add the matching toggle to
gui/RandomWorldEventsFrame.luaCONTROLS list and implement the GUI checkbox binding. - Add the XML element to
xml/RandomWorldEventsFrame.xml. - Register the file in
modDesc.xmlunder<extraSourceFiles>.
utils/animalEvents.lua is a verbatim copy of utils/specialEvents.lua. This means:
- Animal/wildlife events are not implemented.
specialEventsare double-registered (once from each file), creating duplicate event names ing_RandomWorldEvents.EVENTS. The second registration silently overwrites the first (Lua table key collision).- The
wildlifeEventstoggle in settings controls nothing. - Fix: Write actual animal events in
animalEvents.luausing the"wildlife"category (or"animal"- then updatecanTriggerto check for animal husbandry structures and update the setting key toanimalEventsfor consistency).
<filename name="RandomWorldEventsScreen" filename="gui/RandomWorldEventsScreen.xml"/>The name attribute is used twice as name and filename. Depending on the FS25
XML parser this may load incorrectly or be ignored.
- Fix: Verify the correct attribute names from the FS25 SDK and correct the tag.
The <version> element says 1.0.0.0; all Lua headers say 2.0.0.0.
- Fix: Synchronize to
2.0.0.0.
economicEvents.lua and vehicleEvents.lua both inject per-tick logic by
overwriting g_RandomWorldEvents:update and saving the previous function as
originalUpdate. If both modules run this at load time (which they do not - the
guard if g_RandomWorldEvents prevents it since g_RandomWorldEvents does not yet
exist at load time), the second would lose the first module's chain. Currently both
guards evaluate false, so neither per-tick function runs at all.
- Fix: Implement
applyActiveEventEffects()in the core and dispatch per-tick work there, or use a registered listener list instead of monkey-patching:update.
In RandomWorldEventsScreen:setupPages(), the Events frame receives settings.dds
and the Debug/Physics frame receives events.dds.
- Fix: Swap the icon strings.
g_currentMission.fieldController.fields is not the canonical FS25 field API.
- Fix: Use
g_fieldManageror simplyg_currentMission ~= nilas the guard.
Most EVENT_STATE entries (yieldBonus, xpBonus, marketBonus, etc.) are set but
no game hooks read them to actually apply the effects. The events fire, the
notification shows, the flag is set, but gameplay is unchanged.
- Fix: Hook the relevant FS25 callbacks (sell point price calculation, crop yield calculation, XP award) to check and apply the flag.
With frequency = 5, the probability per frame is 0.5%. At 60 Hz this is roughly
once every 3 seconds of real time (with a 30-minute cooldown). At frequency = 10
events fire almost every cooldown period. The UX label should clarify this is a
relative scale, not events-per-hour.
modDesc.xml declares multiplayer supported="true". However:
g_currentMission.addMoneyis client-authoritative on the calling client only; funds are not synchronized to other clients without a network event.- Vehicle-physics changes (
RWEVehiclePhysics) are enforced server-side and applied to the locally controlled vehicle, matching the pre-existing single-vehicle event scope; there is no dedicated network event syncing them to other clients yet. - No
FSCareerMissionInfoor network synchronization code exists in the mod. - Recommendation: Set
multiplayer supported="false"until network sync is implemented, or document clearly that effects are visual/local only in multiplayer.
# From C:\Users\tison\Desktop\FS25 MODS
bash build.sh --deployThe deploy step zips the mod and copies it to:
C:\Users\tison\Documents\My Games\FarmingSimulator2025\mods
After deploy, watch the log for mod output:
C:\Users\tison\Documents\My Games\FarmingSimulator2025\log.txt
Key log lines to verify successful load:
[RandomWorldEvents] Core initialized successfully
[RWE] Processing N pending registrations
[EconomicEvents] Registered 15 economic events
[VehicleEvents] Registered 10 vehicle events
[FieldEvents] Registered 10 field events
[SpecialEvents] Registered 10 special events
[RWEVehiclePhysics] Type hook installed
[RWEVehiclePhysics] Registered for N vehicle types
[PhysicsUtils] Initialized (debug telemetry)
[RWE] GUI loading complete
[RandomWorldEvents] Initialized successfully with N events