29. Particle life

Section author: Ian Bishop <ianbishop3851@gmail.com>

[status: content-mostly-written]

29.1. Motivation, prerequisites, plan

Today, we will be exploring how complex behavior can emerge from simple rules. We will make particles attract and repel one another, then look for unexpected patterns that we didn’t explicitly program.

Prerequisites:

  • The 10-Hour “serious programming” course

Awesome, now that that’s out of the way, let’s take a look at what we’ll be doing today:

  1. calculate one interaction

  2. draw some particles

  3. run the simulation

  4. and experiment with its rules.

29.2. Getting started

On Linux, prepare a Python virtual environment:

sudo apt install python3-venv python3-pip
python3 -m venv particle-venv
source particle-venv/bin/activate
python -m pip install pygame

If the first command fails, install your specific distribution’s Python venv install command.

Note

Pygame is what’s commonly known as a “game engine.” While game engines are built mainly to aid in game development, we can also use them for programs of smaller scope, such as a simple particle simulation. We won’t do anything too fancy, but pygame allows us to do things such as drawing shapes and reading keyboard inputs, which we will use in this chapter.

Our particles will come in three colors: red, green, and blue. Their rules are simple:

  • When particles are too close, they repel

  • When particles are too far, there is no force

  • And when particles are near each other, they follow a preprogrammed rule.

Rules are cool and all, but wouldn’t seeing it in action be way cooler!?

Let’s first test calculating force. Try the following:

distance = 40
attraction = 0.6
push = attraction * (80 - distance) / (80 - 12)
print("Push Magnitude:", push)

This should give about 0.353. Change the sign of attraction and try again.

The model is very simple: just one formula for calculating force.

29.3. Basic Drawing With Pygame

But what does this actually look like?

Save this as particle_life.py and run python particle_life.py:

Listing 29.3.1 Draw two particles
import pygame

pygame.init()
screen = pygame.display.set_mode((400, 300))
screen.fill((15, 18, 25))
pygame.draw.circle(screen, (240, 90, 90), (100, 100), 6)
pygame.draw.circle(screen, (90, 220, 130), (140, 100), 6)
pygame.display.flip()
print("Initialized two particles.")
running = True
while running:
    event = pygame.event.wait()
    if event.type == pygame.QUIT:
        running = False
pygame.quit()

The dots are given positions based on coordinates in the code. Try moving the green dot below the red one. Remember: y increases downward on the screen.

29.4. The simulation

Replace the file with Listing 29.4.1 and run it again. Press Space to pause, R for new positions, and Escape to quit. Watch for clusters and moving groups.

Listing 29.4.1 particle-simulation.py
import math
import random  
import pygame

# These are our constants
WIDTH = 800
HEIGHT = 600
COUNT = 180
CLOSE = 12
REACH = 80
DAMPING = 0.8
STRENGTH = 0.3
COLORS = [(240, 90, 90), (90, 220, 130), (100, 150, 255)]

# These rules define our particle interactions
RULES = [[0.6, 0.3, -0.2],
        [-0.2, 0.6, 0.3],
        [0.3, -0.2, 0.6]]

def make_particles():
    particles = []
    for number in range(COUNT):
        position_x = random.uniform(0, WIDTH) % WIDTH
        position_y = random.uniform(0, HEIGHT) % HEIGHT
        kind = number % len(COLORS)
        particles.append([position_x, position_y, 0.0, 0.0, kind])
    return particles

def wrapped_difference(difference, size):
    if difference > size / 2:
        difference = difference - size
    elif difference < -size / 2:
        difference = difference + size
    return difference

def force(distance, attraction):
    if distance < CLOSE:
        return distance / CLOSE - 1
    if distance < REACH:
        return attraction * (REACH - distance) / (REACH - CLOSE)
    return 0

def step(particles):
    next_particles = []
    for particle in particles:
        position_x, position_y, velocity_x, velocity_y, kind = particle
        push_x = 0.0
        push_y = 0.0
        for other_particle in particles:
            displacement_x = wrapped_difference(other_particle[0] - position_x, WIDTH)
            displacement_y = wrapped_difference(other_particle[1] - position_y, HEIGHT)
            distance = math.hypot(displacement_x, displacement_y)
            if distance == 0 or distance >= REACH:
                continue
            attraction = RULES[kind][other_particle[4]]
            push = force(distance, attraction)
            push_x = push_x + push * displacement_x / distance
            push_y = push_y + push * displacement_y / distance
        velocity_x = (velocity_x + STRENGTH * push_x) * DAMPING
        velocity_y = (velocity_y + STRENGTH * push_y) * DAMPING
        position_x = (position_x + velocity_x) % WIDTH
        position_y = (position_y + velocity_y) % HEIGHT
        next_particles.append([position_x, position_y, velocity_x, velocity_y, kind])
    return next_particles

def draw(screen, particles):
    screen.fill((15, 18, 25))
    for position_x, position_y, velocity_x, velocity_y, kind in particles:
        pygame.draw.circle(screen, COLORS[kind], (int(position_x), int(position_y)), 3)
    """ The previous lines of code draw it in memory, this actually updates the window"""
    pygame.display.flip()

