Quick answer: pygame.Clock.tick(60) oscillates between 45 and 70 FPS because it sleeps via the OS timer, which has only ~15ms resolution on Windows — too coarse for a 16.6ms frame budget. Switch to tick_busy_loop, add delta-time movement so gameplay is framerate-independent, and enable vsync on set_mode when the platform supports it.
You wrote clock.tick(60) expecting a steady 60 frames per second. You print clock.get_fps() and watch the numbers dance between 45 and 70. Characters move in stutters, animations feel uneven, and the gameplay has a subtle but persistent wobble. This is one of the oldest Pygame quirks, and it is entirely explainable once you know what the clock actually does under the hood.
What Clock.tick Actually Does
When you call clock.tick(framerate), Pygame measures how long it has been since the previous call, computes how long it should wait to hit the target framerate, and then calls the operating system’s sleep function for that duration. On Linux and macOS, the sleep function has roughly 1ms granularity and works well. On Windows, the default timer resolution is 15.6ms — meaning a requested sleep of 10ms frequently returns in 15.6ms. That single bit of slop is enough to push each frame past the 16.6ms budget for 60 FPS, and because sleep oversleeps rather than undersleeps, you lose a frame here and gain one there.
The result is the classic ragged FPS counter. The game does not know it is missing frames; it just produces the next frame as soon as the sleep returns, then sleeps again, and the drift compounds.
Switch to tick_busy_loop
Pygame provides Clock.tick_busy_loop specifically to address this. Instead of sleeping, it spins in a tight loop checking the current time against the target frame deadline. Because there is no sleep, there is no 15.6ms rounding error — the method returns within microseconds of the actual deadline.
# main.py - using tick_busy_loop for steady pacing
import pygame
pygame.init()
screen = pygame.display.set_mode((800, 600))
clock = pygame.time.Clock()
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# ... update and draw ...
# Busy-loop pacing - tighter than clock.tick(60)
clock.tick_busy_loop(60)
pygame.quit()
The downside is that one CPU core runs near 100% during the wait. For a desktop game this is usually fine — modern CPUs have many cores and the idle-spin wastes a few hundred milliwatts. For a battery-sensitive target like a laptop or handheld, you can compromise: sleep for most of the frame and busy-loop only the last 2–3ms.
# Hybrid pacing - sleep the bulk, busy-loop the tail
import time
TARGET = 1.0 / 60.0
last = time.perf_counter()
while running:
# ... frame work ...
now = time.perf_counter()
remaining = TARGET - (now - last)
if remaining > 0.003:
time.sleep(remaining - 0.002)
# Busy-wait the final sliver for precision
while time.perf_counter() - last < TARGET:
pass
last = time.perf_counter()
Delta-Time Movement
Even with perfect pacing, a frame will occasionally take 20ms due to GC, a disk read, or a background process. If your movement code says player.x += 5 every frame, a 20ms frame moves the player the same distance as a 16ms frame — the player appears to stutter. Delta-time-based movement fixes this.
Clock.tick returns the number of milliseconds since the last call. Convert that to seconds and multiply all movement by it:
# Delta-time movement
while running:
dt_ms = clock.tick_busy_loop(60)
dt = dt_ms / 1000.0 # seconds
# Player moves at 200 pixels per second regardless of FPS
keys = pygame.key.get_pressed()
if keys[pygame.K_RIGHT]:
player.x += 200 * dt
if keys[pygame.K_LEFT]:
player.x -= 200 * dt
Clamp dt to a sensible maximum (for example, 0.1 seconds) so that a single huge stall — like moving the window during a garbage collection — does not teleport your player across the screen.
VSync and Display Pacing
If the display driver is willing to help, enabling vsync gives the smoothest possible pacing because the OS pipeline throttles you to the monitor’s refresh rate. Pygame 2 added a vsync flag to set_mode:
screen = pygame.display.set_mode(
(800, 600),
flags=pygame.SCALED | pygame.DOUBLEBUF,
vsync=1)
Vsync requires the SCALED or OpenGL rendering paths; it is silently ignored on plain software surfaces. It also caps your framerate at the monitor refresh, which for a game targeting exactly 60 FPS is what you want.
When vsync works, you can drop clock.tick entirely and simply let the swap-buffer call block until the next refresh. Most projects still call clock.tick for delta-time reporting even with vsync on, which is fine as long as the target rate matches the monitor.
“If your FPS is wobbling around the target, you’re fighting the OS timer. Either busy-loop the wait or let vsync do it for you.”
Related Issues
If frame spikes are caused by Pygame asset loading, see Surface Convert Alpha Slow Performance — converting surfaces after they are created pays off in per-frame cost. If keys occasionally seem to drop during a hitch, check Event Queue Dropping Key Presses.
tick_busy_loop + dt*speed + vsync = three fixes. Layer them and the clock stops lying.