The Save/Load Problem

Every game needs to save player progress. But how do you capture the entire game state without exposing internal implementation details? How do you support undo/redo, time travel debugging, or replay systems?

The Memento pattern solves this by capturing and externalizing an object’s internal state without violating encapsulation. Combined with Go’s encoding/gob package, you get powerful, type-safe serialization for game saves, undo systems, and more.

The Naive Approach

Here’s what not to do:

type Game struct {
    playerHealth    int
    playerPosition  Vector2
    inventory       []Item
    questProgress   map[string]int
    // ... hundreds of fields
}

func (g *Game) Save() {
    // Manually serialize every field
    data := fmt.Sprintf("%d,%f,%f,...", g.playerHealth, g.playerPosition.X, g.playerPosition.Y)
    // Error prone, breaks with schema changes, exposing internals
}

// Problems:
// - Manual serialization is error-prone
// - Hard to version/migrate
// - Exposes internal structure
// - No undo/redo support

Memento Pattern Basics

The Memento pattern has three participants:

  1. Originator: The object whose state we’re saving
  2. Memento: A snapshot of the originator’s state
  3. Caretaker: Manages mementos (save/load/history)
graph LR Originator[Originator
Game State]:::lightBlue Memento[Memento
Snapshot]:::lightGreen Caretaker[Caretaker
Save Manager]:::lightYellow Originator -->|CreateMemento| Memento Memento -->|RestoreState| Originator Caretaker -.Stores.-> Memento classDef lightBlue fill:#87CEEB,stroke:#4682B4,stroke-width:2px,color:#000 classDef lightGreen fill:#90EE90,stroke:#228B22,stroke-width:2px,color:#000 classDef lightYellow fill:#FFFFE0,stroke:#FFD700,stroke-width:2px,color:#000

Implementing Memento with encoding/gob

Let’s build a complete save/load system:

package main

import (
    "bytes"
    "encoding/gob"
    "fmt"
    "os"
    "time"
)

// Vector2 represents a 2D position
type Vector2 struct {
    X, Y float64
}

// Item represents an inventory item
type Item struct {
    ID       string
    Name     string
    Quantity int
}

// Quest represents quest progress
type Quest struct {
    ID          string
    Name        string
    Progress    int
    MaxProgress int
    Completed   bool
}

// GameState is the memento - a snapshot of game state
type GameState struct {
    // Player data
    PlayerHealth   int
    PlayerMaxHealth int
    PlayerPosition Vector2
    PlayerLevel    int
    PlayerXP       int

    // Inventory
    Inventory []Item

    // Quests
    Quests map[string]Quest

    // World state
    EnemiesDefeated int
    BossesDefeated  int
    PlayTime        time.Duration

    // Metadata
    SavedAt   time.Time
    SaveSlot  int
    Version   string
}

// Game is the originator - the object we're saving
type Game struct {
    // Private state
    playerHealth   int
    playerMaxHealth int
    playerPosition Vector2
    playerLevel    int
    playerXP       int
    inventory      []Item
    quests         map[string]Quest
    enemiesDefeated int
    bossesDefeated int
    startTime      time.Time
}

func NewGame() *Game {
    return &Game{
        playerHealth:   100,
        playerMaxHealth: 100,
        playerPosition: Vector2{X: 0, Y: 0},
        playerLevel:    1,
        playerXP:       0,
        inventory:      make([]Item, 0),
        quests:         make(map[string]Quest),
        startTime:      time.Now(),
    }
}

// CreateMemento captures current game state
func (g *Game) CreateMemento() *GameState {
    // Deep copy inventory
    inventoryCopy := make([]Item, len(g.inventory))
    copy(inventoryCopy, g.inventory)

    // Deep copy quests
    questsCopy := make(map[string]Quest)
    for k, v := range g.quests {
        questsCopy[k] = v
    }

    return &GameState{
        PlayerHealth:   g.playerHealth,
        PlayerMaxHealth: g.playerMaxHealth,
        PlayerPosition: g.playerPosition,
        PlayerLevel:    g.playerLevel,
        PlayerXP:       g.playerXP,
        Inventory:      inventoryCopy,
        Quests:         questsCopy,
        EnemiesDefeated: g.enemiesDefeated,
        BossesDefeated: g.bossesDefeated,
        PlayTime:       time.Since(g.startTime),
        SavedAt:        time.Now(),
        Version:        "1.0.0",
    }
}

// RestoreMemento loads game state from memento
func (g *Game) RestoreMemento(memento *GameState) {
    g.playerHealth = memento.PlayerHealth
    g.playerMaxHealth = memento.PlayerMaxHealth
    g.playerPosition = memento.PlayerPosition
    g.playerLevel = memento.PlayerLevel
    g.playerXP = memento.PlayerXP

    // Deep copy inventory
    g.inventory = make([]Item, len(memento.Inventory))
    copy(g.inventory, memento.Inventory)

    // Deep copy quests
    g.quests = make(map[string]Quest)
    for k, v := range memento.Quests {
        g.quests[k] = v
    }

    g.enemiesDefeated = memento.EnemiesDefeated
    g.bossesDefeated = memento.BossesDefeated

    // Adjust start time to maintain play time
    g.startTime = time.Now().Add(-memento.PlayTime)
}

// Game methods for gameplay
func (g *Game) TakeDamage(damage int) {
    g.playerHealth -= damage
    if g.playerHealth < 0 {
        g.playerHealth = 0
    }
    fmt.Printf("Took %d damage! Health: %d/%d\n", damage, g.playerHealth, g.playerMaxHealth)
}

func (g *Game) Heal(amount int) {
    g.playerHealth += amount
    if g.playerHealth > g.playerMaxHealth {
        g.playerHealth = g.playerMaxHealth
    }
    fmt.Printf("Healed %d! Health: %d/%d\n", amount, g.playerHealth, g.playerMaxHealth)
}

func (g *Game) Move(x, y float64) {
    g.playerPosition.X += x
    g.playerPosition.Y += y
    fmt.Printf("Moved to position (%.1f, %.1f)\n", g.playerPosition.X, g.playerPosition.Y)
}

func (g *Game) AddItem(item Item) {
    // Check if item already exists
    for i := range g.inventory {
        if g.inventory[i].ID == item.ID {
            g.inventory[i].Quantity += item.Quantity
            fmt.Printf("Added %dx %s (total: %d)\n", item.Quantity, item.Name, g.inventory[i].Quantity)
            return
        }
    }

    g.inventory = append(g.inventory, item)
    fmt.Printf("Added %dx %s\n", item.Quantity, item.Name)
}

func (g *Game) DefeatEnemy() {
    g.enemiesDefeated++
    g.playerXP += 50
    fmt.Printf("Enemy defeated! (+50 XP, Total: %d enemies)\n", g.enemiesDefeated)

    // Level up check
    if g.playerXP >= 100*g.playerLevel {
        g.LevelUp()
    }
}

func (g *Game) LevelUp() {
    g.playerLevel++
    g.playerMaxHealth += 20
    g.playerHealth = g.playerMaxHealth
    fmt.Printf("🎉 LEVEL UP! Now level %d (Health: %d)\n", g.playerLevel, g.playerMaxHealth)
}

func (g *Game) StartQuest(quest Quest) {
    g.quests[quest.ID] = quest
    fmt.Printf("Started quest: %s\n", quest.Name)
}

func (g *Game) UpdateQuestProgress(questID string, progress int) {
    if quest, exists := g.quests[questID]; exists {
        quest.Progress += progress
        if quest.Progress >= quest.MaxProgress {
            quest.Progress = quest.MaxProgress
            quest.Completed = true
            fmt.Printf("✅ Quest completed: %s\n", quest.Name)
        } else {
            fmt.Printf("Quest progress: %s (%d/%d)\n", quest.Name, quest.Progress, quest.MaxProgress)
        }
        g.quests[questID] = quest
    }
}

func (g *Game) PrintStatus() {
    fmt.Println("\n=== Game Status ===")
    fmt.Printf("Level: %d (XP: %d)\n", g.playerLevel, g.playerXP)
    fmt.Printf("Health: %d/%d\n", g.playerHealth, g.playerMaxHealth)
    fmt.Printf("Position: (%.1f, %.1f)\n", g.playerPosition.X, g.playerPosition.Y)
    fmt.Printf("Enemies Defeated: %d\n", g.enemiesDefeated)
    fmt.Printf("Play Time: %v\n", time.Since(g.startTime).Round(time.Second))

    fmt.Println("\nInventory:")
    for _, item := range g.inventory {
        fmt.Printf("  - %dx %s\n", item.Quantity, item.Name)
    }

    fmt.Println("\nQuests:")
    for _, quest := range g.quests {
        status := "In Progress"
        if quest.Completed {
            status = "Completed"
        }
        fmt.Printf("  - %s: %d/%d [%s]\n", quest.Name, quest.Progress, quest.MaxProgress, status)
    }
    fmt.Println("==================\n")
}

