Skip to content

Instantly share code, notes, and snippets.

@shawngmc
Last active November 25, 2021 09:31
Show Gist options
  • Save shawngmc/1742827433f606f5617a9ec7c54b714c to your computer and use it in GitHub Desktop.
Save shawngmc/1742827433f606f5617a9ec7c54b714c to your computer and use it in GitHub Desktop.
Incremental Game Optimization in JS

Incremental games are great - they can also be fun to write - but making one that's truly a keeper requires careful design. I've dropped too many games because they peg my PC or are throttled, and as a developer I know it's avoidable. So - let's discuss it!

Background tab throttling / document focus

One of the first things to keep in mind is that your game should behave differently if it is not in the foreground. Why? As a protective measure, web browsers limit the activity of background tabs:

Managing Display Updates

Why?

Isolate Display Updates

As a first step, all UI changes should be done in a single place. An 'update UI' function is the underpinning of most of the effects below. Modified from Google's Example:

function timerTick() {
  calculateState();
  updateUI();
}

Note that if you are building your game modularly, this can be as simple as a separate listener set. Then, you're still just firing Events:

// In your modules, add/remove event listeners from the document...
document.addEventListener('math', function(e){console.log("math");});
document.addEventListener('display', function(e){console.log("display");});

// ... then, in your timer loop, push the events...
var timerTick = function() {
	document.dispatchEvent(new Event('math'));
	document.dispatchEvent(new Event('display'));
}

// ... and then just start your timer.
setInterval(timerTick, 1000);

Selectively Update the UI

Once you've isolated your math and displays, you can now make it so that you only update when it makes sense to. The easiest way to do this is with the Page Visibility API. Again, going back to the Google example:

var doVisualUpdates = true;

document.addEventListener('visibilitychange', function(){
  doVisualUpdates = !document.hidden;
});

// ... then, in your timer loop, push the events...
var timerTick = function() {
	document.dispatchEvent(new Event('math'));
	if (doVisualUpdates) {
		document.dispatchEvent(new Event('display'));
	}
}

Rethink your math

Incremental games are highly dependent on complex math with large numbers. While on a good machine, performance should never be an issue - but, while your tab is in the background, you need to manage your budget. Consider that your app may only have irregular CPU access. As such, to accurately measure time, you should use a time delta formula.

As an example, consider the following:

var player = {
    gold: 0,
    goldGainPerSec: 50
}

setInterval(timerTick, 1000);

var timerTick = function() {
	player.gold = player.gold + player.goldGainPerSec;
	if (doVisualUpdates) {
		...
	}
}

In this case, a user would expect that their gold after an hour should be 16000g (60x60x50). However, even if the tab was in the foreground the whole time, this may be inaccurate if the app was ever starved of CPU - so, after the hour, they may have less gold than they expect. As low-power devices like tablets and chromebooks become popular, this becomes a very real possibility, and that's ignoring being an output tab.

Instead, we can calculate the difference based on the time delta:

var player = {
    gold: 0,
    goldGainPerSec: 50,
    lastTick: (new Date).getTime();
}

setInterval(timerTick, 1000);

var timerTick = function() {
	var tickTime = (new Date).getTime();
	var actualInterval = tickTime - player.lastTick;
	player.gold = player.gold + player.goldGainPerSec * (actualInterval / 1000);
	player.lastTick = tickTime;
	if (doVisualUpdates) {
		...
	}
}

You can, of course, modify this to work an whole seconds if you want.

Another type of situation is compound interest, where you gain interest based on the base amount and any prior interest value gained. For this, a StackOverflow article provides a useful example (modified to be generic):

var newVal = oldVal * (Math.pow(1 + periodRate, numPeriods) - 1) / periodRate;

It doesn't matter what the period is - second, hour, month - as long as the definition of period is the same between the rate and the count.

Setting up your math this way has a number of advantages:

  • You won't 'lose' value if running on a slow or busy CPU and end up going slightly more than 1 second - 1050ms doesn't sound like much, but over 1 day, you would go from 86,400 ticks to 82,285 ticks, losing about 5% of update ticks.
  • Offline gains - ie, accruing value while the app is closed - are basically free.
  • You can offer players low performance modes.

Low Performance Modes

Once you make the changes above, you gain a ton of flexibility in how you can automatically tune - or let a user choose - how responsive your application is. For example, you can allow a player to specify that the app should completely sleep when not in focus, or only update every minute or so. When in the foreground, you can still limit it to improve UI responsiveness.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment