Evolving Intelligence: Evolutionary Algorithms and Neural Networks in C#

This blog is presented as a part of C# Advent 2025. Follow the link to check out the rest of the excellent C# and .NET content coming out in 2 blogs per day between December 1 and 25.

Big shout-out to Dave Miller, whose famous YouTube video inspired this project. He laid out some pretty good groundwork for how the evolutionary algorithms work. For the purpose of spending time productively, I will assume you’ve watched at least the first 15 minutes of it.

In this blog post we will simulate an evolution of creatures that start with random genes, and over time evolve to survive in a world where only the corners of the map promote reproduction. They will essentially learn to do this:

We will not code that logic, rather allow them to grow their own little neural networks and through a process of natural selection, generation by generation, select those genes (neural networks) that solve the puzzle we gave them best.

This is going to happen through generations and cycles. Each generation will consist of 300 cycles throughout which a creature will be able to act (e.g. move in a different directions). What the creature does will depend on the neurons that it has (we will call it a genome). Initially we will randomly generate all genomes and after every generation we will randomly pair them to create offspring. We will only allow it to happen in the green reproduction areas. Only the offspring genomes carry over to the next generation. Initial position of all creatures is always random.

Creature Brain

Creature brains are intentionally simple, but still take time to process. The runtime depends on how many generations you run: ~100 generations take seconds; ~100,000 might need to run overnight. 

Here’s a random neural network for a creature with two genes (each gene defines a connection between two neurons). It has two input neurons whose outputs depend on the X and Y axis positions, one internal neurons(think of them as aggregators), and one output neuron allowing it to move up.

In every cycle, we compute the source neurons via:

Tanh(input * weight)

and sink neurons via:

Tanh(sum(inputs) * weight)

Neurons always produce a value in [-1, 1]. If the value is greater than zero, the output neuron fires; otherwise it does not. Because sink neurons hold a collection of source connections, it’s easiest (and fastest) to compute from the outputs backward—more on that later.

One final remark before we continue. Neural networks that we will generate might end up having no input neurons, no internal neurons or no output neurons. If the creature’s genome has no output neurons, it will most definitely not pass it genes, that’s why in the last generation you will not see them.

Application Startup

When the app starts, the SimulationContext is registered in the DI container so any part of the app (Simulation, Agent, Neuron) can use it without plumbing it through call chains.

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);

CancellationTokenSource cts = new();

using IHost host = builder.Build();
var simulation = host.Services.GetRequiredService<Simulation>();
await simulation.RunAsync(cts);

World Creation

Simulation is a procedural wrapper that drives the process. It starts by creating the world with rectangle boundaries and an array of terrain objects. IWorldTerrainProvider lets you choose a “map” via SimulationContext. The map defines blocked areas and reproduction zones (implemented as rectangles, too).

// Prepare the world
var worldBlock = new Rectangle(0, 0, context.WorldSize, context.WorldSize);

var terrainProvider = provider.GetRequiredKeyedService<IWorldTerrainProvider>(context.TerrainProvider);

var world = new World(WorldBlock: worldBlock, Terrain: terrainProvider.GetWorldTerrain(worldBlock))
    {
    Agents = Enumerable.Range(0, context.Population).Select(_ => agentFactory.CreateDefaultAgent()).ToArray()
};

public record World(Rectangle WorldBlock, WorldTerrain[] Terrain) : IWorld
{
    public required Agent[] Agents { get; set; }
    private GridCell[,] Grid { get; set; } = new GridCell[WorldBlock.Width, WorldBlock.Height];
    
    public void DistributeAgents()
    {
        ...
    }

    public bool Move(IAgent agent, Vector2Int dest)
    {
        ...
    }
}

public record WorldTerrain(Rectangle Boundary, TerrainType Terrain, Color Color);

[Flags]
public enum TerrainType
{
    None = 1 << 0,
    Blocked = 1 << 1,
    Reproduce = 1 << 2
}

