PVG
37. Save Load Main Menu — Program Video Games

37. Save Load Main Menu

Up until now, the game boots straight into the field scene. There's no main menu, no way to save progress, and no way to come back to where you left off. The battle system also just dumps you back to the field with no record that anything happened.

In this lesson we'll:

  1. Add a Scene_Kind enum to track what mode the game is in
  2. Build a main menu with New Game, Continue, and placeholder items
  3. Create a simple save/load system using SJSON
  4. Wire up autosaving after battles
  5. Restructure the party roster stats format

Throughout this lesson you'll see gs used as a global pointer rather than casting from context.user_ptr. That change was covered in Lesson 36. If you see lines being removed that look like gs := cast(^Game_State)context.user_ptr, that's why. The global gs is set in game_post_window_create and game_post_reload.

Scene_Kind

The game has been using gs.battle.is_active as a boolean toggle between field and battle. That worked when there were only two states, but we now need a third: the main menu. A boolean can't represent three states.

Add the enum and a field to Game_State:

Scene_Kind :: enum {
    MAIN_MENU,
    FIELD,
    BATTLE,
}
Game_State :: struct {
    // ... existing fields ...
    scene_kind: Scene_Kind,
}

The default zero value is MAIN_MENU, which is what we want. The game starts on the main menu without us having to set anything.

Updating game_update

The old game_update ran entity updates unconditionally and used an if/else on battle.is_active for the rest. Replace that with a switch on scene_kind:

switch gs.scene_kind {
case .MAIN_MENU:
case .FIELD:
    entities_update(gs.scene, &gs.events, gs.time.delta)
    triggers_update(gs.scene, &gs.events)

    player := entity_get(gs.scene, gs.scene.player_handle)
    // ... existing field logic (camera follow, encounter checks, ...)

case .BATTLE:
    entities_update(gs.scene, &gs.events, gs.time.delta)
    battle_update(&gs.battle)
}

The main menu case is empty. No entities, no physics, no triggers. Just UI.

Updating game_render

Same idea. Split rendering into two procedures and switch between them:

game_render :: proc(gs: ^Game_State) {
    switch gs.scene_kind {
    case .MAIN_MENU:
        render_main_menu()
    case .FIELD, .BATTLE:
        render_field()
    }
}

render_main_menu :: proc() {
    ui_render(&gs.ui)
}

render_field :: proc() {
    player := entity_get(gs.scene, gs.scene.player_handle)
    // ... existing 3D rendering code moves in here ...
}

Field and battle share the same 3D renderer. The main menu only needs the UI layer.

Updating ui_update

The old UI code exited early if the battle wasn't active. Now it switches on scene_kind:

switch gs.scene_kind {
case .MAIN_MENU:
    ui_main_menu()
case .FIELD:
    // TODO: field UI (inventory, dialogue, etc)
case .BATTLE:
    if gs.battle.turn_state == .VICTORY {
        ui_battle_victory()
    } else if gs.battle.turn_state == .DEFEAT {
        ui_battle_defeat()
    } else {
        ui_battle_active()
    }
}

This gives each game mode its own UI entry point instead of nesting everything under battle checks.

Scene_Change Payload

The Event_Payload_Scene_Change needs to carry the target Scene_Kind alongside the scene key, so the handler can set both in one go:

Event_Payload_Scene_Change :: struct {
    scene_key:  string,
    scene_kind: Scene_Kind,
}

Update the .Scene_Change event handler to use it:

events_subscribe(&gs.events, .Scene_Change, proc(event: Event) {
    payload := event.payload.(Event_Payload_Scene_Change)
    scene_switch(&gs.scene_arena, &gs.scene, payload.scene_key)
    gs.scene_kind = payload.scene_kind
    if gs.scene_kind == .FIELD {
        gs.scene.player_handle = player_init(gs, gs.scene)
    }
})

Only field scenes create a player. Battle scenes and menus don't need one.

Set scene_kind in battle_start as well:

gs.scene = bs.battle_scene
gs.scene_kind = .BATTLE

And in the .Battle_Exit handler, set it back:

gs.scene = gs.battle.return_scene
gs.scene_kind = .FIELD

The Main Menu

Font Setup

The battle UI uses an 18pt font. That's too small for a main menu. Load a second copy at 64pt and store its index on Game_State:

Game_State :: struct {
    // ... existing fields ...
    battle_font:    int,
    main_menu_font: int,
}

In ui_init, track the indices as you append (this is what FUI needs, an int):

ui_init :: proc(uis: ^UI_State, window_width, window_height: f32) {
    gs.battle_font = len(uis.fonts)
    append(&uis.fonts, rl.LoadFontEx("assets/fonts/hitroad.ttf", 18, nil, 0))

    gs.main_menu_font = len(uis.fonts)
    append(&uis.fonts, rl.LoadFontEx("assets/fonts/hitroad.ttf", 64, nil, 0))

    fui.init({}, {window_width, window_height}, measure_text)
}

