Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 66 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,71 @@
# Simulation project (real name TBD)
# **Evolution Simulation**

# Notes:
* there is a github action that runs pylint on every pull request
A Python-based evolutionary simulation where autonomous agents navigate a grid-based environment, consume food sources, reproduce, and evolve over time. The project uses neural network decision making for agent behavior and tracks population statistics through real time charts.

# Libraries used
- pygame
- pygame_gui
- yaml
## Features

- **Agent-Based Simulation**: Autonomous agents with neural network brains making movement decisions
- **Evolutionary Dynamics**: Agents reproduce with genetic crossover and mutations, leading to different behaviors
- **Dynamic Environment**: Multiple food sources (grass patches, berry bushes, fruit trees, cactus pads) with regeneration
- **Real Time Visualization**: Pygame-based UI showing the grid state and population charts
- **Statistics & Monitoring**: Live charts tracking population age, energy levels, and other metrics
- **Configurable Parameters**: YAML-based configuration for world size, mating rules, mutation rates, and more

## How It Works

1. **Agents** spawn in the environment and consume food to gain energy
2. **Decision Making** is governed by neural networks with inputs from food and agent sensing
3. **Mating** occurs when nearby agents of the same group have sufficient energy and age
4. **Evolution** happens through genetic crossover and mutations in offspring
5. **Visualization** displays the grid with agents and food, plus real time population statistics

## Setup

# Set up
```bash
# (in project folder)
python3 -m venv venv # only the first time
# Create virtual environment (first time only)
python3 -m venv venv

# Activate virtual environment
# On Linux/Mac:
source venv/bin/activate
pip3 install -r requirements.txt # only the first time and when requirements.txt changes
python3 src/run.py # to run
```
# On Windows:
venv\Scripts\activate

# Install dependencies
pip3 install -r requirements.txt

