Physics-Based Crate Carrying in Godot 4: A Spring-Damper Approach
1. The Problem: Reparenting Sucks
In our ship-driving game *Runners*, players need to haul crates across ship decks, through doorways, around corners. Early prototypes used the standard Godot approach: press F, reparent the crate to the player, done.
It felt terrible. Instant. Weightless. The crate teleported into position. No inertia. No momentum. Doorways were invisible walls because the crate always tracked the player’s exact position — no physics-based collision resolution. It’s the game dev equivalent of “make it so” without earning it.
We wanted the crate to feel like a physical object. Lag behind when you sprint. Bump into walls. Slide along corridors. Take two players to lift a heavy one. So we threw out reparenting and built a spring-damper force system.
2. The Spring-Damper Model
A spring-damper is a PD (proportional-derivative) controller. The proportional term pulls toward a target. The derivative term resists velocity, suppressing oscillation. Together they produce smooth, stable tracking.
The force formula we apply every physics frame:
spring_force = displacement * Kp
damping_force = -velocity * Kd
total_force = spring_force + damping_force
Where:
displacement = carry_target.global_position - crate.global_position (Vector3)Kp = spring constant (proportional gain)Kd = damping constant (derivative gain)This is the same principle used in Half-Life 2’s gravity gun, robot arm controllers, and industrial servo positioning. It’s battle-tested.
Why This Beats Reparenting
| Approach | Physics Feel | Multi-Carrier | Collision Handling | Latency |
| Reparenting | None — instant snap | Impossible | Ignored | Zero |
| Spring-damper | Full inertia, momentum | Natural (sum forces) | Real physics | Configurable |
The crate remains a fully active RigidBody3D at all times. It collides with walls, ships, and other crates. It has mass, friction, angular momentum. The forces just guide it — they don’t fight physics.
3. Architecture Evolution: Crate-Driven to Player-Driven
Phase 1: Crate Self-Applies Forces (Discarded)
Our first implementation put the spring-damper logic in crate.gd. The crate checked if is_being_carried each _physics_process and applied forces toward its assigned carry_target.
# Original approach in crate.gd (deprecated)
func _physics_process(_delta):
if is_being_carried and carry_target:
var displacement = carry_target.global_position - global_position
var force = displacement * 800.0
apply_central_force(force)
This worked for one player. Then we added local co-op.
Two players picking up the same crate means two carry_target positions. The crate can’t know how many players are involved or their individual strength. Force-capping per player was impossible from inside the crate. So we inverted the architecture.
Phase 2: Player-Driven Force Application (Current)
The player applies forces *to* the crate. Each player runs their own spring-damper loop. The crate receives summed forces from all carriers.
# player.gd:_physics_process
if carried_object and is_instance_valid(carried_object):
apply_carry_force_to_object(carried_object, delta)
This inversion unlocked per-player strength caps, multi-carrier synergy, and a clean separation of concerns: the player decides how hard to pull, the crate just receives forces.
The crate went from active participant to passive body. crate.gd no longer applies any carry forces. Its _physics_process only handles idle jiggle and carrier cleanup.
4. Current Implementation: Force Application
4.1 The Core Loop
apply_carry_force_to_object() runs every physics frame from the player’s _physics_process. It calculates spring and damping forces, caps them per player, and applies them to the crate.
# player.gd:900-934
func apply_carry_force_to_object(body: RigidBody3D, _delta: float) -> void:
if not body or not carry_target or not is_instance_valid(body) or not is_instance_valid(carry_target):
return
if body.sleeping:
body.sleeping = false
var displacement = carry_target.global_position - body.global_position
var distance = displacement.length()
var spring_force = displacement * carry_spring_constant
if distance > max_carry_distance:
spring_force *= max_distance_force_multiplier
var damping_force = -body.linear_velocity * carry_damping_constant
var total_force = spring_force + damping_force
var max_force = max(max_strength * gravity * max_carry_force_multiplier, 0.0)
if max_force <= 0.0:
return
if total_force.length() > max_force:
total_force = total_force.normalized() * max_force
body.apply_central_force(total_force)
4.2 The Force Cap
The force cap models a player’s physical strength. Each player can exert at most max_strength * gravity newtons.
var max_force = max_strength * gravity * max_carry_force_multiplier
# 40.0 * 9.8 * 1.0 = 392N per player
max_strength = 40.0: The kg equivalent. A 40kg lift is reasonable for a worker in a spacesuit.gravity = 9.8: Converts kg to newtons of lifting force.max_carry_force_multiplier = 1.0: A tuning dial. Crank past 1.0 if you want superhuman carry strength.Without the cap, the spring force scales unbounded with distance. A crate 10 meters away would receive 10 * 800 = 8000N, launching it at the player. The cap keeps forces in a sane range and makes multi-carrier cooperation meaningful.
4.3 Distance Boost
When the crate drifts beyond max_carry_distance (3.0m), the spring force gets multiplied by max_distance_force_multiplier (3.0). This is a recovery mechanism — if a collision or explosion sends the crate flying, it snaps back aggressively.
if distance > 3.0:
spring_force *= 3.0 # 2400N/m effective spring beyond 3m
Without this, a crate stuck on the other side of a railing would drift lazily back after clearing the obstacle. With it, recovery is prompt.
5. Multi-Carrier Support
The _carrier_targets dictionary in crate.gd maps each carrier node to its assigned carry target:
# crate.gd:22
var _carrier_targets: Dictionary = {} # Node3D carrier -> Node3D carry_target
5.1 Pickup Protocol
When a player picks up the crate, they call crate.pick_up(player, carry_target):
# player.gd:723-724
carried_object = carryable
var pickup_result = carryable.call("pick_up", self, carry_target)
The crate registers the carrier→target mapping:
# crate.gd:115-116
if carrier:
_carrier_targets[carrier] = target
Then _sync_primary_carry_target() sets carry_target to the first valid target in the dictionary (for backward compatibility with any code that still reads it directly).
5.2 Player-Side Tracking
Each player stores only their own carried reference:
# player.gd:60
var carried_object: RigidBody3D = null
Two players can both have carried_object pointing to the same crate. Each runs apply_carry_force_to_object() independently. The crate receives summed forces from both players.
5.3 Carrier Limits
# crate.gd:9
@export_range(0, 4) var max_carriers: int = 0 # 0 = unlimited
max_carriers = 0 means any number of players can grab the same crate. Set to 2 for a “two-person lift” mechanic on heavy crates. can_pick_up() enforces the limit:
# crate.gd:143-149
func can_pick_up(requesting_carrier: Node3D = null) -> bool:
_cleanup_invalid_carriers()
if requesting_carrier and _carrier_targets.has(requesting_carrier):
return true # Already carrying, allow re-pickup
if max_carriers > 0 and _carrier_targets.size() >= max_carriers:
return false
return true
Already-registered carriers can always re-pick up (useful after a drop-and-grab sequence).
5.4 Cleanup
_cleanup_invalid_carriers() purges entries where the carrier or target node has been freed. Called every _physics_process and before every can_pick_up() check. Prevents stale references from deleted players.
6. The Carry Leash
Carrying a crate is great. Being dragged across the map by a runaway crate is not. The carry leash constrains player movement relative to the carried object.
6.1 Leash Zones
Player ---- soft (2.4m) ---- hard (3.2m) ---- break (6.0m) ---- Drop
carry_leash_away_speed_at_limit (default 0.0, meaning no away movement at all).6.2 Implementation
_apply_carry_leash_to_velocity() decomposes the player’s intended velocity into *toward-crate* and *away-from-crate* components, then scales down the away component based on distance:
# player.gd:349-386
func _apply_carry_leash_to_velocity(target_velocity: Vector3) -> Vector3:
if not carry_leash_enabled or not carried_object:
return target_velocity
var to_object = carried_object.global_position - global_position
to_object.y = 0.0
var distance = to_object.length()
if auto_drop_when_break_distance_exceeded and carry_break_distance > 0.0 and distance > carry_break_distance:
_drop_carried_object()
return target_velocity
if distance <= carry_leash_soft_distance:
return target_velocity
var toward_dir = to_object.normalized()
var along_speed = target_velocity.dot(toward_dir)
var lateral_velocity = target_velocity - toward_dir * along_speed
var toward_speed = max(along_speed, 0.0)
var away_speed = max(-along_speed, 0.0)
if distance >= hard_distance:
away_speed *= carry_leash_away_speed_at_limit # Default 0.0 = freeze
else:
var t = (distance - carry_leash_soft_distance) / (hard_distance - carry_leash_soft_distance)
var away_multiplier = lerpf(1.0, carry_leash_away_speed_at_limit, clamp(t, 0.0, 1.0))
away_speed *= away_multiplier
var limited_along_speed = toward_speed - away_speed
return toward_dir * limited_along_speed + lateral_velocity
6.3 Why This Works
The leash operates on player *input velocity*, not crate forces. It solves a specific problem: if the crate hits a wall and stops, the player shouldn’t be able to keep walking forward, stretching the spring to infinity. The leash keeps the player near the crate, which in turn keeps the spring forces in a reasonable range.
Lateral movement (perpendicular to the crate direction) is never restricted. You can strafe around the crate freely.
The Y component is zeroed for distance calculations (to_object.y = 0.0). We only care about horizontal distance. Vertical separation (crate on a ramp, player below) won’t trigger the leash.
7. Crate Pushing
Walking into an un-carried crate pushes it. We use a PhysicsShapeQueryParameters3D sphere query because CharacterBody3D collisions don’t report RigidBody3D contacts via move_and_slide().
# player.gd:388-464
func _apply_push_to_carryables() -> void:
var horizontal_velocity = Vector3(velocity.x, 0.0, velocity.z)
var vel_length = horizontal_velocity.length()
if vel_length < crate_push_min_speed:
return
var space_state = get_world_3d().direct_space_state
var query = PhysicsShapeQueryParameters3D.new()
var sphere_shape = SphereShape3D.new()
sphere_shape.radius = 1.2
query.shape = sphere_shape
query.transform.origin = global_position + Vector3(0, 0.5, 0)
query.collision_mask = 16 # Interactables layer only
query.collide_with_areas = false
query.collide_with_bodies = true
var results = space_state.intersect_shape(query)
if results.is_empty():
return
var move_dir = horizontal_velocity.normalized()
for result in results:
var collider = result.collider
if not (collider is RigidBody3D and collider.is_in_group("carryable")):
continue
var crate := collider as RigidBody3D
if crate == carried_object:
continue
if "is_being_carried" in crate and bool(crate.get("is_being_carried")):
continue
var to_crate = crate.global_position - global_position
to_crate.y = 0.0
var distance = to_crate.length()
if distance < 0.1 or distance > 1.5:
continue
to_crate = to_crate.normalized()
var speed_toward = move_dir.dot(to_crate)
if speed_toward <= 0.1:
continue
var impulse_magnitude = vel_length * crate_push_impulse * speed_toward
impulse_magnitude *= (1.5 - distance) / 1.5
if impulse_magnitude > crate_push_max_impulse:
impulse_magnitude = crate_push_max_impulse
crate.apply_impulse(to_crate * impulse_magnitude, Vector3.ZERO)
Push to Push Logic
1. Sphere query (radius 1.2) finds all RigidBody3D nodes in the “carryable” group on the Interactables layer.
2. Skips the carried crate and any crate already being carried by another player.
3. Checks we’re actually moving *toward* the crate (speed_toward > 0.1).
4. Computes impulse proportional to player speed, push impulse constant, and directional alignment.
5. Applies linear falloff — crates at 0.1m get full push, crates at 1.5m get nothing.
6. Clamps to crate_push_max_impulse (50.0) to prevent Yeeting.
The impulse is applied at the crate’s center (Vector3.ZERO relative offset). For most crates this gives a clean push. For tall crates you might want to apply it lower to avoid excessive torque.
8. Collision Management
When carrying a crate through a doorway, the crate’s collision shape can catch on the doorframe. Worse, it can push the ship itself if collision layers allow ship↔crate interaction.
The disable_collision_when_carried flag on the crate controls this behavior:
# crate.gd:151-172 (relevant excerpt)
func _set_carry_state(should_be_carried: bool, was_carried: bool) -> void:
is_being_carried = should_be_carried
if is_being_carried:
sleeping = false
freeze = false
linear_damp = carry_damping
if disable_collision_when_carried and not _collision_suppressed:
collision_layer = 0 # Remove from all physics layers
_collision_suppressed = true
else:
carry_target = null
linear_damp = normal_damping
if _collision_suppressed:
collision_layer = original_collision_layer
collision_mask = original_collision_mask
_collision_suppressed = false
When disable_collision_when_carried is true:
collision_layer = 0: The crate stops being detected by other bodies. It won’t block doorways or push ships._ready() and restored on drop.When false (default), the crate keeps full collision. This gives a more physical feel — you have to navigate doorways carefully — but can cause frustration. We ship with it off because it feels better to fight the crate through a tight corridor than to have it ghost through walls.
Additionally, linear_damp jumps from normal_damping (0.1) to carry_damping (8.0) when carried. This extra damping absorbs energy, making the crate feel heavier and more stable in the hand.
9. Visual and Audio Polish
9.1 Control Line
A glowing cylinder mesh connects the player to the carried object. It’s created programmatically in _setup_control_line():
# player.gd:821-896
func _setup_control_line() -> void:
control_line = MeshInstance3D.new()
control_line.name = "ControlLine"
add_child(control_line)
var cylinder_mesh = CylinderMesh.new()
cylinder_mesh.top_radius = line_thickness # 0.05
cylinder_mesh.bottom_radius = line_thickness
cylinder_mesh.height = 1.0
control_line.mesh = cylinder_mesh
var material = StandardMaterial3D.new()
material.emission_enabled = true
material.emission = line_color # Cyan (0.2, 0.8, 1.0)
material.emission_energy = line_emission_energy # 2.0
material.albedo_color = line_color
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.albedo_color.a = 0.7
control_line.material_override = material
control_line.visible = false
Every frame, _update_control_line() repositions and resizes the cylinder:
# player.gd:848-898
func _update_control_line() -> void:
# ...
if carried_object and is_instance_valid(carried_object):
target_position = carried_object.global_position
if target_object:
var player_pos = global_position + Vector3(0, 0.5, 0)
var to_target = target_position - player_pos
var distance = to_target.length()
var midpoint = player_pos + to_target * 0.5
control_line.global_position = midpoint
control_line.scale = Vector3(1.0, distance, 1.0)
var direction = to_target.normalized()
# Align cylinder Y-axis to direction
control_line.look_at(target_position, up)
control_line.rotate_object_local(Vector3(1, 0, 0), PI / 2.0)
The cylinder’s default orientation is Y-up. We look_at() to point -Z at the target, then rotate 90 degrees around local X to align Y with the direction. The Y scale equals the actual distance, making the cylinder span player to crate.
9.2 Crate Glow
An OmniLight3D child node provides a subtle glow on crates that have has_glow = true:
# crate.gd:49-57
if has_glow:
var light = OmniLight3D.new()
light.name = "GlowLight"
light.light_color = glow_color # Cyan
light.light_energy = glow_energy # 0.5
light.omni_range = 2.0
light.omni_attenuation = 1.0
add_child(light)
Useful for power cells or quest items that need to be visible in dark ship corridors.
9.3 Hum Audio
A looping AudioStreamPlayer3D with configurable volume:
# crate.gd:60-68
if hum_vol_db > -80.0 and hum_audio:
var audio_player = AudioStreamPlayer3D.new()
audio_player.name = "HumAudio"
audio_player.stream = hum_audio
audio_player.volume_db = hum_vol_db
audio_player.autoplay = true
audio_player.max_distance = 10.0
add_child(audio_player)
Volume threshold of -80 dB means “off by default.” Set hum_vol_db to something audible (e.g., -10.0) and assign an AudioStream for a persistent ambient hum.
9.4 Idle Jiggle
When the crate is sitting around (not being carried), it gets a small random impulse at a configurable frequency:
# crate.gd:80-91
if jiggle_frequency > 0.0 and jiggle_amount > 0.0:
_jiggle_timer += _delta
var jiggle_interval = 1.0 / jiggle_frequency
if _jiggle_timer >= jiggle_interval:
_jiggle_timer = fmod(_jiggle_timer, jiggle_interval)
var dir = Vector3(
_jiggle_rng.randf_range(-1.0, 1.0),
_jiggle_rng.randf_range(-1.0, 1.0),
_jiggle_rng.randf_range(-1.0, 1.0)
).normalized()
apply_central_impulse(dir * jiggle_amount)
apply_torque_impulse(dir * jiggle_amount * 0.2)
At jiggle_frequency = 1.5 Hz and jiggle_amount = 0.2, the crate gets a tiny nudge every 0.67 seconds plus a slight rotational torque. This prevents the “perfectly still” look that makes physics objects feel dead. It also keeps the crate from falling asleep — a sleeping RigidBody3D ignores all forces.
10. Pickup and Drop Flow
10.1 Detection
_try_pickup_carryable() uses a two-tier detection:
# player.gd:679-706
func _try_pickup_carryable() -> void:
# Tier 1: Raycast forward
var space_state = get_world_3d().direct_space_state
var origin = global_position + Vector3(0, 0.5, 0)
var forward = -global_transform.basis.z
var end = origin + forward * interaction_range
var query = PhysicsRayQueryParameters3D.create(origin, end)
query.collide_with_areas = false
query.collide_with_bodies = true
var result = space_state.intersect_ray(query)
if result and _is_carryable(result.collider):
_pickup_carryable(result.collider as RigidBody3D)
return
# Tier 2: Group proximity fallback
var nearby_carryables = get_tree().get_nodes_in_group("carryable")
for carryable_node in nearby_carryables:
if _is_carryable(carryable_node):
var distance = global_position.distance_to(carryable_node.global_position)
if distance <= interaction_range:
_pickup_carryable(carryable_node as RigidBody3D)
return
Raycast first (precise, fast). Group search as fallback (catches crates partially outside the ray but within range). _is_carryable() validates the candidate is a RigidBody3D, in the “carryable” group, and has can_pick_up() returning true.
10.2 Pickup Handshake
# player.gd:717-726
func _pickup_carryable(carryable: RigidBody3D) -> void:
if carried_object:
return
if not carryable or not carryable.has_method("pick_up"):
return
carried_object = carryable
var pickup_result = carryable.call("pick_up", self, carry_target)
if pickup_result is bool and not bool(pickup_result):
carried_object = null # Crate rejected the pickup
The player sets carried_object optimistically, calls crate.pick_up(), then clears the reference if rejected. The crate can reject due to max_carriers limit, being already at capacity, or any custom condition in can_pick_up().
10.3 Drop
# player.gd:728-736
func _drop_carried_object() -> void:
if not carried_object:
return
var object_to_drop = carried_object
carried_object = null
if object_to_drop and object_to_drop.has_method("drop"):
object_to_drop.call("drop", self)
The player clears their reference first, then tells the crate to remove this carrier. The crate’s drop() method erases the carrier from _carrier_targets. If no carriers remain, _set_carry_state(false) restores normal damping and collision layers.
10.4 State Restoration
# crate.gd:151-172
func _set_carry_state(should_be_carried: bool, was_carried: bool) -> void:
is_being_carried = should_be_carried
if is_being_carried:
sleeping = false
freeze = false
linear_damp = carry_damping # 8.0
# Collision suppression if enabled
else:
carry_target = null
linear_damp = normal_damping # 0.1
# Restore collision layers
if not was_carried and is_being_carried:
picked_up.emit(self)
elif was_carried and not is_being_carried:
dropped.emit(self)
The was_carried check ensures signals fire only on state transitions, not on carrier count changes. If carrier #2 grabs an already-carried crate, picked_up doesn’t fire again.
11. Key Parameters Reference
Player Exports (player.gd)
| Parameter | Default | Description |
carry_spring_constant |
800.0 | Proportional gain. Higher = snappier tracking, more likely to oscillate. |
carry_damping_constant |
50.0 | Derivative gain. Higher = less oscillation, more sluggish. |
max_carry_distance |
3.0 | Distance (m) beyond which the spring force gets boosted. |
max_distance_force_multiplier |
3.0 | Force multiplier when crate is beyond max distance. |
max_strength |
40.0 | Player’s lifting capacity in kg-equivalent. Caps max force. |
max_carry_force_multiplier |
1.0 | Tuning dial on top of the strength cap. |
carry_leash_enabled |
true | Whether the player is leashed to the carried object. |
carry_leash_soft_distance |
2.4 | Distance (m) where away-movement starts being reduced. |
carry_leash_hard_distance |
3.2 | Distance (m) where away-movement is fully clamped. |
carry_leash_away_speed_at_limit |
0.0 | Fraction of away speed allowed at hard limit. 0 = frozen. |
carry_break_distance |
6.0 | Distance (m) at which the crate is auto-dropped. |
auto_drop_when_break_distance_exceeded |
true | Whether to auto-drop when break distance is exceeded. |
crate_push_impulse |
8.0 | Impulse per unit of player speed when pushing crates. |
crate_push_max_impulse |
50.0 | Hard cap on push impulse magnitude. |
crate_push_min_speed |
0.2 | Minimum player speed before pushing activates. |
line_thickness |
0.05 | Radius of the control line cylinder. |
line_color |
Cyan | Color of the control line and its emission. |
line_emission_energy |
2.0 | Glow brightness of the control line. |
Crate Exports (crate.gd)
| Parameter | Default | Description |
carry_damping |
8.0 | linear_damp applied while carried. Higher = heavier feel. |
normal_damping |
0.1 | linear_damp restored on drop. Low = free physics. |
max_carriers |
0 | Max simultaneous carriers. 0 = unlimited. |
disable_collision_when_carried |
false | If true, disables collision layer while carried. |
jiggle_frequency |
1.5 | Hz for idle random impulses. 0 = no jiggle. |
jiggle_amount |
0.2 | Impulse strength per jiggle tick. |
has_glow |
false | Whether to spawn a glow light child. |
glow_color |
Cyan | Color of the OmniLight3D. |
glow_energy |
0.5 | Brightness of the glow. |
hum_vol_db |
-80.0 | Hum audio volume. -80 = off. |
hum_audio |
null | AudioStream for looping hum. |
12. Tuning Guide
Crate Feels Too Floaty
Increase carry_damping on the crate (8.0 → 12.0) or increase the crate’s mass in the editor. Heavier crates require more force to accelerate, reducing float.
Crate Oscillates / Bounces
The spring-damper is underdamped. Increase carry_damping_constant on the player (50.0 → 80.0) or reduce carry_spring_constant (800.0 → 500.0). The goal is critical damping: the crate settles at the target without overshooting.
Crate Can’t Catch Up When Sprinting
Your speed exceeds the crate’s ability to accelerate against inertia and damping. Options:
1. Increase max_strength (40.0 → 60.0) to raise the force cap.
2. Increase carry_spring_constant (800.0 → 1200.0) for stronger pull.
3. Reduce carry_damping (8.0 → 5.0) for less resistance.
4. Reduce the crate’s mass.
Multi-Carrier Crate Feels Weak
With max_strength = 40 and max_carry_force_multiplier = 1.0, each player contributes at most 392N. Two players = 784N total. If the crate is heavy and that’s not enough, raise max_carry_force_multiplier (1.0 → 1.5) or increase per-player max_strength.
Crate Snaps Back Too Aggressively
Reduce max_distance_force_multiplier (3.0 → 1.5) so the boost beyond 3.0m is less violent.
13. Lessons Learned
Invert Early
We wasted a week on crate-side force application before realizing multi-carrier made it untenable. When you find yourself needing data from the *caller* to make decisions in the *callee*, invert the relationship. The player knows its own strength and target position; the crate doesn’t need to.
Damping Is Not Optional
Every spring system oscillates. Without damping, the crate bounces around the carry target like a rubber band. Our first implementation had no damping and the crate was unusable. linear_damp (the Godot built-in) provides broad energy absorption. carry_damping_constant (our velocity-proportional term) provides fine-grained stability. You need both.
Force Caps Keep Physics Sane
Without the cap, a crate 10m away receives 10 * 800 = 8000N. That’s enough to launch a 20kg crate at 400 m/s². The cap at 392N keeps forces in the realm of “strong human” rather than “industrial catapult.”
Collision Layers Are Your Friend
The disable_collision_when_carried flag is a sledgehammer (disables all collision). A more nuanced approach would selectively remove specific layers while carried. We kept the sledgehammer because it’s predictable and the crate is visually close enough to the player that ghosting through geometry isn’t noticeable in fast gameplay.
The Leash Is Separate From Forces
Early designs tried to prevent the player from outrunning the crate by increasing spring forces at range. This created a feedback loop: player moves away → huge force → crate launches at player → player gets physics-pushed. Decoupling the leash (constrain player movement) from the spring (move crate toward player) eliminated the feedback loop entirely.
Test With Two Players
Multi-carrier bugs only surface with two players. A crate that works perfectly solo might never drop when the second player releases it. _cleanup_invalid_carriers() was written specifically because we had carriers go out of scope without calling drop().
14. Code References
All paths relative to runners - ship driving game/:
| File | Lines | What It Contains |
player/player.gd |
900-934 | apply_carry_force_to_object() — spring-damper force loop |
player/player.gd |
679-706 | _try_pickup_carryable() — detection and pickup logic |
player/player.gd |
717-726 | _pickup_carryable() — pickup handshake with crate |
player/player.gd |
728-736 | _drop_carried_object() — drop with cleanup |
player/player.gd |
349-386 | _apply_carry_leash_to_velocity() — leash constraint |
player/player.gd |
388-464 | _apply_push_to_carryables() — proximity push |
player/player.gd |
821-898 | _setup_control_line() / _update_control_line() — visual line |
player/player.gd |
26-48 | All exported carry parameters |
interactables/crate.gd |
97-123 | pick_up() — multi-carrier registration |
interactables/crate.gd |
126-140 | drop() — carrier deregistration |
interactables/crate.gd |
151-172 | _set_carry_state() — damping and collision management |
interactables/crate.gd |
174-194 | _cleanup_invalid_carriers() — stale reference cleanup |
interactables/crate.gd |
49-68 | _setup_effects() — glow and hum audio |
interactables/crate.gd |
80-91 | Idle jiggle — random impulses |
interactables/crate.gd |
7-18 | All exported crate parameters |
*Built in Godot 4. The carry system handles hundreds of force applications per second across multiple players without performance issues. The crate is always a real RigidBody3D — never a kinematic puppet. That’s the difference between something that works on paper and something that feels good in your hands.*
Leave a Reply