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:
- Originator: The object whose state we’re saving
- Memento: A snapshot of the originator’s state
- Caretaker: Manages mementos (save/load/history)
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
- Encapsulation: Internal state remains private
- Undo/Redo: Easy to implement with history stack
- Save/Load: Simple serialization with gob
- Testing: Capture states for test fixtures
- 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!