Begin your thermal adventure. Every machine you build generates heat -- how you manage it is up to you.
Mods introducing new content into the game.
Version: 0.0.4
Date: 2026-08-17
Changes:
- The heat-dissipating boiler's water-to-steam conversion ratio
now scales with its heat energy source temperature: 1:10 at
165C (the minimum working temperature) up to 1:60 at 465C (the
maximum), linear in between. Vanilla's boiler type has no field
for a configurable conversion ratio -- native engine behavior
is always a fixed 1:1 water-to-165C-steam conversion, so this
is implemented as a script-side bonus layered on top of native
behavior rather than a prototype change: each second, on top of
whatever water/steam native conversion already did, a bonus
amount of steam is added directly to the boiler's output fluid
box, with no additional water consumed. This is an analytical
estimate (assuming the boiler runs at its full rated 2.8MW
whenever it's at or above minimum working temperature and has
any water available) rather than one measured from observed
fluidbox deltas -- watching fluidbox levels can't reliably
separate the boiler's own internal conversion from concurrent
external supply/draw through connected pipes, since both share
the same net-change number. At 465C this works out to roughly
5,507 bonus steam/s (on top of the ~93.3/s native baseline),
enough to fully feed around 90 vanilla steam turbines from a
single boiler. This also loops well into the vanilla
"steam-condensation" recipe (Space Age, requires Calcite
processing), which converts 1000 steam into 90 water -- a 0.09
return rate. Break-even for the whole boiler-then-condense loop
is a ratio of about 11.11, so 1:60 clears that comfortably
(net water roughly quintuples per cycle) without being as
extreme as a higher ratio would be.
- Increased the boiler's output_fluid_box volume from 100 to 2000
to give the new bonus steam somewhere to accumulate; the old
100-unit buffer would have discarded most of the bonus the
moment it was produced. This value is a reasonable first guess,
not derived from testing, and may need to go higher if it turns
out to still cap out often in practice.
Version: 0.0.3
Date: 2026-08-15
Changes:
- Heat sink held-in-cursor range preview is now a square, matching
the actual square coverage area used by square_area() in
control.lua (an earlier version of this used a circular ring,
which didn't match the real shape and could mislead players
placing heat sinks near the corners of their coverage). It
shows two colors instead of one: the full preview area is
filled with one color representing the 25-tile range (after
researching Heat Recovery), with a smaller centered square in a
second color covering the middle, representing the 15-tile base
range -- the visual effect is a colored "frame" where the outer
10 tiles of coverage are one color and the inner 15x15 area is
another. Before implementing this, checked the official
RadiusVisualisationSpecification prototype docs directly:
confirmed it's a single struct (one sprite, one distance) with
no array support, and that even the entity types with custom
overrides (agricultural tower, asteroid collector, beacon,
cargo landing pad, electric pole, mining drill) only get one
sprite each -- roboport, whose orange/green dual-zone display
inspired this request, isn't even in that list, confirming its
two-zone rendering is entirely bespoke engine behavior tied to
type="roboport" with no path for other entity types to use it.
With no native option available, both colors are baked into one
static image and always shown together, since there's still no
API to detect research state and redraw the preview dynamically
(see the 0.0.1 entry above, "Investigated making the
held-in-cursor range indicator dynamically show..."). This
doesn't resolve that limitation, it routes around it: showing
both ranges at once means the preview is never wrong, just
intentionally undifferentiated -- no tooltip text was added
explaining which region is which.
- Heat sink entity/item descriptions (all 5 locales) now include
one plain sentence noting that adjacent placement has a
multiplicative effect -- no percentages, no cap, no mention of
what the effect applies to, and no in-game numeric readout of
any kind (see the later "Removed the alt-mode power readout"
entry below for why).
- Diagonally-adjacent heat sinks now count toward the adjacency
bonus, confirmed as intended rather than a side effect of the
previous bugfix: the touching-adjacency check (each heat sink's
selection-box half-extent added together as the dx/dy
threshold) doesn't distinguish orthogonal from diagonal
placement, and this is being kept as-is.
- The natural-cooldown compensation for adjacent heat sinks no
longer applies a flat 1/3 reduction whenever adjacent_count is
at least 1. It now scales linearly with adjacent_count (the
same design as the absorption-rate multiplier): at count=1 it
still offsets 1/3 of that tick's temperature drop (numerically
unchanged from before), at count=3 it fully cancels the drop,
and beyond count=3 the offset exceeds 100%, meaning a heat sink
with enough neighbors gains temperature instead of losing it.
This is intentional, matching the same uncapped "denser cluster
= stronger effect" design as the absorption-rate bonus, not a
missing safety clamp.
- Removed the hard cap on adjacent_count (previously clamped to 6
via HEATSINK_MAX_ADJACENT). count_adjacent_heatsinks() now
returns the actual count with no upper limit, so both the
absorption rate and the decay compensation above scale with
however many heat sinks are genuinely touching -- verified via a
simulated 3x3 grid that an interior cell (8 touching neighbors:
4 orthogonal + 4 diagonal) correctly computes adjacent_count=8
instead of being clamped at 6. HEATSINK_MAX_ADJACENT itself is
left in constants.lua for the record but is no longer
referenced by any code.
- Removed the alt-mode power readout added earlier this version.
While chasing the "no power output despite an available heat
source" report above, connecting two heat sinks directly into a
vanilla nuclear heat network (reactor + heat exchangers, via
heat pipe) caused the network's own power output to jump from
0W to a full 69.8MW -- a real, dramatic effect coming from the
heat sink's heat_buffer being a genuine vanilla heat-network
connection point, entirely independent of this mod's own
absorption/adjacency math. That real effect is far more
interesting than the mod's own flavor "X.X MW" number, and
leaving the readout in would have undersold it -- players would
see a small internal number update instead of noticing the
actual electricity network statistics spike. The readout
(create_heatsink_power_text_overlay, and all register/
unregister/process_heatsink code that created, updated, or
destroyed it) has been removed entirely; no in-game display
shows any power number for heat sinks now. Nothing about the
underlying mechanics changed -- absorb_rate, adjacent_count, and
the heat sink's heat_buffer connections are untouched. The exact
mechanism behind the vanilla heat-network effect hasn't been
investigated yet (current best guess, unconfirmed: the heat
sink's heat_buffer adds extra thermal capacity/transfer paths to
the shared network, similar to running additional heat pipes in
parallel) -- flagged in design_notes.md as follow-up if anyone
wants to dig into it later.
Bugfixes:
- Fixed the heat sink adjacency bonus never actually applying:
heat sinks placed directly touching each other (the normal,
expected placement) were never detected as adjacent, so the
absorption-rate bonus stayed pinned at the base rate regardless
of how many neighbors were placed. Root cause: find_entities_filtered's
area parameter matches against an entity's bounding_box, which
is the entity's collision_box translated to its position (not
its selection_box or its center point). The heat sink's
collision_box was deliberately shrunk to half its selection_box
size in an earlier fix (to stop the engine from blocking
adjacent placement), which left a real gap between two touching
heat sinks' collision boxes -- e.g. two vertical heat sinks
placed side by side (1 tile apart, the normal touching
placement) have a 0.5-tile gap between their collision boxes.
count_adjacent_heatsinks()'s search radius (0.6 tiles) never
reached far enough to detect that gap-separated collision box,
so adjacent_count was always computed as 0 -- geometrically
guaranteed to fail for any standard touching placement, not an
intermittent issue. This bug predates this version; the
adjacency multiplier formula itself was always correct, it was
just always computing with adjacent_count=0 as input.
Fixed by decoupling "is adjacent" from the collision-box-based
area query: candidates are still gathered via a generously-sized
area query (2 tiles, comfortably covering every orientation
combination), then filtered using each heat sink's actual
selection-box half-extent (0.5x1.0 tiles for vertical, 1.0x0.5
for horizontal) to determine true touching adjacency
geometrically, independent of how collision_box happens to be
sized for placement purposes. Verified against six placement
combinations (vertical/horizontal, touching/gapped, same-type/
mixed) with a standalone Lua logic test; all six now return the
expected result, including the exact side-by-side vertical case
from the bug report.
- Extended /thermal-debt-debug to report heat-sink-specific
diagnostics when a heat sink is selected (it previously only
handled tracked buildings in storage.building_heat and printed
nothing useful for heat sinks): adjacent_count, an expected
power value computed from it (debug-only, not shown anywhere in
normal play), and the relative dx/dy of every heat sink
candidate found within a 2-tile search area -- useful for
spotting future edge-of-threshold cases without re-deriving the
math from the source.
- Added /thermal-debt-debug-area [radius], a bulk diagnostic
command that scans a square area around the player (default 20
tiles) and reports how many nearby entities are tracked by
storage.building_heat and, specifically, how many have hit the
398-degree heat threshold and been force-disabled
(entity.active=false) by that system -- listing each one by
name, unit number, and current heat value. Useful for large
arrays (e.g. a bank of heat exchangers) where checking each
entity individually via /thermal-debt-debug is impractical, and
for distinguishing "the mod's own heat-debt system shut this
down" from "vanilla's physical heat network has an issue" --
the native tooltip only shows the latter.
Version: 0.0.2
Date: 2026-08-15
Bugfixes:
- Fixed a likely load-time crash for players without the Space
Age expansion. The two space-platform build/mine events
(on_space_platform_built_entity, on_space_platform_mined_
entity) are engine-level defines tied to Space Age; players
without it may not have those keys in defines.events at all,
and script.on_event(nil, handler) errors. Both registrations
are now guarded with an existence check first. The mod has no
real dependency on Space Age -- without it there are simply no
platform entities to track, so skipping is zero-cost. Declared
space-age as an optional dependency ("? space-age") in
info.json to document this explicitly.
Version: 0.0.1
Date: 2026-08-15
Features:
- Added technology "Heat Dissipation" (root tech, unlocked by
crafting 100 copper plates) which unlocks the heat sink recipe.
- Added technology "Heat Recovery" (prerequisites: Heat
Dissipation + Production science pack) which unlocks the
heat-dissipating boiler recipe, the heat pipe recipe, and
expands the heat sink's coverage range from 15 to 25 tiles.
- Added real crafting recipes for the heat sink (copper ore,
copper cable, pipe-to-ground) and the heat-dissipating boiler
(steel plate, pipe, heat pipe). Both were previously only
obtainable through the debug give-on-join.
- Added a full heat-accumulation loop for boilers, generators,
heat-exchangers, and steam turbines: +4.1 degrees/second while
working, -1 degree/second otherwise, forced shutdown
(entity.active = false) at 398 degrees, automatic restart once
back below that threshold.
- Added heat sink absorption: every 0.5 seconds, each heat sink
draws 0.9 degrees from every tracked heat-producing building in
range and adds it to its own native temperature.
- Added heat sink adjacency bonuses: absorption rate scales with
the number of directly touching heat sinks (up to 6, +40% per
neighbor), and a heat sink's own temperature decay slows to 2/3
speed while adjacent to another heat sink.
- Added a coverage-range visualization circle to the heat sink
(reusing the vanilla reactor neighbor-bonus ring sprite, tinted
cyan), matching the pattern used by card-tech-draft's waste
heat exchanger.
- Added a temperature floor lock to the heat-dissipating boiler:
its heat buffer is never allowed to read below 165 degrees.
- Added on_configuration_changed migration/backfill: converts
the old storage.heatsinks format to the new one, and scans all
surfaces to register any boiler/generator/heat-exchanger/
steam-turbine and any already-placed heat sink or
heat-dissipating boiler that existed before this update, so
existing saves aren't left with untracked entities.
- Added a live heat readout for tracked buildings, shown as
floating text above each building (LuaRendering.draw_text,
only_in_alt_mode) color-coded green/yellow/red by how close the
building is to the 398-degree shutdown threshold.
- Added a hover highlight: selecting a heat sink now draws a
coverage-range outline plus an outline around every building
currently registered as a heat-absorption target, matching the
card-tech-draft waste heat exchanger's on_selected_entity_changed
pattern. Cleared automatically on deselect.
Changes:
- Fixed crafting-menu item order: heat sink now sorts before the
heat-dissipating boiler (was reversed due to alphabetical
"boiler" < "heatsink" in the old order strings).
- Heat Recovery technology now uses a composited icon (heat sink
base icon + a green upward-arrow badge) instead of borrowing
the boiler's icon, signaling that it's a range/capability
upgrade rather than unlocking the boiler itself.
- Removed the heat sink's static radius_visualisation_specification
ring. It's a data-stage-only field with no runtime API to
switch between 15 and 25 tiles based on research state, so it
always showed 15 tiles regardless of whether Heat Recovery was
researched -- this was the actual cause of the reported "range
doesn't look like it changed" observation. The dynamic
on-selection outline (already present, correctly computed from
the current research state) is now the sole range indicator.
- Fixed "Heat Recovery" technology requirements: was incorrectly
gated behind Production science pack (a mid/late-game pack that
didn't fit this stage of the tech tree). Now requires
Automation + Logistic science packs, with the prerequisite tech
changed from production-science-pack to logistic-science-pack
accordingly.
- Heat text scale reduced from 1 to 0.6 -- in dense clusters,
neighboring buildings' floating heat text was overlapping into
unreadable jumbled strings; smaller text reduces both the size
and the overlap chance.
- Added decider-combinator, arithmetic-combinator,
constant-combinator, and selector-combinator to the
heat-tracking exclusion list. Same category of problem as solar
panels: circuit logic evaluates continuously whenever powered,
with no real working/idle distinction, so heat only ever rose.
Worse, entity.active = false does not clear an already-computed
signal on the circuit network (the last computed value persists
until something recomputes it -- this is how Factorio's circuit
network works, not something this mod can override), so a
"shutdown" never actually cut the output signal.
- Accumulator shutdown no longer relies on entity.active, which
has no confirmed effect on accumulators (same category of
uncertainty as the solar panel issue -- only a power-switch
disconnect is documented to freeze an accumulator). At 398
degrees, its energy level is now captured and force-rewritten
every second, hard-freezing charge/discharge until it cools
back down.
- Accumulator and turret (ammo/electric/fluid/artillery) heat
gain no longer uses the generic entity.status == working check,
which could stay "working" without real activity for these
types. Accumulators now heat only while actively
charging/discharging (detected via entity.energy deltas);
turrets now heat only while firing (entity.shooting_target) or
rotating to track a target (entity.orientation deltas).
- Added space-platform-hub and cargo-bay to the heat-tracking
exclusion list.
- Removed accumulator from the exclusion list added last round --
it's tracked again, now using the charge/discharge-aware logic
above instead of being excluded outright.
- Removed the red filter overlay entirely. In a dense factory
with the broadened tracking scope, many adjacent buildings
heating up together made the filled-rectangle overlays merge
into a solid red mass, even where the underlying alpha math
was correct -- a density/UX problem, not a calculation bug
(confirmed against Factorio's own alpha-blending writeup,
FFF-172, which found no relevant engine quirk). The floating
heat-text readout already conveys the same information more
reliably and was kept as the sole visual indicator.
- Heat text scale changed from 1.2 to 1 to better match native
description text size.
- Boiler graphics updated: new base sprites showing water pipe
and steam direction with a directional arrow, for both the
normal and mirrored variant. The north-facing sprite was
replaced and the east/south/west sprites were regenerated as
90/180/270-degree rotations of the new north sprite (matching
the existing per-direction sprite architecture). Icon files
were also regenerated from the same new source art (downscaled
to 64x64), so the item/recipe icons match the new sprite style.
- Heat tracking scope corrected from a narrow whitelist (boiler/
generator/heat-exchanger/steam-turbine only) to a blacklist:
everything except the heat-dissipating boiler, heat sink,
storage tank, pipe, pipe-to-ground, and heat-pipe now
accumulates heat -- including belts, underground belts,
inserters, assembling machines, and the nuclear reactor. See
design_notes.md for the full exclusion list and a flagged UPS
concern this introduces at megabase scale.
- Heat sink absorption is no longer processed for the entire
storage.heatsinks table in a single tick every 30 ticks.
Switched to round-robin batching (unit_number % 30), one batch
every 2 ticks, so a full cycle still covers every heat sink
once every 60 ticks -- spreading out the find_entities_filtered
cost instead of paying it all at once. To keep the per-second
absorption total unchanged (each heat sink is now only visited
once per second instead of twice), HEATSINK_ABSORB_PER_BUILDING
was doubled from 0.9 to 1.8 -- this is a scheduling-frequency
compensation, not a rebalance.
- Heat-dissipating boiler: max power draw raised from 2MW to
2.8MW; operating range changed from 20-1000 degrees to
165-465 degrees (matching vanilla boiler's native minimum
working temperature).
- Heat sink: no longer force-locked to a fixed 300 degrees every
second. Its temperature is now a live value driven by the new
absorption/decay mechanics above.
- Heat sink: heat_buffer max_temperature lowered from 1000 to
465 degrees, matching the heat-dissipating boiler's ceiling so
the two connected components share the same upper bound.
Bugfixes:
- Fixed unbounded heat accumulation ("runaway heat", observed as
1094 degrees on a solar panel far past the 398-degree shutdown
threshold). Heat gain is now hard-capped at the shutdown
threshold via math.min, instead of relying solely on
entity.active toggling to stop growth -- some entity types
(e.g. solar panels) don't respond meaningfully to the active
flag, so growth was previously unbounded for them.
- Added solar-panel and accumulator to the heat-tracking
exclusion list: passive power components with no real
working/idle distinction.
- tick_building_heat now re-validates each tracked entity's
eligibility every cycle instead of only at registration time,
so exclusion-list changes self-apply on the next tick rather
than requiring on_configuration_changed to fire.
- Removed LuaEntity::set_tooltip_field, which does not exist and
caused an unrecoverable "LuaEntity doesn't contain key
set_tooltip_field" crash on every heat update. The pcall around
it did not actually protect against this: indexing a missing
key on a LuaEntity errors immediately, before pcall's protected
call even begins, so the error occurred outside pcall's
coverage. Replaced with LuaRendering.draw_text (see Features).
- Fixed entities built on space platforms (e.g. the railgun
turret) never being registered for heat tracking. Space
platforms build via on_space_platform_built_entity instead of
the normal player/robot build events, which this mod wasn't
listening for; the matching on_space_platform_mined_entity
teardown event was missing too, along with script_raised_revive
for API-driven ghost revival. All are now handled.
- Fixed register_heat_entity crashing with "table index is nil"
on load for saves containing entities without a unit_number
(e.g. simple-entity rocks/decoratives), which the exclusion
list didn't account for. is_tracked_heat_entity now checks for
a unit_number directly instead of relying on type exclusions.
- Added a console command, /thermal-debt-debug, to report whether
the currently selected entity is registered for heat tracking
(and why not, if not) for future troubleshooting.
- Excluded cargo-wagon and fluid-wagon from heat tracking; they're
pure cargo containers, not working machinery.
- Locomotives are no longer judged by entity.status==working
(which can read "working" while idle at a signal or station).
They now heat up only while entity.speed is non-zero, i.e.
actually moving.
- Replaced the single-pass full-map backfill scan with a batched,
chunk-by-chunk version. The unbatched scan measured a 191ms
spike in a 5-player session (profiler-reported), a full-tick-
budget-exceeding stall. The new version walks the map in 32x32
chunks, processing a small fixed number of chunks per tick
until the whole map is covered, keeping each tick's added cost
small regardless of map size. The queue is stored (not local),
so a save mid-scan resumes from where it left off next load
instead of restarting.
- Excluded all chest/container types (container, logistic-
container, linked-container, temporary-container, proxy-
container, infinity-container) from heat tracking; they're
pure storage, not working machinery.
- Transport belts, underground belts, linked belts, splitters,
and lane splitters are no longer judged by entity.status==
working (belts have no real working/idle state machine and
report "working" essentially all the time regardless of
whether anything is on them). They now heat up only while at
least one of their transport lines actually has items on it.
- Fixed a regression in the batched backfill scan: only the
per-chunk entity queries were being spread across ticks, but
the chunk-listing step itself (build_backfill_queue, walking
every surface's chunks up front) still ran as one lump on a
single tick. On this save (8 surfaces: Nauvis + 7 space
platforms, heavily explored) that step alone was apparently
the dominant cost, and the reported peak went up to 291ms
instead of down. The chunk-listing step is now spread too --
one surface enumerated per tick -- so no single tick handles
more than one surface's chunk count or a small fixed number of
chunk entity-queries, whichever comes up that tick. Also
lowered BACKFILL_CHUNKS_PER_TICK from 4 to 2 to be more
conservative.
- Fixed belts/splitters staying pinned at max heat when jammed:
confirmed via a Wube developer forum post (a won't-implement
thread) that Factorio has no API-level way to tell whether a
transport line's contents are moving vs. stalled -- "has
items" and "items are moving" are different things, and a belt
backed up by a full downstream still reports items present.
Switched to comparing total item count on the belt's transport
lines between checks (the same delta approach already used for
turret orientation): unchanged count between two checks means
stalled, changed means items are actually flowing.
- Excluded plain transport belts, underground belts, linked
belts, and loaders from heat tracking entirely. Even with the
delta-based fix above, the same forum thread confirms this
approach has an unavoidable blind spot: a belt that's
permanently saturated and flowing at capacity reads the same
item count on every check and gets misjudged as stalled --
there's no clean fix for that with the data the API exposes.
Given belts are usually the single most numerous entity type
in any base, tracking them was also a non-trivial ongoing
cost. Splitters and lane splitters keep the same delta check
and continue to overheat/jam -- there are far fewer of them,
and the jam behavior was worth keeping despite the same known
edge case.
- Fixed overheated locomotives not actually stopping. Same root
cause as solar panels and accumulators -- entity.active=false
doesn't halt a moving train, since trains run their own
physics/scheduling simulation independent of that flag. This
time there's a real, documented fix: LuaTrain exposes
manual_mode and speed specifically for script control (unlike
accumulator.energy, which was never meant for this). On
overheat, the whole train is switched to manual mode and its
speed forced to 0; it's released once every tracked locomotive
on that train has cooled below the restart threshold, and
restored to whatever manual/automatic mode it was in before
the mod intervened (so a player already driving manually isn't
silently switched back to automatic). Handles multi-locomotive
trains correctly by tracking lock state per train (LuaTrain.id)
rather than per locomotive, since a train's manual_mode/speed
is shared across all its cars, not settable per-locomotive.
- Fixed a crash ("Entity is not turret") when checking artillery
turrets: entity.shooting_target errors on artillery-turret
instead of returning nil, since artillery aims at map
coordinates rather than locking onto a specific entity the way
other turret types do. Wrapped the read in pcall; artillery
turrets now fall back to the orientation-delta check alone.
Known limitation: artillery turrets barely rotate while firing
(they aim, then fire mostly stationary), so the orientation
delta may under-detect genuine firing activity for this turret
type specifically -- noted for anyone who finds artillery
turrets running unusually cool.
- Excluded cargo-landing-pad (Space Age) and roboport from heat
tracking. Cargo landing pads are just a receiving point for
items launched from orbit, not working machinery, same
reasoning as chests. Roboports have no reliable working/idle
signal (same bucket as solar panels) -- treated as static
infrastructure for now rather than building custom detection
logic for "robots currently docked/servicing."
- Excluded artillery-turret from heat tracking. The orientation-
delta detection was technically working but too imprecise to
be useful in practice -- artillery turrets barely rotate while
aiming/firing, so real firing activity often went undetected
(confirmed by field testing: visibly rotating turret, no heat
gained). Preemptively excluded artillery-wagon too, since it
uses the same area-targeting mechanism (not shooting_target)
and would likely hit the same detection problems.
- Fixed the heat sink's held-in-cursor range indicator not
rendering at all, confirmed via screen recording (cursor
visibly holding the item, no ring/square anywhere near it).
The previous fix used a "width"/"height" pair on the sprite
sub-table, copied from a forum bug report about beacons. The
official EntityPrototype documentation's own example uses a
single "size" field instead; the width/height pair was
apparently failing to parse the sprite silently rather than
erroring, so nothing drew. Switched to the documented "size"
field to match the official example exactly.
- Investigated making the held-in-cursor range indicator
dynamically show 15 vs 25 tiles depending on whether "Heat
Recovery" is researched (it currently always shows 15, the
base value, since radius_visualisation_specification.distance
is a static data-stage value). Concluded this is not achievable
with the public modding API: there is no way for a script to
read the mouse cursor's world-map position (LuaPlayer.
cursor_position is in screen coordinates; LuaPlayer.selected is
nil over empty buildable ground), which a custom, cursor-
following indicator would require. A 2020 forum request asking
Wube to add exactly this API (get_cursor_surface_location) has
not been implemented. Accepted as a known limitation for 0.0.1:
the held-in-cursor preview always shows the base range; the
accurate, research-aware range is only shown once the heat sink
is placed and selected (see the entry above from earlier this
version, "the dynamic outline ... is now the sole range
indicator").
Notes:
- See notes/design_notes.md for the reasoning behind each numeric
choice and known open questions.