Auto-Save Design Patterns for Games
Event-Driven Auto-Save
Event-driven auto-save triggers when something meaningful happens in the game: the player completes a level, defeats a boss, enters a new area, acquires a key item, closes a menu, or finishes a transaction. These are natural state boundaries where the game is in a clean, consistent condition. Saving at these moments avoids the problem of capturing mid-action state (during a jump, halfway through an animation, or while a dialogue box is open), which can produce confusing or broken experiences when loaded.
The implementation is straightforward. Define a list of save-worthy events in your game's event system. When any of these events fire, call your save function. The save function captures the current game state, serializes it, and writes it to storage. For LocalStorage, this is a synchronous setItem call. For IndexedDB, this is an asynchronous put in a transaction. The game continues running normally while the async write completes.
Event-driven saves work best for games with clear milestones: level-based games, narrative adventures, RPGs with distinct areas, and puzzle games with discrete levels. They work poorly for open-ended games without natural breakpoints, such as sandbox builders, idle games, and simulations where the player's state changes continuously. For those games, interval-based auto-save is the better primary strategy.
One subtle consideration is event frequency. If your game fires save-worthy events rapidly (the player collects 10 items in 5 seconds, each triggering a save), you should throttle saves to prevent excessive storage writes. A simple throttle stores the most recent state but delays the actual write by 2-3 seconds. If another event fires during the delay, the timer resets. This coalesces rapid events into a single save containing the latest state.
Interval-Based Auto-Save
Interval-based auto-save writes the game state to storage at fixed time intervals, regardless of what the player is doing. Common intervals range from 15 seconds (aggressive, for games where even small progress loss is unacceptable) to 120 seconds (conservative, for games with infrequent state changes). The standard for most web games is 30-60 seconds, which balances data safety against storage I/O overhead.
Use setInterval or a custom timer in your game loop to trigger saves. A dirty flag optimization skips the save if nothing has changed since the last save. Set the dirty flag to true whenever the game state changes (the player moves, an item is collected, a value updates). When the save timer fires, check the flag. If it is false, skip the save entirely. If it is true, save and reset the flag to false. This prevents unnecessary writes during idle periods where the player is reading text, watching a cutscene, or AFK.
For IndexedDB saves (asynchronous), interval-based auto-save has essentially zero performance cost. The save operation runs in a background thread and does not block the game loop. For LocalStorage saves (synchronous), interval saves can cause a brief frame drop if the save data is large. If you must use LocalStorage with interval saves, keep your save data under 50 KB and your interval at 60 seconds or longer to minimize impact.
Idle games are the archetypal use case for interval auto-save. An idle game's state changes every second (resources accumulate, timers tick down, production cycles complete), and losing even a few minutes of progress feels disproportionately bad because the player was "actively" idle. Most idle games save every 15-30 seconds and also save on tab close. The lastActiveTimestamp field in the save enables offline progress calculation when the player returns.
Browser Lifecycle Events
The beforeunload event fires when the page is about to be unloaded: the user is closing the tab, navigating to another URL, or refreshing the page. This is your last chance to save. Add an event listener that calls your save function synchronously. Because the browser gives you very limited time to execute, use LocalStorage here even if your primary save mechanism is IndexedDB. A synchronous localStorage.setItem() completes within the event handler's window, whereas an IndexedDB write might be cancelled when the page unloads.
The visibilitychange event fires when the page becomes hidden or visible. Saving when the page becomes hidden catches tab switches, app switches on mobile, phone lock events, and browser minimization. This event is more reliable than beforeunload on mobile, where the OS may kill the browser without firing beforeunload. Check document.visibilityState === 'hidden' inside the handler and save only when the page is being hidden.
The pagehide event is similar to beforeunload but includes a persisted property that tells you whether the page is entering the back-forward cache (bfcache). If event.persisted is true, the page is being cached rather than destroyed, and you should save state but avoid resource cleanup. Safari and mobile browsers use bfcache aggressively, making pagehide more reliable than beforeunload for detecting tab closures on those platforms.
The practical pattern is to listen for all three events. Use visibilitychange as the primary save trigger on hide, use pagehide and beforeunload as fallbacks, and include a guard that prevents saving twice in rapid succession (since multiple events may fire during a single tab close). A lastSaveTimestamp variable that prevents saves within 500 milliseconds of each other handles this deduplication.
Combining Strategies
The most robust auto-save systems combine all three approaches. Event-driven saves capture progress at meaningful milestones. Interval saves catch gradual changes between events. Lifecycle saves catch unexpected exits. Together, they ensure the maximum possible progress is preserved regardless of how the session ends.
A concrete implementation for a web RPG might look like this: save on entering a new room (event-driven), save every 60 seconds if the dirty flag is set (interval), save on visibilitychange to hidden (lifecycle), and save on beforeunload as a final fallback (lifecycle). The save function itself checks a debounce timer to prevent rapid consecutive writes, uses IndexedDB for the primary save, and falls back to LocalStorage for lifecycle saves where async completion is not guaranteed.
For an idle game: save every 30 seconds unconditionally (interval, no dirty flag needed because the state always changes), save on visibilitychange (lifecycle), save on beforeunload (lifecycle), and save on any significant milestone like prestige, upgrade, or achievement (event-driven). The 30-second interval is the backbone, and the lifecycle saves capture the most recent seconds of progress that the interval has not yet persisted.
Save Indicators and Player Communication
Players want to know when their game is saving. A small spinning icon, a floppy disk icon, or the text "Saving..." displayed briefly during save operations reassures players that their progress is being recorded. The indicator should appear when a save starts and disappear when the save completes (or after a minimum display time of 1-2 seconds, so it is visible even for fast saves).
For asynchronous saves (IndexedDB, cloud sync), the save indicator can show three states: saving (write in progress), saved (write completed successfully), and save failed (write encountered an error). The "saved" state can display a timestamp ("Saved at 3:42 PM") that updates after each successful save. The "save failed" state should be visually distinct (red color, warning icon) and include a suggestion ("Check browser storage settings" or "Try saving manually").
Never show a save indicator during gameplay if it obscures the game or distracts the player. Position it in a corner (top-right is the convention), keep it small, and fade it out after a few seconds. For action-heavy games, consider showing the indicator only when the player pauses or opens a menu. The goal is reassurance, not interruption.
Testing Auto-Save Systems
Auto-save bugs are among the hardest to catch because they are asynchronous, timing-dependent, and often manifest as silent data loss (the player does not realize their save is missing until they return hours later). Thorough testing requires specific scenarios that exercise the edge cases.
Test tab closure at different moments: close the tab during a save write, close it immediately after a level transition, close it while a cloud sync is in progress. Verify that the most recent state was captured. Test storage-full scenarios by filling LocalStorage with dummy data before saving, and confirm the error handler notifies the player. Test rapid event sequences by collecting multiple items in quick succession, then reloading to verify all items are present. Test version migration by loading saves from previous versions after making state structure changes.
Automated testing for save systems should include round-trip tests: create a game state, save it, load it, and compare the loaded state to the original. Any discrepancy indicates a serialization bug. Run these tests with edge-case values: maximum integer values, zero, negative numbers, empty arrays, null fields, very long strings, and special characters. For async saves (IndexedDB), await the transaction completion before running assertions.
Combine event-driven saves (for milestones), interval saves with dirty flags (for gradual changes), and browser lifecycle event saves (for unexpected exits). Use IndexedDB for primary saves and LocalStorage as a synchronous fallback for tab-close saves. Always show a save indicator so players trust that their progress is being preserved.