Yesterday I wanted to remake my Gravo project using Pygame. It’s Christmas break, so I was actually able to do that.
Here I represent each particle with a line of a length proportional to its speed:

The white lines represent each force acting on a particle at any given time. When two particles pass near each other, the forces grow larger and the lines reach farther.
The twinkling effect is rather cool, but I was more interested in the movement of the ends of those force lines. Further experimentation led me to create this:

Here, lines are drawn following each particle, but floating away from it at a distance determined by a force. Each particle has a line corresponding to each other particle. these lines create a figure 8 or an hourglass when two particles slingshot around each other. the color of the line is also determined by the forces acting on a particle (bright green represents a strong horizontal force, bright blue represents a strong vertical force)
I found that I could add at least 64 particles without significant lag:

Finally, I began to experiment with zooming effects. previously, I have been dimming the screen and painting over it again to produce the echo. Now I store the current frame to be painted in the background of the next frame.

Here’s my code:
import random as rand
import pygame
import pygame.event
import math
size = [720,720]
screen = pygame.display.set_mode([1280,720])
pygame.display.set_caption("gravo2d")
back = pygame.Surface([1280,720])
class particle:
def __init__(self, groupSize):
self.pos = (rand.uniform(0.0,1.77777777), rand.uniform(0.0,1.0)) #start anywhere
self.vel = (rand.uniform(-0.01,0.01), rand.uniform(-0.01,0.01)) #start with a random velocity
self.charge = rand.uniform(0.5,1.5)*(rand.randint(0,1)-0.5) #charge varies but is never nearly 0
self.mass = rand.uniform(0.95, 1.05) #mass affects response to forces
self.color = [int(128+self.charge*128),0,int(128-self.charge*128)] #particle color is based on charge
self.forces = [] #all forces between this particle and particles of higher index (longest for the first particles)
self.forceIndex = 0 #the force currently being edited by the simulation
self.sharedForce = (0.0,0.0) #the equal and opposite force that the simulator uses for the other particle in the pair
for i in range(groupSize+1):
self.forces.append((0.0,0.0))
self.netForce = (0.0,0.0) #modified many times before being acted upon and reset to 0
def addForce(self, drawOnto, f): #used to inform a particle of the force from another particle.
#add force to net forces
self.netForce = (self.netForce[0]+f[0],self.netForce[1]+f[1])
#color with velocity influencing red component, horizontal force for green and verticle force for blue.
color = [int(511*abs(self.vel[0]+self.vel[1])),(160*math.atan(10000.0*abs(f[0]))),(160*math.atan(10000.0*abs(f[1])))]
#represent increase in force with a line who's distance from the particle increases.
self.drawLine(drawOnto, color, [self.pos[0]-self.vel[0]+self.forces[self.forceIndex][0]*64,self.pos[1]-self.vel[1]+self.forces[self.forceIndex][1]*64], [self.pos[0]+f[0]*64,self.pos[1]+f[1]*64], 4)
#forces[forceIndex] stores this force until next tick to start the next line segment at the end of this one. sharedForce holds the last force when recording this force, so that the subseuent simulation of the other particle in this pair can use it later in the tick.
self.sharedForce = self.forces[self.forceIndex]
self.forces[self.forceIndex] = (f[0],f[1])
#next time, dealing with a force from a different particle, remember a different last force.
self.forceIndex += 1
return color
def acceptForce(self, drawOnto, f, previous, color): #used when an unknown particle of lower index forces this one.
self.netForce = (self.netForce[0]-f[0],self.netForce[1]-f[1])
self.drawLine(drawOnto, color, [self.pos[0]-self.vel[0]-previous[0]*64,self.pos[1]-self.vel[1]-previous[1]*64], [self.pos[0]-f[0]*64,self.pos[1]-f[1]*64], 4)
def move(self): #move based on velocity
self.pos = (self.pos[0]+self.vel[0],self.pos[1]+self.vel[1])
def react(self): #accelerate based on net force
self.vel = (self.vel[0]+self.netForce[0]*0.1*self.mass,self.vel[1]+self.netForce[1]*0.1*self.mass)
self.netForce = (0.0,0.0)
self.forceIndex = 0
def bounce(self): #bounce off the walls
if (abs(self.pos[0]-0.88888888)>0.88888889): #left and right sides
self.vel = (self.vel[0]*-0.9,self.vel[1]*0.9) #reverse direction
#find new position by mirroring over the edge once past it:
self.pos = (abs(self.pos[0]) if self.pos[0]0.5): #top and bottom sides
self.vel = (self.vel[0]*0.9,self.vel[1]*-0.9)
self.pos = (self.pos[0], abs(self.pos[1]) if self.pos[1]