PVG
27. Saving and Loading — Program Video Games

27. Saving and Loading

[[programvideogames]]In this lesson we'll be creating save slots.

We can create new games and load a previous save.

To start out, we're going to store some extra state about the main menu in a new struct type to keep it together:

Main_Menu_State :: struct {
    menu_type:               Main_Menu_Type,
    save_texture:            rl.RenderTexture2D,
    save_slots:              [SAVE_SLOTS]Save_Data,
    save_list_scroll_offset: f32,
}

Main_Menu_Type :: enum {
    Default,
    Select_Save_Slot,
}

// We also need a type that represents our saved data
Save_Data :: struct {
    slot:                int,
    version:             struct {
        major, minor, patch: int,
    },
    seconds_played:      f64,
    level_iid:           string, // Checkpoint level iid
    location:            string, // Most recent level name
    checkpoint_iid:      string,
    collected_power_ups: bit_set[Power_Up_Type],
}

We're going to store one copy of save_data on Game_State that'll represent the current playthrough.

Game_State :: struct {
    level_definitions: map[string]Level, // typo fix!
    // ... other fields ...
    main_menu_state: Main_Menu_State,
    last_update_time: time.Time, // Used to update seconds played
    save_data: Save_Data,
}

We'll also create a bunch of constants which we'll use shortly.

Move the hard-coded main menu background color into a constant, too.

BG_COLOR_MAIN_MENU :: rl.Color{0, 0, 28, 255}
// ...
SAVE_ITEM_HEIGHT :: 60
SAVE_SLOTS :: 10
SAVE_PANEL_WIDTH :: WINDOW_WIDTH / 2
SAVE_PANEL_HEIGHT :: WINDOW_HEIGHT / 3
SCROLL_SPEED :: 20

VERSION_MAJOR :: 0
VERSION_MINOR :: 1
VERSION_PATCH :: 0

Okay, now down in main_menu_update we'll clear using the new color:

rl.ClearBackground(BG_COLOR_MAIN_MENU)

Next, we're collapsing our "Continue" and "New Game" into one entry, "Play".

I changed "New Game" to "Settings", which we can fill in later.

There's a lot of code in this chunk, so I've put comments directly into the code:

