DEV Community

Cover image for Modern Libraries with Classic Games
Stoyan Shopov
Stoyan Shopov

Posted on

Modern Libraries with Classic Games

Building solitairex.io with PixiJS — and how a tiny ticker change fixed our PageSpeed score

When we launched solitaire online, we wanted buttery‑smooth animations and green Core Web Vitals. Our first build looked and felt great, but Google PageSpeed Insights wasn’t impressed. The culprit was subtle: our render loop (PixiJS’s ticker) was running from the moment the page loaded—even before the user interacted. That constant requestAnimationFrame work (even while idle) inflated CPU usage in Lighthouse’s lab run and dragged down metrics.

The fix was a one‑liner conceptually: don’t start the ticker until the user interacts. Below is how our PixiJS app is set up, why PixiJS was the right choice for a web card game, and how we wired the ticker to “start on first click/touch/key” to keep PageSpeed happy without compromising gameplay.


Why PixiJS for a canvas game?

For Solitaire, we need pixel‑perfect graphics, fast drag‑and‑drop, and a responsive stage that scales from phones to 4K monitors. PixiJS gives us:

  • GPU‑accelerated 2D rendering (WebGL) with automatic batching for sprites, textures, and text.
  • A scene graph with Containers instead of manually redrawing everything each frame on a 2D canvas.
  • Pointer & interaction system (pointer/touch/mouse with normalized coordinates), perfect for dragging cards.
  • Resolution awareness via resolution and autoDensity, so the game looks crisp on high‑DPR displays.
  • A predictable game loop via app.ticker and a clean Application lifecycle (async init, resize, etc.).
  • A healthy ecosystem (filters, bitmap fonts, spine runtimes, texture packing) we can adopt incrementally.

Could we have built this with plain <canvas>? Sure—but we’d be rebuilding much of Pixi’s renderer, event system, batching, and scaling logic. Pixi let us ship faster and spend our time on game design rather than boilerplate rendering code.


Our bootstrap (simplified)

Here’s the core of our setup class as it appears in production. Note the two important parts:

  1. autoStart: false so the ticker doesn’t run after init.
  2. We also call this.app.ticker.stop() for extra safety.
async setup() {
    try {
        // Create PixiJS application
        this.app = new PIXI.Application();

        // Initialize with proper size and settings
        await this.app.init({
            background: 0xffffff,
            resolution: window.devicePixelRatio || 1,
            autoDensity: true,
            antialias: true,
            resizeTo: document.getElementById('sudoku-canvas'),
            autoStart: false
        });

        document.getElementById('sudoku-canvas').appendChild(this.app.view);

        this.app.ticker.stop();

        // Initialize game
        this.initGame();

        // Single resize handler with debounce
        this.setupResponsiveHandling().then(() => {
            if (this.game) {
                this.app.renderer.render(this.app.stage);
            }
        });

        // 👉 PageSpeed fix: only start the ticker after user interacts
        this.startTickerOnFirstInteraction();

        // Bonus: pause when tab is hidden
        this.setupVisibilityPause();

    } catch (error) {
        console.error('Error setting up application:', error);
    }
}
Enter fullscreen mode Exit fullscreen mode

The container ID is sudoku-canvas because we share scaffolding across several games. It’s just the wrapper element for the Pixi canvas.


The PageSpeed problem we hit

Lighthouse (what PageSpeed runs in lab) loads your page with no user input. If your render loop is already spinning, it:

  • Keeps the main thread busy with continuous frame callbacks.
  • Can create or amplify Total Blocking Time (lab metric) by reducing idle time for the main thread.
  • Increases CPU time and sometimes causes extra layout/paint work in the background.

For an idle Solitaire board, there’s no need to run a 60fps loop before the player actually touches the game. So we adopted a “render on demand until interaction” approach.


The fix: start the ticker only after the user interacts

This is the entire pattern:

  1. Don’t start the ticker in init (autoStart: false, then ticker.stop() just in case).
  2. Render once when layout or assets change.
  3. Start app.ticker on the first user gesture (pointer, touch, or key).
  4. Pause when the tab is hidden; resume only if the user has previously interacted.

1) Render-on-demand before interaction

We render a single frame whenever something changes pre‑interaction (initial layout, resize). No loop needed:

renderOnce = () => {
    this.app.renderer.render(this.app.stage);
};
Enter fullscreen mode Exit fullscreen mode

2) Start on first interaction

We attach a few low‑overhead listeners. once: true automatically cleans them up after firing.

startTickerOnFirstInteraction() {
    let interacted = false;

    const start = () => {
        if (!interacted) {
            interacted = true;
            if (!this.app.ticker.started) {
                this.app.ticker.start();
            }
        }
    };

    // Use pointerdown/touchstart for earliest signal; keydown covers keyboard users.
    window.addEventListener('pointerdown', start, { once: true, passive: true });
    window.addEventListener('touchstart',  start, { once: true, passive: true });
    window.addEventListener('keydown',     start, { once: true });
}
Enter fullscreen mode Exit fullscreen mode

3) Pause when the tab is hidden (battery- and metric-friendly)