Let’s create the four-corners terrain provider you saw in the intro.

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 also defines an Agents array and a 2D Grid. Agents is set initially and replaced with offspring after each generation (parents do not carry over). The grid is reset each generation as well. DistributeAgents is called before each generation and places agents randomly in allowed areas.

The Move method moves agent from current to a new position. Simulation doesn’t use this method, Agent does. This is because an agent might have a genome telling it to move up, down, left or right (or more depending on the genome size).

So instead, the simulation tells the agent to process a cycle (as you will see in a moment) and it uses the IWorld handle to move itself upon the grid.

Process Generations

Once the world is created, we process generations. For each generation we:

  1. Distribute new agents (offspring)
  2. Process the cycles (each agent has a chance to move)
  3. Select offspring for the next generation 
// Process generations
for (int g = 1; g <= context.Generations; g++)
{
    world.DistributeAgents();

    // Process cycles
    for (int c = 1; c <= context.Cycles; c++)
    {
        ...
    }

    // select winners and reproduce
    List<Agent> newPopulation = [];

    var areas = world.Terrain
        .Where(x => x.Terrain == TerrainType.Reproduce)
        .Select(x => x.Boundary)
        .ToArray();
    var winners = world.Agents
        .Where(agent => areas.Any(x => x.Contains(new Point(agent.Position.x, agent.Position.y))))
        .ToArray();

    while (newPopulation.Count < context.Population)
    {
        var p1 = random.Next(0, winners.Length);
        var p2 = random.Next(0, winners.Length);
        var child = agentFactory.CreateCrossoverAgent(winners[p1], winners[p2]);
        newPopulation.Add(child);
    }

    world.Agents = newPopulation.ToArray();
}

Process Cycles

 Processing cycles is as simple as calling ProcessCycle method on each agent.

context.CurrentCycle = c;

foreach (var agent in world.Agents.OrderBy(_ => random.Next())) 
    agent.ProcessCycle(world);

In the GitHub repo we also generate a PNG image per cycle showing agents, blockades, and reproduction zones. Later we use ffmpeg to merge all cycle snapshots into a video for that generation.

Agent

This is where things get interesting. In the Agent code you can immediately see the ProcessCycle method invoked above: it simply iterates over the output neurons and attempts to activate each one (this triggers a recursive output processing until it reaches the input neurons).

public void ProcessCycle(IWorld world)
{
    foreach (var neuron in Brain.OutputNeurons.OrderBy(_ => _random.Next())) neuron.Activate(world, this);
}

Each agent also has a genome (collection of genes) that is either random or created from parent genes. A Gene defines a connection between two neurons (along with its weight, used in the output calculation).

public class Genome
{
    public required Gene[] Genes { get; init; }
}

public record Gene
{
    public Type SourceNeuronType { get; }
    public Type SinkNeuronType { get; }
    public double Weight { get; }
}

The gene constructor (not shown) creates two neurons and a weight. We use the same method to randomize genes as in David’s video: generate a 32-bit number that defines the source neuron, sink neuron, and weight. 

With GenomeSize = 8 (as set in SimulationContext), our agent has a neural network consisting of eight connections. Here’s an example of such creature’s Brain:

It has a firing neuron Lx that produces a value depending on it’s position on the X axis

public class Lx(SimulationContext context) : InputNeuronBase
{
    public override double CalculateOutput(IAgent agent, double weight) => 
        CalculateOutput((double)agent.Position.x / (context.WorldSize - 1), weight);
}

Internal neurons N2 and N3 in this case act exactly as output neurons. They simply output a tangent function of sum of its inputs, multiplied by weight.

public abstract class InternalNeuronBase : IInternalNeuron
{
    public abstract double LatchedOutput { get; set; }
    public ICollection<(ISourceNeuron Source, double Weight)> SourceConnections { get; set; } = [];
    private bool CycleGuard { get; set; }
        
    public double CalculateOutput(IAgent agent, double weight)
    {
        if (CycleGuard) return LatchedOutput;
            
        CycleGuard = true;

        if (SourceConnections.Count != 0)
        {
            LatchedOutput = SourceConnections.Sum(c => c.Source.CalculateOutput(agent, c.Weight));
        }
        
        LatchedOutput = Tanh(LatchedOutput * weight);

        CycleGuard = false;
            
        return LatchedOutput;
    }
}

Internal neurons N2 and N3 behave like output neurons in terms of computation: they output 

tanh(sum(inputs) * weight)

If an internal neuron acts as a source (no source connections), it outputs a predefined constant value.

public class N2 : InternalNeuronBase
{
    public override double LatchedOutput { get; set; } = 0.12;
}

Last but not least, output neurons define a Fire function (e.g. move creature down by one grid cell).

public class MVd : OutputNeuronBase
{
    protected override void Fire(IWorld world, IAgent agent) => 
        world.Move(agent, agent.Position with { y = agent.Position.y + 1 });
}

Output neurons don’t always fire; they only do so if 

tanh(sum(inputs) * weight) > 0

The base class handles activation on each cycle.

public abstract class OutputNeuronBase : IOutputNeuron
{
    public ICollection<(ISourceNeuron Source, double Weight)> SourceConnections { get; set; } = [];

    protected abstract void Fire(IWorld world, IAgent agent);
    
    public void Activate(IWorld world, IAgent agent)
    {
        if (IsActive(agent))
        {
            Fire(world, agent);
        }
    }
    
    private double CalculateOutput(IAgent agent) => Tanh(SourceConnections.Sum(c => c.Source.CalculateOutput(agent, c.Weight)));
    
    private bool IsActive(IAgent agent) => CalculateOutput(agent) > 0;
}

Agent’s Brain and how it works

Let’s recap:

  • An agent has a genome (random or inherited).
  • A genome consists of genes.
  • A single gene defines a connection between two neurons.
  • The simulator calls the agent’s ProcessCycle.
  • ProcessCycle activates the brain’s output neurons
  • Output neurons trigger computation of connected source neurons or internal neurons

Let’s now take a look at the Agent’s constructor where the brain is built.

var neuronFactory = ServiceScope.ServiceProvider.GetRequiredService<INeuronFactory>();

foreach (var gene in genome.Genes)
{
    var sourceNeuron = neuronFactory.GetSourceNeuron(gene.SourceNeuronType, this);
    var sinkNeuron = neuronFactory.GetSinkNeuron(gene.SinkNeuronType, this);

    if (!sinkNeuron.SourceConnections.Select(x => x.Source).Contains(sourceNeuron))
    {
        sinkNeuron.SourceConnections.Add((sourceNeuron, gene.Weight));
    }

    if (sinkNeuron is IOutputNeuron outputNeuron && !Brain.OutputNeurons.Contains(sinkNeuron))
    {
        Brain.OutputNeurons.Add(outputNeuron);
    }
}

Now, here’s the agent constructor logic that builds the brain from the genome. It creates source and sink neuron instances and chains them together. Output neurons are added to the brain explicitly; other neurons are already linked internally. We process cycles from the bottom (output neurons first). While it can seem counter-intuitive—neural networks “fire” from sources forward—this greatly improves performance (we don’t waste time processing genomes that have no output neurons).

Note: INeuronFactory is created from the DI container in the context of an Agent. If the genome defines two genes with the same neuron type (e.g., Lx), only one instance of that neuron is created for that agent. This encourages a larger, connected network rather than many tiny, duplicated subgraphs.

Example Walkthrough

Imagine a world with one creature, one generation, and one cycle. Here’s the order of operations:

1. Simulation calls agent.ProcessCycle:

foreach (var agent in world.Agents.OrderBy(_ => random.Next())) 
    agent.ProcessCycle(world);

2. Each of the agent’s output neurons get activated

foreach (var neuron in Brain.OutputNeurons.OrderBy(_ => _random.Next())) neuron.Activate(world, this);

