Example of a RigidBody3D enemy?

Godot Version

4.4.1, Jolt

Question

Hello Godoteers! I am refactoring my FPS enemy zombies from CharacterBody3Ds to RigidBody3Ds (to mainly solve performance issues with 30+ zombies calling move_and_slide and it tanking performance) and I would really like an example of an enemy that is based off RigidBody3D because I have no idea what I’m doing with these physics :face_with_spiral_eyes:

I tried searching for RigidBody3D tutorials but all of them seem to be either for player controllers (not sure if this is applicable for my enemy “zombie” use case) or are more physics related tests like balls moving around or something not “make a 3D FPS enemy using RigidBody3D”.

Here is what I cooked up so far but the zombie doesn’t move at all right now and I think the state machine isn’t initializing but need to debug more.

zombie_RigidBody3D.gd
class_name ZombieRigidBody
extends RigidBody3D

## Simple RigidBody3D zombie with force-based movement and state machine.
## Designed for multiplayer-ready implementation with minimal complexity.

# Signals
signal zombie_killed
signal zombie_melee_attack(damage: int)

# State Machine
enum ZombieState { IDLE, PURSUE, ATTACK, DEAD }
var current_state = ZombieState.IDLE

@export_group("Movement")
## Movement force applied toward target in Newtons. [br]Higher values make zombies accelerate faster.
@export_range(50.0, 500.0, 10.0) var movement_force: float = 200.0
## Maximum speed in meters per second. [br]Prevents zombies from accelerating indefinitely.
@export_range(1.0, 10.0, 0.1) var max_speed: float = 3.0
## How quickly the zombie rotates to face target in radians per second.
@export_range(1.0, 10.0, 0.1) var rotation_speed: float = 5.0

@export_group("Combat")
## Maximum health before zombie dies.
@export_range(1, 100) var max_health: int = 10
## Damage dealt per attack.
@export_range(1, 50) var melee_damage: int = 5
## Distance in meters at which zombie can attack.
@export_range(1.0, 5.0, 0.1) var attack_range: float = 2.0
## Time in seconds between attacks.
@export_range(0.5, 5.0, 0.1) var attack_cooldown: float = 1.0
## Distance in meters to start pursuing target.
@export_range(5.0, 50.0, 1.0) var detection_range: float = 20.0

@export_group("Physics")
## Mass of the zombie in kilograms. [br]Heavier zombies are harder to knock back.
@export_range(10.0, 200.0, 5.0) var zombie_mass: float = 50.0
## Linear damping when moving normally. [br]Lower values = less air resistance.
@export_range(0.0, 5.0, 0.1) var movement_damp: float = 1.0
## Linear damping when attacking. [br]Higher values make zombie stop faster during attacks.
@export_range(5.0, 20.0, 0.5) var attack_damp: float = 10.0

# Internal variables
var health: int
var vehicle_target: Node3D = null
var attack_ready: bool = true
var knockback_active: bool = false

# Node references
@onready var attack_timer: Timer = $AttackTimer
@onready var mesh_instance: MeshInstance3D = $MeshInstance3D
@onready var collision_shape: CollisionShape3D = $CollisionShape3D


func _ready() -> void:
	# Initialize health
	health = max_health
	
	# Set up physics properties
	mass = zombie_mass
	linear_damp = movement_damp
	
	# Set physics properties
	freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
	freeze = false
	
	# Add to zombie group
	add_to_group("Zombies")
	
	# Connect attack timer if it exists
	if attack_timer:
		attack_timer.timeout.connect(_on_attack_timer_timeout)
		attack_timer.wait_time = attack_cooldown
	
	# Find vehicle target
	_find_vehicle_target()


func _physics_process(delta: float) -> void:
	# Skip if dead
	if current_state == ZombieState.DEAD:
		return
	
	# Process current state
	match current_state:
		ZombieState.IDLE:
			_process_idle_state()
		ZombieState.PURSUE:
			_process_pursue_state(delta)
		ZombieState.ATTACK:
			_process_attack_state()


func _process_idle_state() -> void:
	# Check if target is in detection range
	if vehicle_target and global_position.distance_to(vehicle_target.global_position) <= detection_range:
		_change_state(ZombieState.PURSUE)