Menu Item Helper

A reusable procedure for menu items that handles hover highlighting and disabled state:

main_menu_item :: proc(text: string, disabled := false) -> fui.Status {
    config := fui.Text_Config {
        font  = gs.main_menu_font,
        size  = f32(gs.ui.fonts[gs.main_menu_font].baseSize),
        align = .CENTER,
    }

    if disabled {
        config.color = GRAY
    }

    text_status := fui.text(text, &config)

    if text_status.is_hovered && !disabled {
        fui.set_text_color(YELLOW, text_status.handle)
    }

    return text_status
}

set_text_color is a new addition to the FUI library. It reaches into the box for a given handle and overrides the text colour. We need this because the colour is set at config time, but the hover state isn't known until after the element is laid out. The implementation is straightforward:

set_text_color :: proc(color: Vec4, handle := Handle(0)) {
    box := box_from_handle(handle)
    if box == nil do return
    box.text.color = color
}

The disabled items render in grey and don't respond to hover. The disabled parameter defaults to false so most call sites don't need to think about it.

Menu Layout

ui_main_menu :: proc() {
    fui.row(&{}, proc() {
        fui.col()
        fui.col(&{}, proc() {
            fui.col()
            fui.col(&{gap = 24, flags = {.FIT_Y}}, proc() {
                if main_menu_item("New Game").is_pressed {
                    emit(Event{type = .New_Game})
                }
                if main_menu_item("Continue", !gs.autosave_exists).is_pressed {
                    emit(Event{type = .Load})
                }
                main_menu_item("Settings")
                main_menu_item("Exit")
            })
            fui.col()
        })
        fui.col()
    })
}

The nested row > col > col > col structure centres the menu both horizontally and vertically. The outer fui.row fills the screen. Three columns inside it: empty, content, empty. The content column has three rows: empty, menu items, empty. The empty elements act as spacers because FUI distributes remaining space evenly among unsized children.

"Continue" is disabled when gs.autosave_exists is false. Settings and Exit are placeholders for now. They render but don't do anything when pressed.

Colour

GRAY :: Vec4{40, 40, 40, 255}

Add GRAY for the disabled menu items.

New Event Types

Add three new event types to Event_Type:

Event_Type :: enum {
    // ... existing ...
    Autosave,
    New_Game,
    Load,
}

Their handlers are registered in game_post_window_create:

events_subscribe(&gs.events, .New_Game, proc(event: Event) {
    emit(
        Event {
            type = .Scene_Change,
            payload = Event_Payload_Scene_Change{scene_key = "flat", scene_kind = .FIELD},
        },
    )
})

New Game just emits a scene change. It's a thin wrapper, but it gives us a place to add character creation or intro sequences later without touching the menu code.

Save File

Create save.odin. The Save_File struct defines what gets saved to disk:

Save_File :: struct {
    time_played:            f64,
    last_time_played:       i64,
    player_position:        Vec3,
    scene_key:              string,
    current_party_members:  [3]int,
    unlocked_party_members: []bool,
    party_roster:           []Party_Member,
}

Add the save file to Game_State:

Game_State :: struct {
    // ... existing fields ...
    save_file:       Save_File,
    autosave_exists: bool,
}

Think about what gets saved versus what gets recreated. Entity definitions, animations, textures, models are all loaded fresh from disk on startup. They don't change at runtime, so there's no point saving them. What does change is the player's position, which scene they're in, who's in the party, and each member's stats. That's your save file.

Saving

save :: proc(path: string) -> bool {
    player := entity_get(gs.scene, gs.scene.player_handle)
    save_file := Save_File {
        time_played            = gs.save_file.time_played + gs.time.session,
        last_time_played       = time.now()._nsec,
        player_position        = player.position,
        scene_key              = gs.scene.scene_name,
        current_party_members  = gs.current_party_members,
        unlocked_party_members = gs.unlocked_party_members,
        party_roster           = gs.party_roster,
    }

    data, json_err := json.marshal(
        save_file,
        {spec = .SJSON, pretty = true},
        context.temp_allocator,
    )
    if json_err != nil {
        log.errorf("Unable to encode save file '%s': %s", path, json_err)
        return false
    }

    io_err := os.write_entire_file(path, data)
    if io_err != nil {
        log.errorf("Unable to write save file '%s': %s", path, io_err)
        return false
    }

    return true
}

We build a fresh Save_File from the current game state rather than maintaining a live copy. time_played accumulates across sessions by adding the current session time to whatever was previously saved. The temp allocator handles the marshalled bytes since we write them to disk immediately.

SJSON with pretty = true produces a human-readable file. During development, being able to open the save file in a text editor and see exactly what's there is worth more than the few extra bytes.

Loading