setupVisibilityPause() {
    let hasInteracted = false;

    const markInteracted = () => { hasInteracted = true; };
    window.addEventListener('pointerdown', markInteracted, { once: true, passive: true });
    window.addEventListener('touchstart',  markInteracted, { once: true, passive: true });
    window.addEventListener('keydown',     markInteracted, { once: true });

    document.addEventListener('visibilitychange', () => {
        if (document.hidden) {
            this.app.ticker.stop();
        } else if (hasInteracted) {
            // Only resume the loop if the user has actually engaged with the game
            this.app.ticker.start();
        } else {
            // Still idle: render one frame if layout changed
            this.renderOnce();
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

This prevents “wasted” frames on background tabs and saves battery on mobile.


Responsive handling (debounced) without waking the loop

Your snippet calls setupResponsiveHandling() and then triggers a single render. Here’s a minimal implementation that keeps PageSpeed happy by not starting the ticker:

setupResponsiveHandling() {
    return new Promise((resolve) => {
        const el = document.getElementById('sudoku-canvas');
        let tid = null;

        const handle = () => {
            const w = el.clientWidth;
            const h = el.clientHeight;
            // Resize the renderer to the container
            this.app.renderer.resize(w, h);
            // Draw exactly one frame
            this.renderOnce();
        };

        const onResize = () => {
            clearTimeout(tid);
            tid = setTimeout(handle, 120); // debounce
        };

        // For modern browsers, ResizeObserver is ideal:
        const ro = new ResizeObserver(onResize);
        ro.observe(el);

        // Call once after init
        handle();
        resolve();
    });
}
Enter fullscreen mode Exit fullscreen mode

Other small, high‑leverage tweaks

  • Clamp resolution on high‑DPR devices. Ultra‑high DPR can increase GPU load with minimal visual benefit. Consider:
  const DPR = Math.min(window.devicePixelRatio || 1, 2);
  await this.app.init({ resolution: DPR, /* ... */ });
Enter fullscreen mode Exit fullscreen mode
  • Spritesheets & texture atlases. Fewer textures = fewer GPU switches, less memory pressure.
  • Lazy-load audio & non-critical assets. Keep the initial payload light for faster LCP.
  • Turn off filters when idle. Expensive filters (blur, glow) are gorgeous, but don’t waste cycles pre‑interaction.

SEO tie‑in: why this matters

Core Web Vitals influence search visibility, especially on mobile. For games, it’s easy to accidentally burn CPU in the background because a render loop feels harmless. Starting the ticker on the first click/touch/keystroke keeps Lighthouse lab metrics sane (lower CPU usage, lower TBT), and in the field it makes INP and battery usage better as well. The experience remains identical for real users—there’s simply no “invisible” work before they play.


Full example (consolidated)

Below is a compact version that you can drop into your class. It uses your original snippet, plus the PageSpeed‑friendly interaction gate and visibility pause:

class SolitaireApp {
  app = null;
  game = null;

  async setup() {
    try {
      this.app = new PIXI.Application();

      await this.app.init({
        background: 0xffffff,
        resolution: window.devicePixelRatio || 1,
        autoDensity: true,
        antialias: true,
        resizeTo: document.getElementById('sudoku-canvas'),
        autoStart: false
      });

      document.getElementById('sudoku-canvas').appendChild(this.app.view);

      // Hard stop to guarantee no loop pre-interaction
      this.app.ticker.stop();

      this.initGame();

      await this.setupResponsiveHandling();
      if (this.game) this.app.renderer.render(this.app.stage);

      this.startTickerOnFirstInteraction();
      this.setupVisibilityPause();

    } catch (err) {
      console.error('Error setting up application:', err);
    }
  }

  initGame() {
    // Build stage, load assets, add containers/sprites, etc.
    // Add ticker callbacks, e.g.:
    // this.app.ticker.add((dt) => this.game.update(dt));
  }

  renderOnce = () => {
    this.app.renderer.render(this.app.stage);
  };

  setupResponsiveHandling() {
    return new Promise((resolve) => {
      const el = document.getElementById('sudoku-canvas');
      let tid = null;

      const handle = () => {
        const w = el.clientWidth;
        const h = el.clientHeight;
        this.app.renderer.resize(w, h);
        if (!this.app.ticker.started) this.renderOnce();
      };

      const onResize = () => {
        clearTimeout(tid);
        tid = setTimeout(handle, 120);
      };

      const ro = new ResizeObserver(onResize);
      ro.observe(el);

      handle();
      resolve();
    });
  }

  startTickerOnFirstInteraction() {
    let interacted = false;
    const start = () => {
      if (!interacted) {
        interacted = true;
        if (!this.app.ticker.started) this.app.ticker.start();
      }
    };

    window.addEventListener('pointerdown', start, { once: true, passive: true });
    window.addEventListener('touchstart',  start, { once: true, passive: true });
    window.addEventListener('keydown',     start, { once: true });
  }

  setupVisibilityPause() {
    let hasInteracted = false;
    const markInteracted = () => { hasInteracted = true; };

    window.addEventListener('pointerdown', markInteracted, { once: true, passive: true });
    window.addEventListener('touchstart',  markInteracted, { once: true, passive: true });
    window.addEventListener('keydown',     markInteracted, { once: true });

    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        this.app.ticker.stop();
      } else if (hasInteracted) {
        this.app.ticker.start();
      } else {
        this.renderOnce();
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Takeaways

  • PixiJS is a great fit for web card games: GPU speed, clean APIs, and a strong ecosystem.
  • Lighthouse penalizes background work. A running ticker is work.
  • Pattern: autoStart: false → render on demand → start ticker on first interaction → pause on hidden tab.
  • You keep the same player experience while improving Core Web Vitals and PageSpeed scores.

If you’d like, I can adapt this into a polished blog draft for your engineering site (with diagrams and before/after screenshots) or tailor it for your other games (Sudoku, Mahjong) so the same pattern carries across your stack.

Top comments (0)