Summary
A genetic algorithm (GA) is an optimisation technique that evolves a population of candidate solutions through repeated cycles of selection, crossover, and mutation — inspired by biological natural selection. Rather than searching a solution space exhaustively or following a gradient, a GA explores it stochastically, guided by a fitness function that scores how well each candidate solves the problem. Over generations, fitter solutions reproduce more and their traits spread through the population. (Brabazon, O’Neill and McGarraghy, Natural Computing Algorithms, see source-natural-computing-algorithms)
(Shiffman, The Nature of Code, Chs. 9–11, see source-nature-of-code)
JavaScript to Unity/C# bridge
In The Nature of Code, the genetic algorithm examples are shown in JavaScript, often with immediate text or canvas feedback. In Unity, the GA structure stays the same, but the presentation changes:
- the population usually lives in a manager component
- each chromosome becomes a C# class or struct
- per-generation feedback often appears in the Inspector,
Debug.Log, UI text, or visible agents in a scene
The key thing to preserve is the loop of evaluate → select → crossover → mutate, not the original language or exact sketch structure.
The three requirements
Evolution — biological or computational — requires all three of the following. If any is absent, the algorithm degenerates:
| Requirement | Meaning | If absent |
|---|---|---|
| Heredity | Parent traits pass to offspring | Each generation is independent; learning cannot accumulate |
| Variation | The population contains diverse individuals | All children are identical to parents; no exploration |
| Selection | Fitness determines who reproduces | Uniform reproduction; no optimisation pressure |
The algorithm
1. Initialisation
Create a population of N individuals with randomly generated DNA (genotype). Population size is a global parameter — larger populations explore more of the solution space but cost more computation per generation.
2. Evaluate fitness
Score every individual using the fitness function. This is the most important design decision. The function must:
- Return a numerical value
- Accurately reflect the goal
- Be automatable (or interactively provided by a human)
// Example: phrase-matching fitness
float Fitness(string target, char[] genes)
{
int correct = 0;
for (int i = 0; i < genes.Length; i++)
if (genes[i] == target[i]) correct++;
return (float)correct / target.Length;
}Scaling the fitness function changes selection pressure:
- Linear:
fitness = correct / total— moderate pressure - Quadratic:
fitness = (correct / total)²— higher scorers dominate faster - Exponential:
fitness = 2^correct— extreme pressure; risks premature convergence
3. Selection
The goal: individuals with higher fitness should be more likely to reproduce, but not guaranteed. Pure elitism (top N only) reduces variation too quickly.
Probabilistic / “wheel of fortune” selection: Normalise all fitness scores to sum to 1. Draw a random number and walk down the list subtracting scores until crossing it — the individual where the crossing occurs is selected. Higher fitness = more space on the wheel.
// Relay-race selection
DNA PickParent(DNA[] population, float[] fitness)
{
float r = Random.value;
int i = 0;
while (r > 0)
{
r -= fitness[i];
i++;
}
return population[i - 1];
}Accept-reject: Pick a random individual. Accept it with probability proportional to its fitness; otherwise try again. Simpler to implement; slightly less efficient.
4. Crossover
Combine two parents’ DNA to create a child:
char[] Crossover(char[] parentA, char[] parentB)
{
char[] child = new char[parentA.Length];
int midpoint = Random.Range(0, parentA.Length);
for (int i = 0; i < child.Length; i++)
child[i] = i < midpoint ? parentA[i] : parentB[i];
return child;
}Coin-flip variant: Each gene independently chosen from either parent (50/50). Maximum genetic variation.
5. Mutation
Randomly alter individual genes at a low rate after crossover:
void Mutate(char[] genes, float rate)
{
for (int i = 0; i < genes.Length; i++)
if (Random.value < rate)
genes[i] = (char)Random.Range(32, 127);
}Mutation rate trade-offs:
0%— Evolution stalls if the complete solution is not in the initial population.~1–2%— Typical range balancing exploration and exploitation.>10%— Overwhelms selection pressure; devolves to near-random search.
6. Repeat
The new generation becomes the current population. Repeat from step 2 until the fitness target is reached or generation limit exceeded.
Beginner Unity code example
For a simple Unity teaching version, see:
- DNA.cs — minimal genotype container for a text-matching GA
- TextPopulationManager.cs — population loop running evaluate/select/crossover/mutate each generation
This is intentionally simpler than a full agent-based scene. It gives students a clear C# baseline before moving to rockets, creatures, or neuroevolution.
Scene setup for the Unity example
Use the text-matching GA as a console-style Unity lab:
- Create an empty GameObject named
TextGA. - Add
TextPopulationManagerto it. - Set
targetto a short phrase, such asHELLO. - Set
populationSizeto100. - Set
mutationRateto0.01. - Press Play and watch the Console.
- Increase the target length only after the short phrase converges.
This example does not need sprites. The visible output is the best text found each generation, printed through Debug.Log.
Code walkthrough
DNA owns one candidate solution. It stores a char[] called Genes, exposes the current Text, and stores Fitness after evaluation. Randomise() creates a starting candidate from the allowed alphabet.
Evaluate() compares the genes against the target phrase and scores the fraction of matching characters. This is the fitness function. For this beginner example, the goal is deliberately simple so students can see evolution happen.
Crossover() creates a child by taking genes from one parent before a random midpoint and genes from the other parent after that midpoint. Mutate() then gives each gene a small chance to change to a new random character.
TextPopulationManager owns the population loop. Start() creates the first random population. Each Update() calls EvolveOneGeneration(), which evaluates fitness, selects parents, creates children, mutates them, replaces the population and logs the current best text.
PickParent() uses accept-reject selection. A candidate is chosen randomly, then accepted with probability based on fitness. Higher fitness candidates are more likely to reproduce, but lower fitness candidates can still occasionally contribute variation.
What to change first
| Change | Expected effect |
|---|---|
target | longer targets take more generations |
populationSize | larger populations explore more candidates but cost more |
mutationRate | low values may stall, high values can disrupt progress |
alphabet in DNA | changes which phrases can be evolved |
| logging frequency | makes long runs easier to read |
Debugging checklist
- If the output never improves, check that
Evaluate()is called beforePickParent(). - If the target can never be reached, check that every target character exists in
Alphabet. - If evolution looks random, reduce
mutationRate. - If progress is very slow, shorten the target phrase or increase
populationSize. - If the Console floods too quickly, log every tenth or hundredth generation instead of every frame.
Practice
Run three experiments:
- Target
HELLO, population100, mutation0.01. - Same target and population, mutation
0. - Same target and population, mutation
0.15.
For each run, describe whether the population converges, stalls or behaves randomly. Then explain which run best balances selection and variation.
Self-test
- What is the genotype in the text example?
- What is the phenotype?
- What does the fitness function measure?
- Why can a mutation rate of
0cause the search to stall? - Why can a high mutation rate damage progress?
Answers
- The genotype is the
char[] Genesarray. - The phenotype is the text produced from those genes and evaluated against the target.
- It measures the fraction of characters that match the target phrase.
- With no mutation, the algorithm cannot introduce missing characters that are absent from the selected parent lines.
- A high mutation rate can overwrite useful inherited genes so quickly that selection cannot preserve improvements.
Genotype vs. phenotype
The genotype is the raw genetic data — an array of characters, floats, or vectors. The phenotype is how that data is expressed and evaluated. The same genotype can produce different phenotypes depending on how it is interpreted. Separating these two concerns keeps the GA core reusable across different problems.
Elitism
Without elitism, crossover and mutation can accidentally destroy the best chromosomes discovered so far — they represent a small fraction of the population, so selection may simply not choose them.
Elitism: copy the top N% of the population (typically 2–5%) directly to the next generation without modification. The rest of the population is generated normally through selection, crossover, and mutation.
// Elitist selection — copy top agents unchanged
int eliteCount = Mathf.RoundToInt(population.Length * 0.05f);
for (int i = 0; i < eliteCount; i++)
newPopulation[i] = population[sortedByFitness[i]].Copy();
// fill remainder with crossover + mutationTrade-off: Too few elite → slower convergence. Too many → premature convergence to a local optimum (diversity lost). The sweet spot depends on problem size and selection pressure.
(Prof Charles, CRE341 Wks 8–9)
Global parameters
| Parameter | Effect | Typical value |
|---|---|---|
| Population size | Larger = more diversity, more computation | 100–500 |
| Mutation rate | Higher = more exploration, less exploitation | 0.5%–2% |
| Fitness scaling | Linear vs. quadratic vs. exponential | Problem-dependent |
| Crossover method | Midpoint vs. coin-flip | Coin-flip for more variation |
Classic problem encodings
The choice of encoding determines which crossover and mutation operators are valid. (Prof Charles, CRE341 Wks 8–9)
Binary encoding — Knapsack Problem
Encode the chromosome as a bit string: 1 = include item, 0 = exclude.
| Object | Value | Weight |
|---|---|---|
| A | 70 | 31 |
| B | 20 | 10 |
| C | 39 | 20 |
| D | 37 | 19 |
| E | 7 | 4 |
Fitness: total value of included items if total weight ≤ capacity; otherwise 0 (or penalised).
Standard single-point or two-point crossover works because swapping bit substrings produces valid candidates (any subset is encodable).
Permutation encoding — Travelling Salesman Problem
A salesman must visit all cities exactly once, minimising total distance. The chromosome is a permutation of city indices.
Problem with standard crossover: swapping arbitrary substrings creates duplicates (city visited twice). Use specialised operators:
Partially Matched Crossover (PMX):
- Choose two crossover points; note the mapping of swapped elements
- Copy the segment directly; replace duplicates in the remainder using the mapping
Parent A: 1-5-6-2-3-4
Parent B: 1-2-3-4-6-5
Segment (positions 2-4): [6-2-3] ↔ [3-4-6]
Child: 1-5-3-4-6-2 (no city repeated)
Exchange Mutation (EM): Choose two random genes and swap them.
Before: 1-5-3-4-6-2
After: 2-5-3-4-6-1 (positions 0 and 5 swapped)
Fitness: (worst route distance) - (this route distance) — higher is better.
Sequence encoding — Genetic Lunar Lander
A lander is controlled by a sequence of (action, duration) pairs: thrust / rotate-left / rotate-right / do-nothing.
The genome encodes the sequence; each gene is an action + duration.
- Crossover: two-point crossover on whole genes (not individual bits)
- Mutation: randomly change an action or alter its duration
- Fitness: how close to a successful landing (minimise distance to pad, minimise crash velocity)
Design principle: encode solutions so that crossover of two valid individuals always produces a valid individual. The encoding choice constrains which operators are correct.
Neuroevolution
Neuroevolution applies genetic algorithms to neural network weights. Instead of training a single network with backpropagation and labelled data, a population of networks is evaluated by letting them interact with an environment, then evolved using their environmental performance as fitness.
This makes it suited to problems where:
- There is no labelled training data
- The desired behaviour is difficult to specify explicitly
- Performance can only be measured through interaction (game scores, survival time)
Pattern: neuroevolution agent loop
// Each frame, for each agent:
1. Read sensor inputs (normalised environmental values)
2. Feed inputs to neural network → get output action
3. Execute action in simulation
4. Accumulate fitness score
5. When dead/time limit: record fitness
// After generation:
1. Sort by fitness
2. Probabilistic selection of two parents
3. crossover() → child network
4. mutate(childNetwork, rate)
5. Replace population with children
Continuous ecosystem model
Rather than discrete generations, a persistent ecosystem keeps agents alive until health reaches zero, then reproduces them on the fly:
- Agents consume resources to regain health
- Each frame, health decreases slightly
- When an agent dies, two fit parents are selected from the surviving population and a child is spawned
- Population stays roughly constant, evolution is continuous
This feels more like a living world than a batch optimisation.
Sensor-based perception for evolved agents
To limit what an agent “knows”, attach virtual sensors — rays or radii that return normalised distance-to-target values:
float SenseFood(Vector2 foodPosition)
{
float distance = Vector2.Distance(transform.position, foodPosition);
return Mathf.Clamp01(1f - distance / sensorRadius);
// 1.0 = food is here; 0.0 = food is at edge of range or beyond
}The sensor values become the network inputs. The network learns, through evolution, which combination of sensor readings warrants which action — without the programmer specifying the rule.
In practice (game AI applications)
| Use case | Fitness function |
|---|---|
| Racing line | Lap time, distance covered |
| Flappy Bird clone | Frames survived |
| Enemy difficulty calibration | Player win rate deviation from target |
| Procedural level generation | Player completion rate + completion time |
| Weapon stat balancing | Win rate symmetry across options |
| Creature behaviour | Food consumed per lifetime |
Genetic algorithms are best when:
- A fitness function can be computed automatically
- The solution space is too large for exhaustive search
- Local minima are a risk (GAs escape them through mutation)
They are not the best choice when:
- A closed-form solution exists (e.g., pathfinding — use A*)
- Training time must be short
- The fitness function is expensive to evaluate and the population is large
For the Unity/C# Nature of Code strand, GA is best treated as a later batch after motion, particles, and grid systems are already in place. That way students can evolve behaviour in an environment they already understand rather than learning evolution and rendering problems at the same time. See overview-unity-nature-of-code-examples.
Related
- source-nature-of-code — primary source (Chs. 9–11)
- source-cre341-lectures — elitism, Knapsack/TSP/Lunar Lander worked examples, encoding strategies
- machine-learning-games — ML taxonomy; RL and neural networks as alternatives or complements to GA
- particle-swarm-optimisation — another population-based optimiser with memory and social search
- ant-colony-optimisation — constructive swarm search using pheromone trails
- neuroevolution — evolving neural networks with GA-like search
- steering-behaviours — neuroevolution commonly evolves steering behaviour weights
- reward-systems — reinforcement learning overlap (reward = fitness)
- dark-patterns — evolved monetisation optimisation as a real-world application and ethical concern
- cpp-templates —
template<typename Gene>DNA class is a natural use of generics/templates - overview-unity-nature-of-code-examples — staged Unity/C# route for Nature of Code topics