【Godot】セーブ/ロードシステムの実装 - JSON・ConfigFile・カスタムリソースの使い分け

作成: 2025-12-08最終更新: 2026-07-09

Godotでゲームデータを永続化する3つの主要な方法(ConfigFile・JSON・カスタムリソース)を、user://の基礎から実装・使い分け・改ざん対策まで図解で解説。チェックポイントセーブを組む実践例つき。

「せっかくのプレイ記録が消えた」「ロードしたら座標がぐちゃぐちゃになっていた」——セーブ機能は、地味なのに一度バグると一番プレイヤーを怒らせる部分です。しかもGodotには保存方法が複数あり、どれを使えばいいのか最初は迷いがちです。

この記事では、Godotでデータを永続化する3つの定番、 ConfigFileJSONカスタムリソース を、それぞれ「どんな時に使うか」「どう書くか」「改ざんにどう備えるか」まで、図とコードで整理します。最後には、実際に「どこでも中断できるチェックポイントセーブ」を1本組み立てます。

セーブ/ロードのイメージ。ゲームの状態(HP・座標・持ち物)が1つのセーブデータの塊に流れ込み、次回起動時にまた流れ出して復元される様子

この記事でわかること

  • セーブ先の大前提—— user:// パスFileAccess の基礎
  • ConfigFile ——設定情報を手軽に保存する
  • JSON ——外部連携向き。Godot固有型の変換のコツ
  • カスタムリソース ——セーブデータ全般の推奨手法(ResourceSaverResourceLoader
  • .tres.res の違い、 改ざん対策 の現実的なライン

Sponsored

まず結論:3つの手法の使い分け

細かい話に入る前に、最適な使い道を先に押さえましょう。 迷ったらカスタムリソース 、というのが基本の指針です。

ConfigFile・JSON・カスタムリソースの3枚のカードで使い分けを示す図。ConfigFileは設定ファイル、JSONは外部連携、カスタムリソースはセーブデータ全般で推奨バッジつき
特徴ConfigFileJSONカスタムリソース(推奨)
主な用途設定ファイル(音量・キーコンフィグ)外部API連携・汎用データゲームのセーブデータ全般
Godot固有型対応(Vector2などをそのまま)非対応(手動変換が必須)対応(そのまま保存できる)
コード量少ない多い(変換処理が煩雑)最も少ない
速度速いやや遅い(テキスト解析)速い(特にバイナリ形式)
改ざん耐性低い(テキスト)低い(テキスト)設定可能(バイナリ・暗号化)

ざっくり言えば、 「人がいじる設定」はConfigFile、「他のツールと共有するデータ」はJSON、「プレイ記録そのもの」はカスタムリソース 。この対応を頭に入れておくと、以降の実装がすっと入ってきます。

Sponsored

セーブ先の大前提:user:// と FileAccess

3つの手法に共通する土台が2つあります。 どこに保存するか(user:// と、 どうやってファイルを読み書きするか(FileAccess です。ここを最初に押さえておくと、あとの話がぶれません。

保存先は必ず user://

Godotのパスには res://user:// の2種類があります。

  • res:// :プロジェクトのファイル置き場。 ビルド後は読み取り専用 になり、書き込めません。
  • user:// :OSが用意する 書き込み可能な専用フォルダ への別名。セーブデータはここに置きます。

user:// の実体は、OSごとに違う場所へ自動でマッピングされます。コードでは user:// と書くだけでよく、OSの違いを気にせずに済むのがポイントです。

user://が各OSの実フォルダにマッピングされる図。1つのuser://savegame.resが、Windowsは%APPDATA%配下、macはApplication Support配下、Linuxは~/.local/share配下の実ファイルにつながる

res:// に保存しようとして「エディタでは動くのに、書き出したゲームでセーブできない」というのは定番のつまずきです。 セーブ先は必ず user:// 、と覚えておきましょう。

ファイルの読み書きは FileAccess

実際にファイルを開いて中身を読み書きするのが FileAccess です。JSONを扱うときはこれを直接使います(ConfigFileとカスタムリソースは、内部でファイル入出力までやってくれるので直接触りません)。

# 書き込み:WRITEで開くと、無ければ新規作成・有ればまっさらにして書き込む
var file := FileAccess.open("user://memo.txt", FileAccess.WRITE)
if file:                       # open失敗時はnullが返るので必ず確認
    file.store_string("Hello Save")
    file.close()               # 閉じ忘れると書き込みが確定しないことがある

# 読み込み:READで開く
if FileAccess.file_exists("user://memo.txt"):   # 存在チェックを先に
    var read := FileAccess.open("user://memo.txt", FileAccess.READ)
    print(read.get_as_text())
    read.close()

要点は3つです。 open() は失敗すると null を返すので必ずチェックすること、 close() を忘れない こと、そして読む前に file_exists() で存在を確認 すること。この3つを守るだけで、セーブまわりのクラッシュはぐっと減ります。

Sponsored

ConfigFile:設定情報の王道

ConfigFile は、WindowsのINIファイルのように [section]key = value のペアでデータを持つ形式です。 ユーザーがいじる設定 ——音量、フルスクリーンの切り替え、キーコンフィグなど——の保存にうってつけです。Godot固有の型もそのまま扱え、FileAccess を直接触らずに読み書きできます。

ConfigFileの構造図。設定データが[video]セクション(fullscreen/vsync)と[audio]セクション(master_volume)に分かれ、それぞれkey = value形式で1つの.cfgファイルに保存される様子
# settings_manager.gd
extends Node

const SAVE_PATH := "user://settings.cfg"

# デフォルト設定(ファイルが無い・壊れているときの拠り所)
var default_settings := {
    "video": { "fullscreen": false, "vsync": true },
    "audio": { "master_volume": 0.8 },
}

func save_settings(settings: Dictionary) -> void:
    var config := ConfigFile.new()
    for section in settings:
        for key in settings[section]:
            config.set_value(section, key, settings[section][key])

    var err := config.save(SAVE_PATH)          # 保存の戻り値は必ず確認
    if err != OK:
        printerr("設定の保存に失敗: %s" % error_string(err))

func load_settings() -> Dictionary:
    var config := ConfigFile.new()
    # ファイルが無ければデフォルトを返す(初回起動など)
    if config.load(SAVE_PATH) != OK:
        return default_settings.duplicate(true)

    var result := default_settings.duplicate(true)
    for section in default_settings:
        for key in default_settings[section]:
            # 第3引数にデフォルト値を渡せば、キー欠損でも安全に読める
            result[section][key] = config.get_value(section, key, default_settings[section][key])
    return result

ポイントは、 get_value() の第3引数にデフォルト値を渡せる ことです。設定項目を後から増やしても、古いセーブに無いキーは自動的にデフォルトで埋まります。「アップデートで設定が増えたら古いセーブが読めなくなった」という事故を、この一手で防げます。

Sponsored

JSON:Web連携と外部ツール

JSONは、可読性が高くエンジンに依存しない形式です。 Web APIとの通信 や、 Godot以外のツール(表計算・自作エディタなど)とのデータ交換 で強みを発揮します。

ただし弱点が1つ。 Godot固有の型(Vector2やColorなど)をそのまま扱えません 。保存するときは配列や辞書などの素朴な形に変換し、読むときに元の型へ戻す——この「往復の変換」を自分で用意する必要があります。

JSONでのGodot固有型の往復変換の図。保存時はVector2(100,200)を配列[100,200]へ変換してテキスト化し、読み込み時は配列[100,200]をVector2へ復元する双方向の流れ
# save_load_json.gd
extends Node

const SAVE_PATH := "user://save_game.json"

# Godotの型 → JSONで扱える素朴な形へ
func _to_json(value):
    if value is Vector2:
        return { "_type": "Vector2", "x": value.x, "y": value.y }
    return value

# JSONの形 → Godotの型へ復元
func _from_json(value):
    # JSONのオブジェクトはDictionaryとして復元される
    if value is Dictionary and value.get("_type") == "Vector2":
        return Vector2(value["x"], value["y"])
    return value

func save_game(state: Dictionary) -> void:
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if not file:
        printerr("JSONの書き込みに失敗しました")
        return
    var data := state.duplicate(true)
    data["player_position"] = _to_json(state["player_position"])   # 変換をかけてから
    file.store_string(JSON.stringify(data, "\t"))                  # 第2引数はインデント(見やすくする)
    file.close()

func load_game() -> Dictionary:
    if not FileAccess.file_exists(SAVE_PATH):
        return {}
    var text := FileAccess.get_file_as_string(SAVE_PATH)   # 開いて読んで閉じるを1行で
    var parsed = JSON.parse_string(text)                   # 失敗時はnullが返る
    if parsed is Dictionary:
        parsed["player_position"] = _from_json(parsed["player_position"])
        return parsed
    printerr("JSONのパースに失敗しました")
    return {}

もう1つの落とし穴が 数値の型 です。JSONをパースすると、整数もすべて float として復元されることがあります。「HPが 100 のはずが 100.0 になり、== の比較が通らない」といった不一致は、読み込み後に int() で明示的に変換しておくと防げます。

このように、JSONは「素朴なデータを、Godotの外へ持ち出す/外から持ち込む」用途には最適ですが、 プレイ記録の保存にはやや手間がかかる のが実情です。そこで登場するのが次のカスタムリソースです。

Sponsored

カスタムリソース:セーブデータの推奨手法

Resource を継承したカスタムクラス(カスタムリソース)は、Godotの思想に最も合った、 セーブデータ向けの本命 です。@export を付けた変数は エンジンが自動でシリアライズ・デシリアライズ してくれるため、変換処理をまるごと書かずに済み、Vector2やColorも そのまま保存 できます。

カスタムリソースそのものの作り方(class_name@export・継承・duplicate())は カスタムリソースの作成と活用 で詳しく解説しています。ここではそれを ファイルに保存・復元する 部分に絞ります。

ステップ1:セーブデータ用のリソースを定義する

保存したい項目を @export で並べた「入れ物」を1つ作ります。これがセーブデータの設計図になります。

# save_game.gd
class_name SaveGame
extends Resource

@export var player_name: String = "Hero"
@export var health: int = 100
@export var position: Vector2 = Vector2.ZERO      # 固有型もそのまま保存できる
@export var inventory: Dictionary = {}
@export var cleared_levels: Array[String] = []
@export var save_version: int = 1                 # 後述のバージョン管理用

ステップ2:ResourceSaver と ResourceLoader で保存・復元する

保存は ResourceSaver.save()、復元は ResourceLoader.load() の2つだけ。JSONのような変換コードは要りません。

カスタムリソースのセーブ/ロードの流れ図。SaveGame(@exportでhealth/position/inventoryを持つ)がResourceSaver.saveで.res/.tresファイルになり、ResourceLoader.loadで再びSaveGameに戻る双方向の流れ
# save_manager.gd(Autoloadに登録して使うと便利)
extends Node

const SAVE_PATH := "user://savegame.res"    # バイナリ形式(後述)

var current_save: SaveGame

func save_game() -> void:
    if current_save == null:
        current_save = SaveGame.new()
    var err := ResourceSaver.save(current_save, SAVE_PATH)   # 引数の順は (リソース, パス)
    if err != OK:
        printerr("セーブに失敗: %s" % error_string(err))

func load_game() -> bool:
    if not ResourceLoader.exists(SAVE_PATH):
        current_save = SaveGame.new()      # 無ければ新規データで開始
        return false
    # CACHE_MODE_IGNOREで、キャッシュではなくファイルから確実に読み直す
    var res := ResourceLoader.load(SAVE_PATH, "", ResourceLoader.CACHE_MODE_IGNORE)
    if res is SaveGame:                     # 型で受け取れるので安全
        current_save = res
        return true
    printerr("セーブファイルの読み込みに失敗しました")
    current_save = SaveGame.new()
    return false

注意ResourceSaver.save() の引数は (リソース, パス) の順です。Godot 3では (パス, リソース) と逆だったため、古い記事を参考にするときは読み替えてください。

load_game()res is SaveGame型で受け取れる のが、この手法の気持ちよさです。JSONのように「辞書のどのキーが何型か」を気にする必要がなく、current_save.health のように 補完の効く形 でアクセスできます。

.tres.res の使い分け

ResourceSaver.save() の保存先は、拡張子で形式が決まります。

.tresと.resの比較図。.tresはテキスト形式で中身が読めるカードとGit・デバッグ向きのタグ、.resはバイナリ形式で読めないカードと高速・軽量・出荷向きのタグを対比
  • .tres(テキスト形式) :中身を人間が読めます。差分が見えるので Gitとの相性が良く、デバッグ向き 。開発中はこちらが便利です。
  • .res(バイナリ形式) :中身は読めませんが、 速くてファイルも小さい 。改ざんも .tres よりは手間がかかります。 リリースビルドはこちら が定番です。

開発中は .tres で中身を確認しながら作り、出荷時に .res へ切り替える、という運用が扱いやすいです。

セキュリティの注意 :カスタムリソースは便利ですが、.tres.res は内部にスクリプト参照を埋め込めるため、 信頼できない出所のセーブファイル(他人が配布した改造セーブなど)をそのままロードすると、埋め込まれたコードが実行される リスクがあります。自分のゲーム内で完結するローカルセーブなら問題になりませんが、セーブの共有・投稿を許すゲームでは、そうしたデータには JSON を使うか、内容を検証してから読むのが安全です。

Sponsored

実践:どこでも中断できるチェックポイントセーブを組む

RPGのセーブポイント、ローグライクの「やめる」中断セーブ、オープンワールドのオートセーブ——ジャンルは違っても、やることは同じです。 散らばったゲームの状態を1つのセーブデータに集めて保存し、起動時に読み戻して各所へ配り直す 。ここまでのカスタムリソースを使って、この流れを実際に組んでみましょう。

チェックポイントセーブの流れ図。左でプレイヤーのHP・座標・インベントリという別々の状態がSaveManagerに��集められてSaveGameに詰められ、user://へ保存。右で起動時にロードし、各状態がプレイヤーやインベントリに配り直される往復の流れ

先ほどの SaveManager に、「状態を集める write_state()」と「状態を配る read_state()」を足します。 セーブとは、この集約と分配がすべて です。

# save_manager.gd(Autoload)
extends Node

const SAVE_PATH := "user://savegame.res"
var current_save: SaveGame

# ゲーム中の状態を1つのSaveGameに集約する
func write_state(player: Node2D, inventory: Dictionary) -> void:
    current_save = SaveGame.new()
    current_save.health = player.health
    current_save.position = player.global_position   # 固有型もそのまま入る
    current_save.inventory = inventory.duplicate(true)  # 参照共有を避けて複製で持つ
    ResourceSaver.save(current_save, SAVE_PATH)
    print("チェックポイントに記録しました")

# ロードしたSaveGameを、各ノードへ配り直す
func read_state(player: Node2D) -> void:
    if not load_game():           # 前掲のload_game()を流用
        return                    # セーブが無ければ何もしない(初期状態のまま)
    player.health = current_save.health
    player.global_position = current_save.position
    player.refresh_inventory(current_save.inventory)

func load_game() -> bool:
    if not ResourceLoader.exists(SAVE_PATH):
        current_save = SaveGame.new()
        return false
    var res := ResourceLoader.load(SAVE_PATH, "", ResourceLoader.CACHE_MODE_IGNORE)
    if res is SaveGame:
        current_save = res
        return true
    current_save = SaveGame.new()
    return false

呼び出す側は驚くほど単純です。セーブポイントに触れた瞬間や「中断」ボタンで write_state()、ゲーム開始時に read_state() を呼ぶだけ。

# セーブポイントに乗ったとき(Area2Dのシグナルなどから)
func _on_save_point_entered() -> void:
    SaveManager.write_state($Player, inventory_data)

# ゲーム開始時(メインシーンの_readyなど)
func _ready() -> void:
    SaveManager.read_state($Player)

ポイントは2つです。

  • 状態は「集めて詰める・読んで配る」に徹する :ゲームのあちこちにセーブ処理を散らさず、SaveManager が状態の集約と分配を一手に引き受けます。セーブしたい項目が増えても、直すのは SaveGamewrite_stateread_state の1か所だけ。
  • SaveManagerAutoload にする :セーブはシーンをまたいで呼びたい機能なので、グローバルに常駐させておくのが定番です。どのシーンからでも SaveManager.write_state(...) と書けます。

「複数スロットに対応したい」なら SAVE_PATH をスロット番号で切り替え(user://slot_1.res など)、「オートセーブ」なら Timer で定期的に write_state() を呼ぶ——と、この土台のまま自然に育てられます。

よくある間違いとベストプラクティス

よくある間違いベストプラクティス
res:// に保存しようとする必ず user:// を使う。書き出し後は res:// に書き込めない
戻り値やnullを確認しないsave()load() の戻り値と open() のnullを毎回チェックする
Godot固有型をJSONに直接渡すJSONでは変換ヘルパーを用意する。または カスタムリソース を使う
セーブ/ロード中に画面が固まる大きなデータは Thread や非同期ロードでバックグラウンド処理する
セーブの互換性を考えないsave_version を持たせ、古いバージョンの読み替え処理を用意する
よくある間違いと対策を対比した図。左の悪い例(res://に保存・戻り値を見ない)に警告マーク、右の良い例(user://に保存・戻り値を確認)にチェックマークがそろって並ぶ

特に セーブのバージョン管理 は、アップデートを重ねるゲームで効いてきます。SaveGamesave_version を持たせ、ロード時に「バージョンが古ければ新しい形へ変換する」処理を1つ挟んでおくと、 昔のセーブデータを壊さずにアップデートを続けられます 。最初は使わなくても、フィールドだけ用意しておくと将来の自分が助かります。

おまけ:先に知っておくと良いこと

セーブの基本が組めたら、次はこのあたりが視野に入ってきます。今すぐ必要ではないので、名前だけ覚えておけば十分です。

  • 暗号化でカジュアルな改ざんを防ぐFileAccess.open_encrypted_with_pass() でセーブを暗号化できます。ただしパスワードはビルドに埋め込まれるため、腕のあるプレイヤーには解析されえます。「気軽な書き換えを抑止する」程度と捉え、オンラインで通用させたい数値は サーバー側で検証 します。
  • Web(HTML5)ビルドの保存先user:// はブラウザの IndexedDB にマッピングされます。ユーザーがブラウザのキャッシュを消すとセーブも消えるため、重要なデータはサーバー保存も検討します。
  • フリーズを避ける非同期処理 :セーブデータが大きくなってきたら、ThreadResourceLoader.load_threaded_request()バックグラウンド読み書き に切り替え、ロード中もUIが固まらないようにします。基礎は awaitとコルーチンの基礎 が参考になります。

まとめ

  • セーブ先は 必ず user://res:// は書き出し後は読み取り専用
  • ConfigFile は設定情報、 JSON は外部連携、 カスタムリソース はセーブデータ全般——が基本の使い分け
  • カスタムリソースは @exportResourceSaverResourceLoader で、変換なし・型安全に保存・復元できる
  • 開発は .tres、出荷は .res 。信頼できないセーブを読むゲームではセキュリティに注意
  • セーブは 状態を集めて詰める・読んで配る に徹し、SaveManager を Autoload で常駐させると管理が楽

まずは SaveGame リソースを1つ作り、HPと座標だけを保存・復元してみてください。「閉じて開いても続きから遊べる」が動いた瞬間、ゲームが一気に「作品」らしくなります。

さらに学ぶために