Godot 4 · 3D · GDScript · Game Systems

Quest Flag
System Documentation

// personal reference · narrative state · NPC behavior · save & load

01 Concept & Three Layers

Complex JRPG quests cannot use scattered if/else — too many combinations. Instead separate state into three layers, all living in one GameState autoload.

LAYER 1
Flag Store
What happened — permanent, never reset
LAYER 2
Quest State
Where in quest — can change multiple times
LAYER 3
NPC Reaction
Generated at runtime from Layer 1 & 2 — not premade
Layer 3 is Generated NPC evaluates its own state by checking Layer 1 & 2 at runtime. You write the rules (conditions), the game figures out the result. No manual NPC state management needed.

02 Four Data Stores 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
Flags vs Switches flags = things player DID (permanent). switches = world STATE (can toggle). "defeated_goblin_king" is a flag — never resets. "night_mode_active" is a switch — can turn on/off anytime.

03 check_flags() — The Heart CORE

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

// Example conditions

# 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 }
Used Everywhere check_flags() is the same function for NPC dialog, NPC position, door locks, teleport conditions, shop availability, cutscene triggers. One function, entire game.

04 Setters

# 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")

05 NPC Behavior NPC

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"])
Position vs Dialog timing Position evaluated on _ready() — when scene loads. Dialog evaluated on interact — when player talks. Both call get_current_state() but at different moments.

06 Position Markers

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
Also Used in Door System Same marker concept used for door spawn points. Player arrives at destination door marker — copies position AND rotation. Facing direction set by artist in editor.

07 What Triggers Flags

# 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)

08 Save System — Unlimited Slots

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)

09 Load System

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)

10 Save Slot Management

# 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
What each save file contains slot · timestamp · playtime · location · flags · quests · counters · switches. No save limit — files grow as player saves more. Modern storage handles this easily.

11 Full GameState Code AUTOLOAD

# 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

12 Summary

Build Order Build GameState autoload first. Test set_flag + check_flags with one NPC. Then add save/load. Then multiply to many NPCs. All systems (doors, teleport, cutscene) already connect to GameState — no extra work needed.