Intermediate / Game loop

Handle Card Mismatch Delays without Stale Timeouts

Screenshot of a source excerpt explained in this article.
Screenshot of a source excerpt explained in this article.

A delayed card flip is an easy place to introduce a stale callback. Start a mismatch, restart immediately, and an old timeout may hide cards on the new board. The original memory engine guarded its callbacks with a run identifier. The current module uses a different approach: the delay belongs to the round's state.

What you'll build

We will examine Memory Match's 0.85-second mismatch countdown and compare it with a timeout-based design. The purpose is not to ban timers. It is to make the delay obey the same pause and restart rules as the rest of the game.

Working example · Open Memory Match and source

Prerequisites

Know how update(dt) receives elapsed simulation seconds. Open memory.js and find hideIn, then inspect core.js for the loop's delta calculation. Card rendering considers selected indexes visible until update clears them.

Store the remaining duration

A mismatch sets hideIn to 0.85. While it is positive, choose rejects additional selections. The update step subtracts elapsed time and clears the selected indexes when the remaining duration reaches zero.

assets/js/engines/memory.js
function update(dt) {
    if (state.hideIn <= 0) return;
    state.hideIn = Math.max(0, state.hideIn - dt);
    if (state.hideIn === 0) state.selected = [];
  }

Clamping with Math.max is not merely cosmetic. Floating-point subtraction is unlikely to land on exact zero by itself. If the counter becomes negative and the code only checks equality with zero, the cards could remain selected forever. The clamp makes crossing the boundary an explicit transition.

The countdown is independent of frame rate because subtraction uses seconds rather than a fixed amount per callback. A board rendered at a higher refresh rate receives more, smaller steps. Within normal scheduling differences, the active reveal duration remains approximately the same.

Decide which clock the delay follows

The shared loop limits a single update to 0.05 seconds and stops entirely when paused. Consequently, the reveal duration measures accumulated simulation time. It does not promise exactly 850 milliseconds of wall-clock time under every rendering stall.

That policy is reasonable for this board: a reader can pause while inspecting a turn and resume with the same cards visible. If you wanted cards to hide while the page was paused, you would need a different clock and should explain that behavior in the controls.

The distinction matters more than the particular number. A delay of one second can mean one second of active play, one second since the browser's current timestamp, or one second measured by a timeout that may be scheduled later than requested. Choose the meaning before choosing the API.

Compare a timeout-based implementation

A timeout can work if its lifecycle is managed. Store the timeout identifier, cancel it on reset, and guard against callbacks associated with an obsolete round. The old implementation's run identifier served that last purpose.

javascript
const roundAtSchedule = currentRound;
setTimeout(() => {
  if (roundAtSchedule !== currentRound) return;
  hideMismatch();
}, 850);

This illustrates the stale-round guard; it is not the code shipped in the current module. A complete timeout design would also decide what pause does to the remaining duration. The state countdown avoids that extra coordination because the same update lifecycle already governs the rest of the game.

Restart by replacing the model

The wrapper stops the scheduler, creates a new memory instance, rebuilds its controls, and renders the initial board. The old instance may still exist briefly in JavaScript memory, but nothing schedules updates against it. There is no pending mismatch timeout waiting to affect the replacement deck.

This is a useful ownership rule: anything that belongs to a round should be reset with that round. Independent asynchronous work needs an explicit cancellation or validity mechanism. Keeping time-dependent state inside update is one way to reduce that work, not a universal replacement for asynchronous APIs.

Verify boundary and lifecycle behavior

Test a mismatch with a small update, then an update that exceeds the remaining delay. The first should leave two selections visible; the second should clear them and leave hideIn at zero. Calling update again should not change unrelated counters.

Next, restart during a mismatch and verify zero moves, zero pairs, and no selection in the fresh instance. Pause during another mismatch and wait in real time. The cards should remain visible until active updates resume. These tests distinguish the chosen clock from an accidental timeout behavior.

Next steps

Use the memory source viewer to follow the complete selection path. The same countdown technique can drive brief feedback animations or invulnerability windows, provided they should follow active simulation time. Keep network requests and other genuinely asynchronous tasks outside this model and give them their own cancellation policy.