main_menu_update :: proc(gs: ^Game_State) {
    for !rl.WindowShouldClose() {
        center := Vec2{WINDOW_WIDTH, WINDOW_HEIGHT} / 2
        title_text: cstring = "METROIDVANIA"
        title_text_size := rl.MeasureTextEx(gs.font_64, title_text, 64, 4)

        rl.BeginDrawing()
        rl.ClearBackground(BG_COLOR_MAIN_MENU)

        // Draw Title
        rl.DrawTextEx(
            gs.font_64,
            title_text,
            {center.x - title_text_size.x / 2, center.y / 2},
            64,
            4,
            rl.WHITE,
        )

        switch gs.main_menu_state.menu_type {
        case .Default:
            if main_menu_item_draw("Play", center) {
                gs.main_menu_state.menu_type = .Select_Save_Slot
            }

            if main_menu_item_draw("Settings", center + {0, 60}) {
                // TODO
                // return
            }

            if main_menu_item_draw("Quit", center + {0, 120}) {
                rl.CloseWindow()
                return
            }
        case .Select_Save_Slot:
            // To simulate a scrolling box, we are rendering to a different texture
            target := gs.main_menu_state.save_texture

            // This is the position of the scrolling box
            panel_pos := Vec2{(WINDOW_WIDTH - SAVE_PANEL_WIDTH) / 2, WINDOW_HEIGHT / 2}

            mouse_wheel_move := rl.GetMouseWheelMove()

            // This offset is used to draw the correct part of the texture into the box
            gs.main_menu_state.save_list_scroll_offset = clamp(
                gs.main_menu_state.save_list_scroll_offset + mouse_wheel_move * SCROLL_SPEED,
                SAVE_PANEL_HEIGHT - SAVE_ITEM_HEIGHT * SAVE_SLOTS,
                0,
            )

            // Start drawing to the texture
            rl.BeginTextureMode(target)
            rl.ClearBackground(BG_COLOR_MAIN_MENU)

            for save_data, i in gs.main_menu_state.save_slots {
                // We always have SAVE_SLOTS amount of save slots
                // So we check if the seconds_played is > 0 to determine if the slot is taken
                if save_data.seconds_played > 0 {

                    // Save file exists, pass in location and seconds_played
                    if load_game_item_draw(
                        i,
                        panel_pos,
                        gs.main_menu_state.save_list_scroll_offset,
                        save_data.location,
                        save_data.seconds_played,
                    ) {
                        gs.save_data = save_data
                        game_init(gs)

                        // Load level checkpoint is in
                        // If can't find - load at beginning of game
                        level_def := &gs.level_definitions[FIRST_LEVEL_IID]

                        // If we saved at a checkpoint, there should be a level_iid
                        if save_data.level_iid != "" {
                            level_def, _ = &gs.level_definitions[save_data.level_iid]
                            gs.checkpoint_level_iid = save_data.level_iid
                            gs.checkpoint_iid = save_data.checkpoint_iid
                        }

                        for checkpoint in level_def.checkpoints {
                            if checkpoint.iid == save_data.checkpoint_iid {
                                level_def.player_spawn = checkpoint.position
                            }
                        }

                        // Update the level_def to have the player_spawn position
                        gs.level_definitions[level_def.iid] = level_def^

                        gs.collected_power_ups = gs.save_data.collected_power_ups

                        level_load(gs, level_def)
                    }
                } else {

                    // We are starting a new game

                    if load_game_item_draw(
                        i,
                        panel_pos,
                        gs.main_menu_state.save_list_scroll_offset,
                    ) {
                        game_init(gs)

                        gs.save_data.slot = i
                        gs.save_data.version.major = VERSION_MAJOR
                        gs.save_data.version.minor = VERSION_MINOR
                        gs.save_data.version.patch = VERSION_PATCH

                        level_load(gs, &gs.level_definitions[FIRST_LEVEL_IID])

                        // Initial auto save
                        save_data_update(gs)

                        // seconds_played is f64 and will get truncated when turned into i64 duration
                        gs.save_data.seconds_played = 1
                        savefile_save(gs.save_data)
                    }
                }
            }

            rl.EndTextureMode()

            // We finished drawing to the scrolling box
            // Now we draw the box onto the screen at the correct location

            rl.DrawTextureRec(
                gs.main_menu_state.save_texture.texture,
                Rect {
                    0,
                    gs.main_menu_state.save_list_scroll_offset - SAVE_PANEL_HEIGHT,
                    SAVE_PANEL_WIDTH,
                    -SAVE_PANEL_HEIGHT,
                },
                panel_pos,
                rl.WHITE,
            )

            {
                text :: "Back"
                size := rl.MeasureTextEx(gs.font_48, text, 48, 0)
                if main_menu_item_draw(text, {32 + size.x / 2, WINDOW_HEIGHT - 60}) {
                    gs.main_menu_state.menu_type = .Default
                }
            }
        }

        rl.EndDrawing()

        // gs.scene is set when we load_level
        // We need to make sure we end texture and drawing modes
        // Then we can return to change scene

        if gs.scene != .Main_Menu {
            return
        }
    }
}

Alright, next up is the load_game_item_draw proc that we just used.

Similar to the other UI procs, it returns true when clicked.

load_game_item_draw :: proc(
    slot: int,
    panel_pos: Vec2,
    offset: f32,
    location: string = "",
    time_played: f64 = 0,
) -> (
    pressed: bool,
) {
    text: cstring
    if time_played == 0 {
        text = "New Game"
    } else {
        dur := time.Duration(i64(time_played * 1000 * 1000 * 1000))
        buf: [time.MIN_HMS_LEN]u8
        time_played_str := time.to_string_hms(dur, buf[:])
        text = fmt.ctprintf("%d - %s, %s", slot + 1, location, time_played_str)
    }

    pos := Vec2{0, f32(slot) * SAVE_ITEM_HEIGHT}
    mouse_pos := rl.GetMousePosition()

    screen_pos := panel_pos + {0, pos.y + offset}

    if rl.CheckCollisionPointRec(
        mouse_pos,
        {screen_pos.x, screen_pos.y, SAVE_PANEL_WIDTH, SAVE_ITEM_HEIGHT},
    ) {
        rl.DrawTextEx(gs.font_48, text, pos, 48, 0, rl.YELLOW)
        if rl.IsMouseButtonPressed(.LEFT) {
            pressed = true
        }
    } else {
        rl.DrawTextEx(gs.font_48, text, pos, 48, 0, rl.WHITE)
    }

    return
}

