Animated Particle Constellations with Python Tkinter

Create a mesmerizing starfield with drifting particles and constellation-like connections.

1. Introduction

In this tutorial, we’ll build an animated visualization using Tkinter, Python’s built-in GUI toolkit. The program simulates particles drifting across the screen, connecting with lines when they are close enough—forming constellation-like patterns.

2. Prerequisites

3. Setting Up the Canvas

We start by creating a Tkinter window and a canvas where particles will move:

import tkinter as tk

WIDTH, HEIGHT = 900, 600

root = tk.Tk()
root.title("Particle Constellations")
canvas = tk.Canvas(root, width=WIDTH, height=HEIGHT, bg="#0b0f1a", highlightthickness=0)
canvas.pack()

4. Creating Particles

Each particle has a position, velocity, and a small circle drawn on the canvas:

import random, math

class Particle:
    def __init__(self, canvas):
        self.canvas = canvas
        self.x = random.uniform(0, WIDTH)
        self.y = random.uniform(0, HEIGHT)
        angle = random.uniform(0, 2*math.pi)
        speed = random.uniform(0.3, 1.8)
        self.vx = math.cos(angle) * speed
        self.vy = math.sin(angle) * speed
        self.id = canvas.create_oval(self.x-2, self.y-2, self.x+2, self.y+2,
                                     fill="#dbe7ff", outline="")

5. Updating Particle Motion

We update positions each frame and wrap around edges for a seamless starfield:

def update(self):
    self.x += self.vx
    self.y += self.vy
    if self.x < 0: self.x += WIDTH
    if self.x > WIDTH: self.x -= WIDTH
    if self.y < 0: self.y += HEIGHT
    if self.y > HEIGHT: self.y -= HEIGHT
    self.canvas.coords(self.id, self.x-2, self.y-2, self.x+2, self.y+2)

6. Drawing Constellation Lines

We connect particles that are within a certain distance:

def draw_links(canvas, particles, max_dist=85):
    for i, p1 in enumerate(particles):
        for j, p2 in enumerate(particles[i+1:], i+1):
            dx, dy = p2.x - p1.x, p2.y - p1.y
            d = math.hypot(dx, dy)
            if d < max_dist:
                fade = 1 - d/max_dist
                gray = int(200 * fade) + 55
                color = f"#{gray:02x}{gray:02x}{gray:02x}"
                canvas.create_line(p1.x, p1.y, p2.x, p2.y, fill=color)

7. Animation Loop

We continuously update particles and redraw lines:

particles = [Particle(canvas) for _ in range(150)]

def animate():
    canvas.delete("line")  # clear old lines
    for p in particles:
        p.update()
    draw_links(canvas, particles)
    root.after(16, animate)  # ~60 FPS

animate()
root.mainloop()

8. Enhancements

9. Full code


import tkinter as tk
import random
import math
from dataclasses import dataclass

# --- Config ---
WIDTH, HEIGHT = 900, 600
NUM_PARTICLES = 180
PARTICLE_RADIUS = 2
MAX_SPEED = 1.8
LINK_DISTANCE = 85        # pixels: max distance to draw a line
LINK_ALPHA = 0.35         # line opacity (0..1), simulated via grayscale
BG = "#0b0f1a"            # deep night-sky
PARTICLE_COLOR = "#dbe7ff"
LINE_COLOR_BASE = 230     # higher -> brighter lines (0..255)
FADE_EDGES = True         # fade particles near edges for a vignette feel

# Optional “anchor stars” to suggest constellation structure (tuples of x,y)
ANCHORS = [
    (WIDTH * 0.15, HEIGHT * 0.30),
    (WIDTH * 0.25, HEIGHT * 0.50),
    (WIDTH * 0.35, HEIGHT * 0.35),
    (WIDTH * 0.50, HEIGHT * 0.40),
    (WIDTH * 0.60, HEIGHT * 0.60),
    (WIDTH * 0.70, HEIGHT * 0.30),
    (WIDTH * 0.80, HEIGHT * 0.50),
]

# Influence of anchors on nearby particles
ANCHOR_PULL = 0.06        # acceleration towards nearest anchor
ANCHOR_RADIUS = 160       # only pull particles within this radius


@dataclass
class Particle:
    x: float
    y: float
    vx: float
    vy: float
    oval_id: int


