DEV Community

liangqi zhao
liangqi zhao

Posted on Fully Autonomous

Fire 2D Animation Markers Reliably When a Frame Skips

If a character's footstep sound is attached to an animation marker, checking only the frame visible at the end of an update can miss the marker. A slow update might cross the marker and land several frames later; it can even cross the end of a loop.

One simple approach is to represent animation time as a monotonically increasing integer, measured in milliseconds. Then collect every marker crossed between the previous and current update. The loop period is still used to describe where markers repeat, but the clock itself does not wrap.

Define the boundary first

The example below uses the half-open interval (old_ms, new_ms]: exclude the old time and include the new time. That makes consecutive updates meet without triggering the same marker twice at the shared boundary.

A marker at position 0 fires at the start of each subsequent loop. If you also need a marker at time 0 when the animation starts, dispatch that as an explicit initialization step. This function does not infer animation start or playback state.

from collections.abc import Iterable


def crossed_markers(
    old_ms: int,
    new_ms: int,
    period_ms: int,
    markers_ms: Iterable[int],
) -> list[tuple[int, int]]:
    """Return (absolute_time, position_in_loop) in chronological order."""
    if period_ms <= 0 or old_ms < 0 or new_ms < old_ms:
        raise ValueError("expected 0 <= old_ms <= new_ms and period_ms > 0")

    markers = set(markers_ms)
    if any(marker < 0 or marker >= period_ms for marker in markers):
        raise ValueError("markers must be inside [0, period_ms)")

    crossed = []
    for cycle in range(old_ms // period_ms, new_ms // period_ms + 1):
        for marker in markers:
            timestamp = cycle * period_ms + marker
            if old_ms < timestamp <= new_ms:
                crossed.append((timestamp, marker))
    return sorted(crossed)
Enter fullscreen mode Exit fullscreen mode

For a 500 ms loop with markers at 0 and 200 ms, an update from 450 ms to 1150 ms crosses three events: (500, 0), (700, 200), and (1000, 0). This includes both a wrap and more than one loop start.

assert crossed_markers(450, 1150, 500, [0, 200]) == [
    (500, 0), (700, 200), (1000, 0)
]
assert crossed_markers(0, 200, 500, [200, 200]) == [(200, 200)]
assert crossed_markers(200, 700, 500, [200]) == [(700, 200)]
assert crossed_markers(0, 0, 500, [0]) == []
Enter fullscreen mode Exit fullscreen mode

Here duplicate marker positions are intentionally collapsed. If your design needs two different events at the same time, store event IDs alongside each position and define their ordering explicitly instead of using a set.

Integrate it with care

  • Convert to a consistent time unit before calling the function; rounding floating-point seconds separately each update can accumulate drift.
  • Pause the clock while the animation is paused. On an intentional seek, decide whether to replay events, suppress them, or rebuild state: those semantics are game-specific.
  • A long pause or large jump can cross many loops. Put an explicit catch-up budget in your game and choose which events are safe to coalesce; do not silently discard gameplay-critical events.
  • Keep this event clock separate from rendering. The engine's actual animation callbacks, hitboxes, audio and networking may have different timing rules, so test the integration in the target engine.

I’m Allen Zhao, part of the MotifDeck team. MotifDeck is an AI game art workspace for reviewing motion and choosing animation frames and timing. This article was prepared with AI assistance; the example is a general timing utility, not a claim about MotifDeck's export format.

Top comments (0)