SaveManager: The Caretaker

The caretaker manages memento storage:

// SaveManager handles saving and loading game states
type SaveManager struct {
    saves map[int]*GameState // Save slots
}

func NewSaveManager() *SaveManager {
    return &SaveManager{
        saves: make(map[int]*GameState),
    }
}

// Save game to memory slot
func (sm *SaveManager) SaveToSlot(slot int, state *GameState) {
    state.SaveSlot = slot
    sm.saves[slot] = state
    fmt.Printf("💾 Game saved to slot %d\n", slot)
}

// Load game from memory slot
func (sm *SaveManager) LoadFromSlot(slot int) (*GameState, error) {
    state, exists := sm.saves[slot]
    if !exists {
        return nil, fmt.Errorf("no save in slot %d", slot)
    }
    fmt.Printf("📂 Game loaded from slot %d\n", slot)
    return state, nil
}

// Save to disk using encoding/gob
func (sm *SaveManager) SaveToDisk(filename string, state *GameState) error {
    file, err := os.Create(filename)
    if err != nil {
        return fmt.Errorf("failed to create save file: %w", err)
    }
    defer file.Close()

    encoder := gob.NewEncoder(file)
    if err := encoder.Encode(state); err != nil {
        return fmt.Errorf("failed to encode game state: %w", err)
    }

    fmt.Printf("💾 Game saved to disk: %s\n", filename)
    return nil
}

// Load from disk using encoding/gob
func (sm *SaveManager) LoadFromDisk(filename string) (*GameState, error) {
    file, err := os.Open(filename)
    if err != nil {
        return nil, fmt.Errorf("failed to open save file: %w", err)
    }
    defer file.Close()

    var state GameState
    decoder := gob.NewDecoder(file)
    if err := decoder.Decode(&state); err != nil {
        return nil, fmt.Errorf("failed to decode game state: %w", err)
    }

    fmt.Printf("📂 Game loaded from disk: %s\n", filename)
    return &state, nil
}

// Serialize to bytes (useful for network transfer)
func (sm *SaveManager) SerializeState(state *GameState) ([]byte, error) {
    var buffer bytes.Buffer
    encoder := gob.NewEncoder(&buffer)

    if err := encoder.Encode(state); err != nil {
        return nil, fmt.Errorf("failed to serialize: %w", err)
    }

    return buffer.Bytes(), nil
}

// Deserialize from bytes
func (sm *SaveManager) DeserializeState(data []byte) (*GameState, error) {
    buffer := bytes.NewBuffer(data)
    decoder := gob.NewDecoder(buffer)

    var state GameState
    if err := decoder.Decode(&state); err != nil {
        return nil, fmt.Errorf("failed to deserialize: %w", err)
    }

    return &state, nil
}

// List all save slots
func (sm *SaveManager) ListSaves() {
    fmt.Println("\n=== Save Slots ===")
    if len(sm.saves) == 0 {
        fmt.Println("No saves found")
        return
    }

    for slot, state := range sm.saves {
        fmt.Printf("Slot %d: Level %d, %v playtime, saved %s\n",
            slot, state.PlayerLevel, state.PlayTime.Round(time.Second),
            state.SavedAt.Format("2006-01-02 15:04:05"))
    }
    fmt.Println("==================\n")
}

Undo/Redo with History

Extend the caretaker to support undo/redo:

// HistoryManager manages a timeline of game states
type HistoryManager struct {
    history []*GameState
    current int
}

func NewHistoryManager() *HistoryManager {
    return &HistoryManager{
        history: make([]*GameState, 0),
        current: -1,
    }
}

func (hm *HistoryManager) Push(state *GameState) {
    // Truncate forward history when pushing new state
    hm.history = hm.history[:hm.current+1]
    hm.history = append(hm.history, state)
    hm.current++

    fmt.Printf("📝 Saved to history (position %d)\n", hm.current)
}

func (hm *HistoryManager) Undo() (*GameState, error) {
    if hm.current <= 0 {
        return nil, fmt.Errorf("nothing to undo")
    }

    hm.current--
    fmt.Printf("⏪ Undo to position %d\n", hm.current)
    return hm.history[hm.current], nil
}