To make sure the time is stored correctly, we store the last_update_time and we initialise this at the start of game_init.

game_init :: proc(gs: ^Game_State) {
    gs.last_update_time = time.now()
    // ...
}

We'll also update it on each auto-save and at each use of a checkpoint.

We'll cover that shortly - first let's read the save files.

In main, just after setting up the cameras:

// Check for savefiles
{
    curr_path := os.get_current_directory(context.temp_allocator)
    save_dir_path := fmt.tprintf("%s/saves", curr_path)
    save_dir, err := os.open(save_dir_path)
    if err == .Not_Exist {
        make_err := os.make_directory(save_dir_path)
        save_dir, err = os.open(save_dir_path)

        if make_err != nil || err != nil {
            panic("Could not create save directory")
        }
    }

    files, read_dir_err := os.read_dir(save_dir, 0, context.temp_allocator)
    if read_dir_err != nil {
        panic("Failed to read saves directory")
    }

    for file in files {
        save_data, ok := savefile_load(file.fullpath)
        if ok {
            gs.main_menu_state.save_slots[save_data.slot] = save_data
        }
    }
}

// Setup Main Menu Texture
{
    width :: SAVE_PANEL_WIDTH
    height :: SAVE_SLOTS * SAVE_ITEM_HEIGHT
    gs.main_menu_state.save_texture = rl.LoadRenderTexture(width, height)
}

We also set up the main menu render texture.

For the errors here, we panic because it's not a recoverable state.

If the player can't save their game, we don't want them to play.

That's everything we needed to do in main.odin.

Let's create a new file called savefile.odin that handles our saves.

package main

import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:time"

savefile_save :: proc(save_data: Save_Data) -> (success: bool) {
    options := json.Marshal_Options {
        spec = .SJSON,
    }
    data, err := json.marshal(save_data, options, context.temp_allocator)

    if err == nil {
        path := fmt.tprintf("saves/%d.json", save_data.slot)
        success = os.write_entire_file(path, data)
    }

    return
}

savefile_load :: proc(path: string) -> (save_data: Save_Data, ok: bool) {
    data := os.read_entire_file(path) or_return

    // Use permanent storage to save level and checkpoint iid
    // At most, we waste iid * 2 number of bytes
    if json.unmarshal(data, &save_data, .SJSON) == nil {
        ok = true
    }

    return
}

// Not too sure where to put this
save_data_update :: proc(gs: ^Game_State) {
    gs.save_data.collected_power_ups = gs.collected_power_ups
    gs.save_data.location = gs.level.name

    time_since_update := time.diff(gs.last_update_time, time.now())
    gs.last_update_time = time.now()
    gs.save_data.seconds_played += time.duration_seconds(time_since_update)
}

As you can see, the os and encoding/json packages are doing a lot of heavy lifting.

I've opted to use the .SJON format as it's a bit more compact.

Finally, we want to call these new procedures from our gameplay code.

Head over to player.odin and update the following.

First, when we change levels (using a door) we'll auto-save:

gs.level_definitions[level_def.iid] = level_def // Fixed typo
level_load(gs, &gs.level_definitions[door.to_level])
save_data_update(gs) // New
savefile_save(gs.save_data) // New

Finally, when we activate a checkpoint we'll explicitly update the save data:

try_activate_checkpoint :: proc(gs: ^Game_State, player: ^Entity) {
    if rl.IsKeyPressed(.W) {
        for checkpoint in gs.checkpoints {
            rect := Rect{checkpoint.x, checkpoint.y, 32, 32}
            if rl.CheckCollisionRecs(rect, player.collider) {
                gs.checkpoint_level_iid = gs.level.iid
                gs.checkpoint_iid = checkpoint.iid

                // Below is new
                gs.save_data.level_iid = gs.level.iid
                gs.save_data.checkpoint_iid = checkpoint.iid

                save_data_update(gs)
                savefile_save(gs.save_data)
            }
        }
    }
}

And that's it. We now have a working save/load system.

When we want to add more data, we can update the version (currently 0.1.0) and look at potential backwards compatibility.