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
andautoDensity
, so the game looks crisp on highâDPR displays. -
A predictable game loop via
app.ticker
and a cleanApplication
lifecycle (asyncinit
, resize, etc.). - A healthy ecosystem (filters, bitmap fonts, spine runtimes, texture packing) we can adopt incrementally.
Could we have built this with plain ? 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:
-
autoStart: false
so the ticker doesnât run after init. - 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);
}
}
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:
- Donât start the ticker in
init
(autoStart: false
, thenticker.stop()
just in case). - Render once when layout or assets change.
-
Start
app.ticker
on the first user gesture (pointer, touch, or key). - 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);
};
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 });
}
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();
}
});
}
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();
});
}
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, /* ... */ });
- 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();
}
});
}
}
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.