# Run the simulation
python3 src/run.py
```

## Dependencies

- **pygame** - Graphics and event handling
- **pygame_gui** - UI components
- **PyYAML** - Configuration file parsing
- **python-i18n** - Internationalization support

## Configuration

Edit [`config.yaml`](config.yaml) to adjust:
- Window and panel dimensions
- Grid cell size
- Mating parameters (age threshold, energy level, mutation rates)
- Chart display settings

## Project Structure

- `src/agent.py` - Agent class with neural network decision making and genetics
- `src/environment.py` - Main simulation state and update logic
- `src/area.py` - Grid-based spatial representation
- `src/food.py` - Food source types and behavior
- `src/mating.py` - Reproduction and genetic inheritance logic
- `src/terrain.py` - World generation and topology
- `src/ui.py` - User interface management
- `src/charts.py` - Statistics and visualization
- `src/sense.py` - Agent sensory input processing

## Notes

- GitHub Actions runs pylint checks on all pull requests
- The simulation uses torus topology (wraps at edges)
56 changes: 38 additions & 18 deletions src/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ def __init__(self, position: tuple, environment : Environment,/, decision_matrix

self.last_action = self.actions["ACTION_MOVE"]

self.signal_active = False
self.signal_timer = 0
self.signal_duration = 20
self.signal_range = 50.0

self.suggested_action = None
self.suggested_action_timer = 0
self.suggested_action_duration = 50

self.input_size = len(self.sense())
self.action_count = len(self.actions)
self.output_size = self.action_count + 2
Expand Down Expand Up @@ -160,9 +169,6 @@ def sense(self, foods=None, agents=None):

def think(self, inputs):
"""Forward pass through neural network."""



out = [0.0 for _ in range(self.output_size)]
for i in range(self.input_size):
xi = inputs[i]
Expand All @@ -174,6 +180,7 @@ def think(self, inputs):
return out

def decide(self, outputs):
"""Decide action, turn, and intensity from neural network outputs."""
logits = outputs[: self.action_count]
best_i = 0
best_v = logits[0]
Expand All @@ -185,32 +192,23 @@ def decide(self, outputs):
intensity = outputs[self.action_count + 1]
return best_i, turn, intensity


def _move(self, turn: float, intensity: float, speed_modifier: float = 1.0):

turn = (turn + 1) * math.pi

inten = _clamp(0.5 + 0.5 * intensity, 0.0, 1.0)
sp = self.base_speed * (0.20 + 1.30 * inten) * speed_modifier

self.x = (self.x + math.cos(turn) * sp) % (self.environment.grid_width * self.environment.cell_size)
self.y = (self.y - math.sin(turn) * sp) % (self.environment.grid_height * self.environment.cell_size)

cost = 0.0075 + 0.035 * inten
self.energy = max(0.0, self.energy - cost)

self.angle = math.degrees(turn)



def _attack(self):
self.energy = max(0.0, self.energy - 0.16)
agents = self.environment.agents
if agents:
r2 = self.sight * self.sight
best_enemy_d2 = 1e18
best_enemy = None

for a in agents:
if a is self:
continue
Expand All @@ -228,12 +226,27 @@ def _attack(self):
best_enemy : Agent = a
if best_enemy:
best_enemy.hp -= self.attack_power


best_enemy.signal()

def _mate(self):
self.mate_module.mate()

def signal(self):
"""Send signal to call nearby teammates for help."""
self.signal_active = True
self.signal_timer = self.signal_duration

nearby_agents = self.environment.get_nearby_agents(self, self.signal_range)
same_species = [agent for agent in nearby_agents
if agent is not self and agent.group_id == self.group_id]

affected_agents = random.sample(same_species, min(3, len(same_species)))

for agent in affected_agents:
agent.signal_active = True
agent.signal_timer = self.signal_duration
agent.suggested_action = self.actions["ACTION_MOVE"]
agent.suggested_action_timer = agent.suggested_action_duration

def _tick_body(self):
self.age += 1
Expand All @@ -242,6 +255,16 @@ def _tick_body(self):
self.hp = max(0.0, self.hp - 0.03)
if self.age >= self.max_age:
self.hp = 0.0

if self.signal_active and self.signal_timer > 0:
self.signal_timer -= 1
if self.signal_timer <= 0:
self.signal_active = False

if self.suggested_action_timer > 0:
self.suggested_action_timer -= 1
if self.suggested_action_timer <= 0:
self.suggested_action = None

def is_alive(self) -> bool:
return self.hp > 0.0 and self.energy > 0
Expand All @@ -261,17 +284,14 @@ def update(self, speed_modifier):

if action == self.actions["ACTION_MOVE"]:
self.logger.debug(f"Agent - {self.uuid}; action - move")
self._move(turn, intensity)
self._move(turn, intensity, speed_modifier)
elif action == self.actions["ACTION_MATE"]:
self.logger.debug(f"Agent - {self.uuid}; action - mate")
self._mate()
else:
self.logger.debug(f"Agent - {self.uuid}; action - attack")
self._attack()

self.last_action = action / 3


self._tick_body()

def render(self, window: pygame.window, cell_size: int, offset: tuple):
Expand Down
4 changes: 0 additions & 4 deletions src/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,22 +154,18 @@ def register_chart(name: str, value_name: str, callback: ChartCallback) -> Chart
"""Registers a chart to be sampled and rendered."""
return _MANAGER.register_chart(name, value_name, callback)


def update(dt: float) -> None:
"""Advances the polling timer."""
_MANAGER.update(dt)


def render(surface: pygame.Surface, rect: pygame.Rect) -> None:
"""Renders all registered charts inside the given rect."""
_MANAGER.render(surface, rect)


def scroll(dy: float) -> None:
"""Scrolls the chart viewport horizontally (dy is mouse wheel delta)."""
_MANAGER.scroll(dy)


def first_sample_age() -> float:
"""Returns seconds since the oldest datapoint across charts was sampled."""
oldest = None
Expand Down
5 changes: 0 additions & 5 deletions src/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ def _cells_for_area(self, area: Area) -> List[Tuple[int, int]]:
if self.terrain[y][x] == area
]


def _spawn_initial_food_sources(self):
spawn_plan = {
Area.PLAINS: (SimpleGrassPatch, 4),
Expand Down Expand Up @@ -157,8 +156,6 @@ def _spawn_initial_food_sources(self):
)
self.area_food_sources[area] += 1



def _spawn_initial_agents(self):
Agent.bound_x = self.pixel_width
Agent.bound_y = self.pixel_height
Expand Down Expand Up @@ -264,7 +261,6 @@ def _simulation_loop(self):
new_agents.append(agent)
self.agents = new_agents


def set_grid_cell(self, x: int, y: int, value: int):
if 0 <= x < self.grid_width and 0 <= y < self.grid_height:
self.grid[y][x] = value
Expand Down Expand Up @@ -326,7 +322,6 @@ def reset(self):
self._spawn_initial_food_sources()
self._spawn_initial_agents()


def pause(self):
with self.data_lock:
self.pause_sim = True
Expand Down
5 changes: 1 addition & 4 deletions src/food.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

class Food:
"""Represents a single piece of food that an agent can consume."""

def __init__(self, position: tuple, value: int, food_type: str, expiry_time: int):
self.x, self.y = position # Grid position (x, y)
self.value = value # Energy provided
Expand Down Expand Up @@ -52,7 +51,6 @@ def update(self) -> Food | None:
def render(self, window, cell_size: int, food_items: list, panel_offset: tuple):
"""Renders the food source."""


def destroy(self):
"""Marks the source as destroyed."""
if not self.is_destroyed:
Expand All @@ -67,10 +65,9 @@ def increment_age(self):
if self.age >= self.lifespan:
self.destroy()

# Example

class SimpleGrassPatch(FoodSource):
"""A simple herbivore food source that regenerates and periodically drops food."""

FONT = None

def __init__(self, position: tuple, area, env_area_counters):
Expand Down
2 changes: 1 addition & 1 deletion src/mating.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def mate(self):
close_agents = self._get_nearby_agents()
if not close_agents:
return

matrix, vector = self.get_new_genome(choice(close_agents))
new_agent = Agent((self.parent.x, self.parent.y), self.parent.environment, decision_matrix= matrix, genome= vector, species = self.parent.group_id)
energy_level = self.parent.energy / self.parent.max_energy
Expand Down Expand Up @@ -66,7 +67,6 @@ def get_new_genome(self, second : Agent) -> typing.Tuple[typing.List[typing.List
for key,value in vector.items():
vector[key] = value * normalisation


return matrix, vector

def create_new_decision_matrix(self, second : Agent) -> typing.List[typing.List[float]]:
Expand Down
3 changes: 1 addition & 2 deletions src/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
manager = pygame_gui.UIManager((config.WINDOW_WIDTH, config.WINDOW_HEIGHT))
hint_font = pygame.font.Font(None, 22)


def _format_first_sample_age() -> str:
age = charts.first_sample_age()
if age <= 0:
Expand All @@ -28,11 +27,11 @@ def _format_first_sample_age() -> str:
seconds = int(age % 60)
return f"Oldest sample is {minutes}m {seconds}s old"


def _chart_hint_text() -> str:
base = "Scroll over the chart area to pan charts"
age_text = _format_first_sample_age()
return f"{base} — {age_text}" if age_text else base

env_ui = UI(
manager=manager,
x=config.PANEL_WIDTH + 2*config.PANEL_X,
Expand Down
3 changes: 0 additions & 3 deletions src/sense.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,11 @@ def get_area_type(self):
return ground

def sense(self, foods=None, agents=None ):


hp_n, en_n, age_n = self.get_agent_stats()
food_sin, food_cos, food_distance, food_in_sight = self.get_closest_food(foods)
friend_count_n, friend_cord_sin, friend_cord_cos, friend_distance, friend_in_sight, enemy_count_n, enemy_cord_sin, enemy_cord_cos, enemy_distance, enemy_in_sight = self.get_close_agents(agents)
area_type = self.get_area_type()


return [
hp_n,
en_n,
Expand Down
Loading