Welcome to the Metruvia Content Creator Series Scenarios and Campaigns Guide. In Free Play, the player creates their own goals. In a Scenario, you are the director. You dictate the economy, set the challenges, script the dialogue, and control the pacing.
For a PC modder, building a campaign is an exercise in Lua scripting. For a cross-platform creator targeting Mod.io and console ecosystems, it is an exercise in strict state management and hardware-compliant UI design. Because scenarios run custom logic every single frame to check if objectives are complete, a poorly optimized script will cause CPU watchdog timeouts, while an improperly structured save-state will corrupt a player’s progress entirely.
This masterclass will deconstruct the Transport Fever 2 campaign framework. We will explore the directory hierarchy, the mathematics of the mission state machine, event-driven triggers, and the absolute necessity of serializable data for console save-game integrity.
1. The Campaign Directory Architecture
A scenario is not a single file; it is a rigid hierarchy of interconnected directories. If this structure deviates by a single character, the game’s internal parser will fail to register the campaign in the main menu.
Your staging area must follow this exact schema:
res/campaign/my_metruvia_campaign/
campaign.lua (The master index linking all missions together).
image_00.tga (The 16:9 banner image for the campaign menu).
01_first_mission/
mission.lua (The core logic script for mission one).
map.lua (The topological generation script).
image_00.tga (The thumbnail for mission one).
heightmap.png (The 16-bit grayscale terrain map).
strings.lua (The localized text dictionary).
1.1 The campaign.lua Master File
This script is lightweight. Its sole purpose is to declare the sequential order of the missions and define the unlocking logic.
function data()
return {
title = (“The Metruvia Transit Initiative”), description = (“A three-part campaign restoring a fractured network.”),
missions = {
{ id = “01_first_mission”, title = (“Chapter 1: The Foundation”) }, { id = “02_second_mission”, title = (“Chapter 2: Industrial Expansion”) },
{ id = “03_third_mission”, title = _(“Chapter 3: The High-Speed Era”) }
}
}
end
2. The mission.lua State Machine
The brain of your scenario is the mission.lua file. Transport Fever 2 missions operate as Finite State Machines (FSM). The engine constantly evaluates the current “state” of the game and checks if the player has met the conditions to advance to the next state.
2.1 The Three Core Functions
Every mission script must contain three fundamental functions:
init(): Runs exactly once when the mission is first started. This is where you spawn initial towns, place starting industries, and set the starting bank balance.
step(): Runs continuously (multiple times per second). This is your polling loop where you check if a player has delivered the required cargo or built the required tracks.
save() / load(): The serialization hooks. These are the most critical functions for console certification.
2.2 The state Table and Serialization
To track a player’s progress, you store variables in a global state table.
Example: state.steel_delivered = 0
The Console Save-Breaker Trap: When a console player saves the game, the engine serializes (converts to binary data) everything inside the state table and writes it to the SSD. You cannot put functions, userdata, or game engine object references inside the state table.
Fatal Error: state.my_train = api.engine.getEntities()[1] (This stores a dynamic memory pointer. When the game is reloaded, that pointer is dead, and the console will crash).
Compliant: state.my_train_id = 1402 (Store only basic data types: integers, strings, booleans. Retrieve the actual entity using the ID upon reloading).
3. Objective & Task Management
A scenario requires clear objectives. For console compatibility, you must rely entirely on the vanilla UI task tracker.
3.1 The Vanilla Task API
Do not attempt to write a Lua script that draws a custom “Quest Log” window on the screen. Gamepad controllers cannot navigate unmapped UI space, and your mod will be instantly rejected.
You must utilize the built-in game.interface to add tasks to the vanilla left-hand objective panel:
— Adding a task securely using the vanilla API
game.interface.sendScriptEvent(“ui”, “addTask”, {
id = “deliver_steel”,
title = (“Fuel the Forge”), text = (“Deliver 500 units of steel to the Central Plant.”),
type = “PROGRESS”,
progress = { current = state.steel_delivered, max = 500 }
})
3.2 Updating Task Progress
Inside your step() function, you continuously update this vanilla UI element. Once state.steel_delivered >= 500, you send another script event changing the task state to COMPLETED, which triggers the vanilla success chime and green checkmark.
4. Algorithmic Efficiency: Event Listeners vs. Polling
Because the step() function runs constantly, poor algorithmic logic here will decimate a console’s CPU, triggering a watchdog timeout.
4.1 The Polling Danger
If your objective is “Wait for the player to buy a specific locomotive,” the amateur approach is to use step() to scan the entire vehicle registry every single frame to see if the locomotive exists. If the player has 500 vehicles, you are performing 500 checks per frame, 60 times a second. This is $O(n)$ polling, and it will cause massive stuttering.
4.2 Event-Driven Architecture
The professional, certified approach is to use Event Listeners. Instead of asking “Did it happen?” every frame, you tell the engine, “Wake this script up only when a vehicle is purchased.”
In your init() function, register a listener:
api.engine.system.scriptSystem.addScriptEvent(“vehicleAdded”, function(id)
local vehicleData = api.engine.getComponent(id, api.type.ComponentType.MODEL_INSTANCE)
if vehicleData.modelId == “my_target_locomotive.mdl” then
state.loco_purchased = true
end
end)
This reduces the CPU load from continuous polling to near-zero, guaranteeing smooth performance on unified memory architectures.
5. Camera Manipulation and Cutscenes
Transport Fever 2 allows you to hijack the camera to create dramatic cutscenes. This is highly encouraged, provided it respects the player’s control interface.
5.1 The camera.set() Function
You can move the camera to a specific coordinate matrix to highlight a new factory opening or a bridge completing.
game.interface.setCamera({
matrix = { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 150, 200, 50, 1 },
fov = 30,
duration = 5.0 — Camera smoothly pans over 5 seconds
})
5.2 The “Lockout” Warning
When running a cutscene, you must temporarily disable user input to prevent the camera math from fighting the player’s thumbstick input. Always ensure you release the input lock immediately after the duration timer expires. If your script errors out during a cutscene and fails to release the lock, the console player is permanently soft-locked and must hard-reset the game.
6. Localization and Translation Dictionaries
A scenario relies heavily on narrative text. Because Mod.io serves a global audience across all console regions, hardcoding English text directly into your mission.lua is a poor practice.
6.1 The strings.lua File
Every piece of text—mission titles, objectives, character dialogue—must be wrapped in the translation function _("Your Text Here").
The engine then looks inside your mission’s strings.lua file to find the regional equivalent based on the console’s language settings.
function data()
return {
en = {
[“Fuel the Forge”] = “Fuel the Forge”,
[“deliver_steel_desc”] = “Deliver 500 units of steel to the Central Plant.”
},
de = {
[“Fuel the Forge”] = “Die Schmiede anfeuern”,
[“deliver_steel_desc”] = “Liefere 500 Einheiten Stahl an das Hauptwerk.”
}
}
end
By utilizing this dictionary structure, you expand your campaign’s reach to international player bases without altering a single line of your core logical code.
7. Summary: The Campaign Certification Checklist
Before you package your campaign for the Mod.io API, run your structure through this final audit:
Directory Integrity: Does your hierarchy strictly follow the campaign/name/01_mission folder structure?
State Serialization: Are there zero functions, components, or memory pointers stored inside your state table?
UI Compliance: Are all tasks and objectives pushed to the vanilla UI instead of relying on custom window generation?
CPU Optimization: Have you utilized Event Listeners for trigger conditions instead of heavy $O(n)$ polling in the step() function?
Localization: Is all dialogue and objective text routed through a strings.lua dictionary?
Creating a scenario transforms you from an asset modeler into a game designer. By adhering to strict state-machine protocols and event-driven architecture, you ensure your narrative vision executes flawlessly across the entire cross-platform ecosystem.

Leave a Reply