func _process_pursue_state(delta: float) -> void:
	if not vehicle_target:
		_change_state(ZombieState.IDLE)
		return
	
	var distance_to_target: float = global_position.distance_to(vehicle_target.global_position)
	
	# Check if in attack range
	if distance_to_target <= attack_range:
		_change_state(ZombieState.ATTACK)
		return
	
	# Check if target left detection range
	if distance_to_target > detection_range * 1.5:  # Add hysteresis
		_change_state(ZombieState.IDLE)
		return
	
	# Apply movement
	if not knockback_active:
		_apply_movement_force(delta)


func _process_attack_state() -> void:
	if not vehicle_target:
		_change_state(ZombieState.IDLE)
		return
	
	var distance_to_target: float = global_position.distance_to(vehicle_target.global_position)
	
	# Check if target moved out of range
	if distance_to_target > attack_range * 1.2:  # Add hysteresis
		_change_state(ZombieState.PURSUE)
		return
	
	# Perform attack if ready
	if attack_ready:
		_perform_attack()


func _apply_movement_force(delta: float) -> void:
	# Calculate direction to target
	var direction: Vector3 = (vehicle_target.global_position - global_position).normalized()
	direction.y = 0  # Keep movement horizontal
	
	# Apply force if below max speed
	if linear_velocity.length() < max_speed:
		apply_central_force(direction * movement_force)
	
	# Rotate toward target
	var target_transform: Transform3D = transform.looking_at(
		global_position + direction,
		Vector3.UP
	)
	transform = transform.interpolate_with(target_transform, rotation_speed * delta)


func _perform_attack() -> void:
	# Emit attack signal
	zombie_melee_attack.emit(melee_damage)
	
	# Start cooldown
	attack_ready = false
	if attack_timer:
		attack_timer.start()


func _on_attack_timer_timeout() -> void:
	attack_ready = true


func _change_state(new_state) -> void:
	# Exit current state
	match current_state:
		ZombieState.ATTACK:
			linear_damp = movement_damp  # Reset damping
	
	# Enter new state
	current_state = new_state
	
	match new_state:
		ZombieState.ATTACK:
			linear_damp = attack_damp  # Increase damping to stop


func _find_vehicle_target() -> void:
	# Look for vehicle in the scene
	var vehicles: Array = get_tree().get_nodes_in_group("Vehicle")
	if vehicles.size() > 0:
		vehicle_target = vehicles[0]


## Called when zombie is hit by a car
func car_hit(damage: int, knockback_direction: Vector3, knockback_force: float) -> void:
	# Prevent multiple knockbacks
	if knockback_active:
		return
	
	knockback_active = true
	
	# Apply damage
	take_damage(damage)
	
	# Apply knockback impulse
	var impulse: Vector3 = knockback_direction.normalized() * knockback_force
	impulse.y = abs(knockback_force * 0.3)  # Add upward component
	apply_central_impulse(impulse)
	
	# Reset knockback after delay
	await get_tree().create_timer(0.5).timeout
	knockback_active = false


## Apply damage to zombie
func take_damage(damage: int) -> void:
	health -= damage
	
	if health <= 0:
		die()


## Handle zombie death
func die() -> void:
	_change_state(ZombieState.DEAD)
	
	# Emit death signal
	zombie_killed.emit()
	
	# Disable physics
	freeze = true
	collision_shape.disabled = true
	
	# Simple death visualization (tint red and fall)
	if mesh_instance and mesh_instance.material_override:
		mesh_instance.material_override.albedo_color = Color.RED
	
	# Clean up after delay
	await get_tree().create_timer(3.0).timeout
	queue_free()


## Set the vehicle target (useful for spawning systems)
func set_vehicle_target(target: Node3D) -> void:
	vehicle_target = target

Any help is greatly appreciated! Cheers!

Why do you think using a rigidbody instead of characterbody will solve your performance issues?

Many sleepless nights trying to get fps above single digits when 30 CharacterBody3D zombies are all clumped together :rofl:

But also, from the research I have done and physics tests I’ve seen performed by people much smarter than I have shown that CharacterBody3D performance tanks when there are multiple bodies in the area.

Here is a thread on github Terrible performance with more than a few CharacterBody3D's moving around in a production level. · Issue #93184 · godotengine/godot · GitHub
Here is a discord discussion from yesterday Discord

I want to share this information but also I would prefer to keep this thread on topic to implementing an enemy using RigidBody3D examples. Cheers!