We don’t worry about unhackability in our games. It’s not worth it. We just worry about making sure it works in the simpler, more frequent cases of the user changing their device clock, restarting their device, etc. As long as you don’t worry about people decompiling your code, it gets really simple.
You can put in checks to make sure your app isn’t a hacked version, and if it is, behave differently. We had experimented with that in the past, but ended up removing it since it didn’t really help or hurt much. Those checks only work if the hacker doesn’t find them as well, so they would need to be subtle if you want to avoid the hacker finding a workaround.
All that said, we have never seen hackers with a lot of intelligence hack our games. Generally it’s just people who don’t even play the game see that there is a new game that has virtual currency in it, swoop in and change the code to report an infinite balance, and release it with minimal/no testing. Half the time it doesn’t even work. Presumably we are not a big enough target. You probably won’t be either.
How to handle timing locally:
On launch, fetch and store (in a file) the current value of a non-modifiable clock (NMC), eg. CLOCK_MONOTONIC in native C, or SystemClock.elapsedRealtime() in Java. You will also need to track a known elapsed time (KET), starting at zero. Or, if you happen to have a known accurate time (eg. from a server) then you can use that value.
At any point, you can be certain that the KET is no less than the stored KET plus the current value of the NMC minus the stored NMC (assuming the device itself is not running its clock quickly, which I have never seen).
The source for the NMC value is generally a time since the device booted, meaning that if the user resets their device, that value also resets. You can therefore not be certain at any point that you have the actual KET, unless you are also tracking the time from a server, but you can be sure it isn’t past that, which is sufficient for time-gating.
On app close, store the current value of the NMC and the current KET (equal to the KET on launch plus the current NMC minus the stored NMC). Then check on launch if the current NMC is less than the stored NMC. If it is, then you know the KET is at least the stored KET plus the current value of the NMC (the user loses the amount of time between last app close and device reboot). Otherwise (the current value of the NMC is greater than the last stored NMC), the only thing you know is that the KET is the current value of the NMC, minus the stored NMC, plus the stored KET. This is the normal case when the user does not reboot their device, and will guarantee an accurate elapsed time tracker.
Normally, the KET is only advanced by the difference in values of stored NMC and current NMC. If you bring a server into the mix, the KET can also be advanced by differences in server time so that the user can restart their device and have their KET stay up to date (not losing time between last app close and device reboot). To do this, you can store KETs based on the NMC and the server. On launch, if you don’t have server access, use the NMC-based KET. If you do have server access, use the server-based KET and update the NMC-based KET at the same time.