Creating an artificial life simulator in Godot 4.2 is an exciting way to explore the dynamics of ecosystems, predator-prey relationships, and genetic mutations. This guide will walk you through the entire process, from setting up the project structure to implementing detailed behaviors for each element in the simulation.
Project Structure
To start, we need to establish a well-organized project structure. Here's an overview:
Main Scene: The root scene containing the simulation environment and other necessary nodes.
Protein Scene: Base protein with variations for predator and prey.
Food Scene: Represents energy sources for proteins.
Environmental Elements: Static and dynamic environmental factors.
Node Structure
MainScene (Node2D)
└── Simulation (Node2D)
├── Protein (PackedScene - Protein.gd)
├── PreyProtein (PackedScene - PreyProtein.gd)
├── PredatorProtein (PackedScene - PredatorProtein.gd)
├── Food (PackedScene - Food.gd)
├── DynamicEnvironment (Node2D - DynamicEnvironment.gd)
└── StaticObstacle (StaticBody2D - StaticObstacle.gd)
Detailed Implementation
First, create and save a new scene as `MainScene.tscn`.
Node Structure:
- Root Node: `Node2D`
- Add a child node of type `Node2D` named `Simulation`.
Attach the following script to `MainScene`
extends Node2D
@export var protein_scene: PackedScene
@export var prey_protein_scene: PackedScene
@export var predator_protein_scene: PackedScene
@export var food_scene: PackedScene
@export var spawn_rate: float = 1.0
@export var max_proteins: int = 100
@export var max_food: int = 50
var proteins: Array
var foods: Array
func _ready():
proteins = []
foods = []
spawn_timer()
# Recursive timer to spawn proteins and food
func spawn_timer() -> void:
if proteins.size() < max_proteins:
spawn_protein()
if foods.size() < max_food:
spawn_food()
await get_tree().create_timer(spawn_rate).timeout
spawn_timer()
# Function to spawn proteins with different types
func spawn_protein() -> void:
var protein = pick_protein_type()
protein.position = Vector2(randf() * get_viewport().size.x, randf() * get_viewport().size.y)
$Simulation.add_child(protein)
proteins.append(protein)
protein.connect("combined_with", Callable(self, "_on_protein_combined"))
# Function to randomly pick between predator and prey proteins
func pick_protein_type() -> RigidBody2D:
var r = randi() % 100
if r < 70:
return prey_protein_scene.instantiate()
else:
return predator_protein_scene.instantiate()
# Function to spawn food
func spawn_food() -> void:
var food = food_scene.instantiate()
food.position = Vector2(randf() * get_viewport().size.x, randf() * get_viewport().size.y)
$Simulation.add_child(food)
foods.append(food)
# Handle combination of proteins
func _on_protein_combined(protein: RigidBody2D):
proteins.erase(protein)
proteins = proteins.filter(func(p): return p != null)
# Process function to apply forces between proteins
func _process(delta: float) -> void:
for protein in proteins:
if is_instance_valid(protein):
apply_forces(protein)
# Function to apply attraction and repulsion forces between proteins
func apply_forces(protein: RigidBody2D) -> void:
for other_protein in proteins:
if is_instance_valid(protein) and is_instance_valid(other_protein):
if protein != other_protein:
var distance = protein.position.distance_to(other_protein.position)
var direction = (other_protein.position - protein.position).normalized()
if distance < 400: # Attraction/Repulsion range
if distance < 100:
protein.apply_force(-direction * protein.repulsion_force, Vector2.ZERO)
else:
protein.apply_force(direction * protein.attraction_force, Vector2.ZERO)
Next, create a new RigidBody2D scene for the base protein and save it as `Protein.tscn`.
Make sure Contact_Monitor is set to true and that you set max_contacts_reported to 4 or more. This is done in the inspector under the "Solver" tab.
Attach the following script to `Protein`:
extends RigidBody2D
class_name Protein
@export var p_name: String = "Protein"
@export var energy: float = 10.0
@export var mutation_rate: float = 0.1
@export var attraction_force: float = 10.0
@export var repulsion_force: float = 10.0
signal combined_with(protein: RigidBody2D)
func _init() -> void:
randomize()
func _ready() -> void:
self.connect("body_entered", Callable(self, "_on_body_entered"))
apply_random_impulse()
if self.has_method("b_ready"):
self.b_ready()
func b_ready():
pass
# Detect collisions and handle combining or consuming food
func _on_body_entered(body: Node) -> void:
if body is Protein and can_combine_with(body):
combine_with(body)
elif body is Food:
consume_food(body)
# Process function to handle energy consumption and mutation
func _process(delta: float) -> void:
if energy <= 0:
queue_free()
else:
energy -= delta
mutate()
# Apply a random initial impulse to the protein
func apply_random_impulse() -> void:
var direction = Vector2(randf() * 2 - 1, randf() * 2 - 1).normalized()
apply_impulse(direction * energy, Vector2.ZERO)
# Check if the protein can combine with another protein
func can_combine_with(other_protein: RigidBody2D) -> bool:
if self.p_name == other_protein.p_name:
return self.energy > 1.0 and other_protein.energy > 1.0
else:
return false
# Combine with another protein to form a new protein
func combine_with(other_protein: RigidBody2D) -> void:
var new_energy = (energy + other_protein.energy) * 0.5
var new_p_name = p_name + "-" + other_protein.p_name
var new_protein = Protein.new()
new_protein.p_name = new_p_name
new_protein.energy = new_energy
new_protein.position = self.position
queue_free()
other_protein.queue_free()
get_parent().add_child(new_protein)
emit_signal("combined_with", other_protein)
# Mutate the protein's energy
func mutate() -> void:
if randi() % 100 < int(mutation_rate * 100):
energy += randf() - 0.5
# Consume food to gain energy
func consume_food(food: Food) -> void:
energy += food.energy
food.queue_free()
Create a new RigidBody2D scene for the prey protein, inheriting from `Protein.tscn`.
Make sure ContactMonitor is set to true and that you set max_contacts_reported to 4 or more. This is done in the inspector as explained previously. ;)
Attach the following script to `PreyProtein`:
extends Protein
class_name PreyProtein
@export var speed: float = 200.0
func b_ready():
p_name = "PreyProtein"
# Process function to handle fleeing from predators
func _process(delta: float) -> void:
flee_from_predators()
# Function to flee from predators
func flee_from_predators() -> void:
var predators = get_tree().get_nodes_in_group("predators")
for predator in predators:
var direction = (position - predator.position).normalized()
apply_force(direction * speed, Vector2.ZERO)
Create another new RigidBody2D scene for the predator protein, inheriting from `Protein.tscn`.
Make sure ContactMonitor is set to true and that you set max_contacts_reported to 4 or more. This is done in the inspector.
Attach the following script to `PredatorProtein`:
extends Protein
class_name PredatorProtein
@export var prey_energy_gain: float = 20.0
func b_ready():
p_name = "PredatorProtein"
# Detect collisions and consume prey proteins
func _on_body_entered(body: Node) -> void:
if body is PreyProtein:
consume_prey(body)
# Consume prey proteins to gain energy
func consume_prey(prey: PreyProtein) -> void:
energy += prey_energy_gain
prey.queue_free()
Create one last RigidBody2D scene for food and save it as `Food.tscn`.
Make sure ContactMonitor is set to true and that you set max_contacts_reported to 4 or more.
Attach the following script to `Food`:
extends RigidBody2D
class_name Food
@export var energy: float = 5.0
func _ready() -> void:
apply_random_impulse()
# Apply a random initial impulse to the food
func apply_random_impulse() -> void:
var direction = Vector2(randf() * 2 - 1, randf() * 2 - 1).normalized()
apply_impulse(Vector2.ZERO, direction * energy)
That was the last RigidBody2D. Now create a new scene for dynamic environmental elements and save it as `DynamicEnvironment.tscn`.
Attach the following script to `DynamicEnvironment`:
extends Node2D
class_name DynamicEnvironment
@export var change_interval: float = 5.0
@export var force: float = 50.0
func _ready() -> void:
change_environment()
set_physics_process(true)
# Function to periodically change the environment
func change_environment() -> void:
for protein in get_tree().get_nodes_in_group("proteins"):
apply_environmental_force(protein)
await get_tree().create_timer(change_interval).timeout
change_environment()
# Apply a random force to proteins to simulate environmental changes
func apply_environmental_force(protein: RigidBody2D) -> void:
var direction = Vector2(randf() * 2 - 1, randf() * 2 - 1).normalized()
protein.apply_impulse(direction * force, Vector2.ZERO)
Create a new StaticBody2D scene for static obstacles and save it as `StaticObstacle.tscn`.
This class does not require additional scripting.
Putting It All Together
- Create Protein, PreyProtein, PredatorProtein, Food, DynamicEnvironment, and StaticObstacle scenes as described above and save them in the appropriate files.
- Add the PackedScenes to the `MainScene.gd` script, ensuring the export variables point to the correct scenes.
- Run the Main Scene to see the artificial life simulator in action.
You can further expand and refine this simulation by adding more complex interactions, behaviors, and environmental effects. Every function is explained in the code comments. Play around with the variables. Happy coding!