Scripting¶
TF3 scripts read the game through api.engine and api.res, change it by sending commands through api.cmd, and keep their own data in game scripts. This page goes through each part with code from the base game.
The script states¶
The definitions describe the game as separate script states. api.engine is "available as read-only access to the entire engine state (in particular to the entire entity component system). It can be accessed from both the GUI State and the Engine State." (api/tealdef/api/engine.d.tl).
Writing works differently depending on the state. From the header of api/tealdef/api/cmd.d.tl:
On the engine states, commands are executed immediately and the effects are immediately noticeable. Moreover, the callback function will be called immediately.
On the gui and console states, commands are executed in the next simulation step, and the effects are noticeable only after a few frames. The callback function will be called the frame after the command has been executed, after which the changes are visible on the gui and engine state.
In practice:
| Code | Runs in |
|---|---|
Game script update, postUpdate, handleEvent |
engine state |
Game script guiUpdate, guiHandleEvent |
GUI state |
GUI recipes (base/content/gui/...) |
GUI state |
Mod script preRunFn, runFn, postRunFn |
load time, before any game script (see Load-time code) |
Construction updateFn and similar content scripts |
not stated in the definitions or the wiki |
How often the two sides run is not in the definitions. The wiki's Missions page says it for mission tasks, which are driven by the mission game script: onUpdate runs every simulation step and guiUpdate every frame. base/content/mission/mission/mission_sim.script.tl calls a task's onUpdate from its game script postUpdate and the task's guiUpdate from its game script guiUpdate, so the game script functions most likely run at the same rates.
The split of the game script functions follows from GameScriptWithGui: guiUpdate and guiHandleEvent get the simulation state as a read-only GameScriptStateReadOnly and a separate writable GUI state. Some api.gui functions are marked "Only available in GUI thread", and api.util is the one module whose functions "can be called from anywhere".
Reading entities¶
The engine is an entity component system. An entity is a plain integer (Engine.Entity); its data sits in components that you look up by type. Two entities are always there: the world and the player.
local gameTimeComponent : Engine.Component.GameTime
= api.engine.getComponent(api.engine.util.getWorld(), api.type.ComponentType.GAME_TIME)
This line is from base/content/game_mechanics/game_mechanics/game_time/game_time.script.tl. getComponent returns the component, or nil if the entity has none of that type. The component records are listed under Engine.Component, for example Account with balance and loan. The component types are the constants in Engine.ComponentType (GAME_TIME, WORLD, ACCOUNT, PLAYER_OWNED, CONSTRUCTION, VEHICLE_DEPOT, GAME_SCRIPT, ...).
To find entities, filter by component. From base/content/gui/gui/line_vehicle_mgmt/manager_window.tl:
local allPlayerDepotEntities : {Engine.Entity} = api.engine.getEntitiesWithComponent(api.type.ComponentType.VEHICLE_DEPOT, {
requireOwnedByPlayer = api.engine.util.getPlayer(),
filterFn = function(entity : Engine.Entity) : boolean
local vehicleDepot : Engine.Component.VehicleDepot = api.engine.getComponent(entity, api.type.ComponentType.VEHICLE_DEPOT)
return vehicleDepot and (#vehicleDepot.inNodes > 0 or #vehicleDepot.outNodes > 0)
end,
})
The definition warns that filterFn is the "Most flexible - but very slow" filter. Other functions on api.engine are forEachEntityWithComponent, forEachEntity, entityExists and getRevision, which "Keeps track of modifications to entities".
Beyond plain components, api.engine.system has one object per engine system with query functions, for example api.engine.system.transportVehicleSystem.getLineVehicles(line) (used in mission 01) or api.engine.system.gameScriptSystem.getEntityForGameScript(name). They are listed in system. Further helpers are in api.engine.util (util), api.engine.terrain and api.engine.config.
Resources¶
Static data loaded from content files (models, constructions, tracks, cargo types, generic resources) is in the repositories of api.res. The comment on ResTypeRep says "Resources are generally static (initialized when the game starts and don't change)" and "Resource ids are only valid within the same repository".
local gameTimeConfig = api.res.genericRep.get(api.res.genericRep.find("::/game_mechanics/game_time/game_time_config.res")).data as GameTimeConfig
find turns a resource name into an id (-1 if it does not exist), get returns the resource. The GUI has a separate repository for GUI resources (.gres.lua), api.gui.genericRep.
Resources are static once a game runs, but a mod can change them at load time, in its postRunFn (see Load-time code). The wiki's Mod Parameters and Scripts page shows three ways, all on ResTypeRep:
| Function | Effect |
|---|---|
setVisible(id, false) |
hides a resource, e.g. a vehicle from the vehicle store |
getAsTable(id) and setAsTable(id, data) |
copies a resource into a Lua table and writes the edited table back |
addAsTable(name, data) |
adds a new resource under a new name, for example a copy of a vehicle with other costs |
Mod structure has a full postRunFn example with these functions. The wiki notes that scripts cannot add new 3D data; a new model has to reference existing mesh files.
Changing the game with commands¶
Every change to the engine state goes through a command object. api.cmd has a factory per command, make<Name>Cmd, and sendCommand to run it:
sendCommand : function<T is ICommandData>(cmd : Command<T>,
callback? : function(data : T, success : boolean, resultEntities : {{Engine.Entity, Engine.Revision}}),
progress? : CommandProgress)
The callback gets the executed command's data, which "depending on the context ... might contain additional information", a success flag and the list of result entities. Each command has a data record whose fields are described in cmd; fields marked "result only" are filled in after execution.
The base game's custom_entity_util.spawn chains two commands this way. From base/content/game_mechanics/game_mechanics/fun_elements/custom_entity_util.tl:
custom_entity_util.spawn = function(model : string, transf : Mat4f, callback:function(Engine.Entity))
local modelId = api.res.modelRep.find(model) as integer
if modelId < 0 then
log.error("Model not found:", "model = " .. model)
return
end
local cmd = api.cmd.makeCustomEntityCreateCmd(modelId)
local onCommandCallback = function(res : CustomEntityCreateCommandData, success : boolean)
if success == false then
log.error("Model not spawned")
return
end
local spawnedEntity = res.resultEntity as Engine.Entity
api.cmd.sendCommand(api.cmd.makeCustomEntityUpdateTransformationCmd(spawnedEntity, transf), function()
callback(spawnedEntity)
end)
end
api.cmd.sendCommand(cmd, onCommandCallback as function(res : ICommandData, success : boolean))
end
CustomEntityCreateCommandData.resultEntity is documented as "Created entity id (result only)", so the new entity is only known inside the callback.
api.cmd has about sixty factories. Grouped by what they touch:
| Area | Examples |
|---|---|
| Game and time | makeGameSetSpeedCmd, makeGameSetCalendarSpeedCmd, makeGameSetDateCmd, makeGameSetTimeOfDayCmd, makeGameSetCloudCoverageCmd |
| Money | makeJournalBookAssetCmd, makeJournalLogEntryCmd, makeJournalClearAllCmd |
| Vehicles and lines | makeVehicleBuyCmd, makeVehicleSellCmd, makeVehicleSetLineCmd, makeLineCreateCmd, makeLineUpdateCmd |
| Towns and industries | makeTownCreateCmd, makeTownUpdateSizeCmd, makeIndustrySetManualDevelopmentCmd, makeCreateIndustryExtendProposalCmd |
| Building | makeWorldBuildProposalCmd, makeWorldSetBulldozableCmd, makeWorldReplaceTerrainCmd |
| Entities | makeEntitySetNameCmd, makeEntitySetColorCmd, makeCustomEntityCreateCmd, makeAnimalSpawnAtCmd |
| Scripts | makeScriptingSendEventCmd |
A nested Debug record holds a few more (makeMaintenanceCostUpdateCmd, makeSimPersonSetStateCmd, ...).
Game scripts¶
A game script is a script with its own saved state that runs every simulation step and receives events. The base game has 28 of them, for towns, the company, loans, notifications, weather and more. Each one consists of a .gs.lua file that names the functions, and a .script.tl (or .script.lua) that implements them. From base/content/game_mechanics/game_mechanics/game_time/game_time.gs.lua:
function data()
return {
updateScript = {
fileName = "game_time.script@update",
},
handleEventScript = {
fileName = "game_time.script@handleEvent",
},
}
end
The possible keys are those of GameScriptDesc: updateScript, postUpdateScript, handleEventScript, guiUpdateScript and guiHandleEventScript. Each entry can carry params, which the base game's mission script uses (params = { gameScriptMode = "Mission" } in base/content/mission/mission/mission.gs.lua) and which arrive as the userParams argument.
The script file returns a table that matches GameScript<T> or GameScriptWithGui<StateT, GuiStateT> from base/tealdef/scripts/gamescript.d.tl:
global record GameScriptWithGui<StateT, GuiStateT>
update : function(userParams : table, state : GameScriptState<StateT>, dt : number) : any
postUpdate : function(userParams : table, state : GameScriptState<StateT>, dt : number, updateResult : any)
handleEvent : function(userParams : table, state : GameScriptState<StateT>, src : string, id : string, name : string, param : any) : any
guiUpdate : function(userParams : table, state : GameScriptStateReadOnly<StateT>, guiState : GameScriptState<GuiStateT>)
guiHandleEvent : function(userParams : table, state : GameScriptStateReadOnly<StateT>, guiState : GameScriptState<GuiStateT>,
src : string, id : string, name : string, param : any) : any
end
Nothing registers a .gs file by hand. The wiki's resource types table lists .gs.lua as the file type for game scripts, so the game loads them like any other resource, and the base loader in base/content/base/base/mod.lua has a gameScript file filter category and a loadGameScript modifier key for them. The folder does not matter: the campaign mods keep theirs in content/init/init/init.gs.lua, mission 08 has content/mission/mission/rocket/rocket.gs.lua, and the deluxe DLC has content/fun_elements/fun_elements/balloon.gs.lua. Other code finds a game script by its resource name without the .lua, for example getEntityForGameScript("urbangames_campaign_mission_08::/mission/rocket/rocket.gs").
The params table of a script reference is passed as the first argument. The wiki states this for all script references ("commonly called captureParams"); game scripts receive it as userParams. The wiki's own game script page is still marked "Coming Soon", so the order in which several game scripts run, and when a mod's game script first starts in an existing savegame, are not documented.
State¶
The state object (GameScriptState<T>) has get, set and the event subscription functions. The weather script reads its state, fills it on first use and writes it back at the end of update. Shortened from base/content/game_mechanics/game_mechanics/game_time/game_time.script.tl:
local ret : GameScriptWithGui<GameTimeState, GameTimeGUIState> = {
update = function(_userParams : table, state : GameScriptState<GameTimeState>, dt : number) : nil
if not state:hasEventSubscriptions() then
state:subscribeToEvent("SetMode")
end
state:subscribeToEvent("SkipPhase")
local gameTimeState : GameTimeState = state:get()
gameTimeState = initStateIfEmpty(gameTimeState)
local gameTimeComponent : Engine.Component.GameTime
= api.engine.getComponent(api.engine.util.getWorld(), api.type.ComponentType.GAME_TIME)
-- ... compute the next time of day ...
api.cmd.sendCommand(api.cmd.makeGameSetTimeOfDayCmd(newTimeOfDaySec))
state:set(gameTimeState)
end,
-- handleEvent = ...
}
return ret
initStateIfEmpty also stores a version number in the state and upgrades older layouts, which is how the base game keeps savegames compatible.
The state is stored in the engine on an entity with a GAME_SCRIPT component (Engine.Component.GameScript, field state). Other scripts read it from there. From base/content/game_mechanics/game_mechanics/company/company_util.tl:
function company_util.externalGetCompaniesState() : CompaniesState
local entity = api.engine.system.gameScriptSystem.getEntityForGameScript("::/game_mechanics/company/company.gs")
local gameScript = api.engine.getComponent(entity, api.type.ComponentType.GAME_SCRIPT) as Engine.Component.GameScript<CompaniesState>
return gameScript.state
end
Events¶
handleEvent receives src, id, name and param. The base scripts distinguish three kinds of sender.
Events from the game itself have empty src and id. The names handled in the base scripts are initNewGame, initNewGameFromMap, initMission, initTutorial, handleLegacy and storeSavegameMetadata. From base/content/game_mechanics/game_mechanics/company/company_growth.script.tl:
handleEvent = function(_userParams : table, state : GameScriptState<CompaniesGrowthState>, src : string, id : string, name : string, param : any) : any
if src == "" and id == "" then
if name == "initNewGame" or name == "handleLegacy" then
state:subscribeToEvent("_debugAddExperience")
state:subscribeToEvent("applyLevel")
state:subscribeToEvent("storeSavegameMetadata")
state:subscribeToEvent("OnCalcTicketPrice")
end
if name == "initNewGameFromMap" or name == "initMission" then
state:set({ companyState = {} } )
end
-- ...
Events from engine systems carry the system's name as id, for example "TransportVehicleSystem", "SimPersonSystem" or "SimCargoSystem". Some of them use the return value. The same handler answers the ticket price event with a multiplier table:
elseif (id == "TransportVehicleSystem" or id == "SimEntityAtVehicleSystem") and name == "OnCalcTicketPrice" then
local scriptState : CompaniesGrowthState = state:get()
local companyData = scriptState.companyState[api.engine.util.getPlayer()]
if companyData then
local multiplier = companyData.ticketPriceMultiplier
if not multiplier or multiplier < 0 then
return
end
local multipliers : {integer : number} = {}
multipliers[0] = multiplier -- multipliers[0] is used for all
return multipliers
end
end
The event payload types that are defined, such as CalcTicketPriceEvent, StartedLineUsage and ArriveAtStop, are in gamescript. Other event names seen in the base scripts are OnStartedLineUsage, OnCompletedLineUsage and OnToArriveAtDestination. The files contain no complete list of engine events.
Events from other scripts and from the GUI are sent with api.cmd.makeScriptingSendEventCmd(src, id, name, param): "When executed, a script event will be broadcasted to all scripts." The "Skip to Next Time of Day" button in base/content/gui/gui/game_bar/game_bar_widgets.tl does this:
onClick = function()
api.cmd.sendCommand(api.cmd.makeScriptingSendEventCmd("", "GameTime", "SkipPhase", { }))
end,
and game_time.script.tl reacts with if id == "GameTime" and name == "SkipPhase" then .... The scripts subscribe with state:subscribeToEvent(name) (or subscribeToAllEvents) before they get such events; the base scripts do this in update or when initNewGame/handleLegacy arrives.
For events that only the GUI side should handle, api.gui.fireGuiScriptEvent(id, name, param) "is meant to be handled by game scripts (guiHandleEvent), not by react.onEvent()". It differs from react.fireEvent in that it allows to return a value "like an error string to prevent the operation".
Load-time code¶
Code that runs while the game loads, before any game script, is set up in mod.json (preRunScript, runScript, postRunScript). Each of the three functions runs for the base game first and then for each mod in activation order. Mod structure describes the hooks and their arguments, including mod parameters. The scripting side of each phase:
| Phase | Typical job | Tools |
|---|---|---|
preRunFn |
change the base configuration | the baseConfig argument |
runFn |
change resource files while they load, or hide them | addModifier, addFileFilter |
postRunFn |
change, add or hide loaded resources | api.res repositories, api.res.getBaseConfig() |
The wiki recommends postRunFn with api.res over runFn modifiers and filters whenever the job can be done there (Mod Parameters and Scripts).
Base config¶
BaseConfig holds the global simulation and gameplay settings: costs, town and industry placement, economy, animals, terrain tool limits, the construction script and more. The shipped game sets and reads it in three places:
preRunFnreceives it as the fourth argument and writes to it. The base game fills almost all of it there (base/content/base/base/mod.script.tl);urbangames_no_costsonly setsbaseConfig.noCosts = true.postRunFngets it fromapi.res.getBaseConfig()and can still change it. Mission 01 setsadvancedOptions.subventionMode = 0this way.- Game scripts read it with
api.res.getBaseConfig(), for exampleapi.res.getBaseConfig().advancedOptions.subventionRiskin the subsidy scripts underbase/content/game_mechanics/game_mechanics/subventions/.
The wiki's Base Config page is marked as not yet adapted for TF3, and several of its statements don't match the TF3 files:
| The wiki says | The TF3 files do |
|---|---|
initial values come from res/config/base_config.lua |
base/content/base/base/base_config.lua only creates an empty game.config; the values are set in the base game's preRunFn |
write game.config.<property> in runFn, api.res.getBaseConfig() in postRunFn |
no shipped script uses game.config; they write to the baseConfig argument of preRunFn |
the cost.* values and ConstructWithModules are not available through getBaseConfig() |
the record has costs (a map such as costs.terrainRaise) and constructionScript, which the base game sets to ::scripts/construction/construction.script@constructWithModules |
gui.*, audio.*, difficulty, earnAchievementsWithMods, advancedOptions.maximumLoanScale and others |
these fields are not in the TF3 BaseConfig record; its advancedOptions has subsidy, maintenance, landmark and reforestation settings instead |
The wiki table still helps with the fields that exist in both, such as locations, animal, economy.industryDevelopment and the terrain tool limits. For the TF3 field list use the BaseConfig reference.
Resource modifiers¶
A modifier is a function that gets each loaded file of one kind and returns its data, changed or not. Register it in runFn with addModifier(category, fn); fn receives fileName and data (the result of the file's data() function). From mods/release/urbangames_vehicles_no_end_year/content/mod.script.lua:
function data()
return {
runFn = function (captureParams, settings)
addModifier("loadModel", function (fileName, data)
if data.metadata.transportVehicle and data.metadata.availability then
data.metadata.availability.yearTo = 0
end
return data
end)
end
}
end
The parameter names in the definition differ from this use. api/tealdef/main.d.tl declares addModifier : function<T>(fileName : string, data : T) : T, which is the shape of the modifier function, not of addModifier itself. The wiki (Modifiers and Filters) and the base implementation in base/content/base/base/mod.lua (function addModifier(key, fun)) both take a category and a function. In Teal you may need a cast to get past the type check.
Modifiers for the same category run one after another in registration order, each on the result of the previous one (applyModifiers in mod.lua). Several mods can therefore change the same files without overwriting each other's copies.
The categories are the keys of the modifiers table in mod.lua. An unknown key fails, because addModifier inserts into a list that doesn't exist.
| Group | Categories |
|---|---|
| Models and vehicles | loadModel, loadMultipleUnit, loadTransformator |
| Constructions | loadConstruction, loadMetaConstruction, loadModule, loadConstructionCategory, loadConstructionMenu |
| Infrastructure | loadStreet, loadStreetTemplate, loadTrack, loadEdgeDecoration, loadBridge, loadTunnel, loadRailroadCrossing, loadTrafficLight |
| Terrain and environment | loadEnvironment, loadClimate, loadTerrainMaterial, loadTerrainGenerator, loadLayerGenerator, loadGrass, loadGroundTex, loadAutoGroundTex, loadNodeTree |
| Economy and cargo | loadEconomy, loadCargoClass, loadCargoType, loadCargoModelFormat |
| Scripts and data | loadScript, loadGameScript, loadGameRes, loadCampaign, loadMission, loadNameList, loadLanguage |
| Sound and style | loadSoundSet, loadSoundEffect, loadBuilderAudioSet, loadPlaylist, loadStyleSheet |
| Rendering | loadMaterialType, loadDescriptorSet, loadProgram, loadTechnique, loadProperty |
The wiki lists 22 of these, all of which exist. base/content/mission/mission/modifier.lua has helpers built on top (modifier.util.disable, modifier.util.availability(from, to), modifier.treevisitor(tree)), which register one modifier per category and apply functions from a tree keyed by path.
File filters¶
A file filter decides whether a file is loaded at all. addFileFilter(category, fn) is defined in base/content/base/base/mod.lua; fn(fileName, data) returns false to drop the file and true to keep it. Several filters can be registered for one category and all of them must return true. The wiki's example drops every single-engine rail vehicle whose engine is not steam:
addFileFilter("model/vehicle", function(fileName, data)
local rv = data.metadata.railVehicle
if rv and rv.engines and #rv.engines == 1 and rv.engines[1].type ~= "STEAM" then
return false
end
return true
end)
The categories in mod.lua are model/vehicle, model/person, model/car, model/rock, model/tree, model/signal, model/animal, model/other, and one per resource kind with the same names as the modifiers without load (construction, module, street, track, bridge, cargoType, gameScript, climate, ...). As with modifiers, an unknown category fails.
addFileFilter is not declared in api/tealdef/main.d.tl, so it has no entry in the reference and a Teal mod has to declare it itself. No shipped TF3 mod uses it; the official mods hide resources with setVisible in postRunFn instead.
The wiki also describes filefilterutil.lua with ready-made filters. In TF3 it is base/content/base/base/filefilterutil.lua, not res/scripts/. It has util.always, util.combineOr, util.combineAnd, package.base, package.mod, package.reallyMod and model.vehicle, model.person, model.car, model.rock, model.tree, model.signal, model.animal, model.other. package.base and package.mod still test for the Transport Fever 2 prefix ::/res/, which TF3 resource names don't have; package.reallyMod tests whether the name starts with ::.
Useful globals¶
From api/tealdef/main.d.tl (main):
| Global | Use |
|---|---|
ug_require(path) |
load a game script module |
_(id), pGetText, nGetText, npGetText |
translated strings |
debugPrint(...) |
"Prints text to the console/log file" |
resolve(relativePath) |
path relative to the current file |
getCurrentModId() |
id of "the outermost mod in the require hierarchy" |
getOwningModId() |
id of "the mod owning the current file"; "you can not delegate calls to this function into helper files!" |
addModifier(category, fn) |
register a load-time modifier (see Resource modifiers) |
addFileFilter(category, fn) |
register a load-time file filter; defined in base/content/base/base/mod.lua, not in main.d.tl (see File filters) |
The base scripts log with the global log table (log.verbose, log.message, log.warning, log.error).
The wiki's API Reference page suggests trying commands in the in-game console first and printing tables with debugPrint(<someTable>).
Official wiki: Mod Parameters and Scripts, Modifiers and Filters, Base Config, API Reference, Resource types, Missions.