1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
|
#+TITLE: Forts Data & Modding Reference (extracted from the shipped game)
#+AUTHOR: Forts Clone Project
#+DATE: 2026-06-30
#+OPTIONS: toc:3 num:t
* Source
Extracted directly from a local Forts install (unpacked, NOT decompiled):
- Base data: =SteamLibrary/steamapps/common/Forts/data/= (~2650 .lua files)
- Workshop mods: =SteamLibrary/steamapps/workshop/content/410900/= (~80 mods)
The game ships its game-logic data as plain Lua. This file records the bits
that matter for our clone: the canonical physics/material/fire numbers, and the
modding architecture. Numbers here are the GROUND TRUTH that validate (and in a
few places correct) our research notes.
* Canonical physics constants (=data/db/constants.lua=, table =Physics=/=Structure=)
| Constant | Value | Meaning / our equivalent |
|-----------------------------------+-----------+--------------------------------------------------|
| Gravity | 981 | cm/s^2 (game works in cm; 1 grid = 37.5..150 cm) |
| Oversamples | 14 | constraint solver iterations (we use 8) |
| SpringDamping | 800 | global spring damping |
| MinStiffness / MaxStiffness | 1e4 / 1e6 | per-material Stiffness clamps |
| MinimumMass | 15 | |
| Limits.AngleStressPrimaryThreshold| 30 | strut snaps if rotated >30 deg from built angle |
| Limits.AngleStressSecondaryThreshold | 15 | secondary (softer) angle threshold |
| Limits.MinimumStrutDivergenceAngle| 15 | min angle between two struts at a node |
| StressWarning.CompressionThreshold| 0.2 | when the strut turns red (warning, not break) |
| StressWarning.ExpansionThreshold | 0.2 | when the strut turns blue (warning, not break) |
| TempBracing.Duration | 16 | temp rigid hold on fresh struts (our SETTLE_TIME)|
| TempBracing.Scale | 1.1 | |
| Break.Effect | structure_break.lua | strut snap FX |
** IMPORTANT correction to our research
Our research notes claimed "stress is axial only, there is no angular stress
term." The shipped data DISPROVES that: =AngleStressPrimaryThreshold = 30= and
=AngleStressSecondaryThreshold = 15= are real break conditions. So Forts has
BOTH an axial deformation break (MaxCompression/MaxExpansion, see below) AND an
angle-from-built-angle break (30/15 deg). We currently use a force-based axial
break only — adding an angle-stress break is a faithful future option.
** Stress colours (=Structure.Colours=) — matches the in-game strut colouring
Compressed = red, Expanded = blue, AtRest = white, Braced = yellow.
(Our build-grace radial is yellow = "Braced"; our snap is force-based.)
* Material schema (=data/materials/building_materials.lua=)
Materials inherit from base templates =Bracing= and =Armor= via
=InheritMaterial(base, {overrides})=. Key per-material fields:
** Structural / physics
- =Stiffness= spring stiffness (bracing 200000, armor 250000)
- =MaxCompression= / =MaxExpansion= axial break thresholds (bracing 0.90/1.10 = +/-10%!)
- =MinLength= / =MaxLength= / =MaxLinkLength= / =MaxSegmentLength=
- =Mass= (bracing 0.25, armor 0.50 -> 2x heavier, rope 0.001)
- =AirDrag=, =SpringDamping=, =Pretension=
- =AngleStressPrimaryThreshold= / =Secondary= (rope/fuse = 360 -> never break on angle)
- =Node= = StandardNode | CableNode
** Health / combat
- =HitPoints= (bracing 150, backbracing 100, armor 400, rope 50, shield 60, solar 200)
- =AbsorptionMomentumThreshold=, =ReflectionMomentumThreshold=, =PenetrationMomentumThreshold=
- =CollidesWithFriendlyProjectiles= / =CollidesWithEnemyProjectiles=
- =CollidesWithFriendlyBeams= / =CollidesWithEnemyBeams=
- =ReflectsBeams=, =BeamPenetrationBlockDist=
- =CatchesFire=, =DegreesPerSecondMin/Max= (fire spread speed ALONG the strut)
** Economy
- =MetalBuildCost=, =EnergyBuildCost=, =MetalRepairCost=, =EnergyRepairCost=
- =MetalReclaim=, =EnergyReclaim=, =EnergyRunCost=, =BuildTime=, =ScrapTime=
** Concrete values that confirm our model
| Material | HP | Stiffness | Mass | MaxComp/Exp | Metal/Energy build | Fire deg/s |
|---------------+-----+-----------+------+-------------+--------------------+------------|
| bracing (wood)| 150 | 200000 | 0.25 | 0.90 / 1.10 | 0.1 / 0.5 | 20..32 |
| backbracing | 100 | 200000 | 0.25 | 0.92 / 1.08 | 0.1 / 0.5 | 64..92 |
| armor | 400 | 250000 | 0.50 | 0.90 / 1.10 | 1.8 / 1.5 | n/a |
| rope | 50 | 50000 | 0.001| 0.60 / 1.50 | 0.1 / 0.5 | 20..100 |
Notes:
- Wood +/-10% deformation break == our axial snap rule. Armor 25% stiffer + 2x
mass == our research. 3 wood (450 HP) slightly > 1 armor (400 HP) == research.
- *Background bracing* has =CollidesWith{Friendly,Enemy}Projectiles = false= and
=CollidesWithFriendlyBeams = false= but =CollidesWithEnemyBeams = true=:
projectiles pass straight through it; enemy LASERS still hit it. And it burns
3x faster (64..92 vs 20..32 deg/s) — exactly why fire counters the
"hide supports as background bracing" trick.
- Rope: =AngleStressPrimaryThreshold = 360= (no angle break), tension element.
* Fire system (=data/db/constants.lua=, table =Fire=)
| Field | Value | Meaning |
|-------------------------------+-------+---------------------------------------|
| DamageHitpointsPerSecond | 0.5 | burn DoT on struts |
| DamageHitpointsPerSecondDevice| 2.5 | burn DoT on devices (5x faster) |
| DegreesPerSecondMin/Max | 20/32 | base spread speed (per-material overrides) |
| IgnitionTemp / MaxTemp | 100/150 | |
| SegmentLength | 15 | fire is segmented along the strut |
| Alarm.Delay | 7 | "fort on fire" alarm after 7s |
Fire is a per-strut DoT that propagates strut-to-strut at a per-material rate,
independent of the HP/stress systems. Background bracing burns fastest.
* Extrusion (drag-build) constants (=data/db/constants.lua=, table =Extrusion=)
| Field | Value | Our equivalent / note |
|-----------------+-----------+------------------------------------------|
| DefaultMaterial | "bracing" | |
| MinAngle | 20 | min divergence angle of the new box |
| MinOffset | 40 | min drag distance to start a box (cm) |
| SnapAngle | 6 | drag direction snaps to 6-deg increments |
| MaxLengthGrace | 500 | |
Confirms drag-build is angle-flexible (not forced perpendicular) with a snap and
a minimum size — matches what we built (free drag offset + MIN_BRACE_LENGTH).
* Modding architecture
** Layered loading
1. Base =data/...= loads first, populating global tables (=Materials=,
=Weapons=, =Devices=, =Projectiles=, =Physics=, =Fire=, ...).
2. Each active mod runs ON TOP, mutating those globals.
3. Priority (=mod.lua=, 1..10, default 5; higher = loads later = final say),
then alphabetical. (Some workshop mods use larger numbers, e.g. 99.)
4. =RegisterApplyMod(fn)= defers =fn= until AFTER all mods load, so a mod can
modify content added by later-loading mods.
** Mod file layout (mirrors the base data tree)
#+begin_src text
<mod>/
├── mod.lua # Selectable=true, Priority=N, Category="..." [, AIFortSpecialisation]
├── displayname.lua # DisplayName = { ['English']=L"...", ... } (often UTF-16)
├── publishedfileid.lua, itemversion.lua, preview.jpg # Steam Workshop metadata
├── db/constants.lua # override physics/fire constants
├── materials/building_materials.lua # add/modify materials
├── weapons/weapon_list.lua, projectile_list.lua, <weapon>.lua
├── devices/device_list.lua, <device>.lua, <device>/*.dds|png # + art
└── ...
#+end_src
The loader injects a =path= variable = the mod's root, used for asset/file refs
(e.g. =FileName = path.."/devices/ballast.lua"=).
** mod.lua manifest (minimal)
#+begin_src lua
Selectable = true
Priority = 6
Category = "Devices" -- e.g. "Combat", "Physics", "Disable/Weapons"
#+end_src
** The mod API (global Lua functions exposed by the engine)
| Function | Purpose |
|--------------------------------------------+----------------------------------------|
| =IndexOfMaterial/Weapon/Device(saveName)= | find an item's position in its list |
| =FindMaterial/FindWeapon/FindProjectile(n)=| get an item table to mutate |
| =InsertMaterialBefore/Behind(saveName, t)= | insert relative to an existing item |
| =table.insert(Devices, IndexOfDevice(x)+1, t)= | the raw add pattern |
| =MergeLists(t1, t2)= | concat list tables |
| =RegisterApplyMod(fn)= / =DeregisterApplyMod=| run fn after all mods load |
| =InheritMaterial(base, overrides)= | clone a base material with overrides |
| =L"..."= | localized string literal |
** Three concrete mod patterns observed
1. *Disable* (=disable_weapon_cannon=): override file does
=local w = FindWeapon("cannon"); w.Enabled = false=.
2. *Tweak* (commander mods): set globals + =RegisterApplyMod(fn)= where =fn=
scales the already-loaded weapon's fields (=FireStdDev=, =KickbackMean=...).
3. *Add* (=fsballast= device mod): =table.insert(Devices, IndexOfDevice(
"repairstation")+1, { SaveName=..., FileName=path.."/devices/ballast.lua",
MetalCost=50, EnergyCost=250, ... })= plus the device's own definition file
(sets =Mass=, =HitPoints=, =Sprites=, =Root= scene-graph) and art.
* What to adopt for our clone
- *Data-driven materials/weapons/devices*: move the hard-coded material table in
=app.cpp= into Lua data (sol2), keyed by =SaveName=, with the field schema
above. Engine reads the tables at startup. (Tech-stack already plans sol2.)
- *Numbers*: adopt the relative relationships (HP 150/100/400/50, armor 25%
stiffer + 2x mass, bg-brace burns ~3x faster, wood +/-10% break) even if our
absolute scale differs (we use world units, not cm).
- *Layered loader*: base tables -> mods by (priority, name) -> =RegisterApplyMod=
pass. Inject a =path= per mod. Mirror the base directory tree.
- *Reconsider an angle-stress break* (30/15 deg) alongside our force-based axial
break — it is in the real game and cheap to add.
- *Fire (M4)*: per-strut DoT (0.5 hp/s, 2.5 for devices) propagating along
edges at a per-material deg/s rate; bg-brace fastest.
- *Background bracing collision flags*: projectiles pass through, enemy beams
hit, fire reaches it — model these as per-material booleans, not a hard-coded
special case.
#+begin_src text
(Do NOT copy Forts' Lua/assets into the repo — reference only. Our own data
files should be written from scratch using this schema.)
#+end_src
|