How to Make a JSON File in Godot 4: Save System Guide

Saving game progress is a requirement for almost every game, and a broken save system is one of the fastest ways to lose players. Godot 4’s built-in FileAccess and JSON classes give you everything you need to build a robust, cross-platform save system with no plugins and no third-party libraries.

Before any of that, it helps to understand what a JSON file actually is and how to make one by hand — the same rules that apply to a hand-typed JSON file are the rules Godot’s JSON class enforces under the hood. This guide starts there, then builds a complete SaveManager autoload in GDScript that writes game state to JSON and reads it back safely, covers the gotcha almost every JSON save tutorial misses — JSON.parse_string() silently turns your integers into floats — plus multiple save slots, versioned migrations, crash-safe atomic writes, and encryption. All patterns work across Windows, macOS, Linux, Android, iOS, and Web exports in Godot 4.4 through 4.6.x.

Quick Answer

To make a JSON file by hand: open any plain-text editor, type a data structure using curly braces for an object and square brackets for a list — for example {“level”: 3, “name”: “Player1”} — then save the file with a .json extension. To make one in Godot 4: put your game state in a Dictionary, call JSON.stringify(data) to convert it to a string, open a file with FileAccess.open(“user://save_game.json”, FileAccess.WRITE), then call file.store_string(json_text).

How to Make a JSON File From Scratch (No Engine Required)

A JSON file is nothing more than a plain-text file that follows a specific syntax and is saved with a .json extension — there’s no special software required to create one. JSON data is built from two structures: an object, written with curly braces {} and holding comma-separated “key”: value pairs, and an array, written with square brackets [] and holding a comma-separated list of values. Keys are always strings wrapped in double quotes; values can be a string, a number, true, false, null, another object, or another array.

To create one manually: open Notepad, TextEdit, VS Code, or any text editor, type something like {“id”: 101, “name”: “John Doe”, “member”: true}, then use Save As and set the filename to data.json rather than data.txt. Double quotes must wrap every key and every string value, commas separate entries but never trail after the last one, and every open brace or bracket needs a matching close — these three rules cause the vast majority of “invalid JSON” errors. Paste your file into a free online JSON validator before using it anywhere if you want to catch a syntax mistake before it breaks a program that reads the file.

Programmatically, the same file gets generated by serializing a native data structure. In Python, json.dump(data, open(“data.json”, “w”)) writes a dictionary straight to disk as valid JSON. In JavaScript, JSON.stringify(data) produces the text you then write with the fs module. In Godot 4, the equivalent call is JSON.stringify(data) followed by file.store_string() — the section below builds a full save system around exactly this pattern.

Setting Up a SaveManager Autoload

Create save_manager.gd and register it as an Autoload singleton under Project > Project Settings > Autoload, named SaveManager. This exposes SaveManager.save_game() and SaveManager.load_game() from any node without passing references through the scene tree. Declare const SAVE_PATH = “user://save_game.json” and const SAVE_VERSION = 1 at the top of the script.

The user:// prefix resolves to a writable, OS-specific directory on every platform and stays writable after export, unlike res://, which becomes read-only. Use Project > Open User Data Folder in the editor to inspect saved files during development.

Where Are Godot 4 Save Files Stored on Disk?

The user:// path resolves differently per platform: Windows maps it to %APPDATA%\Godot\app_userdata\[ProjectName]\, macOS to ~/Library/Application Support/Godot/app_userdata/[ProjectName]/, and Linux to ~/.local/share/godot/app_userdata/[ProjectName]/. Android and iOS keep it inside the app’s sandboxed container, not directly browseable from outside the app. On Web (HTML5) exports, user:// maps to the browser’s IndexedDB, which persists across sessions but is scoped to that browser and device — clearing site data wipes it.

Call OS.get_user_data_dir() at runtime to log the exact resolved path, and note the folder name comes from Project Settings > Application > Config > Name, so renaming your project mid-development orphans existing saves.

Writing the Save Function

Collect state into a Dictionary and pass a tab character as JSON.stringify()’s second argument during development so the file stays human-readable: JSON.stringify(data, “\t”). Always guard with if file: after FileAccess.open() — it returns null on an invalid path or denied permissions, and calling a method on null crashes the game.

Godot 4 closes the FileAccess object automatically when it goes out of scope, so file.close() isn’t required. Call save_game() at natural checkpoints — level transitions, item pickups, the pause menu — or drive it from a Timer node for periodic auto-save.

Writing the Load Function (and Handling a Missing or Corrupted Save)

A safe load function handles two failures: no save file yet (first launch) and an unparseable file (corruption or a leftover save from an older build). Check FileAccess.file_exists(SAVE_PATH) first and return sensible defaults if it’s false — never assume the file is there.

For parsing, use var json = JSON.new() and var error = json.parse(text). If error != OK, call json.get_error_message() and json.get_error_line() to log exactly what broke, then fall back to defaults instead of crashing. If you use the simpler JSON.parse_string(text) shortcut instead, treat a null return the same way — it means the JSON was invalid.

The Int-to-Float Gotcha Every JSON Save System Hits

JSON has no separate integer type, so JSON.parse_string() always returns numbers as float — a Dictionary saved with {“level”: 3} comes back as {“level”: 3.0}. This is expected JSON-spec behavior, not a bug, but it breaks code that expects an int, compares values with == against an integer literal in a match statement, or uses the number as a dictionary key.

Cast explicitly on load: var level: int = int(data[“level”]). Do this for every whole-number field the moment you read it out of the parsed Dictionary, rather than trusting the type Godot handed back.

Handling Godot Types JSON Cannot Store

JSON only understands strings, numbers, booleans, arrays, and objects — it has no native concept of a Vector2, Color, or any other Godot-specific type. Convert these to plain data before stringifying and rebuild them after parsing: store a Vector2 as {“x”: pos.x, “y”: pos.y} and reconstruct it with Vector2(data.x, data.y), and store a Color as its hex string via color.to_html() and rebuild it with Color(html_string).

For anything more complex, such as a custom Resource or an enum, write a small to_dict() and from_dict() pair on the object itself so the conversion logic lives next to the data it describes, instead of scattered across the save and load functions.

Implementing Multiple Save Slots

Swap the single SAVE_PATH constant for a function that builds a per-slot path, such as “user://save_slot_%d.json” % slot_number, and pass the active slot index into save_game() and load_game(). List existing slots with DirAccess.open(“user://”).get_files() filtered to your save file naming pattern, so a save-select screen can show which slots are occupied along with metadata like playtime or level reached.

Store that slot metadata — timestamp, level name, playtime — as a small header inside the same JSON file so the save-select UI can read it without loading the entire save.

Save Versioning and Migration

Write the SAVE_VERSION constant into every save file, then check it on load and branch into migration logic when an old save is opened with a newer game build. A simple pattern is a chain of if data.version < N: apply_migration_N(data) blocks that each bump the version by one step, so a save from three versions ago migrates forward incrementally instead of needing one migration function per possible jump.

Skipping versioning is the single most common reason a JSON save system breaks after an update ships — a field gets renamed or removed, and every existing player’s save silently fails to load.

Crash-Safe Writing

Writing directly to SAVE_PATH means a crash or power loss mid-write leaves a truncated, corrupted file with no fallback. Write to a temporary file first — SAVE_PATH + “.tmp” — and only after the write succeeds, use DirAccess.rename() to atomically replace the real save file with the temporary one.

Keep the previous save as a numbered backup (save_game.json.bak) before overwriting it, so a corrupted write still leaves a recoverable prior save on disk.

JSON vs .tres Resources vs Binary — When to Use Each

JSON is human-readable, easy to diff and hand-edit, and trivially portable to modding tools or external save editors — the right default for most game saves. A .tres Resource file integrates natively with Godot’s type system and the inspector, which makes it convenient for editor-authored data like level layouts, but it ties the save format tightly to your class definitions, so refactoring a script can break old saves. Godot’s binary format (FileAccess store_var / get_var) is the most compact and fastest to read and write, at the cost of being unreadable and harder to migrate across versions.

For most projects, JSON’s readability during development and portability across platforms outweighs the small size and speed cost compared to binary.

Encrypting Your Godot 4 JSON Save File

Encryption on a local save file mainly deters casual save editing, not a determined attacker with access to the device — treat it as a light deterrent, not real security. Godot 4 supports this natively: open the file with FileAccess.open_encrypted_with_pass(path, FileAccess.WRITE, password) instead of the plain open() call, and use the matching open_encrypted_with_pass(path, FileAccess.READ, password) to read it back.

Store the password as a constant in your compiled script rather than plain text alongside the save file — it won’t stop reverse engineering, but it blocks a player from opening the file in a text editor and hand-editing values.

Tips and Common Mistakes

Always null-check the result of JSON.parse_string() before touching any keys — a malformed file returns null, and indexing into null crashes the game. Cast every whole number back with int() on load rather than trusting the parsed type, since JSON always hands back floats.

Test your save system by killing the game process mid-save at least once — it’s the fastest way to confirm your atomic-write logic actually protects against a real crash rather than just the failure cases you imagined.

how to make a json file (godot 4 save system) FAQs

How do I make a JSON file?

Open a plain-text editor, type a data structure using curly braces for key-value pairs (e.g. {“level”: 3}), and save the file with a .json extension. Programmatically, serialize your data structure with a JSON library — in Godot, JSON.stringify(data) followed by file.store_string() writes it to disk.

Where are Godot 4 save files stored on disk?

Windows: %APPDATA%\Godot\app_userdata\[ProjectName]\. macOS: ~/Library/Application Support/Godot/app_userdata/[ProjectName]/. Linux: ~/.local/share/godot/app_userdata/[ProjectName]/. Mobile keeps it in the app’s sandbox, and Web exports store it in the browser’s IndexedDB.

Should I use JSON or a .tres Resource file for save data in Godot 4?

JSON is more portable, human-readable, and safer across refactors since it isn’t tied to your class definitions. .tres Resources integrate better with the editor inspector but can break when the underlying script changes.

How do I implement multiple save slots in Godot 4?

Build a per-slot file path like “user://save_slot_%d.json” % slot_number, pass the slot index into your save and load functions, and list existing slots with DirAccess to populate a save-select screen.

Can I encrypt my Godot 4 JSON save file?

Yes — use FileAccess.open_encrypted_with_pass() instead of open() for both writing and reading. It deters casual save editing but isn’t a substitute for real security against a determined attacker.

How do I handle a corrupted or missing save file without crashing?

Check FileAccess.file_exists() before loading, and check for a null result or a parse error code from JSON.parse() or JSON.parse_string(). Fall back to sensible default values in both cases instead of crashing.

Does the JSON save system work on Android, iOS, and web exports?

Yes. The user:// path resolves to the app’s sandboxed storage on Android and iOS, and to the browser’s IndexedDB on Web exports, so the same FileAccess and JSON code works unchanged across all Godot 4 export targets.

Why does my saved integer come back as a float after loading?

JSON has no separate integer type, so JSON.parse_string() always returns numbers as float. Cast explicitly with int() on every whole-number field after parsing.

How do I save a Vector2 or Color in a Godot 4 JSON save file?

Convert them to plain JSON-compatible values first: store a Vector2 as {“x”: pos.x, “y”: pos.y} and a Color as its hex string via color.to_html(), then rebuild the Godot type from that data after parsing.

Build It With GTStudios

Need help with your website, app, or small-business tech? GTStudios builds web, apps, and software for small businesses. See how GTStudios can help.