Quick answer: clock.tick() uses OS sleep, which is imprecise by several milliseconds, so the actual FPS will waver around your target rather than hitting it exactly. For tighter capping use clock.tick_busy_loop(), or enable vsync via pygame.display.set_mode(..., vsync=1). More importantly, store dt = clock.tick(60) / 1000.0 and multiply all movement by dt so your game speed is independent of the actual frame rate.

You call clock.tick(60) at the bottom of your game loop and expect 60 frames per second. But clock.get_fps() shows 58, or 63, or it dips to 45 during busy scenes. On a faster machine it runs at 62; on your friend’s older laptop it runs at 55. The cap is not a hard cap — it’s a suggestion, and the OS decides how closely it follows it. Here’s what’s actually happening and how to handle it properly.

The Symptom

The most common manifestations:

What Causes This

OS Sleep Imprecision

pygame.time.Clock.tick(fps) works by calculating how many milliseconds should remain until the next frame deadline and calling time.sleep() for that duration. The fundamental problem is that OS sleep is not a real-time guarantee — it is a minimum sleep time. The OS scheduler can wake your process later than requested.

On Windows, the default timer resolution is 15.625ms (64 Hz). Requesting a 16.67ms sleep for 60 FPS may round to 31.25ms (giving you ~32 FPS) or wake immediately (giving you uncapped FPS), depending on the scheduler’s current state. Applications can request higher timer resolution with timeBeginPeriod(1), and Pygame does this internally on recent versions, but you may still see 1–3ms variance.

On Linux with a standard kernel, sleep resolution is typically 1ms or better. On macOS it’s usually 1–2ms. This is why the same Pygame code behaves differently across platforms.

The First Few Frames Always Exceed the Cap

On startup, the clock has no accumulated time from a previous frame. The first call to tick(60) computes a delta of 0ms (or the very small time since the clock was created) and returns immediately without sleeping. The second frame has proper delta tracking. This is why profiling always shows a spike on frame 1: the cap doesn’t apply until the clock has measured at least one full frame.

Uncapped FPS Causes Physics Inconsistency

The deeper issue isn’t the FPS number itself — it’s that many Pygame tutorials show movement code like:

# BROKEN: frame-rate dependent movement
player_x += 5  # moves 5 pixels per frame, not per second

At 60 FPS this moves 300 pixels/second. At 30 FPS it moves 150 pixels/second. At 120 FPS it moves 600 pixels/second. The game plays completely differently depending on the machine.

The Fix

Use Delta Time for All Movement

clock.tick(fps) returns the number of milliseconds elapsed since the previous call. Divide by 1000 to get seconds, and multiply all movement values by this delta time:

import pygame

pygame.init()
screen = pygame.display.set_mode((800, 600))
clock = pygame.time.Clock()

player_x = 400.0
SPEED = 300  # pixels per second

while True:
    # dt is milliseconds elapsed; divide by 1000 for seconds
    dt = clock.tick(60) / 1000.0

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()

    keys = pygame.key.get_pressed()
    if keys[pygame.K_RIGHT]:
        player_x += SPEED * dt  # 300 pixels/second regardless of FPS
    if keys[pygame.K_LEFT]:
        player_x -= SPEED * dt

    screen.fill((30, 30, 30))
    pygame.draw.rect(screen, (255, 80, 80), (int(player_x) - 16, 284, 32, 32))
    pygame.display.flip()

With this pattern, the player moves at exactly 300 pixels per second whether the game runs at 30, 60, or 120 FPS. Frame drops cause a larger single delta rather than a slower game speed.

Verify Actual FPS with get_fps()

clock.get_fps() returns a rolling average of the last 10 frames. Display it during development to confirm your cap is working as expected:

# Add to your draw loop for real-time FPS display
font = pygame.font.SysFont("monospace", 14)
fps_text = font.render(
    f"FPS: {clock.get_fps():.1f}",
    True, (255, 255, 0))
screen.blit(fps_text, (8, 8))

Note that get_fps() averages the last 10 frames, so it lags slightly behind reality during sudden frame rate changes. For single-frame measurement, compute it from dt directly: 1.0 / dt (when dt > 0).

tick_busy_loop() for Tighter Precision

If your game requires consistent frame timing — for example a rhythm game where frame variance causes missed beat windows — use tick_busy_loop() instead:

# Precise but CPU-intensive: spins instead of sleeping
dt = clock.tick_busy_loop(60) / 1000.0

This keeps one CPU core at 100% utilization while waiting for the next frame deadline, but eliminates OS sleep imprecision almost entirely. You’ll get within 0.1–0.5ms of your target consistently. The tradeoff is power consumption and heat — not appropriate for mobile or laptop-targeted games, but fine for desktop where precise timing matters.

Vsync via Display Flags

Pygame 2.x supports hardware vsync through the display creation flags. This ties your frame rate directly to the monitor’s refresh rate (typically 60Hz or 144Hz) and eliminates screen tearing:

import pygame

pygame.init()

# SCALED resizes the logical surface to fit the window
# DOUBLEBUF enables double buffering (required for vsync)
# vsync=1 ties flip() to the display refresh rate
screen = pygame.display.set_mode(
    (800, 600),
    pygame.SCALED | pygame.DOUBLEBUF,
    vsync=1
)
clock = pygame.time.Clock()

while True:
    # With vsync=1, tick() without argument still measures dt
    # tick(0) or no cap lets vsync control the frame rate
    dt = clock.tick(0) / 1000.0

    # ... update and draw ...

    # display.flip() blocks until vsync signal
    pygame.display.flip()

With vsync=1, pygame.display.flip() blocks until the vertical blanking interval, giving you a naturally capped frame rate at the monitor’s refresh rate. You still need delta time in your movement code, because users may have 144Hz or 240Hz displays where vsync would cap you higher than 60.

Note that vsync=1 requires Pygame 2.0 or later and a display backend that supports it. Fall back gracefully:

try:
    screen = pygame.display.set_mode(
        (800, 600), pygame.SCALED | pygame.DOUBLEBUF, vsync=1)
    vsync_enabled = True
except pygame.error:
    screen = pygame.display.set_mode((800, 600))
    vsync_enabled = False

Handling Frame Spikes with Delta Time Clamping

Delta time introduces a subtle problem: if a frame takes unusually long (a garbage collection pause, a disk read, a brief OS preemption), dt becomes very large, and your objects teleport across the screen in a single frame. Fix this by clamping dt to a maximum value:

MAX_DT = 1.0 / 20.0  # treat anything slower than 20 FPS as 20 FPS

dt = min(clock.tick(60) / 1000.0, MAX_DT)

This caps the maximum simulation step to what a 20 FPS frame would produce. The game will slow down perceptibly during severe spikes (rather than jumping), which is usually the better player experience.

Related Issues

If you’re using Pygame for physics simulation (gravity, velocity, acceleration), frame-rate independence requires integrating with delta time at every layer, not just movement. A common mistake is applying gravity as a fixed increment per frame:

# BROKEN: gravity is frame-rate dependent
velocity_y += 0.5      # 0.5 pixels/frame²
player_y += velocity_y

# CORRECT: gravity in pixels/second²
GRAVITY = 800  # pixels per second squared
velocity_y += GRAVITY * dt
player_y += velocity_y * dt

Also: pygame.time.delay(ms) is a blocking sleep that prevents event processing and should never be called inside the main game loop. Use the clock for all frame timing.

Store dt, multiply everything by it, clamp it to avoid spike teleporting — do those three things and frame rate becomes an implementation detail rather than a game design constraint.