A C# simulation where simple genomes and tiny neural nets evolve useful behavior—no hand-coded strategies required.
- Overview
- Demo
- How it works
- Quick start
- Configuration
- Architecture
- Roadmap
- FAQ
- Contributing
- License
- Acknowledgements
This project simulates populations of “creatures” whose genomes encode tiny neural networks. Over many generations and cycles, only those that reach reproduction zones pass on genes; offspring are formed through random pairing and (optionally) mutation—no explicit path-finding is written by you.
At a high level:
- Creatures start with random genomes and positions.
- Each cycle, a creature’s brain may fire output neurons (e.g., move up/down/left/right).
- At the end of a generation, only creatures standing in green reproduction areas produce offspring that form the entire next generation. Parents never carry over.
The repo can emit one PNG snapshot per cycle and you can stitch them into a video with ffmpeg to visualize emergent behavior.
# example: combine cycle_*.png -> generation.mp4
ffmpeg -framerate 30 -i cycle_%04d.png -pix_fmt yuv420p generation.mp4A SimulationContext drives sizing, population, genome size, and timing. Services are registered and the Simulation orchestrates the run.
var context = new SimulationContext(
WorldSize: 128,
Population: 2000,
GenomeSize: 8,
Generations: 1,
Cycles: 300,
Zoom: 5,
MutationChance: 0,
TerrainProvider: TerrainProviders.FourCorners);
builder.Services.RegisterServices(context);
using IHost host = builder.Build();
var simulation = host.Services.GetRequiredService<Simulation>();
await simulation.RunAsync(cts);Each generation:
- Distribute agents randomly into allowed areas.
- Run N cycles; agents decide and move based on their brains.
- Select winners (agents in reproduction zones) and reproduce until the population is refilled. Parents do not persist.
// Process cycles (randomized order for a bit of variety)
context.CurrentCycle = c;
foreach (var agent in world.Agents.OrderBy(_ => random.Next()))
agent.ProcessCycle(world);The world is a rectangle plus an array of terrain regions with flags like Blocked and Reproduce. The “four corners” provider below makes green reproduction zones in each corner.
[Flags]
public enum TerrainType { None = 1<<0, Blocked = 1<<1, Reproduce = 1<<2 }
public class FourCornersTerrainProvider : IWorldTerrainProvider
{
private static int _ratio = 6;
public WorldTerrain[] GetWorldTerrain(Rectangle world) => [
new(new(0, 0, world.Width/_ratio, world.Height/_ratio), TerrainType.Reproduce, Color.LightGreen),
new(new(world.Width - world.Width/_ratio, 0, world.Width, world.Height/_ratio), TerrainType.Reproduce, Color.LightGreen),
new(new(0, world.Height - world.Height/_ratio, world.Width/_ratio, world.Height), TerrainType.Reproduce, Color.LightGreen),
new(new(world.Width - world.Width/_ratio, world.Height - world.Height/_ratio, world.Width, world.Height), TerrainType.Reproduce, Color.LightGreen),
];
}The world exposes Move(IAgent, Vector2Int) which agents call to change grid positions. Grids and agent arrays reset each generation; only offspring persist.
An agent holds a genome: a collection of genes. Each gene defines a connection from a source neuron to a sink neuron with a weight. During construction, the agent turns its genome into a minimal neural network and tracks output neurons explicitly (so we can compute from outputs backward efficiently).
public record Gene
{
public Type SourceNeuronType { get; }
public Type SinkNeuronType { get; }
public double Weight { get; }
}
// Build brain: de-duplicate neuron instances and wire connections
foreach (var gene in genome.Genes)
{
var source = neuronFactory.GetSourceNeuron(gene.SourceNeuronType, this);
var sink = neuronFactory.GetSinkNeuron(gene.SinkNeuronType, this);
if (!sink.SourceConnections.Select(x => x.Source).Contains(source))
sink.SourceConnections.Add((source, gene.Weight));
if (sink is IOutputNeuron outN && !Brain.OutputNeurons.Contains(sink))
Brain.OutputNeurons.Add(outN);
}On each cycle the agent activates its output neurons in random order:
public void ProcessCycle(IWorld world)
{
foreach (var neuron in Brain.OutputNeurons.OrderBy(_ => _random.Next()))
neuron.Activate(world, this);
}- Input neurons produce a normalized value based on agent/world state (e.g.,
Lxreturns a function of the agent’s X position). - Internal neurons aggregate inputs and apply
tanh. If they have no sources, they output a constant. - Output neurons compute
tanh(sum(inputs) * weight)and fire if the value is > 0 (e.g.,MVdmoves the creature down one cell).
// Input example: normalized X
public class Lx(SimulationContext context) : InputNeuronBase
{
public override double CalculateOutput(IAgent agent, double weight) =>
CalculateOutput((double)agent.Position.x / (context.WorldSize - 1), weight);
}
// Output example: move down when active
public class MVd : OutputNeuronBase
{
protected override void Fire(IWorld world, IAgent agent) =>
world.Move(agent, agent.Position with { y = agent.Position.y + 1 });
}Computing from outputs backward avoids wasted work on genomes that lack any outputs and improves performance for large populations and cycle counts.
-
Clone & build (requires modern .NET SDK):
git clone <your-fork-or-repo> cd evolutionary-csharp dotnet build
-
Run a small experiment:
dotnet run --project src/Simulation
Tweak
SimulationContext(population, world size, genome size, generations, cycles). -
Render frames (optional) and compose a video with
ffmpeg(see Demo).
Key knobs in SimulationContext:
WorldSize— grid size (width = height).Population— agents per generation.GenomeSize— number of gene connections per creature.Generations,Cycles— runtime scale; bigger = slower but richer behavior.MutationChance— enable to introduce variation across generations.TerrainProvider— choose world maps (e.g.,FourCorners).
Rule of thumb: ~100 generations run in seconds; ~100,000 may require an overnight run depending on your machine and settings.
- Simulation: orchestrates world creation, cycles, and reproduction.
- World: grid, terrain, movement rules.
- Agent: holds genome and brain; processes a cycle; asks world to move.
- Genome/Gene: declarative wiring for neurons (source → sink, weight).
- Neuron types: input, internal, output (factory-created, DI-friendly).
- Terrain providers: plug in reproduction/blocked regions per map.
- Mutation operators (point, weight jitter, structural).
- More terrain providers (rings, mazes, moving zones).
- Fitness shaping & elitism options.
- Better render pipeline + real-time viewer.
- Telemetry & profiling hooks.
Why compute from outputs backward?
To avoid traversing subgraphs that don’t influence behavior (e.g., genomes with no outputs). It’s simpler and faster under randomized genomes.
What if a genome has no inputs or outputs?
That agent will likely fail to act and won’t reproduce—natural selection handles it.
How are pairs chosen?
Randomly from agents standing in reproduction zones at generation end; offspring fully replace the previous population.
PRs welcome! If you add neuron types, please:
- Register them with the neuron factory / DI.
- Keep inputs pure and internal nodes aggregative.
- Add a short example and a unit test.
MIT (or your preferred OSS license—add the LICENSE file).
- Inspiration: classic evolutionary simulation demos showing genomes as tiny neural nets.
