Physics-Based Crate Carrying in Godot 4: A Spring-Damper Approach

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
  • Inside 2.4m: No restriction. Player moves freely.
  • 2.4m to 3.2m: Away-from-crate movement is progressively reduced.
  • At 3.2m: Away movement is clamped to carry_leash_away_speed_at_limit (default 0.0, meaning no away movement at all).
  • Beyond 6.0m: Crate is auto-dropped. Something yanked it.
  • 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.
  • Original layer and mask are stored at _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.*


    Posted

    in

    by

    Tags:

    Comments

    Leave a Reply

    Your email address will not be published. Required fields are marked *