Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions apps/frontend/src/features/games/falling-words/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,16 @@ export function useEngine(
let runStartTime = 0;
let elapsedBeforeRun = 0;

let pauseStartTime = 0;

const [phase, setPhase] = createSignal<GamePhase>("idle");
const [difficulty, setDifficulty] = createSignal<DifficultyKey>("easy");
const [fieldWidth, setFieldWidth] = createSignal(0);
const [fieldHeight, setFieldHeight] = createSignal(0);
const [activeWords, setActiveWords] = createSignal<FallingWord[]>([]);
const [currentInput, setCurrentInput] = createSignal("");
const [elapsedMs, setElapsedMs] = createSignal(0);
const [loopKey, setLoopKey] = createSignal(0);

const config = createMemo(() => difficultyConfigs[difficulty()]);
const score = createMemo(() => formatScore(elapsedMs()));
Expand Down Expand Up @@ -269,15 +272,37 @@ export function useEngine(
}
};

const handleVisibilityChange = () => {
if (document.hidden && phase() === "running") endGame();
const pauseGame = () => {
if (phase() !== "running") return;
stopLoop();
elapsedBeforeRun = getElapsedMsNow();
pauseStartTime = performance.now();
};
Comment on lines +275 to +280

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Double-pause on tab switch corrupts elapsed time and score

When the user switches tabs, the browser fires visibilitychange (hidden) first, then window blur — so pauseGame is called twice in quick succession. The second call overwrites elapsedBeforeRun with getElapsedMsNow(), which at that point returns the already-saved elapsedBeforeRun plus the full performance.now() - runStartTime delta (still using the original runStartTime). This roughly doubles the recorded elapsed time before resume even starts. Then resumeGame only compensates for the gap since the second pause call, not the first, so the final in-game timer shows approximately 3× the actual play time.

Concrete example: 5 s of play, 3 s tab-away → on return the timer reads ~15 s instead of 5 s.

The simplest fix is to make pauseGame idempotent — skip the elapsedBeforeRun update if a pause is already in progress:

const pauseGame = () => {
  if (phase() !== "running") return;
  if (pauseStartTime !== 0) return; // already paused
  stopLoop();
  elapsedBeforeRun = getElapsedMsNow();
  pauseStartTime = performance.now();
};

Alternatively, guard handleWindowBlur with if (document.hidden) return; so it short-circuits when the tab is already hidden.


const resumeGame = () => {
if (phase() !== "running" || pauseStartTime === 0) return;
const duration = performance.now() - pauseStartTime;
runStartTime += duration;
lastSpawnTime += duration;
pauseStartTime = 0;
setLoopKey((k) => k + 1);
};

const handleWindowBlur = () => {
if (phase() === "running") endGame();
const handleVisibilityChange = () => {
if (document.hidden) {
pauseGame();
return;
}
resumeGame();
setTimeout(focusInput, 0);
};

const handleWindowBlur = () => pauseGame();

const handleWindowFocus = () => resumeGame();

createEffect(() => {
loopKey();
if (phase() !== "running") {
stopLoop();
return;
Expand Down Expand Up @@ -349,11 +374,13 @@ export function useEngine(
if (fieldRef) observer.observe(fieldRef);

window.addEventListener("blur", handleWindowBlur);
window.addEventListener("focus", handleWindowFocus);
document.addEventListener("visibilitychange", handleVisibilityChange);

onCleanup(() => {
observer.disconnect();
window.removeEventListener("blur", handleWindowBlur);
window.removeEventListener("focus", handleWindowFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
});
Expand Down
5 changes: 4 additions & 1 deletion apps/frontend/src/features/games/falling-words/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ function View(props: GameViewProps) {
<GameMeta wordBankLabel={wordBank.label} gameName={meta.name} />
</div>

<div class="relative min-h-[60vh] overflow-hidden rounded-2xl bg-(--sub-alt)/10 transition-all hover:bg-(--sub-alt)/20">
<div
onClick={actions.focusInput}
class="relative min-h-[60vh] overflow-hidden rounded-2xl bg-(--sub-alt)/10 transition-all hover:bg-(--sub-alt)/20"
>
<Show when={gameState.phase() === "game-over"}>
<GameOver score={gameState.score()} />
</Show>
Expand Down