TF3: models draw only inside subconstructions TF3
A top-level result.models draws nothing, even beside edgeLists. You get no model, or the pink placeholder cube with "Collision".
A model-only construction becomes a decoration TF2TF3
A construction holding only a model, with no ground paint, roads or snap patch, is flattened into an ASSET_GROUP (a decoration), not a CONSTRUCTION. It can't be clicked, and its .con is never read again. Only position and rotation are kept, so scale is lost. Adding groundFaces or a snapPoint ground patch, or owning roads, keeps it a construction. This is a trap for toll booths, and the key to pasting decorations (see below).
It's also a trap for scripts that place such a .con and later look for it again, like PYT's speed boards. A CONSTRUCTION search never finds it, and any custom params are gone. Search ASSET_GROUP instead, match the model name from MODEL_INSTANCE_LIST fatInstances (api.res.modelRep.getName), and remove it with a full api.type.Proposal (toRemove), not SimpleProposal.constructionsToRemove. Every rebuild we did the other way stacked another copy.
The held tool caches its preview TF2
The game runs a construction's updateFn when the tool is picked up and caches the result. It never re-reads your data file. Click another asset, then this one, to refresh. No API opens or pokes the placement tool from script. Construction icon textures load only at game launch (160×100, 32-bit uncompressed TGA).
edgeLists in a .con: bridges need their type TF2
edgeLists render native ghosts with native collision and snapping, and snapNodes are 0-based row indices. Bridge or tunnel geometry needs edgeType = "BRIDGE"/"TUNNEL" plus edgeTypeName (for example cement.lua), as cloverleaf/t_interchange do. Without it, elevated track counts as a ground crossing and the ghost collides with itself.
TF3: a construction with its own roads (edgeLists that belong to it) TF3
Learned from the TF3 mod Tollgate (by GreatRM), whose script explains it well. A road piece in edgeLists counts as the construction's own (one of its frozenEdges) only if it touches a fixed node, one that is in neither snapNodes nor freeNodes. A single piece whose two ends are both snap nodes gives 0 frozen edges; split it in the middle and both halves are frozen. If the pieces aren't the construction's, road and structure get bulldozed separately. Where a piece meets a narrower ordinary road, the game wipes that piece's lane markings to blend the widths, so build it as approach / main / approach and let the short approach pieces take that. Two lists (for example an approach road type and a toll-lane type) join at nodes on the same spot. A speed limit inside the construction is simply the lane speed in that road type. To let the player drop it onto an existing road like a depot, give it a snapPoint a few metres inside its front end with snapToBaseEdgeTypes = { "STREET" } and allowSnapToBaseEdgeEnds = true.
TF3: a construction's own road types — keep them out of the menu and away from towns TF3
A construction's own road pieces need their own street templates. The easy way is to copy a base one, so the pieces match the player's road with no seam: the 1900 dirt roads are country_old_small / country_old_medium (street style country_old.street). A whole copy, though, keeps two fields meant for player roads. menuCategory puts every copy in the road build menu, and simBuildable = true lets towns build it as their own streets, including your slow toll pieces. Delete menuCategory and set simBuildable = false. With availability = { yearFrom = 0, yearTo = 0 } the copy also never expires, where the base 1900 roads end in 1940. Change an option on a placed one (a speed step, say) from its window as Tollgate does: createProposalReplaceConstruction(entity, params), then api.cmd.makeWorldBuildProposalCmd(proposal, nil, false, true). It rebuilds in place and is safe from the GUI thread. Construction options arrive in the script 1-based, and defaultIndex counts from 1.
TF3: give a construction a job — real staff with personCapacity TF3
Also from Tollgate (by GreatRM). Its author notes that every clickable building in the base game has a function (depot, station, industry, workplace), and a construction without one wasn't treated as a building. Their toll gate is a workplace, like the company headquarters: the construction's result carries personCapacity = { type = "INDUSTRIAL", capacity = N } plus a footpath in laneLists so workers can walk in. Residents then take jobs there and commute to it, and the window shows "Staff 4/4". To read it, find the subconstruction with a PERSON_CAPACITY component in construction.subconstructions.
Only STREET_CONSTRUCTION joins the road graph TF2
An ASSET_DEFAULT with snapping = { road = true } only snaps the placement ghost; it has no road-graph membership. skipCollision is fixed per file, so a dropdown can't toggle it; ship twin .con files instead.
TF3: snapping to roads TF3
The con-level snapping = { road = true } did nothing. result.snapPoint = { transf, snapToBaseEdgeTypes = {"STREET"}, allowSnapToBaseEdgeEnds = true } works, but snaps to the road's edge, so offset by half the road width.
TF3: edge objects snap only as stops or signals TF3
edgeObject = { snapToStreet = true } with result.edgeModels snaps only if the object is a stop or a signal (EdgeObjectType has only STOP_LEFT / STOP_RIGHT / SIGNAL). The result is an EDGE_OBJECT entity that the game treats as a bus stop. The placement preview ignores an edgeModels translation.
TF3: roads inside constructions TF3
Use your own street templates in result.edgeLists: base highway templates came out as dirt roads. Roads join only at an identical end point. An edge entry can carry a third value, a tag ({pos, tangent, tag}): ends with the same tag become ONE node even at different positions, which fixed a pinched lane split. At a construction-built junction, the first piece added is wired as the through road.
TF3: script lanes need a station or depot TF3
laneLists in a subconstruction, at the top level, or in model metadata were dropped (no lanes in route view), even with a dummy station.
TF3: collision can depend on rotation TF3
Construction junctions collided at some angles only. Sweep angles with dry runs (makeProposalData per angle step and params variant) to find them, and space pieces off the junction point.
TF3 .con basics that bite TF3
- A
.con's menu params reach updateFn as 1-based indexes of their values -- the 1st choice arrives as 1 (seen 2026-10-17: the Mega Warehouse's sizes 250 / 500 / 1,000 / 2,000 read 0-based built one size too big, and 2,000 fell off the end). A DYNAMIC template's createTemplateFn gets them 1-based too. Log the raw value once before relying on it.
- Model paths are case-sensitive; a wrong case gives the pink cube. Renaming a model breaks old saves ("N models are missing").
- Top-level
result.cost is multiplied by costs.infrastructureCostScale (era inflation: ×1 in 1900, ×6 later).
- A maintenanceCost of 0 or missing becomes cost/6 a year; set 1 to make it negligible.
- No construction script sees the game year, so era variants are separate entries with yearFrom/yearTo. yearTo 1 hides an entry but keeps it for old saves.
TF3: build-menu tabs are data TF3
gui/construction/menu/menu_categories/category_<x>.res.lua returns { type = "menu_category", data = { category, order, name, icon, menu = "ROADS" } }. The icon is 48×48 white-on-black, @2x. Put your entries in that category and they get their own tab. The icon must be an 8-bit greyscale TGA (type 3): white is the glyph, black is see-through, and the game tints it like its own tabs. A full-colour RGBA icon shows its background as a dark square. menu = "LANDSCAPING" works the same way, and a tile there can open a window: fire a react event when its action mounts.
TF3: a template whose preview fails crashes the build menu TF3
For a constructionTemplates (DYNAMIC) entry, the build menu builds a preview result while you hover it (api.engine.util.construction.getConstructionResult). If your script fails, the game's own code crashes the UI: bad argument #1 to 'pairs' (table expected, got nil) in table_util.tl(107): shallowCopy, from construction.tl(3674): getNameAndDescription. Follow the base modular pieces (headquarter.script.tl): put everything (models, groundFaces, terrainAlignmentLists, colliders) in result.subconstructions[1], with only slots at the top, and give the subconstruction a metadata table, because the menu reads subconstruction.metadata.
TF3: a UI script can open a web page in the player's browser TF3
app.getUserProfile():openUrl("https://example.com/page?x=1") opens the page in the player's own browser. The game's mod-upload window uses it for its mod.io terms link. Seen working from a mod's button (2026-10-08). Gone since build 40420 (2026-10-09): UserProfile has no openUrl any more (not in api/tealdef, not in the game's exe); calling it errors with 'attempt to call method openUrl (a nil value)'. Point players to the in-game mod browser instead (search the mod's name). The profile still has subscribeMod(id). Put whatever the page needs in the address (?speed=313&noise=quiet) and let the page read location.search. Players can't copy text out of the game, so this is how a mod hands data to a website. Wrap the call in pcall.
TF3: a construction's own extra values are hidden from c.params in UI scripts TF3
A construction can carry values its .con doesn't declare (a script sets them when it rebuilds it with createProposalReplaceConstruction), and the game keeps them. An engine script reads them as con.params.yourKey. In a UI script, though, getComponent(e, CONSTRUCTION).params only shows the declared options, so those keys read as nil. Read them with c.params_native:asTable(). A rebuild started from the UI must copy its params from params_native:asTable() too; copying c.params silently drops the undeclared values.
TF3: a hand-written .mdl needs version = 2 TF3
A model file written by hand (not exported) with version = 1 stopped the game at start-up, before the menu: Assertion `legacyLocation || version >= 2' failed, naming the .mdl. Only models in the old (legacy) folders may be version 1. End every model you write with version = 2, as the game's own and the Model Editor's exports do. The model doesn't even have to be used: every .mdl in a mod is read when the game loads.
TF3: a module's addModel doesn't work TF3
A .module.lua's updateScript gets (captureParams, result, transform, tag, slotId, addModel, params), but the base game names that addModel argument _addModelFnInvalid and never calls it. A module inserts its models into result.subconstructions[1].models itself, with its tag, usually at the slot's transform from result.slots.
TF3: a modular construction's base needs a model of its own TF3
The module bulldozer (key B) refuses to remove the last model of a construction: "Empty Construction". If the base only has ground paint and one module holds the only model, that module can never be swapped. Give the base a small permanent model (for example the base game's ::/assets/streets/cable_junction.mdl). In the module builder, swapping is remove (B), then pick the new module and click the slot.
TF3: a live build ghost with ProposalViewer, and loading your own scripts TF3
Inside a tool's action, builtin.ProposalViewer { simpleProposal = sp } draws a whole planned build as the game's own see-through preview: roads, track and stations. Rebuild sp when the cursor moves a few metres. The game colours it red when the proposal has errors. Several stations in one proposal collide with each other, so check with makeProposalData(sp).errorState / collisionInfo.
Fatal: every viewer must be a direct builtin child of the tool's ActionDescriptor. Wrapping each ProposalViewer in a recipe of your own crashed the game with "Assertion it != storage.frontend.identity2node.end()" (BuiltinActionDescriptor CollectActionConfig). And there can be only one ProposalViewer per tool: a second one is fatal, with "Must not specify more than 1 ProposalViewer". To stop a ghost going red from colliding with itself, trim it instead. makeProposalData(sp).collisionInfo.collisionEntities is a list of records whose .entity is the id. A negative id is one of the proposal's own new pieces, so drop those pieces from the ghost and check again.
Fatal: do not put builtin.ActionTooltip { recipe = construction_react_util.SimpleTooltipRecipe, param = ... } among an ActionDescriptor's children to get a message at the cursor. The game died with "Assertion (recipeId) < recipeNames.size()", actual 1634886000: those are the bytes "para", the start of param, read as a recipe id. The base game only hands an ActionTooltipParam back from a selector-based action's hover function. Show your messages somewhere safe instead, such as a text beside your button in the mod button area.
Read the cursor from the Selector's mouse Moved events. A step timer mostly saw no terrain position, so the outline it drew kept vanishing.
Loading code from your own mod in a UI script:
ug_require "/bpx/bpx_select_core.tl" found a plain module.
ug_require "/bpx/bpx_dev.script.tl" was looked up in the base game (::/bpx/..., "not found").
- A
.script file loads with the full mod path: ug_require "yourmod_1::/bpx/bpx_dev.script.tl".
TF3: change vanilla data live with a postRunScript TF3
Learned by reading SteveB's "Country Roads 100 km/h" (mod.io; method only). In mod.json, set "postRunScript": { "fileName": "yourmod_1::/mod.script@postRunFn" }. That function runs once every resource has loaded. To change a road type:
- Get it with
api.res.streetTemplateRep.find(name).
- Read it as a table with
getAsTable(id).
- Change it (here every
laneConfigs[i].speed, in metres per second: 100 / 3.6).
- Write it back with
setAsTable(id, t).
The game's own roads change, including ones already built, with no new road types. Read the template back to check, since some builds don't return the advertised success value.
TF3: timers run on the simulation threads; userdata has no sub-folders TF3
The function you give engine_react_util.useStepStateTimer(fn, seconds) runs on the simulation threads ("Main Pool N" in the log), not the UI thread. There, app.loadUserdata fails with "API is currently restricted". Keep the timer's function to reading state and maths, return a value, and do file work only from a mouse or keyboard event (UI thread). We saw a tool's render run on the simulation threads too, so the render isn't safe either. For anything rebuilt while the mouse moves, load the data once and keep it in memory.
The same goes for api.gui.fireReactEvent: called from those threads it does nothing, and it gives no error inside a pcall. A message we sent from a tool's render, after a check there, never arrived. Sent from the tool's Selector.onProcessMouseEvent on a "Moved" event instead (the UI thread), it arrives.
Declare before you assign. In a UI script, a function that sets a variable whose local line comes later in the file is setting an undeclared global, and TF3 throws. Ours did it after sending a build command, so the build happened and everything after it in the same mouse handler silently stopped. Move the local above every function that uses it.
The log you are looking for. TF3 writes its whole running log to <Steam userdata>/<id>/3493540/local/crash_dump/stdout.txt: every debugPrint, every caught Lua error with its file and line, "Failed to load userdata", and at a native crash the MinidumpCallback line. The last of your own lines before that line names the step that crashed. Read it before building trace files or asking testers for screenshots.
Fatal: stacking an ImageView and text with builtin.AbsoluteLayout { AbsoluteLayoutChild {...}, ... } inside a window crashed TF3 natively the moment the window opened ("Internal error", no Lua message, so a pcall fallback never runs). Our text box was a BoxLayout given a meta.styleSheet, a field BoxLayoutParam doesn't declare. That is the likeliest cause, but unconfirmed: try overlapping layouts on a spare save, one change at a time.
app.saveUserdata(folder, name, data) refuses a slash in the folder: "Forbidden file name (blueprint_exchange/saved) detected, another valid version could be 'saved'". Use side-by-side folders such as mymod_saved.
Cursors are a fixed engine list. Set mouseCursor on the tool's ActionDescriptor to one of gui_react_util.getMouseCursorInteger's numbers, for example Default 0, Camera 1004, Bulldoze 1008, Grab 1010, Cut 1014, PlaceAsset 1023. The images are gui/cursors/*.tga. Replacing one changes it everywhere in the game, so a mod can't have its own cursor. A build-menu tile can open a window: on mount, fire a react event that an always-mounted plugin (a mod button area) handles.
The game's own rotate keys for building are input actions. From settings_keys_v3.lua: constructOpt2 = N / D-pad left and constructOpt1 = M / D-pad right. Hook them with react.useInputAction("constructOpt1", react.iaHandler(fn)) in your tool's top-level recipe, where the base tools do it, and wrap each in pcall. That gives PC keys and console D-pad in one go. The base game's own step is 10 degrees per press, 1 while holding Shift (IA_PRECISION_MODE), from construction_react_util's rotation stepValueFn. It applies the value negated, so M turns clockwise. IA_APPLY, the click, never reached our tool, so take clicks from the Selector's mouse events.
TF3: removing road / track by script -- use the engine's helper TF3
SimpleProposal.constructionsToRemove removes stations and other constructions fine. Road and track pieces are different: a SimpleProposal with streetProposal.edgesToRemove, with or without nodesToRemove and edgeObjectsToRemove, made makeWorldBuildProposalCmd throw "Unknown exception", even with nothing else in it. The engine has a helper for it: api.engine.util.proposal.makeSegmentsRemoveProposal({ pieces }) returns a full Proposal to pass to makeWorldBuildProposalCmd.
In the same table:
createProposalReplaceConstruction(entity, params, newResName) rebuilds a construction with new parameters.
refreshConstruction(entity) refreshes a construction.
- A segment refresh that takes a new street template rebuilds a road or track piece with a different template.
Never remove a construction's own pieces (its frozenEdges) by hand.
TF3: a custom tool's mouse, and a fatal zero-length line TF3
A construction_tool (a .res.lua of that type whose getDefinition returns action = "ACTION_CUSTOM" plus customAction = { recipe }, as in the base game's Marketing Campaign tool) can put a builtin.Selector on the map. Its onProcessMouseEvent(evt) receives events: Entered 3, Moved 5, PressedOutside 8, Pressed 0, but after a Pressed we saw no Released. Three more findings:
- The tealdef's
record Mouse exists at runtime as api.gui.mouse (lower case). api.gui.Mouse is nil.
- Shift is
api.gui.inputAction.modifierOnlyActionIsActive("IA_PRECISION_MODE").
- An
EdgeGeometry of type CUBIC_SPLINE drew a straight chord: its tangents were ignored. To follow a curved piece, sample its Hermite curve yourself and draw short STRAIGHT steps. Fatal: handing builtin.EdgeRenderable a straight edge of zero length ends the game with "Assertion len > .0f failed". Never draw a line until it has length.
Fatal: putting that selection layer (an ActionDescriptor with a Selector) inside an ordinary UI strip, such as a mod button area plugin, ends the game with "Assertion !IsTransformWithContext(node.recipeId)". It only runs as a build-menu tool's action. highlightedEntities on the descriptor lit nothing for us, so we draw the selection with EdgeRenderable. To send the player to your tool from your own button, fire react.fireEvent(nil, "constructionMenuSelectTabForConstruction", { resName = ... }). The build menu opens on the tab holding it, even from closed; it's how the entity windows' Configure button opens the menu. No event switches a tool on: the player still clicks it. Fatal too: the tool's action function (params.setActionFn(fn)) must return the ActionDescriptor itself. Wrapping it in a recipe of your own gives "Assertion descriptorNodeId.has_value()". Share code as plain functions called inside the action function; hooks still work there.
TF3: a looping model animation needs no script ("forever") TF3
The town buildings' rotating shop logos (for example assets/buildings/com.zip, com/logos_b/b_cof_rot_l.mdl) give the moving node animations = { forever = { type = "FILE_REF", params = { id = "ani/forever/<name>.ani" } } }. The id is relative to the model's folder. The .ani is { times = { 0, 100, ... } (ms), transfs = { 16-float matrices } }, and the coffee logo uses 41 keys of 9 degrees for one turn every 4 s. The key transform applies on top of the node's own transf, so the pivot is the node's origin. It loops with no script or metadata, inside constructions too. To make part of a model move, split it into its own node with its origin on the pivot. Splitting by material alone isn't enough. The same trick switches lights on and off: scale a node to near zero and back, with each key time repeated so the switch is instant. Give three nodes the same 1 s loop with offset switch times, and a lit chevron sign's arrows chase in sequence. This works on a static construction model, as seen in game.
Seen in our own model too (a searchlight sweeping 35 degrees each way over 11 s, 45 keys): the moving nodes can have an identity node transf with the pivot at the model origin. Several nodes (one per material) can share the same .ani. A metadata spot light doesn't need to move with it. On another point: an EMISSIVE material ignores its texture's alpha (its COLOR pass is color.prog, not color_alpha.prog), so a glowing "light beam" made of planes draws as solid panels. Use PHYS_TRANSPARENT for anything see-through.
TF3: a construction that owns roads can't take modules TF3
A road-owning construction (a toll plaza that builds its booth lanes through result.edgeLists) with a module slot shows its modules in Configure, but placing one is refused: "Construction Not Possible", with the construction's own lane pieces drawn red. This happened joined to roads or not, and with collider-free models. The module builder won't rebuild a construction's own road pieces. The fix that works: two constructions. The road one owns the roads, and a road-free one placed over it carries the slots and swaps freely.
The road-free layer can find exact spots (lamps, barriers) without copying them: in the same .script.lua, keep the returned table in a local (local T; T = { ... }; return T). The layer's updateFn calls T.updateFnRoad(captureParams, {its size params}) and reads the models it would place, then puts a slot at each one. An empty slot can still draw the default model, so the layer looks finished before anyone configures it. One module can fit several kinds of spot if the base stores per-slot data in an extra result field (keyed by slotId) that the module reads.
TF3: react-replacement-config in practice -- exported recipes only, and a local one by its id TF3
The resource { type = "react-replacement-config", data = { filePath = "<mod>::/x/y.script", doReplaceFn = "doReplaceFn" } } makes the game call doReplaceFn(replacementApi); replacementApi.ReplaceRecipe(recipeFn, replacement) swaps an EXPORTED recipe of the base GUI for your function, which can call the original (_react.originalRecipeFn[id]) and wrap its result -- a bar under it, a button beside it. No vanilla file is overridden. A recipe a base file keeps local cannot be named: 'Save Your Train' (DysonHeluck) finds the Buy Vehicles cart by registration order (ids are handed out in order: the cart is two before the exported VehicleStoreWindow, found with a marker replacement), checks its parameters on the first call and disables itself if they don't match -- clever, but a game update that reorders the file silently turns it off. The line manager's action row (A*, colour, Buy Vehicles) is such a local recipe. 2026-10-17, read from Save Your Train 1.0
TF3: GUI save data -- savegame storage from the GUI side TF3
api.gui.game.getGuiSaveData(modId) / api.gui.game.setGuiSaveData(modId, table) keep a table in the savegame from the GUI thread, no game script or events needed (wrap both in pcall). Store vehicles by model FILE name (api.res.modelRep.getName(modelId), back with api.res.modelRep.find(name)) so a saved consist survives sessions and fails cleanly when a mod is removed; vehicle_util.makePart(modelId, forward, colour) (gui/line_vehicle_mgmt) rebuilds a part, its load settings as compartment2loadConfig of api.type.LoadConfig with loadConfigIndex. 2026-10-17, read from Save Your Train 1.0
TF3: place a paired sign relative to its partner, not by left / right arithmetic TF3
Harbour City's proven rule for a board's readers -- they drive against its face, so their right is (-face.y, face.x) -- gave a Town Sign pair that came out on the wrong side in game (SEEN 2026-10-17), and two rounds of formula flipping cost an hour. What settles it for a PAIR (Welcome / See you soon): keep the one the player placed as the reference and never move it; put the partner on the OPPOSITE side of the road from it (its offset across the road's line, sign reversed) and face it the OPPOSITE way (its face along the road, reversed). No left / right convention involved. 2026-10-17, PYT - Town Signs (TS5d)
TF3: a terminal's stop node needs a station edge AFTER it -- or the game asserts and dies TF3
A custom station terminal with vehicleEdges set to the lane's last edge only, and vehicleNode on the edge before it, loaded fine and showed in every window -- and the first time a vehicle computed a path into that bay the game crashed: transport_vehicle_util_path_common.cpp: AppendUntilEndOfStation, Assertion (s)>=(1) failed, 0 < 1, a fatal error with a crash save. The path builder walks from the stop node to the end of the terminal's edges and wants at least one. Rule: the stop node must lie on one of the terminal's vehicleEdges with at least one of them still ahead -- the safe shape is two station edges with the node between them (the base game's bays). To make a queue lane (vehicles waiting behind a one-vehicle bay), split the lane into approach + bay + bay-end: only the two bay edges are station edges, the node between them. 2026-10-17, PYT Fleet Control (H21 -> H27)
TF3: the warehouse window's cargo pin needs a stock tag and a param read-back TF3
A custom warehouse construction (warehouse = {} + a STORAGE_STOCK) shows the vanilla stock-type buttons, but the choice never sticks. The window (entity_window_util.tl) pins a type by writing constructionParams.tagToCargoType[stock.tag] = cargoResName for every stock whose tag >= 0, then createProposalReplaceConstruction + makeWorldBuildProposalCmd. So the stock needs a tag (an integer) and the script must read it back: cargoType = params.tagToCargoType and params.tagToCargoType[tag] or nil, as the base warehouse's addStock does (warehouses/warehouse.script.lua). A universal (mixedTypes = true) stock is not pinnable. A building placed before the tag existed never gets the pin -- it has to be placed again. 2026-10-17, PYT Fleet Control (S6)
TF3: a painted-on detail that casts no shadow TF3
A badge, letter or emblem modelled as a thin plate in front of a sign has to keep the flicker gap (20 mm here), and in sunlight it then throws its own shadow on the board: it reads as a stuck-on object, not paint. Give its material params.light_receiver.disableShadowMapWrite = true (the base game does this on some street materials) and it casts no shadow while still being lit. Use a COPY of the material for the badge only; the board keeps its shadows. 2026-10-17, PYT Distance Signs (DS19)
TF3: real road distance, and 'what is down THIS road' TF3
The pathfinder answers both. api.engine.util.pathfinding.findPathNodeToNode(fromNodes, toNodes, { api.type.enum.TransportMode.CAR }) returns the route as { {EdgeId, dir} }; sum getComponent(edgeId.entity, TRANSPORT_NETWORK).edges[edgeId.index + 1].geometry.length for metres (nodes from api.engine.util.octree.findTransportNetworkNodesInCircle). About 20 ms per sign for 5 towns. For direction: findPath(starts, toNodes, modes, maxLength) with starts api.type.EdgeIdDirAndLength.new(api.type.EdgeId.new(roadEdgeEntity, i), true, 0) on the car lanes of the road piece under you (the BASE_EDGE entity's own TRANSPORT_NETWORK; a lane is one-way, start to end of its geometry). Route once from the lanes running your way and once from those running back: a town behind comes out LONGER from your lanes, but only by a short loop (cars turn round at a piece's end, turnaroundEdges), so compare the sign of the difference, not a big margin. Searching for nodes 60 m ahead of you instead fails on long country pieces (their nodes are only at the piece ends). Seen: a dead-end road's board read just its own town. Construction scripts and mesh-less models can't do any of this: the fill runs in a GUI-thread game script, and labels draw only on a model with a surface. 2026-10-17, PYT Distance Signs (DS4-DS11)
TF3: a class store takes one cargo at a time -- the first that arrives, unless pinned TF3
Every non-universal warehouse store is mixedTypes = false with cargoType = params.tagToCargoType[tag] (base warehouses/warehouse.script.lua addStock). Unpinned (cargoType = nil) it holds whichever cargo of its class arrives first and stays that cargo until it is empty again -- one unit of the wrong cargo blocks it. The window pins one exact cargo: clicking a cargo icon on the store (entity_window_util.tl) writes tagToCargoType[tag] = cargo res name and rebuilds the construction (createProposalReplaceConstruction); clicking the pinned icon clears it. The icons offered are only cargos api.engine.util.stock.isCargoTypeCurrentlyProduced -- a cargo nobody makes on the map yet cannot be pinned. Adding or removing a module clears that slot's pin (preProcessFn). The window carries a commented-out direct route, api.cmd.makeStockListSetStocksCargoTypeCmd(stockListEntity, stockIds, cargoTypeId) -- untested, and with no param behind it, a rebuild would likely drop it. Read from the game's source 2026-10-17, PYT Fleet Control
TF3: resizing a warehouse store by rebuilding keeps its stock -- unless it shrinks below it TF3
A store's capacity comes from the construction script, so a player can resize stores by writing new params and rebuilding (createProposalReplaceConstruction(con, params) + makeWorldBuildProposalCmd, as the vanilla window pins a cargo). Seen in game: the rebuild keeps every store's amount and the same entity, and the new capacities show at once. A store rebuilt SMALLER than its contents is cut to the new size and the rest is destroyed (175 logs into 174 left 174). Block shrinking below the stock in your UI. The warehouse window takes cards through react-plugin ::WarehouseEowExtensionPoint. 2026-10-17, PYT Mega Warehouse (MW3)
TF3: what sets how fast cargo loads -- vehicle x cargo x terminal x stock TF3
The engine multiplies: the vehicle part's transportVehicle.loadSpeed (.mdl metadata: trucks 4-10, a universal truck about half its specialised twin, wagons 2-8 rising by era, ships 10-35) x the cargo's loadSpeedFactor (every freight cargo 0.0625, passengers 1) x the terminal's cargoTransferSpeeds[].loadSpeedModifier (a Bulk / Goods / Flatbed / Liquid platform or street cargo terminal = 2, universal = 1; modular_station.script.lua, modular_terminal.script.lua) x the stock's loadSpeedModifier (vanilla warehouse modules addStock(class, 500, 2.0), the universal pool 1.0). Penalties, 0.25x each (gui/main/cargo_react_util.tl): the part of a vehicle hanging past the terminal (each rail platform segment = 40 m of terminal, a street piece 10 m) and a terminal whose specialisation doesn't match the cargo. No speed comes from the distance inside the catchment, the number of terminals (only how many load at once) or the station buildings (they set the catchment radius only). A vehicle also stalls on 'Waiting For Cargo' when the stock is empty. So for a warehouse feeding trains: matching specialised platforms long enough for the whole train, and STORAGE_STOCK loadSpeedModifier = 2.0. Read from the game's files 2026-10-17, PYT Mega Warehouse
TF3: a station's reach is its catchment circles -- the warehouse has none TF3
Cargo coverage belongs to the STATION: its station.catchmentAreas, a list of { position, radius } circles (api.type.CatchmentArea), added from each module's catchmentCargo.radius by constructionutil.addCatchmentCircles (rail main building 150-200 m, street entrance 175 m). A warehouse or industry only declares its STORAGE_STOCKs -- no radius, no position per stock. A mod can widen a station's reach by appending circles to catchmentAreas in the station's terminateConstructionHook (the 'Warehouse Station Coverage' mod adds one over every occupied vanilla warehouse module). Which part of a big warehouse a circle must touch (its footprint or its origin) is decided in the engine -- that mod tests footprint contact; not yet seen for a 720 m camp. Read 2026-10-17, PYT Mega Warehouse
TF3: text on a model in the player's language -- labels, the game's translator and its own words TF3
Painted words can't follow the language, CUSTOM labels can: put the text in labelText from the construction's params and let a GUI-side script fill those params with api.util.getLanguage().code (en de fr it es nl pl ru ja ko pt_BR zh_CN zh_TW). Seen 2026-10-17: a German game turned the same sign into WILLKOMMEN IN / AUF WIEDERSEHEN. Three traps. (1) A cargo's api.res.cargoTypeRep.get(id).name read at LOAD (postRunFn) is the untranslated English key -- the game's window translates when it draws, so a German window said Wolle while our boards said Wool. Pass it through the translator _(), and catch _ in a local first: for id, _ in pairs(...) hides it inside the loop. (2) The game's own translations live in base/strings/<lang>/LC_MESSAGES/base.mo (gettext; Python's gettext.GNUTranslations reads it) -- copy the words you need into your strings.json and you print exactly the game's names in all its languages. (3) A label's transf is 16 numbers; 17 (one stray 0) gives no error, it shifts the translation into the wrong slot and the text vanishes or squashes into a line. string.upper only knows A-Z: upper-case accented and Cyrillic names yourself (UTF-8 C3 A0-BE minus 0x20, D0 B0-BF / D1 80-8F to D0 90-AF) and cut long names at a whole letter. PYT - Town Signs International (TSI1-2), PYT - Mega Warehouse (MW18)
TF3: specialised vs Universal warehouse stores -- loading speed and the delivery timer TF3
Read 2026-10-17 (base warehouses/warehouse.script.lua + transportfever.guide 'Warehouses and transfers'): a specialised store (Bulk / Flatbed / Goods / Liquid) is STORAGE_STOCK with mixedTypes = false, one cargo class, loadSpeedModifier = 2.0 -- handled at 2x and the cargo's delivery timer pauses while it is stored. The Universal store is mixedTypes = true, class UNIVERSAL, loadSpeedModifier = 1.0 (the vanilla module passes 2.0 to addStock, but all Universal modules merge into one stock that keeps 1.0) -- 1x, and the timer keeps running. There is no timer field in the stock: it follows mixedTypes. Every module is 500 units. A mod's own stores get the specialised behaviour by copying those three fields (PYT Mega Warehouse does: one locked store per cargo, 2.0). Storage never completes a delivery and towns / industries never draw from it -- an onward line is always needed.
TF3: coloured lines over the map, what a station reaches, industry productivity -- read from another mod TF3
Read 2026-10-17 in a mod.io mod ('Town & Industry Controller'), for the PYT map, Fleet Control and the Control Room: (1) Lines drawn in the 3D world, over the terrain: a GUI recipe returns builtin.ActionDescriptor { terrainCirclePolicy = "Never", children = { builtin.EdgeRenderable { edges = edges, ignoreDepth = true }, builtin.Selector { filter = fn, onSelect = fn }, builtin.LineViewer { showLines = lines, ... } } }; each edge builtin.type.EdgeRenderable.Edge.new(shape) (shape = an EdgeGeometry: positions, tangent, length, width) with edge.colors = { vec4, vec4 }, edge.width, edge.offsetZ = 4, edge.stepSize = 8. LineViewer highlights the player's own lines; Selector makes chosen entities clickable. (2) What a station reaches: api.engine.system.catchmentAreaSystem.getStationCatchables(station, false) (its catchment's buildings / industries / warehouses), and getStation2stationsAndDistancesMap() (stations within walking reach of each other). (3) An industry's productivity: api.cmd.makeStockListSetModifiersCmd(entity, modifiers) with modifiers = api.type.StockList.Modifiers.new(stockList.modifiers) and modifiers.productivity = x. (4) Where people travel: api.engine.system.townBuildingSystem.getPersonCapacity2townBuildingMap() plus each SIM_PERSON's destinations (1 = home) -- private cars are SimPersons, the player's fleet TransportVehicles. Also there: makeTownCreateCmd, makeTownDestroyCmd, makeTownSetDevelopmentActiveCmd. Not yet tried in a PYT mod.
TF3: fill a store for a screenshot (or a test) with the debug stock command TF3
api.cmd.sendCommand(api.cmd.debug.makeStockSetCargoAmountCmd(stockListEntity, stockId, amount)) sets one store's amount outright. stockListEntity is the entity with the STOCK_LIST component (the warehouse window's entityId); stockId is the 0-based id from cargo_util.calculateStockConfigs(...)[i].ids[1] (list.stocks[id + 1]). The store's own locked cargo fills; the window's Stocks card and the map labels show it at once. It sits under api.cmd.debug, not api.cmd.make -- keep it on a DEV button and take it out before release. Seen 2026-10-17: every store of a 5,000 Mega Warehouse to 5,000/5,000 from the GUI thread, no crash. PYT Mega Warehouse (MW16)
TF3: copy the game's require paths with '::/' in front TF3
The game's own GUI scripts write ug_require "/gui/main/cargo_util.tl". Copied into a mod, that bare /gui/... path resolves inside the MOD (yourmod_1::/gui/main/cargo_util.tl): "module not found", and the window that loads the script reloads the UI. Write ug_require "::/gui/main/cargo_util.tl" for a game file. 2026-10-17, PYT Mega Warehouse (MW3b)
TF3: a store locked to a cargo the climate lacks is fatal TF3Fatal
Placing a warehouse whose STORAGE_STOCK had cargoType set to a cargo this game does not have killed the game: Assertion `stk.m_cargoTypeRep->IsVisible(stock.cargoType)' failed (create_proposal_data.cpp). Cargos belong to climates through their categoryList (temperate.eco, dry.eco, tropic.eco, subarctic.eco): Rubber and Tires are tropical only; Books, Cement, Paper and Sawdust subarctic only; Clay is temperate and dry; Beverages, Bricks, Fish and Vegetables each miss one climate. A construction script has no api.res to ask. The base game solves the same problem for industries in its postRunFn (base/mod.script.tl): it keeps only cargos where api.res.cargoTypeRep.isVisible(id) and writes a new api.type.ScriptRef (same fileName, edited params) onto con.updateScript; the construction script then reads the list from its first updateFn argument. 2026-10-17, PYT Mega Warehouse (MW2)
TF3: a construction with no collider is free ground to the game -- an industry spawned on it TF3
A construction whose models all have collider NONE (they stand over lanes) and whose subconstructions carry no colliders occupies nothing as far as the game is concerned: the map's trees stood through its buildings, and later the industry spawner put a steel mill on top of it. Vegetation and spawning are both governed by the construction's colliders, not its ground faces. The base game's fields do it with one flat POINT_CLOUD over the whole extent (industries/fieldutil.lua): colliders = { { type = "POINT_CLOUD", transf = IDENT, params = { points = { the site's corners at z 0 and z 14 } } } } on a subconstruction that is actually emitted (we first put it on a helper sub whose models and lanes were merged into another sub -- the collider was left behind and the hub had none). The collider must not overlap the construction's own street stubs (those are refused "Construction Not Possible"). 2026-10-17, PYT Fleet Control (H20 / H25)
TF3: a module's model collider counts against its own construction TF3
A module whose model still had a BOX collider (from the Model Editor's default) was refused on a road-owning construction: "Construction Not Possible", with the construction's own lane pieces drawn red where the box overlapped them. Set the collider to NONE on models that stand over roads or inside other pieces, modules included. The Model Editor resets it to BOX on every re-save.
TF3: a red slot means the slot itself collides TF3
An empty module slot drawn red, with the module ghost refusing to snap anywhere, meant the slot's own volume collided with something nearby: the base's own model inside it, a street light. result.slotConfig = { <slotType> = { skipCollisionCheck = true } } fixed it (the finished construction is still collision-checked), and the slot turned blue and took the module. Slot fields from api/tealdef/api/type.d.tl: id, type, transf, spacing (left, right, south, north), height, shape (0 square, 1 triangle, 2 rectangle), alignToTerrain, autoFill, replaceable. Setting replaceable = true alone did not let a module be placed over an occupied slot in our test.
TF3: templates: 1-based params, and marks don't persist TF3
A DYNAMIC template's data.params are the menu's parameters. createTemplateFn(captureParams, params) gets them 1-based (the default 4th choice arrived as 4), plus params.year, and returns the starting modules { [slotId] = "path.module" }. A value you set on params inside createTemplateFn (a "this one is new" mark) did not reach the construction's later updateFn.
TF3: loading a save doesn't re-run construction scripts TF3
Changing a .con's script (for example making it modular) doesn't change constructions already in a save: they keep the result saved with them until something rebuilds them. Existing pieces only get the new behaviour (here, a module slot) after a rebuild, for example api.engine.util.proposal.refreshConstruction or an upgrade. New placements use the new script at once.
TF3: a react-plugin's condition hides the whole plugin TF3
A react-plugin resource can name a condition function. When it returns false, the plugin isn't built at all (no log line, nothing). Don't let the condition do double duty, such as also flagging something for a UI replacement: change one meaning and you silently lose the other.
TF3: Configure is the module builder TF3Inferred
The Configure button opens TF3's module builder (setModuleBuilderEntity). Its panel lists only params marked postConstructionModifiable, and it has no close button (Esc only). Modular constructions use constructionTemplates (type DYNAMIC), createTemplateScript, result.slots and .module.lua files. Untested beyond that.
Fix off-centre models in the .con, not in Blender TF2
A "skewed" gantry was a model authored with its origin at one edge. Fix it with per-model dx/dy offsets in the .con transform (and the terrain patch). Check the bounding box and named parts in the compiled .mdl before blaming the mesh. Helper transforms are tricky too: transf.rotZYXTransl(rot, transl) takes yaw from rot.x. Read res/scripts/transf.lua.
Stretching one model to fit any road TF3
A model scaled across the road in a .con transf (a Y scale) moves every part about its origin. Per-lane parts land on the lanes only if they are symmetric about the origin and the scale is measured from the road the model was drawn for, not from its bounding box (posts outside the pavement make the box wider than the traffic). Two one-way gantries with an off-centre lane layout missed the lanes on every road until their heads were centred.
Never point a .con at a model that isn't there yet TF2Fatal
A .con offering a .mdl that doesn't exist yet is a hard "material/model not found" crash. Wire a dropdown option only after its model is compiled.
Ship shared assets byte-identical TF2TF3
A save refers to constructions and models by file name. If two mods ship identical files, either mod loads the other's saves, and removing one keeps the placed objects. Any difference forks behaviour. Keep a checker script that compares the copies.
Upgrading a station in place: createProposalReplaceConstruction TF3
To change a placed station's modules (for example its platform tracks to a newer track type), take its params from params_native:asTable(), rename the modules, and build api.engine.util.proposal.createProposalReplaceConstruction(entity, params). Send it with api.cmd.makeWorldBuildProposalCmd(proposal, nil, false, true). The engine keeps the track joined to the station, and its lines. A SimpleProposal that removes the station and adds the new one (with old2new = { [entity] = 0 }, as the base game's mission helper does for buildings) was refused "Construction Not Possible" for a station with track joined to it, in the dry run and the real build alike. TF3's own track upgrade renames each track module to "::/trainstation_" .. resName:gsub("::", "__"), so ::/trainstation___/infrastructure/track/standard/standard.street_template. The signature is in api/tealdef/api/engine/util.d.tl, beside refreshConstruction.
A road bridge over a station's platforms is a collision in TF3 TF3
TF2 layouts with roads crossing over a modular station's platform ends on a bridge (and under them in a tunnel) paste in TF2, but in TF3 the road pieces are refused with a non-critical "Collision" against the station (an entity that isn't the construction, a piece or a node). Checking the pieces and the station separately against the map says it fits, so a preview built that way gives no warning. What worked: build the station first, check the pieces against the map before anything is built, and if the pieces are then refused with only a non-critical "Collision", send the same proposal once more with makeWorldBuildProposalCmd(proposal, nil, true, true) (errors ignored). The result looked right in game.
TF2 modular-station slots carry over to TF3 TF2TF3
Both games build a rail station's slot ids with the same GetId: type base (3400000 buildings, 6400000 cargo platforms, 7400000 passenger platforms, 8400000 track, 10400000 roofs) + 1000 i + 10 j for platforms and track, + 3000 i + 40 j + 10 k + o for buildings. So a TF2 station's modules map slot for slot. Three changes: module paths gain ::/stations/rail/modular_station/; a track slot holds a track type, not platform_track.module (::/trainstation___/infrastructure/track/standard/standard.street_template); and the building-size digit o (the slot id's last digit) differs. TF2 uses 0 / 1 for main buildings and 5 / 6 / 7 for side buildings of size 1 / 2 / 3; TF3 keeps 0 / 1 / 5 for size 2 / 3 / 1, main and side alike. The digit must match the TF3 building's own size (each module file says rail_building_sizeN): main 1 and 2 and side 2 are size 2, main 3 and side 3 are size 3, side 1 is size 1, and side_building_3_cargo is size 2. Building sizes differ between the games: a community TF2 station had side_building_3 in size-2 slots. Mapped across digit for digit, TF3 logged No main building data for slotId ... with offset 1 and crashed on Duplicate edges found. Moving it to a size-3 slot crashed too: it overlapped its neighbours and their footpaths doubled up. What works: keep TF2's size (its digit) and use the TF3 building of that size, so the layout keeps its spacing. A dry run did not catch either crash. Station params.length is one higher in TF3 (its length table starts one earlier).
TF3's era years (base content) TF3
Track: simple always, simple catenary from 1921, standard 1930, high speed 1980. Roads: _old_ to 1940, _new_ from 1940, and one-way roads are new-only (from 1940; town small and medium only, no large). Signals: signal_path_a to 1950, _c from 1950. Rail station modules: _era_a to 1950, _b to 1990, _c after. Bridges: stone always, steel and suspension 1940, concrete 1970, cable 2000. Road tunnel tunnel_a_car from 1900. Read them from the availability blocks in base/content/*/*.zip; anything placed from a script for another era should be swapped to the year's version first.