# Frame Pacing and Animation in Python/tkinter

The goal of each frame of the animation to be the same length as the one before it, regardless of the amount of processing that takes place as the frame is being built.

This is a little animation demo that I wrote to show off the basics of a game loop in Python. With a newbies eyes in mind I have tried to keep to a limited tool kit; avoid pygame and threading and sticking to tkinter (included with most installations) to make the code as clear and as easy to understand as possible.

The end result works, but I am dissatisfied with the pacing on the game loop (onTimer). "After" isn't really to tool for the job.

In addition to being open to general comments, I am looking for suggestions to make the animation smoother and less at the whims of "after" and background event processing.

#Bounce

import tkinter
import time
import math
import random

#--- Initialize Globla Variables ----------------------------------------------
winWid = 640    #window size
winHei = 480
frameRate = 20  #length of a frame (include delay) in millesconds
frameDelay = 0  #the delay at the end of the current frame
frameStart = 0  #when the current frame started

balldiameter = 7
ballShape = []
ballX = []
ballY = []
spdX = []
spdY = []
ballCnt = 0

#--- Function/Tookkit list ----------------------------------------------------

def onTimer():

#An animation frame is the work being done to draw/update a frame,
#  plus the delay between the frames.  As the work to draw the
#  frame goes up, the delay between the frames goes down
elapsedTime = round((time.perf_counter() - frameStart)* 1000)  #time from start of frame until now
frameDelay = frameRate - elapsedTime   #delay for this frame is the frame size, minus how long it took to process this frame
if frameDelay < 1: frameDelay = 1  #bottom out with a single millesecond delay
frameStart = time.perf_counter()   #start a new frame

#if the frame delay hasn't bottomed out and a half second has passed
if (time.perf_counter() - addTimer) > 0.25 and frameDelay > 1:
window.title("FD:" + str(frameDelay) + " - " + str(ballCnt))

moveTheBalls()  #update the position of the balls

timerhandle = window.after(frameDelay,onTimer) #fill update rest of this frame with a delay

def onShutdown():
window.after_cancel(timerhandle)
window.destroy()

global ballCnt
newX = random.randrange(0,winWid)
newY = random.randrange(0,winHei)
color = randomColor()
ballShape.append(canvas.create_oval(newX,newY, newX+balldiameter,newY+balldiameter, outline=color, fill=color))
ballX.append(newX)
ballY.append(newY)
spdX.append((random.random() * 2)-1)
spdY.append((random.random() * 2)-1)
ballCnt = ballCnt + 1

def moveTheBalls():
for i in range(0,ballCnt):     #for each ball
ballX[i] = ballX[i] + spdX[i]    #update its position
ballY[i] = ballY[i] + spdY[i]
for j in range(i+1,ballCnt):     #check for colision between other balls
dist = math.sqrt(( (ballX[i]+(balldiameter/2)) - (ballX[j]+(balldiameter/2)))**2 + ( (ballY[i]+(balldiameter/2)) - (ballY[j]+(balldiameter/2)))**2)
if dist < balldiameter:      #if the balls are inside each other
hold = spdX[i]           #swap their directions
spdX[i] = spdX[j]
spdX[j] = hold
hold = spdY[i]
spdY[i] = spdY[j]
spdY[j] = hold
if ballX[i] < 0 or ballX[i] > winWid-balldiameter: spdX[i] = spdX[i] * -1   #top or bottom? reverse directions
if ballY[i] < 0 or ballY[i] > winHei-balldiameter: spdY[i] = spdY[i] * -1   #left or right? reverse directions
canvas.coords(ballShape[i], (ballX[i], ballY[i], ballX[i]+balldiameter, ballY[i]+balldiameter))

#Random color - This is a helper function
#Returns a random color.
#Usage c4 = randomcolor()
def randomColor():
hexDigits = ["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F"]
rclr = "#"
for i in range(0,6):
rclr = rclr + hexDigits[random.randrange(0,len(hexDigits))]
return rclr

#--- Main Program - Executes as soon as you hit run  --------------------------

window = tkinter.Tk()   #Sets the window
canvas = tkinter.Canvas(window, width=winWid, height=winHei, bg="white")
canvas.pack()