load :: proc(path: string) -> bool {
    data, io_err := os.read_entire_file(path, context.temp_allocator)
    if io_err != nil {
        log.errorf("Unable to open save file '%s': %s", path, io_err)
        return false
    }

    json_err := json.unmarshal(data, &gs.save_file, .SJSON)
    if json_err != nil {
        log.errorf("Unable to parse save file '%s': %s", path, json_err)
        return false
    }

    return true
}

Loading deserialises into gs.save_file. It doesn't apply the data to the game state directly. The caller is responsible for that, which keeps the load procedure reusable if we ever need to read a save file without acting on it (previewing save slots, for instance - though we may want to provide a pointer for that case).

The Load Handler

The .Load event handler does the actual restoration:

events_subscribe(&gs.events, .Load, proc(event: Event) {
    success := load("autosave.json")
    log.info("Loading...", success ? "Success" : "Something went wrong :(")

    scene_switch(&gs.scene_arena, &gs.scene, gs.save_file.scene_key)

    gs.current_party_members = gs.save_file.current_party_members
    gs.unlocked_party_members = gs.save_file.unlocked_party_members
    gs.party_roster = gs.save_file.party_roster

    gs.scene.player_handle = player_init(gs, gs.scene)
    player := entity_get(gs.scene, gs.scene.player_handle)
    player.position = gs.save_file.player_position

    gs.scene_kind = .FIELD
})

It loads the file, switches to the saved scene, creates the player, repositions them, and restores the party data. Order matters here. The scene must exist before you can create a player in it, and the player must exist before you can set its position.

Autosave Trigger

The .Battle_Exit handler now emits an Autosave event after restoring the field scene:

events_subscribe(&gs.events, .Battle_Exit, proc(event: Event) {
    gs.battle.is_active = false

    gs.scene = gs.battle.return_scene
    gs.scene_kind = .FIELD

    player := entity_get(gs.scene, gs.scene.player_handle)
    player.position = gs.battle.return_position

    mem.dynamic_arena_free_all(&gs.battle.arena)

    emit(Event{type = .Autosave})
})

The Autosave handler itself is simple:

events_subscribe(&gs.events, .Autosave, proc(event: Event) {
    success := save("autosave.json")
    log.info("Saving...", success ? "Success" : "Something went wrong :(")
})

After every battle, the game writes autosave.json. Add it to your .gitignore so save files don't end up in version control.

Checking for Existing Saves

At the end of game_post_window_create, check whether an autosave exists. This determines whether the Continue button is enabled:

gs.autosave_exists = os.exists("autosave.json")

And remove the old code that loaded the field scene on startup. The game now starts on the main menu, and scene loading happens through events.

Party Roster Stats

The party roster data format was flat:

hp = 120
atk = 19
def = 20

This doesn't support the min/max/current pattern we use in battle. Restructure party_roster.json to nest stats with explicit max values:

stats = {
  hp = {max = 120}
  atk = {max = 19}
  def = {max = 20}
  spd = {max = 10}
}

This also adds spd (speed), which was missing from the roster data.

In battle_start, override the entity definition values with the party roster stats when creating party member battle entities:

party_member := gs.party_roster[idx]

def_key := party_member.battle_entity_def_key
// ... create entity from def ...

// Override entity_def values with party_roster
e.stats.hp.max = party_member.stats.hp.max
e.stats.atk.max = party_member.stats.atk.max
e.stats.def.max = party_member.stats.def.max
e.stats.spd.max = party_member.stats.spd.max
e.stats.hp.curr = e.stats.hp.max
e.stats.atk.curr = e.stats.atk.max
e.stats.def.curr = e.stats.def.max
e.stats.spd.curr = e.stats.spd.max

The entity definition provides the visual setup (model, animations, collider). The party roster provides the actual stats. This separation means an artist can tweak the entity definition without accidentally changing game balance, and a designer can tune stats without touching entity files.

entity_get Safety

One small defensive change to entity_get. Add a nil check on the scene state pointer:

entity_get :: proc(ws: ^Scene_State, handle: Entity_Handle) -> (entity: ^Entity = nil) {
    if ws != nil && handle.index < len(ws.entities) {
        stored_generation := ws.generations[handle.index]
        if stored_generation == handle.generation {
            entity = &ws.entities[handle.index]
        }
    }
    return entity
}

Now that the game starts on the main menu where gs.scene is nil, any code that calls entity_get before a scene is loaded won't crash. It returns nil instead, and the caller can handle it.

Note the signature change too. The return value now uses a named return with a default of nil, which is just a style preference.

Summary

Scene_Kind replaces the boolean is_active check with a proper state enum. Every system that needs to behave differently per mode (update, render, UI) switches on it.

The save system is deliberately simple: one struct, marshalled to SJSON, written to a single file. Loading deserialises into a staging area (gs.save_file) and a separate handler applies it. Autosave fires after every battle exit.

The main menu is built from the same FUI system as the battle UI. The layout uses empty elements as spacers for centring. Menu items support hover highlighting and a disabled state.

Party roster stats now use a nested format with explicit max values, and battle entities pull their stats from the roster rather than from entity definitions alone.