If you build games in Godot long enough, you hit this question:
How do I make one object behave like two different roles without ugly inheritance?
A classic example is a turret that is still a ship actor, but can also be picked up like a physics prop.
In GDScript, you cannot do this with multiple inheritance. A script can only extends one base class.
This article shows a practical pattern that solves it cleanly: capability contracts.
The Problem
Imagine this setup:
SpaceTurretalready extendsEnemyShipso it can target and shoot.- You now want the player to pick it up, carry it, and drop it.
Your first instinct might be:
- “Can I make
SpaceTurretinherit bothEnemyShipandCarryable?”
In Godot, the answer is no.
If you force this through inheritance hacks, you usually end up with:
- duplicated logic,
- tightly coupled systems,
- brittle type checks like
if collider is Crate.
What Is a Capability Contract?
A capability contract is a runtime agreement:
- “If a node claims capability X, it must provide behavior Y.”
For a carry system, the contract can be:
- Group:
"carryable" - Required methods:
can_pick_up(),pick_up(target),drop() - Optional state:
is_being_carried
This keeps your code role-based instead of type-based.
The player does not care whether it is a crate, turret, battery, or drone. It only cares whether that node satisfies the carryable contract.
Why This Is Better Than More Inheritance
Capability contracts let each class keep its primary identity.
SpaceTurretstays a combat ship (extends EnemyShip).- It also exposes carry behavior via the contract.
- Player logic depends on behavior, not concrete class names.
Result:
- fewer special cases,
- easier extension,
- cleaner architecture.
Step 1: Define the Consumer (Player) Rules
The consumer is the system that uses the capability. In this case, your player script decides what can be carried.
func _is_carryable(candidate: Variant) -> bool:
if not (candidate is RigidBody3D):
return false
if not candidate.is_in_group("carryable"):
return false
if not candidate.has_method("can_pick_up"):
return false
return bool(candidate.call("can_pick_up"))
Important detail:
- Group membership is discovery.
- Method checks are safety.
You want both.
Step 2: Implement the Provider Contract
Any object can become carryable if it implements the contract.
Example provider methods:
var is_being_carried: bool = false
var carry_target: Node3D = null
var original_collision_layer: int = 0
var original_collision_mask: int = 0
func _ready() -> void:
add_to_group("carryable")
func can_pick_up() -> bool:
return not is_being_carried
func pick_up(target_node: Node3D) -> void:
if is_being_carried:
return
is_being_carried = true
carry_target = target_node
original_collision_layer = collision_layer
original_collision_mask = collision_mask
collision_layer = 0
func drop() -> void:
if not is_being_carried:
return
is_being_carried = false
carry_target = null
collision_layer = original_collision_layer
collision_mask = original_collision_mask
That is enough for your player system to handle it.
Step 3: Keep Gameplay Groups Separate
One lesson that saves pain later:
"crate"is a gameplay category (mission cargo, objective logic)."carryable"is a capability category (pickup behavior).
Do not collapse those into one meaning.
A crate can be both:
- in
"crate"for mission systems, - in
"carryable"for interaction systems.
A turret may only be in "carryable" and not "crate".
Step 4: Migrate Safely With Compatibility Wrappers
If your existing project uses old names (carried_crate, _try_pickup_crate, etc.), do not hard-break everything in one pass.
Use compatibility shims while moving to cleaner names:
- new canonical field:
carried_object - compatibility alias:
carried_crategetter/setter - wrapper methods that call the new methods internally
This lets you refactor architecture without immediately breaking tests and older scripts.
Common Mistakes
1) Type-locking the system
Avoid this:
if collider is Crate:
That blocks reuse for future carryables.
2) Using only groups
add_to_group("carryable") does not enforce method presence. Always validate required methods before calling them.
3) Not restoring physics state on drop
If pick_up() modifies collision, freeze, or velocities, drop() must restore expected values.
4) Confusing capability with class hierarchy
Capabilities describe what an object can do. Inheritance describes what an object is. They solve different problems.
Contract Template You Can Reuse
Copy this into your own docs as a team contract:
Capability: carryable
Requirements:
- Node is RigidBody3D
- Node is in group "carryable"
- Implements:
- can_pick_up() -> bool
- pick_up(target: Node3D) -> void
- drop() -> void
Optional:
- is_being_carried: bool
When to Use Composition Instead
Capability contracts are great for shared API. If implementation details become heavy or duplicated, add composition:
- a helper node like
CarryableBehavior, - or a resource that centralizes carry logic.
Then each provider delegates to the helper while still satisfying the same contract.
Final Takeaway
You do not need multiple inheritance to get multi-role behavior in Godot.
Use capability contracts:
- keep your main inheritance chain focused,
- expose secondary behaviors via groups + required methods,
- make consumers depend on capabilities, not concrete types.
It is simple, scalable, and much easier to maintain as your game grows.
Leave a Reply