Entity Serialization and Persistence
The Bappa Framework’s warehouse package includes a powerful serialization system that allows you to convert entities and their components to JSON and back. This enables save/load functionality, game state persistence, and data transfer between different parts of your application.
Serialization Overview
The serialization system provides two main capabilities:
- Entity Serialization: Convert entities and their components to a JSON-compatible format
- Storage Serialization: Save and load entire entity collections to/from disk
The system handles component type registration, component data conversion, and entity reference preservation automatically, making it straightforward to implement save/load functionality in your games.
Basic Usage
Saving Game State
To save your game’s entity state to a file:
// Save all entities in the storage to a file
err := warehouse.SaveStorage(storage, "savegame.json", currentTick)
if err != nil {
log.Fatalf("Failed to save game: %v", err)
}
The SaveStorage
function serializes the entire storage (including all entities and their components) and writes it to the specified file. The currentTick
parameter allows you to store the current game tick for time-based systems.
Loading Game State
To load a previously saved game state:
// Load serialized data from a file
serializedWorld, err := warehouse.LoadStorage("savegame.json")
if err != nil {
log.Fatalf("Failed to load game: %v", err)
}
// Create a new storage or use an existing one
schema := table.Factory.NewSchema()
storage := warehouse.Factory.NewStorage(schema)
// Deserialize the data into the storage
storage, err = warehouse.DeserializeStorage(storage, serializedWorld)
if err != nil {
log.Fatalf("Failed to deserialize game: %v", err)
}
Advanced Serialization Features
Selective Entity Serialization
Sometimes you may want to serialize only specific entities or components. The SerializeInclude
and SerializeExclude
methods allow for more granular control:
// Serialize an entity including only specific components
serializedEntity := entity.SerializeInclude(
positionComponent,
healthComponent,
)
// Serialize an entity excluding specific components
serializedEntity := entity.SerializeExclude(
temporaryEffectsComponent,
debugComponent,
)
Non-Destructive Updates
The deserialization system supports non-destructive updates, meaning you can update existing entities rather than replacing them entirely. This is particularly useful for implementing:
- Partial saves/loads
- Incremental state updates
- Mod patches that modify specific entities
// Deserialize without purging existing entities not in the serialized data
storage, err = warehouse.DeserializeStorageNoPurge(storage, serializedWorld)
Entity References
The serialization system automatically handles entity references within components. If a component stores a reference to another entity (via its EntryID
), that reference will be correctly maintained when deserializing:
// Example component with an entity reference
type Relationship struct {
ParentID table.EntryID
}
When serializing and deserializing, the entity IDs are preserved, ensuring that relationships between entities remain intact.
Type Registration
The framework automatically registers component types when they’re created. This registration is essential for the serialization system to properly map component types during deserialization.
// Component types are registered automatically when created
positionComp := warehouse.FactoryNewComponent[Position]()
velocityComp := warehouse.FactoryNewComponent[Velocity]()
If you create multiple components with the same underlying structure, ensure they use type aliases to distinguish them during serialization:
// Use type aliases to distinguish similar components
type Position struct { X, Y float64 }
type StartPosition Position // Alias type
Serialization Format
The serialized data is stored in JSON format with the following structure:
{
"version": "1.0",
"current_tick": 1234,
"entities": [
{
"id": 1,
"recycled": 0,
"components": ["Position", "Velocity"],
"data": {
"Position": { "X": 10.0, "Y": 20.0 },
"Velocity": { "X": 1.0, "Y": 2.0 }
}
},
{
"id": 5,
"recycled": 0,
"components": ["Position", "Health", "Player"],
"data": {
"Position": { "X": 50.0, "Y": 60.0 },
"Health": { "Current": 100, "Max": 100 },
"Player": {}
}
}
]
}
Performance Considerations
When working with serialization, keep these performance considerations in mind:
File Size: The JSON format prioritizes readability over compactness. For very large game states, consider implementing compression.
Serialization Frequency: Frequent serialization of large storages can impact performance. Consider serializing only when necessary (e.g., during explicit save points).
Selective Serialization: Use the selective serialization methods to reduce the amount of data being processed.
Example: Implementing Save/Load in a Game
Here’s a complete example showing how to implement save/load functionality in a simple game:
package main
import (
"log"
"os"
"github.com/TheBitDrifter/bappa/blueprint/vector"
"github.com/TheBitDrifter/bappa/table"
"github.com/TheBitDrifter/bappa/warehouse"
)
// Game components
type Position struct {
X, Y float64
}
type Health struct {
Current, Max int
}
type PlayerTag struct{}
func main() {
// Create storage
schema := table.Factory.NewSchema()
storage := warehouse.Factory.NewStorage(schema)
// Create components
posComp := warehouse.FactoryNewComponent[Position]()
healthComp := warehouse.FactoryNewComponent[Health]()
playerTagComp := warehouse.FactoryNewComponent[PlayerTag]()
// Create a player entity
playerEntities, err := storage.NewEntities(1, posComp, healthComp, playerTagComp)
if err != nil {
log.Fatalf("Failed to create player: %v", err)
}
player := playerEntities[0]
// Set initial values
pos := posComp.GetFromEntity(player)
health := healthComp.GetFromEntity(player)
pos.X = 100
pos.Y = 200
health.Current = 100
health.Max = 100
// Save the game
err = warehouse.SaveStorage(storage, "savegame.json", 0)
if err != nil {
log.Fatalf("Failed to save game: %v", err)
}
log.Println("Game saved successfully!")
// Simulate player movement and damage
pos.X += 50
pos.Y += 25
health.Current -= 30
// Load the saved game (which will revert the changes)
serializedWorld, err := warehouse.LoadStorage("savegame.json")
if err != nil {
log.Fatalf("Failed to load game: %v", err)
}
// Create a new storage for the loaded data
newSchema := table.Factory.NewSchema()
loadedStorage := warehouse.Factory.NewStorage(newSchema)
// Deserialize the data
loadedStorage, err = warehouse.DeserializeStorage(loadedStorage, serializedWorld)
if err != nil {
log.Fatalf("Failed to deserialize game: %v", err)
}
log.Println("Game loaded successfully!")
// Verify the loaded data
loadedPlayer, err := loadedStorage.Entity(int(player.ID()))
if err != nil {
log.Fatalf("Failed to get player: %v", err)
}
loadedPos := posComp.GetFromEntity(loadedPlayer)
loadedHealth := healthComp.GetFromEntity(loadedPlayer)
log.Printf("Loaded player position: (%v, %v)", loadedPos.X, loadedPos.Y)
log.Printf("Loaded player health: %v/%v", loadedHealth.Current, loadedHealth.Max)
}
Internal Implementation Details
PrepareForJSONMarshal
The PrepareForJSONMarshal
function handles special cases during serialization:
// Convert Go structures to JSON-compatible formats
stateForJson, err := warehouse.PrepareForJSONMarshal(serSto)
if err != nil {
return nil, err
}
// Now safe to marshal to JSON
jsonData, err := json.Marshal(stateForJson)
This function:
- Handles special float values like
Infinity
andNaN
- Converts structs to maps for JSON compatibility
- Processes nested structures recursively
ForceSerializedEntity
For advanced use cases where you need to create entities with specific IDs:
// Create or update an entity with the exact ID from serialized data
entity, err := storage.ForceSerializedEntity(serializedEntity)
This method ensures that entity IDs are preserved during deserialization, which is essential for maintaining entity references.
Best Practices
Component Design: Design your components with serialization in mind. Use basic Go types that can be easily converted to/from JSON.
Error Handling: Always check for errors during serialization and deserialization. File I/O can fail for various reasons.
Versioning: Add version information to your saved data to handle format changes over time.
Testing: Thoroughly test your serialization code, especially with complex component structures and entity relationships.
Security: If loading save files from untrusted sources, implement validation to prevent potential exploits.
Conclusion
The warehouse serialization system provides a robust foundation for implementing save/load functionality in your Bappa games. By leveraging these capabilities, you can create games with persistent state, implement save points, and even support modding through external data files.
For more advanced serialization needs, you can extend the system with custom component serialization, compression, or encryption as required for your specific game.