3. Each output neurons’ source gets calculated recursively as per:

private double CalculateOutput(IAgent agent) => Tanh(SourceConnections.Sum(c => c.Source.CalculateOutput(agent, c.Weight)));

Next Steps

Feel free to clone and run the simulator. Here’s the GitHub link to a repo. If you have any questions reach out to me or contact us at Trailhead to get our help building something cool for you with neural networks.

Picture of Piotr Kolodziej

Piotr Kolodziej

Born and raised in Poland, Piotr did his first programming on Atari 800XL. He covered his first dial-up modem with a duve, so his mom wouldn’t hear it and freak over the internet bills. He graduated with a degree in Telecommunication and Business Application Programming. Piotr is a certified .NET and Azure Developer, and is passionate about excellent software architecture.

Related Blog Posts

We hope you’ve found this to be helpful and are walking away with some new, useful insights. If you want to learn more, here are a couple of related articles that others also usually find to be interesting:

Sentiment Analysis in C#: Azure AI Language or LLMs

Explore how C# developers can leverage both Azure AI Language and Azure OpenAI for sentiment analysis. This post compares traditional NLP services with LLM-based approaches using real-world code from an exit interview system, helping you choose the right tool for your specific use case.

Read More

Our Gear Is Packed and We're Excited to Explore With You

Ready to come with us? 

Together, we can map your company’s software journey and start down the right trails. If you’re set to take the first step, simply fill out our contact form. We’ll be in touch quickly – and you’ll have a partner who is ready to help your company take the next step on its software journey. 

We can’t wait to hear from you! 

Main Contact

This field is for validation purposes and should be left unchanged.

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the form below. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Montage Portal

Montage Furniture Services provides furniture protection plans and claims processing services to a wide selection of furniture retailers and consumers.

Project Background

Montage was looking to build a new web portal for both Retailers and Consumers, which would integrate with Dynamics CRM and other legacy systems. The portal needed to be multi tenant and support branding and configuration for different Retailers. Trailhead architected the new Montage Platform, including the Portal and all of it’s back end integrations, did the UI/UX and then delivered the new system, along with enhancements to DevOps and processes.

Logistics

We’ve logged countless miles exploring the tech world. In doing so, we gained the experience that enables us to deliver your unique software and systems architecture needs. Our team of seasoned tech vets can provide you with:

Custom App and Software Development

We collaborate with you throughout the entire process because your customized tech should fit your needs, not just those of other clients.

Cloud and Mobile Applications

The modern world demands versatile technology, and this is exactly what your mobile and cloud-based apps will give you.

User Experience and Interface (UX/UI) Design

We want your end users to have optimal experiences with tech that is highly intuitive and responsive.

DevOps

This combination of Agile software development and IT operations provides you with high-quality software at reduced cost, time, and risk.

Trailhead stepped into a challenging project – building our new web architecture and redeveloping our portals at the same time the business was migrating from a legacy system to our new CRM solution. They were able to not only significantly improve our web development architecture but our development and deployment processes as well as the functionality and performance of our portals. The feedback from customers has been overwhelmingly positive. Trailhead has proven themselves to be a valuable partner.

– BOB DOERKSEN, Vice President of Technology Services
at Montage Furniture Services

Technologies Used

When you hit the trails, it is essential to bring appropriate gear. The same holds true for your digital technology needs. That’s why Trailhead builds custom solutions on trusted platforms like .NET, Angular, React, and Xamarin.

Expertise

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

  • Project Management
  • Architecture
  • Web App Development
  • Cloud Development
  • DevOps
  • Process Improvements
  • Legacy System Integration
  • UI Design
  • Manual QA
  • Back end/API/Database development

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

Our Gear Is Packed and We're Excited to Explore with You

Ready to come with us? 

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the contact form. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Thank you for reaching out.

You’ll be getting an email from our team shortly. If you need immediate assistance, please call (616) 371-1037.