Capability Contracts in Godot: The Clean Alternative to Multiple Inheritance

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:

  • SpaceTurret already extends EnemyShip so 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 SpaceTurret inherit both EnemyShip and Carryable?”

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.

  • SpaceTurret stays 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_crate getter/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.


Posted

in

by

Tags:

Comments

Leave a Reply

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