func (hm *HistoryManager) Redo() (*GameState, error) {
    if hm.current >= len(hm.history)-1 {
        return nil, fmt.Errorf("nothing to redo")
    }

    hm.current++
    fmt.Printf("⏩ Redo to position %d\n", hm.current)
    return hm.history[hm.current], nil
}

func (hm *HistoryManager) CanUndo() bool {
    return hm.current > 0
}

func (hm *HistoryManager) CanRedo() bool {
    return hm.current < len(hm.history)-1
}

Putting It All Together

func main() {
    fmt.Println("=== Memento Pattern Demo ===\n")

    game := NewGame()
    saveManager := NewSaveManager()
    history := NewHistoryManager()

    // Gameplay session 1
    fmt.Println("--- Session 1: Early Game ---")
    game.Move(5, 10)
    game.AddItem(Item{ID: "sword", Name: "Iron Sword", Quantity: 1})
    game.AddItem(Item{ID: "potion", Name: "Health Potion", Quantity: 3})
    game.StartQuest(Quest{ID: "main_1", Name: "Defeat 5 Goblins", MaxProgress: 5})

    game.DefeatEnemy()
    game.UpdateQuestProgress("main_1", 1)
    game.TakeDamage(30)

    game.PrintStatus()

    // Save to slot 1
    saveManager.SaveToSlot(1, game.CreateMemento())
    history.Push(game.CreateMemento())

    // Continue playing
    fmt.Println("\n--- Session 2: Mid Game ---")
    game.DefeatEnemy()
    game.DefeatEnemy()
    game.UpdateQuestProgress("main_1", 2)
    game.Heal(20)
    game.Move(10, 15)

    game.PrintStatus()

    // Save to slot 2
    saveManager.SaveToSlot(2, game.CreateMemento())
    history.Push(game.CreateMemento())

    // More gameplay
    fmt.Println("\n--- Session 3: Advanced ---")
    game.LevelUp()
    game.AddItem(Item{ID: "potion", Name: "Health Potion", Quantity: 2})
    game.DefeatEnemy()
    game.DefeatEnemy()
    game.UpdateQuestProgress("main_1", 2)

    game.PrintStatus()

    // Save to disk
    saveManager.SaveToDisk("savegame.gob", game.CreateMemento())
    history.Push(game.CreateMemento())

    // List all saves
    saveManager.ListSaves()

    // Undo to previous state
    fmt.Println("\n--- Testing Undo ---")
    if state, err := history.Undo(); err == nil {
        game.RestoreMemento(state)
        game.PrintStatus()
    }

    // Redo
    fmt.Println("\n--- Testing Redo ---")
    if state, err := history.Redo(); err == nil {
        game.RestoreMemento(state)
        game.PrintStatus()
    }

    // Load from slot 1
    fmt.Println("\n--- Load from Slot 1 ---")
    if state, err := saveManager.LoadFromSlot(1); err == nil {
        game.RestoreMemento(state)
        game.PrintStatus()
    }

    // Load from disk
    fmt.Println("\n--- Load from Disk ---")
    if state, err := saveManager.LoadFromDisk("savegame.gob"); err == nil {
        game.RestoreMemento(state)
        game.PrintStatus()
    }

    fmt.Println("=== Demo Complete ===")
}

Advanced: Versioned Saves

Handle save format migrations:

type SaveVersion string

const (
    Version1_0 SaveVersion = "1.0"
    Version2_0 SaveVersion = "2.0"
)

type VersionedGameState struct {
    Version SaveVersion
    Data    interface{}
}

func (sm *SaveManager) SaveVersioned(filename string, state interface{}, version SaveVersion) error {
    versionedState := VersionedGameState{
        Version: version,
        Data:    state,
    }

    // Save with version info
    // Migration logic can check version and transform data
    return nil // Implementation details
}

Benefits of Memento Pattern

  1. Encapsulation: Internal state remains private
  2. Undo/Redo: Easy to implement with history stack
  3. Save/Load: Simple serialization with gob
  4. Testing: Capture states for test fixtures
  5. Debugging: Time-travel debugging by saving snapshots

When to Use Memento

Memento pattern is ideal for:

  • Game save systems
  • Undo/redo functionality
  • Checkpointing in long-running processes
  • Debugging (state snapshots)
  • Rollback mechanisms

Thank you

The Memento pattern with Go’s encoding/gob delivers robust, type-safe state management. Whether you’re building game saves, undo systems, or debugging tools, this pattern ensures your object’s state can be captured and restored without breaking encapsulation.

Please drop an email at [email protected] if you would like to share any feedback or suggestions. Peace!