class ParticleConstellations:
    def __init__(self, root):
        self.root = root
        self.root.title("Animated Particle Constellations — Tkinter")
        self.canvas = tk.Canvas(root, width=WIDTH, height=HEIGHT, bg=BG, highlightthickness=0)
        self.canvas.pack(fill="both", expand=True)

        # UI controls
        self.toolbar = tk.Frame(root, bg=BG)
        self.toolbar.pack(fill="x")
        self.show_links = tk.BooleanVar(value=True)
        tk.Checkbutton(self.toolbar, text="Show Constellations", variable=self.show_links,
                       fg="white", bg=BG, activeforeground="white", selectcolor=BG).pack(side="left", padx=8)
        tk.Button(self.toolbar, text="Randomize", command=self.randomize,
                  fg="white", bg="#1b2234", activebackground="#243050").pack(side="left", padx=8)
        tk.Label(self.toolbar, text="Particles:", fg="white", bg=BG).pack(side="left", padx=(16,6))
        self.num_var = tk.IntVar(value=NUM_PARTICLES)
        tk.Spinbox(self.toolbar, from_=30, to=600, width=5, command=self.reset_particles,
                   textvariable=self.num_var, fg="white", bg="#1b2234", insertbackground="white").pack(side="left", padx=6)

        self.particles = []
        self.lines = []  # line ids
        self.anchor_ids = []
        self.create_anchors()
        self.create_particles(self.num_var.get())

        # Animation loop
        self.running = True
        self.root.protocol("WM_DELETE_WINDOW", self.on_close)
        self.animate()

    def create_anchors(self):
        self.anchor_ids.clear()
        for ax, ay in ANCHORS:
            s = 3
            aid = self.canvas.create_oval(ax - s, ay - s, ax + s, ay + s,
                                          fill="#f3f6ff", outline="")
            self.anchor_ids.append(aid)

    def create_particles(self, n):
        # Clear existing particles
        for p in self.particles:
            self.canvas.delete(p.oval_id)
        self.particles = []

        for _ in range(n):
            x = random.uniform(0, WIDTH)
            y = random.uniform(0, HEIGHT)
            speed = random.uniform(0.3, MAX_SPEED)
            angle = random.uniform(0, 2 * math.pi)
            vx = math.cos(angle) * speed
            vy = math.sin(angle) * speed
            pid = self.canvas.create_oval(x - PARTICLE_RADIUS, y - PARTICLE_RADIUS,
                                          x + PARTICLE_RADIUS, y + PARTICLE_RADIUS,
                                          fill=PARTICLE_COLOR, outline="")
            self.particles.append(Particle(x, y, vx, vy, pid))

    def reset_particles(self):
        self.create_particles(self.num_var.get())

    def randomize(self):
        # Jitter positions and velocities
        for p in self.particles:
            p.x = random.uniform(0, WIDTH)
            p.y = random.uniform(0, HEIGHT)
            speed = random.uniform(0.3, MAX_SPEED)
            angle = random.uniform(0, 2 * math.pi)
            p.vx = math.cos(angle) * speed
            p.vy = math.sin(angle) * speed

    def on_close(self):
        self.running = False
        self.root.destroy()

    def animate(self):
        if not self.running:
            return

        # Physics update
        for p in self.particles:
            # Anchor attraction (nearest anchor)
            if ANCHORS and ANCHOR_PULL > 0:
                ax, ay, dist = self.nearest_anchor(p.x, p.y)
                if dist < ANCHOR_RADIUS:
                    dx, dy = ax - p.x, ay - p.y
                    # normalize and apply pull
                    if dist > 1e-4:
                        p.vx += (dx / dist) * ANCHOR_PULL
                        p.vy += (dy / dist) * ANCHOR_PULL

            # Soft boundary wrap with damping
            p.x += p.vx
            p.y += p.vy

            # Wrap-around for a seamless starfield
            if p.x < 0: p.x += WIDTH
            if p.x > WIDTH: p.x -= WIDTH
            if p.y < 0: p.y += HEIGHT
            if p.y > HEIGHT: p.y -= HEIGHT

        # Render particles
        for p in self.particles:
            self.canvas.coords(p.oval_id,
                               p.x - PARTICLE_RADIUS, p.y - PARTICLE_RADIUS,
                               p.x + PARTICLE_RADIUS, p.y + PARTICLE_RADIUS)

            if FADE_EDGES:
                # Simulate vignette by adjusting color near borders
                edge_pad = 180
                dx = min(p.x, WIDTH - p.x)
                dy = min(p.y, HEIGHT - p.y)
                m = min(dx, dy) / edge_pad
                m = max(0.25, min(1.0, m))
                # compute grayscale based on m
                gray = int(200 * m) + 55
                color = f"#{gray:02x}{gray:02x}{gray:02x}"
                self.canvas.itemconfig(p.oval_id, fill=color)
            else:
                self.canvas.itemconfig(p.oval_id, fill=PARTICLE_COLOR)

        # Render constellation lines
        self.clear_lines()
        if self.show_links.get():
            self.draw_links()

        # Schedule next frame
        self.root.after(16, self.animate)  # ~60 FPS

    def nearest_anchor(self, x, y):
        best = None
        bestd = 1e9
        for ax, ay in ANCHORS:
            dx, dy = ax - x, ay - y
            d = math.hypot(dx, dy)
            if d < bestd:
                bestd = d
                best = (ax, ay)
        return best[0], best[1], bestd

    def clear_lines(self):
        for lid in self.lines:
            self.canvas.delete(lid)
        self.lines.clear()

    def draw_links(self):
        # Spatial hashing for efficiency (coarse grid)
        cell = int(LINK_DISTANCE)
        grid = {}
        for i, p in enumerate(self.particles):
            gx = int(p.x // cell)
            gy = int(p.y // cell)
            grid.setdefault((gx, gy), []).append(i)

        # Neighbor cells to check
        neighbors = [(dx, dy) for dx in (-1, 0, 1) for dy in (-1, 0, 1)]

        for (gx, gy), indices in grid.items():
            # Check nearby cells
            candidates = []
            for dx, dy in neighbors:
                candidates.extend(grid.get((gx + dx, gy + dy), []))
            for i in indices:
                p1 = self.particles[i]
                for j in candidates:
                    if j <= i:
                        continue
                    p2 = self.particles[j]
                    dx = p2.x - p1.x
                    dy = p2.y - p1.y
                    d = math.hypot(dx, dy)
                    if d < LINK_DISTANCE:
                        # Fade line by distance
                        fade = 1.0 - (d / LINK_DISTANCE)
                        alpha = LINK_ALPHA * fade
                        # Simulate alpha using grayscale intensity
                        gray = max(30, min(255, int(LINE_COLOR_BASE * alpha)))
                        color = f"#{gray:02x}{gray:02x}{gray:02x}"
                        lid = self.canvas.create_line(p1.x, p1.y, p2.x, p2.y,
                                                      fill=color, width=1)
                        self.lines.append(lid)


if __name__ == "__main__":
    root = tk.Tk()
    app = ParticleConstellations(root)
    root.mainloop()

Next Python tutorial: Plasma effect