Skip to content

pygame.time.get_ticks() returns 0 every 256 calls (legacy backend, Windows) #833

@BertHoekzema

Description

@BertHoekzema

Whilst I was trying to test a particular bug, I stumbled across this other one. With Sigmund’s help, I ran a number of tests, but after a while Sigmund insisted that I submit his bug report:

Bug: pygame.time.get_ticks() returns 0 every 256 calls (legacy backend, Windows)

Summary

In the legacy backend on Windows, pygame.time.get_ticks() periodically returns 0 instead of a sensible millisecond value. The interval is exactly 256 calls (count-based, not time-based — confirmed by varying the per-trial sleep). This breaks every higher-level OpenSesame API that depends on it, including:

  • openexp.Clock.time() (returns 0.0)
  • openexp.Mouse.get_click(timeout=N) (the timeout no longer fires, because the exit condition time - start_time >= timeout evaluates 0 - 0 = 0 forever; the call only returns if a real mouse event arrives)

A related symptom — Clock.time() returning 0.0 — was previously reported by user RemcoRenken (forum discussion 9858). I believe this is the same underlying bug.

Environment

  • OpenSesame: [4.1.14]
  • OS: Windows [11] [64-bit]
  • Backend: legacy (pygame)
  • pygame: [2.6.1]
  • SDL: [2.28.4]
  • Python: [3.13.5]

Minimal reproducer

Place this inline_script in the Run phase of a loop item with 1000 cycles. No Mouse, no Canvas, no Keyboard — just a timing probe:

import pygame
import time as pytime
import os

if not hasattr(exp, "_clk_log"):
    log_path = os.path.join(exp.experiment_path, "clock_bug.log")
    exp._clk_log = open(log_path, "w", buffering=1)
    exp._clk_log.write(f"pygame: {pygame.version.ver}  SDL: {pygame.version.SDL}\n")
    exp._clk_log.write("trial,pg_ticks,clock_time,perf_counter_ms\n")
    exp._clk_trial = 0
    exp._clk_zeros = 0

exp._clk_trial += 1
trial = exp._clk_trial

pg_ticks = pygame.time.get_ticks()
clock_t  = clock.time()
perf_ms  = pytime.perf_counter() * 1000.0

exp._clk_log.write(f"{trial},{pg_ticks},{clock_t:.1f},{perf_ms:.1f}\n")

if pg_ticks == 0 or clock_t == 0:
    exp._clk_zeros += 1
    exp._clk_log.write(
        f"  *** ZERO at trial {trial}: pg_ticks={pg_ticks} clock_t={clock_t} "
        f"(total zeros so far: {exp._clk_zeros})\n"
    )

pytime.sleep(0.05)  # 50 ms also reproduces — bug is count-based, not time-based

if trial % 50 == 0:
    exp._clk_log.write(f"--- progress trial={trial} zeros={exp._clk_zeros} ---\n")

⚠️ Important: this reproducer must run inside a loop item. A standalone inline_script that iterates internally does not trigger the bug (pygame's timer subsystem is in a different initialization state in that context).

Observed behaviour

In the clean reproducer above, pg_ticks == 0 (and clock_t == 0) occurs deterministically at trials:

253, 509, 765, ...   (interval = 256 calls exactly)

In my real VAS experiment, where each trial also calls Mouse.get_click() internally, the same bug occurs at:

252, 507, 762, ...   (interval = 255 calls)

The fact that time.perf_counter() advances normally throughout proves this is a pygame/SDL2 timer-subsystem bug, not a system-clock or scheduling issue.

Why 255 vs 256?

The interval differs by 1 between the clean reproducer (256) and the real VAS task (255). The most likely explanation: Mouse.get_click() internally calls pygame.time.get_ticks() one extra time per trial (for its start_time), so a trial-counter-based interval of 256 underlying-ticks-calls maps to one fewer trial. 256 = 2^8 is therefore the canonical interval, strongly suggesting an 8-bit counter wraparound somewhere in SDL2's Windows timer code (or in how pygame interfaces with it).

Cascading failure in Mouse.get_click()

In openexp/_mouse/legacy.py::_get_mouse_event:

start_time = pygame.time.get_ticks()
while not event_found:
    ...
    time = pygame.time.get_ticks()
    if timeout is not None and time - start_time >= timeout:
        break

When get_ticks() returns 0, both start_time and the loop's time become 0, so time - start_time = 0 < timeout forever. The timeout never fires. The loop exits only if a real mouse event arrives — so a participant who clicks can still continue the task, but the intended timeout=N no-response branch is unreachable until then. In an automated/unattended run (no user input), the experiment stalls indefinitely.

My experiment-level symptoms

  1. At trial 253, my log shows:

    === TRIAL 253 start=0.0 ===
    

    (where start was set from clock.time())

  2. Even after I replaced clock.time() with time.perf_counter() * 1000 in my own vas_gui.py, the task still stalled at the same trial number — this time inside Mouse.get_click(), because that uses pygame.time.get_ticks() internally where I cannot reach it. The stall is resolved as soon as the participant clicks the VAS, but the intended timeout behaviour is lost, and in an automated run the task hangs.

  3. Replacing Mouse.get_click() with direct pygame.event.get(MOUSEBUTTONDOWN) polling using time.perf_counter() for the timeout eliminated the stall completely.

Workaround (working)

In application code:

# Instead of: t = clock.time()
t = time.perf_counter() * 1000

# Instead of: button, pos, t = mouse.get_click(timeout=N)
start = time.perf_counter() * 1000
button = pos = t = None
while (time.perf_counter() * 1000) - start < N:
    for event in pygame.event.get():
        if event.type == pygame.MOUSEBUTTONDOWN:
            button = event.button
            pos = event.pos
            t = time.perf_counter() * 1000
            break
    if button is not None:
        break

After this, my experiment runs to completion without stalling.

Recommended fixes (for OpenSesame)

Ranked from minimal to robust:

  1. Defensive guard in _get_mouse_event: if start_time == 0, refetch it on the next iteration. One-line fix, prevents the broken timeout.

  2. Add a max-iteration safety counter in _get_mouse_event to break out after, say, timeout * 10 iterations regardless.

  3. Replace pygame.time.get_ticks() with time.perf_counter() * 1000 throughout openexp/_clock/legacy.py and openexp/_mouse/legacy.py (and analogous keyboard code). This is the robust fix — time.perf_counter() is monotonic, high-resolution, and immune to whatever is breaking SDL2's timer on Windows.

Related issues

  • Forum discussion 9858 (RemcoRenken): clock.time() returning 0 with deterministic trial-number stalls — same underlying bug.

Why this matters

Any long-running experiment on Windows using the legacy backend is at risk of broken timeout behaviour after every 256 timer queries. For Mouse-based response collection this is roughly every 256 trials. The task can still recover when the participant produces a response, but:

  • The intended timeout=N "no response" branch is unreachable for that trial.
  • An automated/unattended run (calibration sweeps, pilot scripts, EEG/fMRI auto-pauses, etc.) will hang.
  • Any analysis or trigger logic that relies on clock.time() will receive 0.0 for the affected trials, silently corrupting timing data.

Attached

  • clock_bug 3.log from the minimal reproducer above, showing zeros at trials 253, 509, 765 with time.perf_counter() advancing normally.
  • Snippets from my real vas_gui.py showing the same bug at trials 252, 507, 762, and the working workaround (vas_gui test 1000 x.csv).

Part of modified vas_gui.txt

clock_bug 3.log

vas_gui test 1000 x.csv

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions