Removing wall-clock flakiness from a scripted playthrough test

Category: SDL Adventure Game

This post was written with AI assistance.

Vania Volpe is a small point-and-click adventure written in C99 and SDL2. Its main gameplay gate in CI is a headless scripted playthrough: a test harness brings the game up with SDL’s offscreen video driver and a dummy audio driver, replays a fixed sequence of taps and waits, and asserts that the expected dialogue lines were spoken, in order. The script lives in a single JSON file that both the native C test and a Puppeteer browser test consume.

This post describes a flaky failure in that test, its root cause, and the fix.

The symptom

The native test intermittently failed one dialogue assertion — Il salvagente e' sull'albero (“the float is on the tree”) — roughly one run in three, passing again on re-run with no code change. Because the failure was nondeterministic, it could turn CI red on any pull request regardless of what that pull request changed. It first appeared on a change whose diff only touched rendering and therefore could not affect dialogue at all, which was the first signal that the problem was in the harness, not the code under test.

How the harness asserts dialogue

The playthrough is a list of steps. Each step performs an action (a tap) and then runs the game loop for a fixed wait_ms so walks and spoken lines can finish:

1
2
3
4
case SCRIPT_CLICK:
  harness_click(s->x, s->y);
  harness_pump_for(s->wait_ms);
  break;

Everything the game logs at INFO — including each spoken line — is captured into a buffer. At the end, the harness scans that buffer for each expected line in order:

1
2
3
4
5
6
7
8
int harness_check_lines_in_order(const char *const *expected, size_t count) {
  size_t offset = 0;
  for (size_t i = 0; i < count; i++) {
    const char *hit = strstr(log_buf + offset, expected[i]);
    if (hit == NULL) { /* MISS */ }
    else { offset = (hit - log_buf) + strlen(expected[i]); }
  }
}

A line only appears in the log if the game reached the state that speaks it within that step’s wait_ms. If it did not, the ordered scan comes up one line short, and the run fails.

Root cause: the simulation ran on the wall clock

The harness advanced the game using real elapsed time:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void harness_step_frame(void) {
  process_input();
  Uint32 now = SDL_GetTicks();
  float delta_time = (now - last_frame_time) / 1000.0;
  last_frame_time = now;
  game_update(delta_time);
  /* render */
}

void harness_pump_for(Uint32 ms) {
  Uint32 until = SDL_GetTicks() + ms;
  while (game.is_running && SDL_GetTicks() < until) {
    harness_step_frame();
    SDL_Delay(8);
  }
}

SDL_Delay(8) is a minimum delay; under load the OS can return later, so the number of frames and the exact simulated time per step vary between runs. The engine compounded this: animations and dialogue were also timed against SDL_GetTicks() directly — for example game_update advanced the current scene’s animations with update_scene_animations(scene, SDL_GetTicks()), and the actor recorded started_talking_at = SDL_GetTicks().

The failure has a specific mechanism. Dialogue is blocking: while an actor is TALKING, a walk request is refused. If a previous line’s talk duration overran a step’s window because that step ran slightly slower, the next tap’s walk never completed, so its arrival callback — the one that speaks the next line — never fired. The line was missing from the log, and the ordered scan failed. None of this is stable across machines, because the clock driving the simulation was the machine’s wall clock.

The fix: a clock seam and a virtual clock

The engine now reads the current time through one function instead of calling SDL_GetTicks() directly:

1
2
3
4
5
// clock.h
Uint32 clock_now_ms(void);      // SDL_GetTicks() in real mode; the virtual counter in virtual mode
void clock_set_virtual(bool on);
void clock_advance(Uint32 ms);
bool clock_is_virtual(void);
1
2
3
4
5
6
7
// clock.c
static bool virtual_mode = false;
static Uint32 virtual_now = 0;

Uint32 clock_now_ms(void) { return virtual_mode ? virtual_now : SDL_GetTicks(); }
void clock_set_virtual(bool on) { virtual_mode = on; virtual_now = 0; }
void clock_advance(Uint32 ms) { if (virtual_mode) virtual_now += ms; }

In production nothing changes: the clock stays in real mode, clock_now_ms() is SDL_GetTicks(), and the normal game loop is untouched. Every engine site that timed simulation state was routed through the seam: the actor’s fidget schedule, animation tick, and talk start/end; the animation start_time; the scene animation tick in game_update; and the subtitle timer.

The harness switches to virtual mode once, before any actor or animation exists, and then advances the clock a fixed amount per frame:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define HARNESS_STEP_MS 16

void harness_step_frame(void) {
  process_input();
  clock_advance(HARNESS_STEP_MS);
  game_update(HARNESS_STEP_MS / 1000.0F);
  /* render */
}

void harness_pump_for(Uint32 ms) {
  Uint32 elapsed = 0;
  while (game.is_running && elapsed < ms) {
    harness_step_frame();
    elapsed += HARNESS_STEP_MS;
  }
}

Two things matter here. First, game_update receives a fixed delta and the animation and talk timers read the same virtual clock, so frame-delta motion and clock-based timing advance in lockstep. Second, harness_pump_for runs a fixed number of frames rather than for a wall-clock duration, so a step always advances the simulation by the same amount however fast or slow the host is. SDL_Delay is gone, which also makes the test run faster.

One consequence to handle: the clock is virtual for the whole test process, and the pure unit tests run after the playthrough. A couple of them read the clock directly (to age an animation past its runtime, or to assert a fidget timer is scheduled in the future), so those were pointed at clock_now_ms() too. During the unit tests the virtual clock is simply frozen, which is exactly what those assertions want.

Verification

The point of the change is that the result no longer depends on timing, so the test:

Scope

This fixes the native harness. The browser playthrough consumes the same JSON script but runs in a real browser in real time, so it cannot use a virtual clock; a deterministic version there would wait until the expected dialogue appears rather than for a fixed duration. That is tracked separately and left out of this change.

Takeaway

A scripted simulation test is only as deterministic as its clock. If the simulation reads wall-clock time — directly, or through frame deltas measured from it — the test inherits the host’s scheduling jitter, and fixed per-step waits turn that jitter into flakiness. Routing time through a single seam and driving it with a fixed timestep under test makes the run reproducible and, usefully, faster.

The change: potomak/VaniaVolpe#156 (issue #155).