timerhandle = window.after(20,onTimer)  #start the game loop
window.protocol("WM_DELETE_WINDOW",onShutdown)  #provide a graceful exit
window.mainloop()   #Start the GUI


## Typos

Globla -> Global

Tookkit -> Toolkit?

millesconds -> milliseconds

## Nomenclature

By the PEP8 standard, your functions and variables would be called

win_width
win_height
ball_diameter
ball_count
on_timer


etc.

And this is minor, but a name like moveTheBalls by convention would not include an article, thus move_balls.

## String interpolation

"FD:" + str(frameDelay) + " - " + str(ballCnt)


can be

f'FD: {frame_delay} - {ball_count}'


## Range default

range(0,ballCnt)


does not need the 0 since that is default.

## Swap via tuple unpacking

            hold = spdX[i]           #swap their directions
spdX[i] = spdX[j]
spdX[j] = hold


does not need a temporary variable:

speed_x[i], speed_x[j] = speed_x[j], speed_x[i]


randomColor needs some re-thinking. hexDigits needs to go away completely. Instead, calculate a single random number between 0 and 16**6, and then format it as a hex string.

## In-place increment

ballCnt = ballCnt + 1


can be

ball_count += 1


## Globals and entry points

Everything at Main Program and beyond needs to be in a method; and you should attempt to reduce your dependence on global variables. These constants:

winWid = 640    #window size
winHei = 480
frameRate = 20  #length of a frame (include delay) in millesconds


can stay there (and should be capitalized); but everything else should not. For example, onTimer can be a method on a class whose member variables give it context.

## "Graceful" exit

Given the nature of the application I find it odd to call after_cancel. Typically it's only appropriate to do this if there's something the user could lose on exit, such as unsaved data; but that's not the case here so I'd get rid of it.

## Factor-less times

I recommend that instead of storing everything in milliseconds, only store fractional seconds - since that's what your performance counters use - and only convert to a non-base-multiple of milliseconds when passing to window.after.

## Encapsulation

There is a lot of work - particularly in moveTheBalls - being done to balls from the outside, with all of their attributes split into multiple variables. This is a perfect situation for an OOP representation of a ball with all of its attributes, and methods to do the physics.

## Physics

Your swap their directions is not a correct model of two circular objects that undergo elastic collision. The correct model takes into account the normal vector formed by the alignment of the balls when they contact.

## Suggested

This is equivalent in some ways to your code, and a significant departure in others - it attempts to be more careful with collision and overlapping physics, and is object-oriented. I have also increased the ball diameter to 20 to better see the effect of the new collision model.

# Bounce
from math import sqrt
from tkinter import Tk, Canvas
from random import randrange, random
from time import perf_counter
from typing import List, Tuple

WIN_WIDTH = 640     # pixels
WIN_HEIGHT = 480
BALL_DIAMETER = 20   # pixels

FRAME_RATE = 0.020  # length of a frame (including delay) in fractional seconds
MIN_FRAME = 0.001   # fractional seconds

def random_colour() -> str:
return f'#{randrange(0x1000000):06X}'

class Ball:
def __init__(self, canvas: Canvas):
self.x = randrange(WIN_WIDTH)
self.y = randrange(WIN_HEIGHT)
self.colour = random_colour()
self.shape = canvas.create_oval(
*self.coords, outline=self.colour, fill=self.colour,
)
self.speed_x = 2*random() - 1
self.speed_y = 2*random() - 1

@property
def coords(self) -> Tuple[float, ...]:
return (
)

def move_x(self) -> None:
self.x += self.speed_x

elif self.x > WIN_WIDTH - BALL_RADIUS:
else:
return

self.speed_x = -self.speed_x

def move_y(self) -> None:
self.y += self.speed_y

elif self.y > WIN_HEIGHT - BALL_RADIUS:
else:
return

self.speed_y = -self.speed_y

def move(self):
self.move_x()
self.move_y()

def distance2_from(self, other: 'Ball') -> float:
return (
(self.x - other.x) ** 2 +
(self.y - other.y) ** 2
)

def collides_with(self, other: 'Ball') -> bool:
return self.distance2_from(other) < BALL_DIAMETER**2

def deoverlap(self, other: 'Ball', nx: float, ny: float) -> None:
# Push objects away if they overlap, along the normal
dist2 = nx*nx + ny*ny
if dist2 < BALL_DIAMETER*BALL_DIAMETER:
xm, ym = (other.x + self.x)/2, (other.y + self.y)/2
xd, yd = f*nx, f*ny
self.x, other.x = xm - xd, xm + xd
self.y, other.y = ym - yd, ym + yd

def collide(self, other: 'Ball') -> None:
# Assume a fully-elastic collision and equal ball masses. Follow

# Normal vector and magnitude. Magnitude assumed to be
# BALL_DIAMETER and is enforced.
nx, ny = other.x - self.x, other.y - self.y
un = BALL_DIAMETER

# Unit normal and tangent vectors
unx, uny = nx/un, ny/un
utx, uty = -uny, unx

self.deoverlap(other, nx, ny)

# Initial velocities
v1x, v1y = self.speed_x, self.speed_y
v2x, v2y = other.speed_x, other.speed_y

# Projected to normal and tangential components
v1n = v1x*unx + v1y*uny
v2n = v2x*unx + v2y*uny
v1t = v1x*utx + v1y*uty
v2t = v2x*utx + v2y*uty

# New tangential velocities are equal to the old ones;
# New normal velocities swap
v1n, v2n = v2n, v1n

# Back to vectors
v1nx, v1ny = v1n*unx, v1n*uny
v2nx, v2ny = v2n*unx, v2n*uny
v1tx, v1ty = v1t*utx, v1t*uty
v2tx, v2ty = v2t*utx, v2t*uty

# Convert from normal/tangential back to xy
self.speed_x, self.speed_y = v1nx + v1tx, v1ny + v1ty
other.speed_x, other.speed_y = v2nx + v2tx, v2ny + v2ty

class Game:
def __init__(self):
self.window = Tk()  # Sets the window
self.canvas = Canvas(self.window, width=WIN_WIDTH, height=WIN_HEIGHT, bg="white")
self.canvas.pack()

self.balls: List[Ball] = []

self.frame_delay: float = 0  # the delay at the end of the current frame; fractional seconds
self.frame_start: float = 0  # when the current frame started; fractional seconds
self.timer_handle: str

def run(self) -> None:
self.timer_handle = self.window.after(20, self.on_timer)  # start the game loop
self.window.mainloop()  # Start the GUI

@property
def ball_count(self) -> int:
return len(self.balls)

def on_timer(self) -> None:
# An animation frame is the work being done to draw/update a frame,
#  plus the delay between the frames.  As the work to draw the
#  frame goes up, the delay between the frames goes down
elapsed = perf_counter() - self.frame_start  # time from start of frame until now

# delay for this frame is the frame size, minus how long it took to process this frame
# bottom out with a single millisecond delay
self.frame_delay = max(MIN_FRAME, FRAME_RATE - elapsed)
self.frame_start = perf_counter()  # start a new frame

# if the frame delay hasn't bottomed out and a half second has passed
self.window.title(f"FD: {1e3*self.frame_delay:.0f}ms - {self.ball_count}")

self.move_balls()  # update the position of the balls

# fill update rest of this frame with a delay
self.timer_handle = self.window.after(
round(1000*self.frame_delay),
self.on_timer,
)

self.balls.append(Ball(self.canvas))

def move_balls(self) -> None:
for ball in self.balls:
ball.move()

for other in self.balls:
if other is ball:
continue
if other.collides_with(ball):
other.collide(ball)

self.canvas.coords(ball.shape, ball.coords)

def main():
Game().run()

if __name__ == '__main__':
main()

• Wow... that is a very in depth analysis. Thank you. Items are on point if this was production code, at the college level, or if the topic was OOP. For high school students in an intro course, things like class structure, underscores in variable names, and statements such as: f"FD: {1e3*self.frame_delay:.0f}ms - {self.ball_count}, will send them away screaming. This is also why I kept the physics simple. I am all for teaching best practices and developing good habits, but if you don't keep the basics, simple and easy to follow, students won't stick around long enough to develop any habits. Apr 11, 2021 at 16:23