Need Game Art? Edit Game Trailers Launch Your Dev Site Sell Your Merch
Need Game Art? Sell Your Merch

Game Save Formats and Serialization

Updated July 2026
Serialization is the process of converting a game's live state into a storable format and converting it back without data loss. The format you choose affects save file size, read/write speed, human readability, and the complexity of your save system code. JSON is the default for web games and handles 90% of cases well. Binary formats and compression fill the gap for games with large or performance-critical save data.

JSON: The Default Choice

JSON.stringify() and JSON.parse() are the workhorses of web game serialization. They convert JavaScript objects, arrays, numbers, strings, booleans, and null values to a text format and back. JSON is human-readable (you can inspect save files in a text editor or browser dev tools), universally supported (every language and platform can read JSON), and requires zero additional libraries. For any web game where save data fits comfortably in a few hundred kilobytes, JSON serialization is the right choice.

JSON has specific limitations that game developers must understand. It does not support undefined (properties with undefined values are omitted during stringification). It does not support Infinity, -Infinity, or NaN (they are serialized as null). It does not support Map and Set (they serialize as empty objects). It does not support typed arrays like Float32Array or Uint8Array (they serialize as regular objects with numeric keys). It does not handle circular references (it throws a TypeError). And it loses the prototype chain, meaning class instances come back as plain objects.

The practical impact of these limitations is that your game state model must use only JSON-safe types. Use plain objects instead of Maps, use regular arrays instead of Sets, use regular arrays instead of typed arrays (unless you add custom serialization), and avoid circular references in your state structure. If your game logic uses Maps or Sets internally, convert them to arrays or objects before serialization and convert them back after deserialization. For example, serialize a Map as Array.from(map.entries()) and rebuild it with new Map(savedEntries).

Reducing JSON Save File Size

JSON is verbose. Property names are repeated for every object in an array, quotes surround every string, and whitespace (if you use JSON.stringify(data, null, 2) for pretty-printing) adds significant overhead. For games with compact state, this verbosity is irrelevant. For games with thousands of entities, large tile maps, or extensive world state, reducing JSON size can meaningfully improve save/load performance and storage consumption.

Abbreviate property names. Instead of {"positionX": 100, "positionY": 200, "healthPoints": 50, "movementSpeed": 3.5}, use {"x": 100, "y": 200, "hp": 50, "spd": 3.5}. For a single object, the savings are small. For an array of 500 entities, each with 10 properties, short keys can reduce file size by 30-50%. Maintain a mapping document that records what each abbreviated key means, and consider writing adapter functions that convert between human-readable internal state and compact serialized state.

Use arrays for homogeneous data. Instead of an array of objects ([{"x":1,"y":2,"type":3}, {"x":4,"y":5,"type":6}]), store a flat array with a known stride ([1,2,3, 4,5,6]) and reconstruct the objects on load. This eliminates all key overhead. A 10,000-entity save that takes 500 KB as an array of objects might shrink to 80 KB as a flat numeric array. The tradeoff is readability: inspecting a flat array in dev tools is harder than inspecting named objects.

Omit default values. If 95% of your entities have full health, do not store health for every entity. Only store health values that differ from the default. On load, apply the default to any entity without an explicit health value. This approach works well for any property where most instances share the same value, including boolean flags (store only the true ones), state enums (store only non-default states), and position data (store only objects that have moved from their initial positions).

Delta encoding stores only what changed since a reference point. Instead of saving the complete world state, save the initial state once and then save only the differences. If a player modifies 50 tiles in a 100,000-tile world, the delta save is three orders of magnitude smaller than the full save. Delta encoding adds complexity (you need the reference state to reconstruct the full state) but dramatically reduces save size for games with large, mostly-static worlds.

The Replacer and Reviver Functions

JSON.stringify() accepts a replacer function as its second argument, which runs for every value during serialization. This function can transform values before they become JSON. Use it to convert Maps to arrays, Sets to arrays, Dates to ISO strings, typed arrays to regular arrays, and class instances to plain objects. The replacer sees the key and value of each property and returns the value to include in the output (or undefined to omit it).

JSON.parse() accepts a reviver function as its second argument, which runs for every value during deserialization. This function reverses the replacer's transformations: converting arrays back to Maps, ISO strings back to Dates, and plain objects back to class instances. Together, the replacer and reviver form a complete custom serialization layer on top of JSON, handling types that JSON does not natively support.

The replacer/reviver pattern works best when you tag transformed values with a type marker. For example, serialize a Map as {"__type": "Map", "entries": [[key1, val1], [key2, val2]]}. The reviver checks for the __type property and reconstructs the original type accordingly. This approach is self-describing: the serialized data contains enough information to deserialize correctly without external metadata. It also survives version migrations because adding new tagged types does not break existing data.

Binary Formats

Binary serialization stores data as raw bytes in an ArrayBuffer, eliminating all the overhead of JSON's text encoding. A 32-bit float takes exactly 4 bytes in binary, compared to up to 18 bytes as a JSON number string (e.g., -123456.789012345). A boolean takes 1 byte in binary, versus 4-5 bytes in JSON (true or false). For games with large numeric datasets (tile maps, particle systems, terrain heightmaps, physics state), binary formats can reduce save size by 3-5x compared to JSON.

JavaScript provides ArrayBuffer for raw binary data and DataView for reading and writing individual values at specific byte offsets. To write a save, create an ArrayBuffer of the required size, create a DataView over it, and call methods like setFloat32(offset, value), setUint16(offset, value), and setUint8(offset, value) to write each field at its defined position. To read a save, create a DataView over the loaded ArrayBuffer and call the corresponding get methods at the same offsets.

The layout of a binary save file must be defined and documented precisely. A header section might contain a magic number (4 bytes to identify the file as a valid save), a version number (2 bytes), the number of entities (4 bytes), and the number of chunks (4 bytes). The entity section follows with a fixed-size record for each entity. The chunk section stores variable-length data with a length prefix for each entry. This layout is essentially a custom file format, and changing it requires versioning and migration logic just like JSON saves.

Binary formats sacrifice human readability for efficiency. You cannot inspect a binary save file in a text editor, and debugging save/load issues requires writing hex dump utilities or using browser dev tools to view ArrayBuffer contents. For most web games, the efficiency gains of binary formats are not worth the development and debugging overhead. Binary makes sense for games with save data exceeding 1 MB, games that save thousands of entities per frame (like physics simulations), or games targeting low-end devices where storage I/O is a bottleneck.

Compression

The Compression Streams API, available in all modern browsers (Chrome, Firefox, Safari, Edge), provides native gzip and deflate compression in JavaScript. Compressing a JSON string before storing it in IndexedDB or sending it to a cloud server typically achieves 60-80% size reduction. A 200 KB JSON save compresses to 40-60 KB. A 1 MB save compresses to 200-300 KB. The compression ratio depends on data repetitiveness: highly repetitive data (like tile maps with many identical tile IDs) compresses better than varied data (like random float coordinates).

To compress: encode the JSON string as a Uint8Array using TextEncoder, pipe it through a CompressionStream('gzip'), and collect the output as an ArrayBuffer. To decompress: pipe the ArrayBuffer through a DecompressionStream('gzip'), collect the output, and decode it with TextDecoder. The API is stream-based, so it works well for large data. For saves under 1 MB, the compression and decompression complete in under 10 milliseconds.

When to use compression: if your JSON save exceeds 100 KB and you store it in IndexedDB or send it to a cloud server, compression is almost always worth the minimal code complexity. If your save is under 50 KB, compression overhead (both in code and CPU time) is not justified. If you use binary format, compression still helps but the gains are smaller because binary data has less redundancy than JSON text.

MessagePack and CBOR

MessagePack and CBOR (Concise Binary Object Representation) are binary serialization formats that preserve JSON's structure (objects, arrays, strings, numbers) while using a compact binary encoding. They handle the same data types as JSON but produce output 20-50% smaller. Unlike custom binary layouts, they are self-describing: the serialized data includes type markers that allow generic deserialization without knowing the schema in advance.

JavaScript libraries like msgpack-lite (MessagePack) and cbor-x (CBOR) provide encode/decode functions that serve as drop-in replacements for JSON.stringify/parse. The API is nearly identical: msgpack.encode(gameState) produces a Uint8Array, and msgpack.decode(data) returns the original object. Both formats handle Maps, typed arrays, and binary data that JSON cannot.

For web games, MessagePack and CBOR sit between JSON and custom binary formats in both complexity and efficiency. They are easier to work with than custom binary layouts (no manual offset management) and more space-efficient than JSON (no key repetition, compact number encoding). They are a good fit for games that need better-than-JSON size efficiency without the development cost of designing a custom binary format. The main downside is the library dependency: msgpack-lite adds about 10 KB to the bundle, and cbor-x adds about 15 KB.

Export and Import for Player Portability

Regardless of your primary save format, providing an export function that lets players download their save data as a file adds a valuable safety net. The simplest implementation creates a Blob from the serialized save data, generates an object URL, and triggers a download. The file extension communicates the format: .json for JSON saves, .bin for binary saves, or a custom extension like .gamesave.

The import function reads a file selected through an <input type="file"> element, parses the contents, validates the data structure (checking for the correct format version and required fields), and applies it to the game state. Validation is especially important for imported files because they come from outside the game's control and could be corrupted, modified, or from an incompatible version.

Export/import gives players a way to back up their progress independently of any browser storage or cloud service. It also enables save file sharing: players can send save files to friends, content creators can distribute interesting game states, and developers can create and distribute debug saves for testing specific scenarios. For games with modding communities, save file portability is a feature that players actively value.

Key Takeaway

Use JSON serialization for most web games, optimize with short keys and default omission when save sizes grow, apply compression via the Compression Streams API for saves over 100 KB, and reserve binary formats for games with megabytes of numeric data. Always provide an export/import function so players can protect their progress independently.