Why Your Game Lags: Identifying Hidden Bottlenecks in Puckit!
Are players struggling with lag in your game? Does it run smoothly on a PC, but lag on mobile? In this post, I’ll share a practical use-case from my web game, Puckit!. Discover how I tracked down performance bottlenecks that caused unresponsiveness, and how to fix them to keep gameplay smooth and responsive. These quick fixes could save your game from frustrating lag!
Updated: Sep 6, 2024
Published: Sep 9, 2024
Identifying and Fixing Performance Bottlenecks in Puckit!
Players have reported that Puckit! is unresponsive on Android devices, which is frustrating when you don’t have an Android device for testing. Debugging this remotely has been a challenge, but I’ve dug into the issue using tools available in the browser to identify what might be causing the lag.
Is Phaser the Problem?
As a loyal fan of Phaser, my first thought was to blame the game engine. But is it really Phaser's fault? Before jumping to conclusions and filing an issue on GitHub, I decided to dig into the details of how JavaScript runs in the browser and how that impacts performance.
I found an excellent article on optimizing long tasks, which breaks down how JavaScript execution can bottleneck performance. I’ll walk you through what I’ve learned, and how it can be applied to prevent Puckit! from lagging, especially on lower-end devices.
Understanding JavaScript Task Execution
In short, JavaScript code is put in a queue, and executed as a task in the browser. The main thread can only run one task at a time. If a single task takes more than 50ms, we essentially block the main thread long enough for the the player to experience lag.
A JavaScript task can take more than 50ms depending on the work involved, and the power of the device it's running on. I don't really notice these performance issues when I develop my game on a gaming PC. On a low-end Android device however, performance is key to improve the player's experience.
Identifying Long Tasks
I’ve prepared an interactive example at the end of this post. Open your developer console and follow along with these steps:
- Click one of the blocking task buttons.
- Try clicking on window to increase the counter
- Observe how the counter doesn’t update instantly when running a blocking task. (Especially the second button.)
The non-blocking task button behaves differently: the counter updates (almost) instantly, even though both types of tasks perform the same work. Let’s dive deeper into why.
Profiling Performance in the Browser
Open the Performance tab in your developer console and start recording while you interact with the buttons. After a few clicks, stop the recording and inspect the results. Let's take a look at the result from my own profiling
You'll notice three sections. The yellow part in the profiling above is JavaScript execution time. We have three different sections, each representing the work done when we click our task buttons
- The first section is short, but it does occupy the main thread for over 1 second, making the page unresponsive. for the same amount of time.
- The Second section. The process is even slower, and we occupy the main thread for a whopping 6 seconds. Imagine having that kind of lag in your game.
- The third section represents a non-blocking task. Although it still takes around 6 seconds in total, the task is split into smaller chunks, allowing the page to remain responsive during the process.
Why? If we take a closer look, the whole process consists of smaller chunks of tasks, each taking ~300ms, so we're still experience a little input lag, but at least we give the player some room for interaction while the heavy task runs in the background.
The browser is able to prioritize the click events by the user, and consider them more important than the background task. In between each 300ms task, we have a tiny task that process the click-event, and incrementing the counter. Better user experience.
In the longer 6second task however, the click-events are queued up, and executed once the main thread is free from the heavy task. Once the work is complete, we can see the counter increasing (if we clicked the page 10 times while the JavaScript task was occupying the main thread, the counter will increase with 10 instantly after the heavy work is complete.)
A real world game is much more complex, and it may be difficult to identify the performance issues, and how to fix them. We can look at the performance profile and identify tasks that take a lot of resources. We can inspect the functions that are being called in each task, and from there, we can find where we do heavy work in our code.
Practical Solutions for Long Tasks
We’ve identified the problem—some tasks in my game take too long and block the main thread. Now, how do we fix it?
Create smaller tasks
The solution is to break large tasks into smaller ones. This is especially important in a game where maintaining a high frame rate (ideally 60fps) is crucial. At 60fps, each frame must be processed in 16.67ms or less. If any task takes longer, the frame rate drops, resulting in choppy gameplay.
In my example, I broke a heavy task into smaller pieces using setTimeout()
, allowing the browser to process user input between tasks. We are creating a closure that the browser adds to the task queue, giving other high-priority tasks time to execute. This technique, known as yielding, is effective in giving the main thread breathing room to handle high-priority actions, like user clicks.
Here's an example of how it can be done:
yield-example.js
function someLongProcess(){
setTimeout(taskOne);
setTimeout(tasktwo);
}
function taskOne(){
// Do some lighter work
}
function taskTwo(){
// Do some other work
}
However, there’s a caveat: The order of execution is not guaranteed when splitting tasks, which may lead to unexpected behavior. Careful planning is needed to ensure your game logic remains intact.
Real-World Example: Fixing Bottlenecks in Puckit!
Now that we’ve covered the basics, let’s see how this applies to Puckit!.
Before we go hands on, I want to introduce the game to give some context. Here's a video with some gameplay
You can play Puckit! here.
In the game’s "score attack" mode, which runs endlessly, performance issues were inevitable. Here’s how I tackled a specific bottleneck.
In the screenshot below, I profiled my game while playing it for 10 seconds. I'm looking for tall yellow spikes, and red marks.
While profiling my game, I noticed a task that took 74ms to complete, with the function handleLevelZones()
accounting for 68ms of that. This function dynamically adds and removes sections of the map as the player progresses, making it a prime candidate for optimization.
Breaking Down the Bottleneck
Here’s a breakdown of the handleLevelZones()
function:
- createLevelFromSvg()
- createPathsFromSvg()
- createCollisionBoxesFromPaths()
- addLevel()
- createEnemies()
- createHoles()
These functions are not interdependent, meaning we can split them into smaller, asynchronous tasks. Instead of relying on setTimeout()
all over the codebase, I prefer using async/await
and Promises for better readability and maintainability. Below is a simplified version of how I split the tasks.
game.js
function yieldToMain() {
return new Promise((r) => {
setTimeout(r);
});
}
async function handleLevelZones() {
createLevelFromSvg()
await yieldToMain();
addLevel()
await yieldToMain();
}
async createLevelFromSvg(){
createPathsFromSvg()
await yieldToMain();
createCollisionBoxes()
await yieldToMain();
}
Results
After splitting the handleLevelZones()
function into smaller tasks, the main thread no longer gets blocked for 68ms. Instead, the total time for execution is spread over several smaller tasks, which improves responsiveness significantly.
Note: This approach introduces a slight overhead. Although the total execution time increased from 70ms to 100ms, the game no longer lags, providing a far better user experience overall.
With these techniques, I was able to fix a major performance bottleneck in Puckit!. If you want to learn more about memory management and preventing memory leaks, stay tuned for my next post!
You can try out Puckit! here.
More Than Just Long Tasks
It’s important to note that this post focuses on identifying and fixing tasks that block the main thread. However, performance is not only about long tasks. Other factors like memory management and preventing memory leaks are just as crucial for keeping a game running smoothly.
I’ll cover these topics in an upcoming post, so stay tuned!
Don't hesitate to reach out via my contact form, or 𝕏@ReitGames. I'm here to assist you on your game development journey. You can also leave comments directly on this blog post via the discussion thread below.
Consider sharing this post and following me on 𝕏@ReitGames. Subscribe to my newsletter to get notified about new posts about game development.
Happy coding!