Intermediate / Game state & scoring

Define What Happens When Win and Loss Conditions Coincide

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

A collector catches its final required gem in the same update that a hazard removes its last life. Which result should the player see? There is no answer hidden in Canvas or JavaScript. The game must define a priority, and the order of ordinary-looking statements can encode it accidentally.

What you'll build

We will identify the current outcome policy in Collector and Racer, construct simultaneous-event tests, and sketch an explicit alternative. The aim is to make a rule visible before a refactor changes it.

Working example · Open Gem Collector and source

Prerequisites

Understand how the collector updates objects and increments score or removes lives. Completion is checked after the object loop. The wrapper reads finished and won to decide whether to display completion or a round-ended message.

Read the existing priority

After processing contacts, Collector sets won from whether the score reached the goal. It then sets finished if the player won, lost all lives, or reached the active-time limit. The wrapper treats a true won value as completion even if another ending condition is also true.

assets/js/engines/collector.js
function update(dt, direction = 0) {
    if (state.finished) return;
    state.time += dt;
    state.spawn += dt;
    state.x = clamp(state.x + direction * 500 * dt, 45, WIDTH - 45);
    if (state.spawn >= 0.52 - difficulty * 0.035) {
      state.spawn = 0;
      state.objects.push({ x: 40 + random() * (WIDTH - 80), y: -25,
        bad: random() < 0.23 + difficulty * 0.015 });
    }
    for (const object of state.objects) {
      object.y += (150 + difficulty * 25 + state.time * 1.2) * dt;
      if (object.y > HEIGHT - 78 && object.y < HEIGHT - 25 &&
          Math.abs(object.x - state.x) < 52) {
        object.y = HEIGHT + 60; // Consume once, then remove below.
        if (object.bad) state.lives--;
        else state.score++;
      }
    }
    state.objects = state.objects.filter(object => object.y < HEIGHT + 30);
    state.won = state.score >= goal;
    state.finished = state.won || state.lives <= 0 || state.time >= 75;
  }

Consequently, reaching the gem target and losing the final life in the same update favors the win. Reaching the target at the timeout boundary also sets the win flag. These are consequences of the actual model, not assumptions based on what usually happens during play.

The racer uses a similar pattern with progress and lives. Its progress includes continuous movement and coin bonuses, so it too can reach its target during a frame containing a barrier contact.

Separate events from outcomes

For a more complex game, it can help to collect events first and resolve the result afterward. Events describe what happened; the outcome policy decides which facts take priority.

javascript
const result = {
  reachedGoal: score >= goal,
  lostAllLives: lives <= 0,
  expired: activeSeconds >= limit
};
const outcome = result.reachedGoal ? 'won' :
  result.lostAllLives ? 'lost' : result.expired ? 'timeout' : 'playing';

This sketch preserves the current win-first policy but names it directly. Switching to loss-first would be a deliberate change to the order of those conditions. A tie state would require another explicit rule and corresponding interface text.

Do not add an elaborate event system solely to resolve three conditions. The useful idea is that the policy should be reviewable. A short named helper or clear comment may be enough.

Consider what happens after completion

Each engine guards its update or action methods against a finished state. The wrapper also stops scheduling when completion is observed. Both layers matter: the engine protects its own rules, while the wrapper avoids unnecessary updates and rendering.

Timing Tap's action method resolves victory or loss immediately after a tap. Its timeout is handled during update. That means input timing relative to an update boundary can matter. Before imposing a cross-engine outcome abstraction, examine whether the engines truly share the same event ordering.

Build a simultaneous-contact test

Set the collector score one below its target and lives to one. Insert a gem and hazard at the paddle's contact position, then perform a controlled update. Verify both the score and lives changed, and verify the selected outcome matches the documented priority.

Repeat with the objects in reverse array order. Under the current post-loop policy, reversing them should not change the final outcome. If you move completion checks inside the loop later, this test can reveal an unintended dependence on object order.

Add a timeout-boundary case separately. Use a small known delta or zero-delta state setup rather than waiting 75 real seconds. The purpose is to exercise the condition intersection, not the browser's scheduling accuracy.

Make the interface agree

A status line saying only “Round ended” can hide which condition won. If you introduce separate outcomes, make their labels reflect the rule without claiming an event that did not occur. Keep a final score display, but do not let a presentation string become the source of truth for completion.

The same principle applies to analytics or future replay records. Store a meaningful outcome value if another system needs it; do not parse human-readable status text to reconstruct the result.

Next steps

Inspect Collector and Racer before changing completion order. Write a short policy statement and test it with simultaneous conditions. This small exercise prevents a seemingly harmless cleanup from changing which rounds count as wins.