DEV Community

haibo kan
haibo kan

Posted on Edited on

Keeping a game widget from eating CPU when 40 tabs are open

Collapse, a hidden tab, and switching games all get called "pause". They need three different code paths, and lumping them together is where the state bugs come from.

The widget is a small draggable ball in the corner of every page: click it, pick a game, press Space, click again to fold it away with the round intact. Across forty open tabs, taken literally, that's forty game loops. The CSS half of keeping that ball on arbitrary pages is in an earlier write-up. This is the other half.

The widget is everywhere; the game isn't

The content script mounts one host element, a Shadow DOM root and a drag handler. It does no work when idle. Pick a game and only then does an iframe get created, the bundle load, and the loop start. Switch games or collapse the panel and the iframe is destroyed. No persistent background page holding a loop, no service worker ticking on a timer.

At most one loop per active widget, usually zero. Forty tabs means forty idle elements, not forty requestAnimationFrame loops.

Three kinds of pause

  • Collapse / Esc — explicit user action. Freeze immediately, keep state. This one has to feel instant.
  • Tab hidden — the browser already throttles requestAnimationFrame in background tabs. A loop driven by rAF stops advancing on its own; tearing anything down here just adds a state-restore bug.
  • Switching games — not a pause. Tear down the iframe, start clean. Keeping a hidden game alive "just in case" is exactly how you end up with forty loops.

Store the smallest thing that restores a round

Serializing the whole game object is tempting. Most of it is derived — sprite positions, timers, DOM nodes — and rehydrating it is brittle. What needs to survive:

{
  score: 120,
  level: 3,
  grid: [...],           // board state only
  direction: 'right',
  nextDirection: 'right',
  tick: 1840             // logical ticks elapsed, not wall-clock ms
}
Enter fullscreen mode Exit fullscreen mode

tick instead of Date.now() is the part I'd keep even if everything else changed. Derive your speed curve from wall-clock time and a round that sat paused for twenty minutes comes back at a different difficulty. A logical counter only advances inside the loop.

The other trap is writing on every frame. chrome.storage.local isn't a frame buffer: pause, game over, and a coarse interval, never inside the loop.

One exception, again

Click-through means the widget stops intercepting the mouse entirely. pointer-events: none on the widget surface is the obvious implementation, but the drag handle still needs events. So the handle keeps pointer-events: auto and the rest is disabled as a layer — toggling one global flag leaves you wondering why the ball can't be moved.

What I'd do differently

"Paused" should have been three states from day one. And the save-state shape should have been designed before the game code, not after — deciding what to serialize late is how the game object ends up leaking into storage, versioning a format nobody designed.

If you want to see it running, the extension is InTab Arcade. It's free, with no account and no subscription; a small sponsored banner sits on the game-selection screen.

Top comments (0)