def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    clock = pygame.time.Clock()

    """Here, we set the seed. While we use "random" values to simulate more
    spread across initial positions, the seed allows us to ensure that this
    "randomness" will be consistent across runs."""
    random.seed(7)

    particles = make_particles()
    print("Particles:", COUNT)
    print("Rules: rows respond to columns in order of red, green, blue.")
    for row in RULES:
        print(row)
    print("Space: pause/resume. R: new positions. Escape: quit.")
    paused = False
    running = True

    # This is the event loop, where there program runs until termination
    while running:
        """Pygame allows us to handle user input, which we can see below."""
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_ESCAPE:
                    running = False
                elif event.key == pygame.K_SPACE:
                    paused = not paused
                    print("Paused:", paused)
                elif event.key == pygame.K_r:
                    particles = make_particles()
                    print("New, randomized position. Rules have been kept the same")
        if not running:
            break
        if not paused:
            particles = step(particles)
        draw(screen, particles)
        clock.tick(60)
    pygame.quit()
    print("Simulation has ended.")

if __name__ == "__main__":
    main()

There are a couple things to note here:

  • make_particles() creates all the particles that we see on screen

  • wrapped_difference() corrects the value for distance between particles to account for the particles being on opposite sides of the window, since we’re simulating these particles as if one end of the window connects to the other.

  • force() calculates the amount of force applied to the particle based on distance and hard-coded attraction values

  • step() uses the calculated force and applies it to each particle in the simulation

  • draw() clears the entire window, and then draws each particle using its position

You may also notice that some of the variable names here are capitalized, and that’s because these are variables we intend to keep as constants. Constants are variables that never change state.

But ask yourself: Where does a cluster’s shape appear in the code?

It doesn’t!

You can search for as long as you’d like, but you will never find these behaviors intentionally programmed.

Each particle responds to its neighbors, and those movements change the neighborhood. The resulting group behavior is emergence. Though the simulation may make it seem so, these particles are not alive, and the rules are invented. Some arrangements settle down instead of continuing to move.

29.4.1. Have some fun with it!

Try changing one setting at a time. Predict the result, run the program, and observe what happens. Restart with the same seed for comparisons, then try other seeds like 8 and 9.

Exercise 29.1

Try changing the values in RULES. What changes about the behavior of the particles?

Exercise 29.2

Set every table entry to zero. Theoretically, the particles shouldn’t move, right?

So then… why can nearby particles still move?

Exercise 29.3

Change the rules back to their original state. Compare DAMPING = 0.5 and 0.9.

Which value of DAMPING allows the particles to glide longer? How come?

Exercise 29.4

Try changing REACH to 50. Try 100. What happens to the groups? Keep 0 < CLOSE < REACH and REACH below half the smaller window dimension for the most interesting results.

Exercise 29.5

Double the value of COUNT. The nested loops check COUNT * COUNT pairs. How much more work is that? Reduce the count if the simulation runs slow.

29.5. Conway’s Game of Life

Conway’s Game of Life follows a few simple guidelines. It uses a grid of live and dead cells, on or off. Every step, each cell’s eight neighbors (the cell directly adjacent), including diagonals, are used to evaluate the following two rules:

  • A live cell survives with two or three live neighbors; otherwise it dies.

  • A dead cell becomes alive with exactly three live neighbors.

All cells update simultaneously. Here, # is alive and . is dead. Also, let’s assume cells outside the drawing are dead:

Listing 29.5.1 A blinker repeats after two steps
.....       .....       .....
.....       ..#..       .....
.###.  -->  ..#..  -->  .###.
.....       ..#..       .....
.....       .....       .....

Check the neighbor counts yourself. Other arrangements also have names, such as gliders, which move across the grid. As with particle life, local rules produce a pattern that no single cell is programmed to create.

Conway’s Game of Life is known as a cellular automaton.

29.6. Connections to real life

In flocking models, individuals avoid crowding while aligning their direction and stay near their neighbors. These rules are simple alone, but together they produce coordinated movement. While our particles have attraction and repulsion, they have no alignment rule.

Also think economics. Imagine buyers choosing affordable goods and sellers adjusting prices when stock sells out or remains unsold. Repeated decisions produce market-wide price patterns that we can predict. What rules might you give each buyer and seller? What could you measure?

The connection here is that local decisions can produce complex, collective behavior.

29.7. Further reading

  • Firstly, there is the chapter on Emergent behavior, which talks a bit more about Conway’s Game of Life, so I would recommend taking a look at that.

  • https://www.youtube.com/watch?v=p4YirERTVF0&t=379s - This video gives another high-level explanation of particle life and has some absolutely wicked visuals, so if you’re interested in particle life I would highly recommend it.

  • https://www.red3d.com/cwr/boids - This explains the flocking model from earlier. It isn’t super pretty, but the explanation is clear and concise.

  • https://www.youtube.com/watch?v=bqtqltqcQhw - This explains the flocking model in a high-level video format. This same channel also has some other phenomenal videos that I highly recommend checking out to get your head-gears turning.

  • https://math.mit.edu/traffic/ - This is a bit of a fun one. It describes how traffic jams can happen even when there is no real obstruction.