// personal reference · narrative state · NPC behavior · save & load
Complex JRPG quests cannot use scattered if/else — too many combinations. Instead separate state into three layers, all living in one GameState autoload.
| store | type | purpose | can reset? | example |
|---|---|---|---|---|
| flags | bool / any | things player DID | never | "met_elder": true |
| quests | String | current quest stage | advances only | "main_quest": "chapter_2" |
| counters | int / float | numeric values | yes | "gold": 250 |
| switches | bool | world state toggles | yes — on/off | "war_started": false |
# GameState.gd — Autoload singleton
extends Node
# things player DID — permanent
var flags = {
"met_elder": false,
"defeated_goblin_king": false,
"chose_save_village": false,
"chest_001_opened": false,
}
# current quest progress
var quests = {
"main_quest": "not_started",
"side_quest_blacksmith": "not_started",
"side_quest_missing_girl": "not_started",
}
# numeric values
var counters = {
"gold": 0,
"enemies_killed": 0,
"times_talked_to_elder": 0,
}
# world state toggles — can flip on/off
var switches = {
"night_mode_active": false,
"war_started": false,
"shop_closed": false,
}
# meta
var current_scene: String = "town_01"
var playtime_seconds: float = 0.0
One function used everywhere — NPC dialog, NPC position, doors, teleports, shops, cutscenes. Handles all four store types automatically.
func check_flags(requires: Dictionary) -> bool:
for key in requires:
var expected = requires[key]
# handle NOT prefix — "not_met_elder": true
if key.begins_with("not_"):
var real_key = key.trim_prefix("not_")
if flags.get(real_key, false) == true:
return false
continue
# boolean — flags or switches
if expected is bool:
var flag_val = flags.get(key, false)
var switch_val = switches.get(key, false)
if flag_val != expected and switch_val != expected:
return false
# string — quest stage exact match
elif expected is String:
if quests.get(key, "not_started") != expected:
return false
# array — quest at ANY of these stages
elif expected is Array:
if not quests.get(key, "not_started") in expected:
return false
# int/float — counter must be >= expected
elif expected is int or expected is float:
if counters.get(key, 0) < expected:
return false
return true # all conditions passed
# simple boolean flag
{ "met_elder": true }
# quest at specific stage
{ "main_quest": "chapter_2" }
# multiple conditions — ALL must pass
{ "met_elder": true, "main_quest": "chapter_2" }
# quest at any of these stages
{ "main_quest": ["chapter_2", "chapter_3"] }
# counter check — player has enough gold
{ "gold": 500 }
# world switch active
{ "war_started": true }
# NOT condition — quest not done yet
{ "not_met_elder": true }
# complex — multiple stores combined
{ "main_quest": "completed", "chose_save_village": true, "gold": 100 }
# flags — permanent
func set_flag(key: String, value: bool):
flags[key] = value
# quests — advance stage
func advance_quest(quest_id: String, stage: String):
quests[quest_id] = stage
# counters
func set_counter(key: String, value: int):
counters[key] = value
func add_counter(key: String, amount: int):
counters[key] = counters.get(key, 0) + amount
# switches — world state
func set_switch(key: String, value: bool):
switches[key] = value
func toggle_switch(key: String):
switches[key] = !switches.get(key, false)
# usage examples
GameState.set_flag("defeated_goblin_king", true)
GameState.advance_quest("main_quest", "chapter_2")
GameState.add_counter("gold", 100)
GameState.set_switch("war_started", true)
GameState.toggle_switch("night_mode_active")
NPC defines a list of conditions. First matching condition wins. Last entry is always the default (empty requires = always matches).
# NPC_Elder.gd
extends CharacterBody3D
# conditions checked top to bottom — first match wins
var conditions = [
{
"requires": { "main_quest": "completed", "chose_save_village": true },
"dialog": 200,
"position": "marker_throne_room"
},
{
"requires": { "defeated_goblin_king": true },
"dialog": 150,
"position": "marker_tavern"
},
{
"requires": { "met_elder": true },
"dialog": 142,
"position": "marker_house"
},
{
"requires": {}, # default — always matches
"dialog": 100,
"position": "marker_market"
},
]
func get_current_state() -> Dictionary:
for condition in conditions:
if GameState.check_flags(condition["requires"]):
return condition
return conditions[-1] # fallback
# position evaluated on scene load
func _ready():
var state = get_current_state()
var marker = get_node(state["position"])
global_position = marker.global_position
global_rotation = marker.global_rotation # facing direction
# dialog evaluated on player interact
func on_player_interact():
var state = get_current_state()
DialogSystem.start(state["dialog"])
Empty Node3D nodes placed in the scene by the artist. NPC reads position AND rotation from marker — no code needed for facing direction.
# Scene tree example for Town:
# Town (Node3D)
# ├── NPCs
# │ └── Elder (NPC_Elder.gd)
# └── Markers (Node3D — invisible at runtime)
# ├── marker_market ← artist places + rotates
# ├── marker_house ← artist places + rotates
# ├── marker_tavern ← artist places + rotates
# └── marker_throne_room ← artist places + rotates
# NPC reads both position and rotation from marker
var marker = get_node(state["position"])
global_position = marker.global_position # where to stand
global_rotation = marker.global_rotation # which way to face
# 1. Dialog sets flag on finish
# in dialog data:
{
"dialog_id": 100,
"text": "Welcome traveler...",
"on_finish": {
"set_flag": { "met_elder": true },
"advance_quest": { "main_quest": "chapter_1" }
}
}
# dialog system calls GameState.set_flag() when done
# 2. Enemy defeated
func on_defeated():
GameState.set_flag("defeated_goblin_king", true)
GameState.advance_quest("main_quest", "chapter_2")
GameState.add_counter("enemies_killed", 1)
# 3. Item obtained
func on_item_picked_up(item_id: String):
GameState.set_flag(item_id + "_obtained", true)
# 4. Chest opened — one time event
func on_chest_opened():
GameState.set_flag("chest_001_opened", true)
# 5. World event
func trigger_war():
GameState.set_switch("war_started", true)
# 6. Gold transaction
func buy_item(cost: int):
GameState.add_counter("gold", -cost)
Each save slot is a JSON file. No slot limit — modern storage is not a concern. Files named by slot number.
func _ready():
# create saves folder if not exists
if not DirAccess.dir_exists_absolute("user://saves/"):
DirAccess.make_dir_absolute("user://saves/")
func save_game(slot: int):
var save_data = {
"slot": slot,
"timestamp": Time.get_datetime_string_from_system(),
"playtime": playtime_seconds,
"location": current_scene,
"flags": flags,
"quests": quests,
"counters": counters,
"switches": switches,
}
var file = FileAccess.open(
"user://saves/save_%03d.json" % slot,
FileAccess.WRITE
)
file.store_string(JSON.stringify(save_data))
file.close()
print("[SAVE] slot %d saved" % slot)
func load_game(slot: int):
var path = "user://saves/save_%03d.json" % slot
if not FileAccess.file_exists(path):
print("[LOAD] no save at slot %d" % slot)
return
var file = FileAccess.open(path, FileAccess.READ)
var data = JSON.parse_string(file.get_as_text())
file.close()
# restore all four stores
flags = data["flags"]
quests = data["quests"]
counters = data["counters"]
switches = data["switches"]
current_scene = data["location"]
playtime_seconds = data["playtime"]
# load scene player was in
get_tree().change_scene_to_file(
"res://scenes/" + current_scene + ".tscn"
)
print("[LOAD] slot %d loaded" % slot)
# list all saves — for save screen UI
func get_all_saves() -> Array:
var saves = []
var dir = DirAccess.open("user://saves/")
if dir:
dir.list_dir_begin()
var file_name = dir.get_next()
while file_name != "":
if file_name.ends_with(".json"):
var file = FileAccess.open(
"user://saves/" + file_name,
FileAccess.READ
)
saves.append(JSON.parse_string(file.get_as_text()))
file.close()
file_name = dir.get_next()
# sort newest first
saves.sort_custom(func(a, b): return a["timestamp"] > b["timestamp"])
return saves
# delete a save slot
func delete_save(slot: int):
var path = "user://saves/save_%03d.json" % slot
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
print("[SAVE] slot %d deleted" % slot)
# get next available slot number
func get_next_slot() -> int:
var slot = 1
while FileAccess.file_exists("user://saves/save_%03d.json" % slot):
slot += 1
return slot
# GameState.gd — Add to Autoload in Project Settings
extends Node
# =============================================
# DATA STORES
# =============================================
var flags = {}
var quests = {}
var counters = {}
var switches = {}
var current_scene: String = ""
var playtime_seconds: float = 0.0
var spawn_door_id: String = "" # used by door system
func _ready():
if not DirAccess.dir_exists_absolute("user://saves/"):
DirAccess.make_dir_absolute("user://saves/")
init_default_state()
func init_default_state():
# define all flags with defaults here
flags = {
"met_elder": false,
"defeated_goblin_king": false,
}
quests = {
"main_quest": "not_started",
}
counters = {
"gold": 0,
"enemies_killed": 0,
}
switches = {
"night_mode_active": false,
"war_started": false,
}
# =============================================
# READ
# =============================================
func check_flags(requires: Dictionary) -> bool:
for key in requires:
var expected = requires[key]
if key.begins_with("not_"):
var rk = key.trim_prefix("not_")
if flags.get(rk, false) == true: return false
continue
if expected is bool:
if flags.get(key, false) != expected and switches.get(key, false) != expected:
return false
elif expected is String:
if quests.get(key, "not_started") != expected: return false
elif expected is Array:
if not quests.get(key, "not_started") in expected: return false
elif expected is int or expected is float:
if counters.get(key, 0) < expected: return false
return true
# =============================================
# WRITE
# =============================================
func set_flag(key: String, value: bool): flags[key] = value
func advance_quest(id: String, stage: String): quests[id] = stage
func set_counter(key: String, val: int): counters[key] = val
func add_counter(key: String, amt: int): counters[key] = counters.get(key, 0) + amt
func set_switch(key: String, val: bool): switches[key] = val
func toggle_switch(key: String): switches[key] = !switches.get(key, false)
# =============================================
# SAVE / LOAD
# =============================================
func save_game(slot: int):
var data = {
"slot": slot, "timestamp": Time.get_datetime_string_from_system(),
"playtime": playtime_seconds, "location": current_scene,
"flags": flags, "quests": quests, "counters": counters, "switches": switches,
}
var f = FileAccess.open("user://saves/save_%03d.json" % slot, FileAccess.WRITE)
f.store_string(JSON.stringify(data))
f.close()
func load_game(slot: int):
var path = "user://saves/save_%03d.json" % slot
if not FileAccess.file_exists(path): return
var f = FileAccess.open(path, FileAccess.READ)
var data = JSON.parse_string(f.get_as_text())
f.close()
flags = data["flags"]; quests = data["quests"]
counters = data["counters"]; switches = data["switches"]
current_scene = data["location"]; playtime_seconds = data["playtime"]
get_tree().change_scene_to_file("res://scenes/" + current_scene + ".tscn")
func get_all_saves() -> Array:
var saves = []
var dir = DirAccess.open("user://saves/")
if dir:
dir.list_dir_begin()
var fn = dir.get_next()
while fn != "":
if fn.ends_with(".json"):
var f = FileAccess.open("user://saves/" + fn, FileAccess.READ)
saves.append(JSON.parse_string(f.get_as_text()))
f.close()
fn = dir.get_next()
saves.sort_custom(func(a,b): return a["timestamp"] > b["timestamp"])
return saves
func delete_save(slot: int):
DirAccess.remove_absolute("user://saves/save_%03d.json" % slot)
func get_next_slot() -> int:
var slot = 1
while FileAccess.file_exists("user://saves/save_%03d.json" % slot): slot += 1
return slot