Company
Game
Date
Duration
Team Size
Game Programmer, Network Programmer
For the longest time during my work at Chasing Carrots I was assigned to Hypothermia. It's a cooperative exploration game where players step into the role of Arctic researchers and pilot a massive cruiser across a white frozen wasteland, searching for and investigating mysterious anomalies.
The project went through many iterations. At the beginning of our prototyping phase, the original idea was very different from what it eventually became. Here I want to talk about the steps I went through and how I was able to support and accompany the project's development, from the first prototype all the way to the MVP. Hypothermia is still in development, with an early access release currently planned for the end of 2026.
The project started during the Global Game Jam in 2024. My colleagues explored the feasibility of using Godot to build a cooperative burglar game with proximity voice chat. Players would need to collaborate to sneak into mansions or museums, steal high-value objects and bring them back to their truck. We especially wanted to experiment with noise and how it could lead to detection. Enemy AI reacted to sound events such as running footsteps or falling objects, but also to players talking to each other. The long-term goal was for the game to become a tense but humorous stealth experience.
When I joined the project, my task was to refactor the enemy AI, which was a landlord roaming the property. In the current state the landlord was only able to follow a noise or detect a player and walk towards them. I was asked to implement an attack state and refactor the AI logic to use coroutines. The reason for this was that Godot provides a very accessible way to work with coroutines and we wanted to evaluate how manageable more complex gameplay logic would be. With a simple while loop and the await keyword, it is possible to write code that runs once every frame. This structure allows us to build layered and more complex AI behavior.
while true:
run_cool_logic()
await get_tree().process_frameA major advantage of this approach is readability. The logic can be read directly from the code. Our AI will chase a target if one exists. Otherwise it will begin searching for the target. If it is neither chasing nor searching, the AI will listen for noises or fall back to its idle behavior.
func _ready():
if multiplayer.is_server():
_handle_behaviour()
func _handle_behaviour():
while true:
if target:
await chase_target_routine()
elif search_in_area:
await search_area_routine()
elif Sensor.check_for_recently_heared_noise():
await handle_noise_routine()
else:
await idle_routine()
await get_tree().process_frameInside the coroutine functions we define the individual behaviors. In this example, the routine handles searching an area after the AI loses sight of its target. The AI will search for a fixed amount of time defined by `search_time` and will abort the coroutine early if a target is found or the timer runs out.
func search_area_routine():
Sensor.is_detecting = false
var timer := get_tree().create_timer(search_time)
while true:
detect_player()
if target:
return
if timer.time_left <= 0:
Sensor.noise_heared = false
search_in_area = false
set_aggressive.rpc(false)
return
await get_tree().process_frameWith this, the refactor for our first enemy, the Landlord, was done and he was able to roam, detect players and attack them. We even recorded voice lines to give our landlord some personality.
An issue that came up quickly during development with coroutines was the challenge of debugging. Because coroutines are used, stepping through the code with a debugger becomes more difficult. At every await keyword, the debugger logically jumps to a different point in the codebase. This sometimes made it hard to determine exactly which state caused an error and what needed to be fixed. Visibility is key here. A tool that could show the current state of the AI would make debugging much easier. This is where a Godot extension called Limbo AI came in. It adds behavior trees and a visual editor to Godot. Ultimately, the coroutines I implemented are nothing other than a behavior tree, so we wanted to try this tool as well.
With some effort, I was able to transfer the current AI behavior controlled by coroutines to Limbo AI and restored the previous behavior of the Landlord. As shown in the following video, the debug menu displays which state within the behavior tree the AI is currently in. This makes it easy to trace problems and spot potential bugs.
After completing the implementation in Limbo AI, I presented my findings to our tech lead. It was decided that future AI in the game would be implemented using Limbo AI.
Another task was the implementation of a simple inventory system. It allowed players to pick up items, store them in one of the inventory slots and throw them away to free up a slot. It was important to create a synchronized system so that other players could see which item someone was holding in their hand or which item they had just thrown away. I also implemented a placeholder UI to visualize which items were stored in the inventory and which slot was currently active.
Another important aspect was handling larger objects like the TV shown in the video clip. Large objects were defined as non-storable in the inventory. I implemented it in a way that any item currently held in the player's hand would automatically be holstered when picking up a large object. If the player switched the active inventory slot, the large object would be dropped. Parts of this inventory system are still in use, although it was refactored in later iterations.
With the AI in place and the ability to store items, we were able to properly test the prototype. But we quickly realized it was not enough. While it was fun trying to avoid the landlord, we were missing a clear objective, a challenge the players needed to overcome. We also wanted to actively encourage collaboration, so I implemented a first obstacle tied to the main loot. It was a security door with a periodically changing access code.
Players had to use their walkie-talkies to communicate the door code so that another player could open the door and start looting the main objective, stealing the gold ingots. Both the security terminal and the door were placed along the landlord's default patrol path. Because of that, players had to stay alert and carefully time their actions to avoid getting caught.
Another idea was traps. Small obstacles scattered around the level to make things a bit more difficult for the players. I was tasked with implementing a bear trap. As mentioned before, noise was meant to play a bigger role in the game, so the bear trap would not only stun the player and deal damage, it would also attract nearby enemies because of the loud sound it created.
In this showcase I increased the damage so the player would immediately die for demonstration purposes. In the actual prototype, the player would get stuck and another teammate had to help release them from the trap. These small traps created a lot of funny situations during playtests. It was hilarious to run from the landlord while the only thing you could hear was your colleague stepping into a bear trap with a loud snap.
As professional burglars, of course we needed a reliable truck. We wanted to experiment with having this kind of base entity inside the level, something that felt more meaningful than just a storage space for collected loot. So we decided to give the truck a bit more purpose. I implemented an interactable laptop inside the truck. Once used, it opened a window where players could analyze the building layout and gather information about important locations within the level.
Through this task I played more around with UI implementation, especially in a 3D environment. The laptop screen is a viewport texture using a canvas object to display the UI. The same UI is also displayed during the starting sequence of the game where additional information can be bought for money. In this case I enabled all information for showcasing purposes, which can be seen under "Location Information".
I highlighted some of the key tasks I worked on during the prototyping phase, but there were additional systems as well. For example, I implemented the logic for handling player death. This included applying Godot ragdoll physics, spawning a spectator camera and monitoring the lose conditions. I also created a scoreboard that was shown at the end of the level once either the win condition or the lose condition was fulfilled. Going into full detail on all of that would probably go beyond the scope of this blog post.
During the prototyping phase, our project leads came up with a new direction for the game. It kept some similar features, but the setting changed completely. Instead of burglars, the players became Arctic explorers uncovering the secrets of a frozen desert. This was also a time when a lot changed in my own life, because I started my master's degree and moved to Copenhagen. As a result, I went from working full-time to part-time and started working remotely.
My gameplay programming tasks included implementing the cruiser's engine, its electrical power setup and a temperature mechanic that allowed heat to move between rooms. Another major part of my work was the player damage logic, including knockout and revive mechanics. I also worked on item storage and created a synchronized solution for how and where items are kept.
In the following sections, I want to talk about three gameplay features that influenced each other but can also be used separately. They all came together inside the cruiser, but an important part of the game design was that they should also work in other contexts. There could, for example, be other engines somewhere else in the world, maybe inside a broken cruiser and the electrical setup also needed to be reusable in different situations. Because of that, I tried to build these modules in a way where they did not need to know where they were being used, but would always behave consistently.
There was already an existing engine module implemented by our tech lead and it exposed a lot of different engine stats. At that point, though, many of them were not really being used yet. The idea was that the engine should feel like an object in the world that needs constant maintenance. My task was to add engine parts that introduced new values which influence the engine and are repairable when broken.
Every engine part is tied to a specific value and directly affects how the engine behaves. Parts like the turbocharger increase the maximum rpm of the engine but the effect is reduced depending on the turbocharger durability. Others, like the fuel pump, cause the whole engine to fail when they break. The engine cooler, on the other hand, is needed to remain a stable engine temperature. This means with less durability the engine will start to increase temperature and because it is also tied to the temperature system the whole room around the engine will get hotter.
As an example, here is the code for the turbocharger:
extends CompositeNodeModule
@export var RPMFactorCurve : Curve
var _owning_engine : CompositeNode
var _boost_charge : float = 0.0
var _max_boost_charge : float = 0.0
var _max_rpm : float = 0.0
var _next_update_time : float = 0.0
var _displayed_value_range : Vector2 = Vector2(0, 0.5)
func _ready_composite_node() -> void:
register_data_updated_callback(&"BoostCharge", _update_boost_charge)
register_data_updated_callback(&"OwnerID", _update_owning_engine_id)
func _process(_delta: float) -> void:
if not _composite_node.IsAuthority(): return
if GameTime.GameTime > _next_update_time:
_next_update_time = GameTime.GameTime + 0.2
if not _owning_engine: return
if not _owning_engine.HasData(&"EngineRPM"):
return
var current_rpm : float = _owning_engine.GetData(&"EngineRPM")
var boost_charge = _boost_charge * RPMFactorCurve.sample(inverse_lerp(0.0, _max_rpm, current_rpm))
_owning_engine.SetData(&"BoostCharge", boost_charge)
if is_zero_approx(_max_boost_charge):
_max_boost_charge = _composite_node.GetData(&"MaxBoostCharge")
var displayed_boost_charge = remap(boost_charge, 0, _max_boost_charge, _displayed_value_range.x, _displayed_value_range.y)
_owning_engine.SetData(&"DisplayedBoostCharge", displayed_boost_charge)
func _update_owning_engine_id(id: int):
if id == CompositeNode.INVALID_ID:
if _owning_engine:
_owning_engine.SetData(&"DisplayedBoostCharge", 0)
_owning_engine = null
return
var slot : CompositeNode = CompositeNode.GetCompositeNodeByID(id)
if not slot:
return
if not slot.ParentCompositeNode:
return
_owning_engine = slot.get_node(slot.ParentCompositeNode)
if not _owning_engine:
printerr("Parent path is invalid in %s" % name)
return
if _owning_engine.HasData(&"MaxRPM"):
_max_rpm = _owning_engine.GetData(&"MaxRPM")
func _update_boost_charge(value: float):
_boost_charge = value
"BoostCharge" is the specific value influenced by the turbocharger. In the real world, turbochargers generate boost based on the amount of exhaust gas the engine produces, so there is a direct relationship with RPM. More RPM creates more boost, and more boost increases RPM in return. What I found especially interesting about this implementation was that it created an actual feeling of turbo lag. In real engines, turbo lag happens when there is not enough exhaust gas for the turbocharger to deliver its full power. We can see a direct relation to my turbocharger implementation because in the beginnen the RPM is low and later with a bigger RPM the boost charge will increase. The effect of the boost charge on the engine RPM is implemented as follows:
if _engine_running.value:
var new_max_rpm = MaxRPM * ((_boost_charge.value + _injection_flow_rate.value) / 2)
if _vehicle_speed.value > ClutchEmulationSpeed:
_targetRPM = lerpf(IdleRPM, new_max_rpm, _vehicle_speed.value / SPEED_TO_RPM_FACTOR)
else:
_targetRPM = lerpf(IdleRPM, new_max_rpm, minf(1.0, absf(_throttle.value) + absf(_steering.value)))In total, I worked on five different engine parts, each with its own impact on the engine. But of course, we also needed some more gameplay around it. This is where the repair mechanic came in. A part's durability always has a direct impact on its specific value. For example, a turbocharger with 25% durability left has a reduced maximum boost charge.
The engine parts consist of several different modules and I am only showing the ones here that are relevant to the overall engine behavior. To apply the durability penalty to a part, I created a more general module that is shared across all the different engine parts.
extends CompositeNodeModule
@export var InfluencedValue : StringName
@export var InfluencedValueMax : float = 1
@export var InfluencedValueDetached : float = 0
@export var RepairFactorWorn : float = 0.9
@export var RepairFactorDamaged : float = 0.5
@export var RepairFactorBroken : float = 0
var _attached_parent : CompositeNode
var _influenced_value : CompositeNodeValue
var _influenced_value_max : CompositeNodeValue
var _repair_status : E.RepairStatus
func _ready_composite_node() -> void:
register_data_updated_callback(&"OwnerID", _on_owner_id_changed)
register_data_updated_callback(&"RepairStatus", _set_repair_status)
_influenced_value = create_non_synchronized_value(InfluencedValue, InfluencedValueDetached)
_influenced_value_max = create_non_synchronized_value("Max" + InfluencedValue, InfluencedValueMax)
func _on_owner_id_changed(owner_id: int):
#Engine part got detached
if owner_id == CompositeNode.INVALID_ID:
if not _attached_parent: return
if _attached_parent.HasData(InfluencedValue):
_attached_parent.SetData(InfluencedValue, InfluencedValueDetached)
return
#Engine part got attached
_attached_parent = CompositeNode.GetCompositeNodeByID(owner_id)
if not _attached_parent: return
# we have to set the data, even when the value doesn't exist (yet)
var repair_status_factor = _get_repair_status_factor()
_influenced_value.value = repair_status_factor #Safe value also in engine parts composite node so you can directly access it in other modules (e.g. AlternatorModule.gd)
_attached_parent.SetData(InfluencedValue, repair_status_factor)
func _set_repair_status(new_status: E.RepairStatus):
_repair_status = new_status
if _attached_parent:
var repair_status_factor = _get_repair_status_factor()
_influenced_value.value = repair_status_factor #Save value also in engine parts composite node so you can directly access it in other modules (e.g. AlternatorModule.gd)
_attached_parent.SetData(InfluencedValue, repair_status_factor)
func _get_repair_status_factor() -> float:
match _repair_status:
E.RepairStatus.Good:
return InfluencedValueMax
E.RepairStatus.Worn:
return InfluencedValueMax * RepairFactorWorn
E.RepairStatus.Damaged:
return InfluencedValueMax * RepairFactorDamaged
E.RepairStatus.Broken:
return InfluencedValueMax * RepairFactorBroken
return 0
This code also handles what happens when an engine part is detached. As described earlier, the engine should not work when a part like the fuel pump is missing. To actively decrease part durability over time, another module is used that periodically lowers the quality of an engine part based on RPM and engine temperature.
extends CompositeNodeModule
@export var WearFactor : float = 1
@export var WearFactorRandomVariation : float = 0.1
@export var DurabilityUpdateInterval : float = 0.25
@export var WornThreshold : float = 0.75
@export var DamagedThreshold : float = 0.35
@export var BrokenThreshold : float = 0
@export var TemperatureDamageCurve : Curve
var _attached_parent : CompositeNode = null
var _durability_timer : GameTime.IntervalTickTimer
var _durability : CompositeNodeValue
const ENGINE_RPM_FACTOR : float = 0.000001
func _ready_composite_node() -> void:
_durability = create_non_synchronized_value(&"Durability")
register_data_updated_callback(&"OwnerID", _set_attached_parent_id)
if _composite_node.IsServer():
_durability_timer = GameTime.get_interval_timer(DurabilityUpdateInterval)
_durability_timer.tick.connect(_decrease_durability)
func _decrease_durability() -> void:
if not _attached_parent:
return
if _durability.value and _durability.value > 0:
var current_rpm = _attached_parent.GetData(&"EngineRPM")
if current_rpm == null:
return
var engine_temperature = _attached_parent.GetData(&"EngineTemperature")
if engine_temperature == null:
return
if not TemperatureDamageCurve:
return
var temperature_factor = TemperatureDamageCurve.sample(engine_temperature)
var wear_factor := WearFactor
if WearFactorRandomVariation != 0:
wear_factor += randf_range(-WearFactorRandomVariation, WearFactorRandomVariation)
_composite_node.CallFunctionOnAuthority(
&"ChangeDurability",
[-DurabilityUpdateInterval * current_rpm * ENGINE_RPM_FACTOR * wear_factor * (1 + temperature_factor)])
func _set_attached_parent_id(composite_id: int):
if composite_id == CompositeNode.INVALID_ID:
_attached_parent = null
return
var parent = CompositeNode.GetCompositeNodeByID(composite_id)
_attached_parent = parent
To repair an engine part, the player first has to detach it from the engine. Since missing parts directly affect engine behavior, the engine also needs to react when a component is removed. Once detached, the part can be picked up and placed on the workbench, which opens the repair UI. After the repair is finished, the player can pick up the part again and reattach it to the engine. To support this workflow, I reused an existing attachment system and used it for both the workbench and the engine itself.
Of course, the engine also needs fuel to run. Part of my work was to add refueling to the cruiser's tank and make sure the engine shuts down once that tank is empty. Players can store fuel canisters in the cruiser and later use them to transport fuel from depots or other tanks in the world. To support that, I split the system into two reusable modules: one for storing fuel and one for transferring it.
The fuel storage module is intentionally simple. It keeps track of the current fuel level, checks whether a tank is empty or full and provides helper functions for increasing or decreasing the amount of fuel. That made it easy to plug the same logic into different objects while also giving the engine a clear source of truth for whether it is still able to run.
extends CompositeNodeModule
@export var TankName : String = "Fuel Tank"
@export var CanBeRefueld : bool = true
@export var MaxFuel : float = 500.0
@export var InitialFuel : float = 500.0
var _fuel_level : CompositeNodeValue
var _can_be_refueld : CompositeNodeValue
func _ready_composite_node() -> void:
_composite_node.SetData(&"ObjectName", TankName)
_fuel_level = create_synchronized_value(&"FuelLevel", maxf(minf(InitialFuel, MaxFuel), 0.0), CompositeNode.OnChange, CompositeNode.HalfFloat)
_can_be_refueld = create_non_synchronized_value(&"CanBeRefueld", CanBeRefueld)
register_function(&"CheckIfEmpty", CheckIfEmpty)
register_function(&"CheckIfFull", CheckIfFull)
register_function(&"DecreaseFuelLevel", DecreaseFuelLevel)
register_function(&"IncreaseFuelLevel", IncreaseFuelLevel)
register_function(&"GetUsedFuelAmount", GetUsedFuelAmount)
func CheckIfEmpty():
return _fuel_level.value <= 0.0
func CheckIfFull():
return _fuel_level.value >= MaxFuel
func DecreaseFuelLevel(decrease_by : float):
_fuel_level.value = maxf(_fuel_level.value - decrease_by, 0)
func IncreaseFuelLevel(increase_by : float):
_fuel_level.value = minf(_fuel_level.value + increase_by, MaxFuel)
func GetUsedFuelAmount():
return MaxFuel - _fuel_level.valueThe transfer module is a bit more involved because it connects directly to our interaction system. It needs to support two player actions, filling a tank and pumping fuel out of a tank. This is what allows a fuel canister to act as a bridge between different fuel sources in the world. In practice, that means the same system can be used to move fuel into the cruiser, take fuel back out again, or connect other compatible tanks without building a separate solution for each case.
extends CompositeNodeModule
@export var FuelingRate : float = 1.0
var _currently_interacting_composite : CompositeNode
var _finish_fueling_at_gametime : float = 0.0
var _total_refueling_time : float = 0.0
var _last_game_time : float = 0.0
var _current_action_type : E.PlayerActionType = E.PlayerActionType.None
var _other_tank_node : CompositeNode
func _ready_composite_node() -> void:
register_function(&"StartInteraction", StartInteraction)
register_function(&"UpdateInteraction", UpdateInteraction)
register_function(&"EndInteraction", EndInteraction)
register_function(&"CanUsePrimaryInteraction", CanUsePrimaryInteraction)
register_function(&"CanUseSecondaryInteraction", CanUseSecondaryInteraction)
register_function(&"GetPrimaryActionText", GetPrimaryActionText)
register_function(&"GetSecondaryActionText", GetSecondaryActionText)
func CheckIfFuelCanBeTransfered(other_tank_node: CompositeNode, action_type: E.PlayerActionType) -> bool:
match action_type:
E.PlayerActionType.PrimaryInHandAction:
if other_tank_node.CallFunction(&"CheckIfFull", []) || _composite_node.CallFunction(&"CheckIfEmpty", []):
return false
E.PlayerActionType.SecondaryInHandAction:
if _composite_node.CallFunction(&"CheckIfFull", []) || other_tank_node.CallFunction(&"CheckIfEmpty", []):
return false
return true
func CanUsePrimaryInteraction(interacted_with_composite_node : CompositeNode) -> bool:
if _can_be_refueld(interacted_with_composite_node):
_other_tank_node = interacted_with_composite_node
return true
return false
func CanUseSecondaryInteraction(interacted_with_composite_node : CompositeNode) -> bool:
return _can_be_refueld(interacted_with_composite_node)
func GetPrimaryActionText() -> Variant:
if _other_tank_node:
var tank_name : String
if _other_tank_node.HasFunction(&"GetItemName"):
tank_name = _other_tank_node.CallFunction(&"GetItemName", [])
if not name: return
return "refill %s" % tank_name.to_lower()
return null
func GetSecondaryActionText() -> Variant:
if _other_tank_node:
var tank_name : String
if _other_tank_node.HasFunction(&"GetItemName"):
tank_name = _other_tank_node.CallFunction(&"GetItemName", [])
if not name: return
return "pump fuel from %s" % tank_name.to_lower()
return null
func StartInteraction(_ray_start:Vector3, _ray_end:Vector3, other_tank_node:CompositeNode, action_type: E.PlayerActionType):
var missing_fuel : float = 0.0
var remaining_fuel : float = 0.0
if not CheckIfFuelCanBeTransfered(other_tank_node , action_type):
return
_currently_interacting_composite = other_tank_node
_current_action_type = action_type
match action_type:
E.PlayerActionType.PrimaryInHandAction:
missing_fuel = other_tank_node.CallFunction(&"GetUsedFuelAmount", [])
remaining_fuel = _composite_node.GetData(&"FuelLevel")
E.PlayerActionType.SecondaryInHandAction:
missing_fuel = _composite_node.CallFunction(&"GetUsedFuelAmount", [])
remaining_fuel = other_tank_node.GetData(&"FuelLevel")
var fuel_delta = missing_fuel if (missing_fuel < remaining_fuel) else remaining_fuel
_total_refueling_time = fuel_delta / FuelingRate
_finish_fueling_at_gametime = GameTime.GameTime + _total_refueling_time
_last_game_time = GameTime.GameTime
func UpdateInteraction(_ray_start:Vector3, _ray_end:Vector3, _world_relative_mov:Vector3) -> bool:
if is_instance_valid(_currently_interacting_composite):
if not CheckIfFuelCanBeTransfered(_currently_interacting_composite, _current_action_type):
return false
var time_delta = GameTime.GameTime - _last_game_time
var fueling_amount = FuelingRate * time_delta
match _current_action_type:
E.PlayerActionType.PrimaryInHandAction:
_currently_interacting_composite.CallFunctionOnAuthority(&"IncreaseFuelLevel", [fueling_amount])
_composite_node.CallFunctionOnAuthority(&"DecreaseFuelLevel", [fueling_amount])
E.PlayerActionType.SecondaryInHandAction:
_composite_node.CallFunctionOnAuthority(&"IncreaseFuelLevel", [fueling_amount])
_currently_interacting_composite.CallFunctionOnAuthority(&"DecreaseFuelLevel", [fueling_amount])
UI.HUD_set_progress_indicator((_finish_fueling_at_gametime - GameTime.GameTime) / _total_refueling_time)
_last_game_time = GameTime.GameTime
return true
return false
func EndInteraction() -> void:
UI.HUD_set_progress_indicator(0)
_currently_interacting_composite = null
_current_action_type = E.PlayerActionType.None
_other_tank_node = null
func _can_be_refueld(other_node : CompositeNode) -> bool:
if is_instance_valid(other_node):
var can_be_refueld = other_node.GetData(&"CanBeRefueld")
if not can_be_refueld:
return false
return true
return false
With these two modules in place, players can use fuel canisters to keep the cruiser running, but they can also use the exact same mechanics in other contexts. That was an important goal throughout this feature, because the engine systems were designed to work not only inside the cruiser but anywhere else in the game world where similar machinery might appear.
Another important detail is that the complete engine logic is calculated on the server. Clients only receive the specific values they need for feedback, UI, or interactions inside the cruiser. This keeps the behavior consistent for all players in multiplayer while still allowing the engine to feel responsive and readable from the client side.
Taken together, the engine became much more than a simple object that can be switched on and off. Fuel, detachable parts, durability and temperature all influence each other, which makes the cruiser feel like a machine that players have to understand and maintain over time. That interplay was what made the system interesting to build, because each individual mechanic is useful on its own, but together they create a much stronger gameplay loop.
A big part of the game takes place in the endless cold of Antarctica, so temperature plays an important role throughout the whole experience. Because of that, I was tasked with implementing a system that allows heat sources in the game to directly affect the spaces around them. A good example is the cruiser, where the engine acts as a heat source and warms up the engine room. The system can then transfer heat from one room to another, which makes it possible to gradually warm up the whole cruiser. The player character is also affected by the temperature of the room they are currently in. If they stay in the cold for too long, they slowly freeze and eventually get knocked out, so they have to search shelter in a warm area.
I started by creating heat sources with a shared base class that other heat sources could inherit from. This base class stores the current temperature of the source, its exchange rate and a reference to the room the source is affecting.
extends Node
class_name TemperatureSource
@export var CurrentRoom : Room
@export var ExchangeSpeed : float = 1.0:
set(value): _current_exchange_speed = value
@export var StartTemperature : float = 0
var _room_temp_node : Node
var _current_temperature : float = 0.0
@onready var _current_exchange_speed : float = ExchangeSpeed
func _ready() -> void:
await get_tree().process_frame
_room_temp_node = CurrentRoom.get_meta(&"RoomTemperature")
if _room_temp_node:
_room_temp_node.temperature_sources.push_back(self)
func set_current_temperature(new_temp : float):
_current_temperature = new_temp
func get_current_temperature() -> float:
return _current_temperature
func get_exchange_speed() -> float:
return _current_exchange_speed
To handle temperature exchange between rooms, I implemented room doors as heat sources. I called this module ConnectionTemperatureSource, since it is specifically used for transferring heat between connected spaces. The module stores a reference to another room, reads that room's current temperature and uses it to influence the temperature of the room it belongs to. The connection can also be configured with a float value that defines how much heat is allowed to pass through. This made it possible to support doors that are fully open, fully closed, or somewhere in between.
extends TemperatureSource
@export var ConnectedRoom : Room
@export var ConnectedToOutside : bool
## A value of 1 means the connection is fully permeable when closed and therfore dosen't change exchange speed.
## A value of 0.1 will reduce the exchange speed by 90%
@export var ClosedPermeability : float = 1.0
var _composite_node : CompositeNode
var _room_connection : RoomConnection
func _ready() -> void:
await super._ready()
_room_connection = get_parent()
if not _room_connection:
printerr("Connection Temperature Source needs to be child pf a room connection! %s" % get_path())
return
_composite_node = CompositeNode.GetCompositeNodeInParents(self)
if ConnectedToOutside:
ConnectedRoom = Room.OutsideRoom
_composite_node.RegisterDataUpdatedCallback(_room_connection.ClosedByDataValue, on_door_value_changed, true)
func _exit_tree() -> void:
if not is_instance_valid(_room_connection):
return
_composite_node.UnregisterDataUpdatedCallback(_room_connection.ClosedByDataValue, on_door_value_changed)
func get_current_temperature():
if not is_instance_valid(ConnectedRoom):
printerr("[%s] ConnectionTemperatureSource needs to be connected to a room" % get_parent().name)
return
if not ConnectedRoom.has_meta(&"RoomTemperature"):
printerr("ConnectionTemperatureSource needs to be connected to a room with temperature")
return
var room_temp = ConnectedRoom.get_meta(&"RoomTemperature").get_room_temperature()
return room_temp
func on_door_value_changed(value : float):
if value <= 0.0:
_current_exchange_speed = ExchangeSpeed
else:
_current_exchange_speed = ExchangeSpeed * ClosedPermeability
To actually apply temperature to a room, I implemented a room temperature module. This module stores references to all heat sources inside the room and periodically updates the current room temperature based on those sources and the room's volume coefficient. That coefficient lets bigger rooms change temperature more slowly than smaller ones. One example of a heat source here is the engine, which produces heat based on its RPM. If the engine cooler is broken, it produces even more heat, but that also puts additional strain on the other engine parts and reduces their durability.
extends Node
@export var RoomName : String = ""
@export var VolumeCoefficient : float = 1
@export var IsOutsideRoom : bool
var temperature_sources : Array[TemperatureSource]
var _room_temperatue : float = 20
var _check_timer : float = 0.0
var _check_wait_time : float = 0.2
var _time_last_check : float = 0.0
var _composite_node : CompositeNode
func _ready() -> void:
_composite_node = CompositeNode.GetCompositeNodeInParents(self)
_composite_node.RegisterCallback("init_authority", init_authority)
_composite_node.SetupDataMultiplayerSynchronization("%s_Temperature" % RoomName,
CompositeNode.DataSynchronizationMode.LowFrequency,
CompositeNode.DataSynchronizationType.S16)
var parent_node : Node = get_parent()
parent_node.set_meta(&"RoomTemperature", self)
_time_last_check = GameTime.GameTime
func init_authority(_player_id:int) -> void:
if _composite_node.IsAuthority():
_composite_node.SetData("%s_Temperature" % RoomName, _room_temperatue)
else:
_composite_node.RegisterDataUpdatedCallback("%s_Temperature" % RoomName, on_temperature_update)
func _exit_tree() -> void:
if not _composite_node.IsAuthority():
_composite_node.UnregisterDataUpdatedCallback("%s_Temperature" % RoomName, on_temperature_update)
func _process(delta: float) -> void:
if not _composite_node.IsAuthority():
return
if _check_timer <= _check_wait_time:
_check_timer += delta
return
_check_timer = 0.0
var check_delta_time = GameTime.GameTime - _time_last_check
var sum = 0
for source in temperature_sources:
var temperature = source.get_current_temperature()
var temp_diff = temperature - _room_temperatue
var temp_change = check_delta_time * source.get_exchange_speed() * temp_diff
if absf(temp_change) > absf(temp_diff):
temp_change = temp_diff
sum += temp_change / VolumeCoefficient
_room_temperatue += sum
_composite_node.SetData("%s_Temperature" % RoomName, _room_temperatue)
_time_last_check = GameTime.GameTime
func on_temperature_update(newTemperature: float):
if _composite_node.IsAuthority():
return
_room_temperatue = newTemperature
func get_room_temperature():
if _composite_node.IsAuthority():
return _room_temperatue as int
return _room_temperatue
Finally, I needed to connect the temperature system to the player so it would have a real gameplay impact. The player has a freezing state that tracks how far the freezing process has progressed. Depending on the current temperature of the room the player is currently in, I decrease or increase the freezing state. Based on that value, the character receives a movement speed penalty. I implemented this by defining a freeze speed reduction that is sampled through a curve using the current freezing state as input.
extends CompositeNodeModule
@export var FreezingScaleStart : float = 1.0
@export var FreezingScaleEnd : float = 0
@export var CoolingDownSpeedFactor : float = 5
@export var WarmingUpSpeedFactor : float = 15
##Temperature required for the player to start warming up again in °C.
@export var TemperatureThreshold : float = 10.0
@export var MovmentReductionCurve : Curve
@export var CanFreeze : bool = true
var _unconscious : bool = false
var _freeze_speed_reduction : CompositeNodeValue
var _freezing_state : CompositeNodeValue
func _ready_composite_node() -> void:
_freeze_speed_reduction = create_synchronized_value(&"FreezeSpeedReduction", 1.0, CompositeNode.OnChange, CompositeNode.HalfFloat)
_freezing_state = create_non_synchronized_value(&"FreezingState", 1.0)
register_data_updated_callback(&"Unconscious", on_unconscious_changed)
func _process(delta: float) -> void:
if not _composite_node.IsAuthority(): return
if not CanFreeze: return
var current_room := PlayerRoomProvider.LocalPlayerRoom
if not current_room.has_meta(&"RoomTemperature"):
printerr("Room has no temperature in CharacterTemperatureModel")
return
var room_temperature = current_room.get_meta(&"RoomTemperature").get_room_temperature()
var offset_temperature = room_temperature - TemperatureThreshold
var curr_freezing_state = _freezing_state.value
if not curr_freezing_state:
return
#Check if player is getting warmer or colder and adjust speed factor
if offset_temperature >= 0.0:
curr_freezing_state += offset_temperature * 0.0001 * WarmingUpSpeedFactor * delta
else:
curr_freezing_state += offset_temperature * 0.0001 * CoolingDownSpeedFactor * delta
curr_freezing_state = clamp(curr_freezing_state, FreezingScaleEnd, FreezingScaleStart)
if not _unconscious and curr_freezing_state <= FreezingScaleEnd:
_unconscious = true
_composite_node.SetData(&"Unconscious", _unconscious)
_composite_node.CallFunction(&"KnockOutCharacter", [])
var speed_reduction = MovmentReductionCurve.sample(1 - curr_freezing_state)
_freezing_state.value = curr_freezing_state
_freeze_speed_reduction.value = speed_reduction
func on_unconscious_changed(unconscious: bool):
if not unconscious:
_freeze_speed_reduction.value = MovmentReductionCurve.sample(0)
_freezing_state.value = FreezingScaleStart
_unconscious = false
We need power, so I was tasked with implementing a small electricity system for the game. Electricity affects objects such as light sources and the engine ignition button, which means the system needed to be flexible enough to support different kinds of gameplay interactions. To generate power, I implemented an alternator that produces electricity and feeds it into a battery. That battery can then be charged or discharged depending on what is currently connected to it.
A key part of the system was making batteries reusable across different objects. The cruiser, for example, needs its own battery, but portable equipment such as the player's flashlights also relies on one. Because of that, I structured the system around three simple building blocks: electricity producers, electricity consumers and batteries that store the energy. Producers and consumers are fairly lightweight, since they mainly contribute a value that increases or decreases the battery load and that value can also be changed dynamically during gameplay.
extends CompositeNodeModule
@export var MaxBatteryLevel : float = 1000
@export var MaxVoltage : float = 12
@export var MaxAmpereConsumption : float = 100
@export var VoltageDropCurve : Curve
@export var AmpereDropCurve : Curve
var _battery_level : CompositeNodeValue
var _battery_max_voltage : CompositeNodeValue
var _battery_voltage : CompositeNodeValue
var _ampere_consumption : CompositeNodeValue
var _battery_charging_rate : CompositeNodeValue
var _ampere_net_current : CompositeNodeValue
var _battery_voltage_internal : float
func _ready_composite_node() -> void:
_battery_voltage = create_synchronized_value(
&"BatteryVoltage", MaxVoltage,
CompositeNode.DataSynchronizationMode.OnChange,
CompositeNode.DataSynchronizationType.HalfFloat)
_battery_voltage_internal = MaxVoltage
_ampere_consumption = create_non_synchronized_value(&"AmpereConsumption", 0)
_battery_max_voltage = create_non_synchronized_value(&"BatteryMaxVoltage", MaxVoltage)
_battery_charging_rate = create_non_synchronized_value(&"BatteryChargingRate", 0)
_battery_level = create_synchronized_value(&"BatteryLevel",MaxBatteryLevel, CompositeNode.OnChange, CompositeNode.HalfFloat)
_ampere_net_current = create_synchronized_value(&"DisplayAmpereNetCurrent", 0, CompositeNode.LowFrequency, CompositeNode.HalfFloat)
func _process(delta: float) -> void:
if not _composite_node.IsAuthority(): return
var consumption = _ampere_consumption.value
var battery_level = _battery_level.value
var charging_rate = _battery_charging_rate.value
if battery_level >= 0 and consumption > 0:
battery_level -= consumption * delta
battery_level = clamp(battery_level, 0, MaxBatteryLevel)
if battery_level < MaxBatteryLevel and charging_rate > 0:
battery_level += charging_rate * delta
battery_level = clamp(battery_level, 0, MaxBatteryLevel)
_battery_level.value = battery_level
_battery_voltage_internal = MaxVoltage * VoltageDropCurve.sample(remap(battery_level, 0, MaxBatteryLevel, 0, 1)) * AmpereDropCurve.sample(remap(consumption, 0, MaxAmpereConsumption, 0, 1))
# we only want to update the actual BatteryVoltage DataValue when it has
# changed enough. otherwise there will be oscilations and it will change all the time
if _battery_voltage_internal <= 0.1:
_battery_voltage_internal = 0.0
if absf(_battery_voltage_internal - _battery_voltage.value) > 0.1:
_battery_voltage.value = _battery_voltage_internal
_ampere_net_current.value = _battery_charging_rate.value - _ampere_consumption.value
The battery itself is responsible for tracking all active producers and consumers and periodically updating its current charge based on their combined values. In addition to that, I introduced a voltage value that makes it possible to detect when the system is overloaded. If too many consumers are active at the same time, the voltage drops and connected devices can start to fail. This gave the whole setup a more believable behavior and made electricity feel like an actual gameplay system rather than just a simple on and off switch.
Fail states matter because they give the game real tension and make success feel earned. In our game, players fail when every character in the team has been knocked out and there is no way to bring them back. I worked on several parts of that system. When a character is knocked out, they are replaced by a new mesh representing their unconscious body, which other players can pick up and carry. I also implemented a spectator camera so knocked-out players can continue following the match by switching between their active teammates. To complete the loop, I created a revive mechanic tied to the cruiser bunk bed, allowing characters to respawn and rejoin the game.
I implemented a function and registered it on the player character so a character can be knocked out from anywhere in the codebase. If the character is not already unconscious, the function sets the unconscious boolean to true. The actual knockout logic is then handled through a callback that is triggered when this value changes. This includes disabling the player character, spawning the knocked out body mesh and switching the player camera to the spectator camera through the player interaction mode.
extends CompositeNodeModule
@export var CollisionShapes : Array[CollisionShape3D]
var _unconscious : CompositeNodeValue
var _incapacitated_body_id : CompositeNodeValue
func _ready_composite_node() -> void:
_unconscious = create_synchronized_value(&"Unconscious", false, CompositeNode.OnChange, CompositeNode.U8)
_incapacitated_body_id = create_synchronized_value(&"IncapacitatedBodyID", CompositeNode.INVALID_ID, CompositeNode.OnChange, CompositeNode.U32)
register_data_updated_callback(&"Unconscious", on_unconscious_changed)
register_function(&"RespawnAtPosition", RespawnAtPosition)
register_function(&"KnockOutCharacter", KnockOutCharacter)
func KnockOutCharacter():
if _unconscious.value: return #Character is already unconscious
_unconscious.value = true
func RespawnAtPosition(transform: Transform3D, forward_dir: Vector3) -> bool:
var incapacicated_character : CompositeNode = CompositeNode.GetCompositeNodeByID(_incapacitated_body_id.value)
if is_instance_valid(incapacicated_character):
var answer = incapacicated_character.CallFunctionOnAuthority(&"FreeIncapacitatedCharacter", [])
if not answer.has_value(): await answer.ValueWasSet
_unconscious.value = false
return _composite_node.CallFunction(&"TeleportTo", [transform, forward_dir])
func on_unconscious_changed(unconscious: bool):
if unconscious:
for shape in CollisionShapes:
shape.disabled = true
if _composite_node.IsAuthority():
#Check if our character is currently occupying an object
var occupying_composite_id : Variant = _composite_node.GetData(&"OccupyingCompositeID")
if occupying_composite_id != null and occupying_composite_id != CompositeNode.INVALID_ID:
var occupying_node : CompositeNode = CompositeNode.GetCompositeNodeByID(occupying_composite_id)
if occupying_node != null:
occupying_node.CallFunction(&"Vacate", [])
_composite_node.SetData(&"PlayerInteractionMode", E.PlayerInteractionMode.Incapacitated)
_composite_node.CallFunction(&"HolsterInHandObject", [])
if _composite_node.IsServer():
var pos : Vector3 = _composite_node.CallFunction(&"GetFocusPoint", [])
var rot : Vector3 = _composite_node.GetData(&"VisibleThingsRotation")
var incapacicated_character : CompositeNode = SpawnManager.spawn(&"IncapacitatedCharacter", pos, rot, _composite_node.GetServerID())
if not incapacicated_character:
printerr("Could not spawn incapacicated character")
return
incapacicated_character.SetData(&"RepresentedPlayerID", _composite_node.CompositeID)
_composite_node.SetDataOnAuthority(&"IncapacitatedBodyID", incapacicated_character.CompositeID)
else:
if _composite_node.IsAuthority():
_composite_node.SetData(&"PlayerInteractionMode", E.PlayerInteractionMode.FirstPerson)
for shape in CollisionShapes:
shape.disabled = false
As an example, this is the code the cruiser uses to run over player characters. It uses a simple collision shape that knocks out the hit player based on the current velocity of the referenced physics object.
extends CompositeNodeModuleArea3D
@export var KnockOutVelocity : float = 5
func _ready_composite_node() -> void:
body_entered.connect(on_body_entered)
func on_body_entered(body: Node3D):
if not _composite_node.IsAuthority():
return
var body_composite_node := CompositeNode.GetCompositeNodeInParents(body)
if not body_composite_node: return
var current_mover_velocity : Vector3 = _composite_node.CallFunction(&"GetPointVelocity", [body.global_position])
if not current_mover_velocity:
return
if current_mover_velocity.length_squared() > KnockOutVelocity * KnockOutVelocity:
body_composite_node.CallFunctionOnAuthority(&"KnockOutCharacter", [])
With this logic in place, I was able to drive over the character and knock them out during gameplay. This was useful because it let me properly test the full knockout flow in an actual game situation instead of only triggering it manually through code. It also helped show that the knockout state worked correctly when caused by a moving gameplay object like the cruiser, which made the whole feature feel much more grounded in the game.
One of the design constraints we have for the game is that a player is never truly dead. As long as there is another player who can still revive them, the body can be rescued. Players can pick up the unconscious body and carry it back to the cruiser to respawn them, or use a medkit to revive them on the spot. I implemented a function which can be used to respawn the player at a specific position, which can also be seen in the code block above. To revive the player in the cruiser, the unconscious body gets attached to the bunk bed and after a duration of 5 seconds the player respawns.
In Hypothermia, there are several use cases where an item needs to belong to a specific owner. One example is the inventory system. When a player picks up an item and stores it in their inventory, ownership needs to be transferred to the player character. An item can only ever have one owner, but one owner can hold multiple items.
The following code block shows part of the inventory system implementation. With these four methods, I can assign an item to a new owner, in this case the player. Another system that uses the same approach is the attachment slot logic, for example for engine parts mentioned earlier. Attachment slots implement the same methods, which means they can also become the owner of an item. I intentionally implemented four separate methods because we need one method that can be called on the owner and one that can be called from the owned item. For example, AddToOwner can be called when the character picks up an item, so the owner initiates the process. OwnerAdded only reacts to that event and can be used when an item attaches itself to an owner.
func AddItem(item_id: int) -> bool:
if not _composite_node.IsAuthority():
printerr("[CharacterInventoryModule.gd | AddItem] Trying to add item but node is not authority")
return false
if item_id == CompositeNode.INVALID_ID:
return false
var item : CompositeNode = CompositeNode.GetCompositeNodeByID(item_id)
if not item:
return false
var answer := item.CallFunctionOnAuthority(&"OwnerAdded", [_composite_node.CompositeID])
if not answer.has_value(): await answer.ValueWasSet
if answer.get_value():
_add_to_inventory(item_id)
return true
return false
func ItemAdded(item_id: int) -> bool:
if not _composite_node.IsAuthority():
printerr("[CharacterInventoryModule.gd | ItemAdded] Trying to add item but node is not authority")
return false
if item_id == CompositeNode.INVALID_ID:
return false
_add_to_inventory(item_id)
return true
func RemoveItem(item_id: int) -> bool:
if not _composite_node.IsAuthority():
printerr("[CharacterInventoryModule.gd | RemoveItem] Trying to remove item but node is not authority")
return false
if item_id == CompositeNode.INVALID_ID:
return false
if _in_hand_composite_id.value == item_id:
_in_hand_composite_id.value = CompositeNode.INVALID_ID
var item : CompositeNode = CompositeNode.GetCompositeNodeByID(item_id)
if not item:
_remove_from_inventory(item_id)
return true
if not item.IsAuthority(): #We need to enable the item before we get a response from the authority to prevent lag when throwing
item.CallFunction(&"EnableItem", [])
var answer := item.CallFunctionOnAuthority(&"OwnerRemoved", [])
if not answer.has_value(): await answer.ValueWasSet
if answer.get_value():
_remove_from_inventory(item_id)
return true
return false
func ItemRemoved(item_id: int) -> bool:
if not _composite_node.IsAuthority():
printerr("[CharacterInventoryModule.gd | ItemRemoved] Trying to remove item but node is not authority")
return false
if item_id == CompositeNode.INVALID_ID:
return false
if _in_hand_composite_id.value == item_id:
_in_hand_composite_id.value = CompositeNode.INVALID_ID
_remove_from_inventory(item_id)
return trueThe other side looks very similar. If I want to create an item that can be owned, it needs to implement the corresponding methods as well. The following code is part of the StoringModule, which contains the logic required for an item to be stored in an inventory.
func OwnerAdded(id: int) -> bool:
if not _composite_node.IsAuthority():
printerr("[InteractableStoringModule.gd | OwnerAdded] Trying to add owner but node is not authority")
return false
if id == CompositeNode.INVALID_ID:
return false
_owner_id.value = id
_on_added()
return true
func AddToOwner(id: int) -> bool:
if not _composite_node.IsAuthority():
printerr("[InteractableStoringModule.gd | AddToOwner] Trying to add owner but node is not authority")
return false
if id == CompositeNode.INVALID_ID:
return false
_owner_id.value = id
var owning_comp_node = CompositeNode.GetCompositeNodeByID(id)
if not is_instance_valid(owning_comp_node):
return false
var answer := owning_comp_node.CallFunctionOnAuthority(&"ItemAdded", [_composite_node.CompositeID])
if not answer.has_value(): await answer.ValueWasSet
if answer.get_value():
_on_added()
return true
return false
func RemoveFromOwner() -> bool:
if not _composite_node.IsAuthority():
printerr("[InteractableStoringModule.gd | RemoveFromOwner] Trying to remove owner but node is not authority")
return false
var owning_comp_node = CompositeNode.GetCompositeNodeByID(_owner_id.value)
if not is_instance_valid(owning_comp_node):
return false
var answer := owning_comp_node.CallFunctionOnAuthority(&"ItemRemoved", [_composite_node.CompositeID])
if not answer.has_value(): await answer.ValueWasSet
if answer.get_value():
_owner_id.value = CompositeNode.INVALID_ID
EnableItem()
return true
return false
func OwnerRemoved() -> bool:
if not _composite_node.IsAuthority():
printerr("[InteractableStoringModule.gd | OwnerRemoved] Trying to remove owner but node is not authority")
return false
_owner_id.value = CompositeNode.INVALID_ID
EnableItem()
return true
In both cases, synchronization naturally plays a major role. The function calls are guarded to ensure the logic is currently running on the authority. If not, a remote call is triggered and the result is awaited. This prevents situations where multiple owners could accidentally be assigned, for example when two players try to interact with the same item at the same time.
While I really enjoy developing gameplay mechanics and designing game systems, networking technology definitely also became a big interest of mine. Most of my favorite games are multiplayer games or at least support co-op in some form. As soon as I felt more confident working on high-level synchronized systems, I wanted to dig deeper and started focusing more on the networking side of Hypothermia.
One of my first networking tasks was fixing a bug in our physics synchronization. In Hypothermia we have three different kinds of physics objects that need synchronized state: player characters, pickup objects and the cruiser. The cruiser in particular was causing trouble. Even when standing still the cruiser was twitching up and down. Our state synchronization works by having the authority calculate the current physics state of an object and then send that state to the other clients.
Because there is always a delay between sending a state packet and receiving it, the missing physics steps get resimulated on the clients. After digging into the cruiser issue, I found out that the more physics steps the cruiser had to resimulate, the bigger the jump became. And if we really push it and try to resimulate more than 100 steps, which can happen during a big lag spike, we start getting some very interesting behavior...
Probably a good mechanic for one of the Tony Hawk games, but not really feasible for Hypothermia. The problem was that our resimulation only applied the physics step of the object itself and not a full physics engine step, because doing that properly every time would of course be way too expensive. But that also meant gravity was not being integrated during resimulation, so we started accumulating a strong upward force which caused the jumping.
The actual implementation of the fix was only a few lines of code, but getting there was much more of a research task. Since we are using Jolt as the physics engine, I had to go through the codebase and figure out what a normal physics step actually looks like and what exactly we were missing in our resimulation step.
void JoltBody3D::simple_physics_step(const float p_step) {
_integrate_forces(p_step, *jolt_body);
JPH::MotionProperties *mp = jolt_body->GetMotionProperties();
ERR_FAIL_NULL(mp);
const JPH::Quat rotation = jolt_body->GetRotation();
mp->ApplyForceTorqueAndDragInternal(rotation, {0.f,0.f,0.f}, p_step);
mp->ResetForce();
mp->ResetTorque();
}I implemented a new method directly within the Jolt source code and called it simple_physics_step. The most important part here is _integrate_forces. It is a member function of JoltBody3D and updates linear and angular velocity based on damping and gravity. With simple_physics_step I created a function that can be called from GDScript to get a much better result during physics resimulation.
func apply_physics_state_update(physics_frame:int, pos:Vector3, rot:Vector3, vel:Vector3, ang_vel:Vector3):
global_position = pos
global_rotation = rot
linear_velocity = vel
angular_velocity = ang_vel
if not Global.CruiserPhysicsResimulation:
return
var state = PhysicsServer3D.body_get_direct_state(get_rid())
if not state:
return
state.linear_velocity = vel
state.angular_velocity = ang_vel
var frame_offset : int = (GameTime.PhysicsFrame - physics_frame)
if frame_offset > MAX_PHYSIC_STEP_OFFSET:
push_warning("TankVehicle received a physics_state_update that was older than 250ms, will only resimulate 250ms.")
frame_offset = MAX_PHYSIC_STEP_OFFSET
if frame_offset > 0:
for _i in frame_offset:
# every physics frame that happened since the update has to be re-simulated
_physics_process(get_physics_process_delta_time())
# update treads for next step
for tread in Treads:
tread.force_shapecast_update()
# integrate forces
simple_physics_step()
# update velocity based und direct body state
linear_velocity = state.linear_velocity
angular_velocity = state.angular_velocity
# update rotation
var angular_velocity_times_dt: Vector3 = angular_velocity * get_physics_process_delta_time()
var angle: float = angular_velocity_times_dt.length()
if angle != 0.0:
var axis: Vector3 = angular_velocity_times_dt.normalized()
var delta_rotation: Quaternion = Quaternion(axis, angle)
var current_quat: Quaternion = Quaternion.from_euler(global_rotation)
var new_quat: Quaternion = (delta_rotation * current_quat).normalized()
global_rotation = new_quat.get_euler()
# update position
var collider : KinematicCollision3D = move_and_collide(linear_velocity * get_physics_process_delta_time())
if collider:
linear_velocity = Vector3.ZERO
angular_velocity = Vector3.ZERO
state.linear_velocity = Vector3.ZERO
state.angular_velocity = Vector3.ZEROInside our GDScript code I could now call this new function and integrate the missing forces. That made the physics resimulation much more stable and stopped the cruiser from bouncing around, even during heavy lag spikes.
The state synchronization in the project is inspired by the State Synchronization Article by Glenn Fiedler. Since he does not really go into frame resimulation, I also wanted to test how the game feels without using it at all.
Resimulation gives a more accurate representation of the server state, but especially with faster movement you can also clearly see how much jerkier it looks. Our current network setup is a mesh, so clients have authority over their own player character and are the ones calculating the current physics state and sending it to the other connected peers.
The cruiser, on the other hand, is handled by the client that is hosting the game. That means other clients controlling the cruiser can feel the movement lagging a bit behind, because they first have to send their input to the authority, then the physics state gets calculated there and only after that gets sent back. Therefore, with the physics resimulation we get a more accurate result of the current cruiser position. We have not fully decided yet whether resimulation feels better or not, so I added toggle buttons to our settings menu to enable and disable it for better testing.
Creating multiplayer games in Godot is still something fairly new and there are not that many shipped examples yet. The default networking implementation is a fast and accessible way to get a multiplayer project running, but for our project it relied on features we did not want to build around, especially things like MultiplayerSynchronizer and MultiplayerSpawner. Because of that, we decided to refactor the networking layer and that became one of the tasks I was responsible for.
The main goal was to move away from relying so heavily on Godot's default MultiplayerAPI and build a setup that gives us more direct control over authority, message routing and peer-to-peer communication. I ended up working on a custom networking layer around our Communication Line System, direct MultiplayerPeer access and mesh support for both gameplay and voice chat. That work changed a lot under the hood, but for the project the most important result was simply that we had more control and a better foundation for the kind of co-op game we were building.
Since that refactor grew into a much bigger task on its own, I wrote a separate breakdown in my Custom Multiplayer Networking in Godot project page. There I go much deeper into the actual architecture, the mesh implementation and the problems I had to solve while changing that part of the engine.
Late joining is important for Hypothermia because players need to be able to reconnect after a disconnect or a game crash. Earlier in development this already worked, but as the project kept growing, that feature needed maintenance to keep working reliably. So I started digging into why late joining had broken down and fixed the issues one by one.
Some of the bugs were relatively small on their own, but together they made reconnecting very unreliable. Pickups could for example be synchronized as if they were already in a player's hand, but never actually get attached visually. Another issue came from scene loading. During loading the game process was paused, which meant the MultiplayerPeer stopped polling and could trigger a disconnect right in the middle of the connection flow. Late joining mostly failed because of a lot of problems like that stacking on top of each other.
The biggest issue showed up when reconnecting through Epic Online Services. EOS only allows packets up to 1170 bytes, but for a reconnect I need to send the full current game state as a one-time packet. Once the project became larger, that packet could exceed the limit, so I needed a way to split it up and still reconstruct it correctly on the other side.
To solve that, I implemented packet slicing based on Glenn Fiedler's article Sending Large Blocks of Data. The basic idea is to detect packets that are too large, treat them as a chunk and split that chunk into smaller slices. Those slices are then sent individually and recreated by the receiver once all of them arrived. The important part here is reliability. If a chunk is split into 256 slices, even a small packet loss rate gives you a very high chance of losing the whole reconstructed packet, because every single slice matters. Glenn also shows how to build custom reliability, but in my case I did not need to reimplement that part because Godot already provides reliable packet delivery. So I could focus on slicing and reassembling the chunk.
On the sender side, I first calculate how many slices are needed and check that the packet still stays within the limits I defined for chunked data. After that, the packet is split into equally sized slices, with only the last slice being smaller if needed. Each slice gets its own metadata, including the chunk id, the total number of slices, the current slice index and the size of the contained data, before it is sent to the target peer.
void ChunkSender::send_as_chunk(const int to, const PackedByteArray &packet) {
ERR_FAIL_COND_MSG(_send_buffer.is_null(), "_send_buffer is null");
ERR_FAIL_COND_MSG(_multiplayer_peer.is_null(), "_multiplayer_peer is null");
ERR_FAIL_COND_MSG(packet.is_empty(), "Cannot chunk empty packet");
ERR_FAIL_COND_MSG(packet.size() > MAX_CHUNK_SIZE, "Packet exceeds MAX_CHUNK_SIZE");
const int num_slices = (packet.size() + SLICE_SIZE - 1) / SLICE_SIZE;
ERR_FAIL_COND_MSG(num_slices > MAX_SLICES_PER_CHUNK, "Packet exceeds MAX_SLICES_PER_CHUNK");
print_line("Start chunking packet of size: ", packet.size());
_chunkId++;
const uint8_t *src = packet.ptr();
//Slice packet and send the slices
for (int i = 0; i < num_slices; i++) {
_send_buffer->clear();
const int offset = i * SLICE_SIZE;
const int bytes_to_copy = MIN(SLICE_SIZE, packet.size() - offset);
_send_buffer->put_u8(static_cast<uint8_t>(CommunicationLinePacketTypes::SendDataChunk));
_send_buffer->put_16(_chunkId);
_send_buffer->put_32(num_slices);
_send_buffer->put_32(i);
_send_buffer->put_32(bytes_to_copy);
_send_buffer->put_data(src + offset, bytes_to_copy);
PackedByteArray slice = _send_buffer->get_data_array();
_multiplayer_peer->set_target_peer(to);
_multiplayer_peer->put_packet(slice.ptr(), slice.size());
print_line("Send slice: ", i, " | with size: ", slice.size());
}
}
On the receiving side, I created a ChunkReceiverthat collects slices until a full chunk can be rebuilt. One important detail here is that I do not just key the data by chunk id, but by a combination of sender peer id and chunk id. That way multiple chunk transfers can exist at the same time without colliding. The receiver stores which slices already arrived, ignores duplicates and only knows the final total packet size once the last slice has been received. As soon as all slices are there, the rebuilt packet is passed back into the normal process_packet flow, so the rest of the networking code can handle it like any other packet.
void ChunkReceiver::receive_chunk_data(const int from, Ref<StreamPeerBuffer> buffer) {
ERR_FAIL_COND_MSG(buffer.is_null(), "Buffer is null");
const uint16_t packet_chunkId = buffer->get_u16();
const int num_slices = buffer->get_32();
const int current_slice_id = buffer->get_32();
const int slice_size = buffer->get_32();
ERR_FAIL_COND_MSG(num_slices <= 0 || num_slices > MAX_SLICES_PER_CHUNK, "Invalid num_slices");
ERR_FAIL_COND_MSG(current_slice_id < 0 || current_slice_id >= num_slices, "Invalid slice index");
ERR_FAIL_COND_MSG(slice_size < 0 || slice_size > SLICE_SIZE, "Invalid slice size");
ERR_FAIL_NULL_MSG(_communication_line_system, "Communication Line System was not correctly initialized in ChunkReceiver");
const int offset = current_slice_id * SLICE_SIZE;
ERR_FAIL_COND_MSG(offset + slice_size > MAX_CHUNK_SIZE, "Chunk write exceeds MaxChunkSize");
//We create a key based on the peer the packet is from and the chunk id
uint64_t key = ((uint64_t)from << 32) | packet_chunkId;
//Check if we already have a chunk with this id, if not create one
auto it = _chunks.find(key);
if (it == _chunks.end()) {
print_line("Start receiving a chunk");
ChunkData chunk{};
chunk.received.resize_initialized(num_slices);
chunk.data.resize_initialized(num_slices * SLICE_SIZE);
chunk.num_slices = num_slices;
chunk.total_size = 0;
chunk.last_slice_time = OS::get_singleton()->get_ticks_msec();
it = _chunks.emplace(key, std::move(chunk)).first;
}
ChunkData &chunkData = it->second;
if (chunkData.num_slices != num_slices) {
_chunks.erase(key);
ERR_FAIL_MSG("Chunk num_slices mismatch");
}
print_line(vformat("Received slice %d of %d", current_slice_id + 1, num_slices));
if (chunkData.received[current_slice_id]) {
//If we receive the same slice again the data should normally be the same but just to be safe we will not overwrite it. But we update the last slice time.
chunkData.last_slice_time = OS::get_singleton()->get_ticks_msec();
return;
}
buffer->get_data(chunkData.data.ptr() + offset, slice_size);
chunkData.received[current_slice_id] = true;
chunkData.last_slice_time = OS::get_singleton()->get_ticks_msec();
//The last chunk slice could have a different size as the other slices, therefore we need to wait until we received it to define the total size.
if (current_slice_id == num_slices - 1) {
chunkData.total_size = offset + slice_size;
}
for (int i = 0; i < num_slices; i++) {
if (!chunkData.received[i]) {
return;
}
}
if (chunkData.total_size <= 0) {
_chunks.erase(key);
ERR_FAIL_MSG("Completed chunk missing total_size");
}
print_line("All slices received, continuing packet processing");
_communication_line_system->process_packet(
from, chunkData.data.ptr(), chunkData.total_size
);
_chunks.erase(key);
}In our playtests we suffered more and more from disconnects, especially during the connection phase of the clients. To detect these problems and find potential solutions, I tried to stress test the game. I used a third-party tool called Clumsy. With this tool I was able to test under heavy lag, packet loss, or out of order packets.
With this setup in place, I started cleaning up the initialization process during the connection phase. There was still some leftover code from our prototyping phase, so I removed parts that were no longer needed and adjusted the order in which clients get initialized. We also had issues with forced disconnects. In some cases the host would crash when a client tried to leave, so I worked on stabilizing that flow and also implemented the option to leave the game scene and return to the lobby UI while keeping all players in the lobby.
This is still an ongoing task and I always keep an eye on potential networking issues during development. Over time I managed to reduce the number of disconnects during playtests, and we can already test the game with noticeably fewer networking problems than before.
The lobby UI itself also became part of my stability work. There was no proper exception handling for wrong IP addresses or invalid Epic IDs when trying to connect. I added handling for those cases and also implemented the ability to disconnect from the lobby UI and reconnect again afterwards.