Skip to content

Persistable Types

Persistable types allow you to store data that persists beyond the current game session. This is essential for saving player progress, preferences, and other game state that should be maintained across multiple play sessions.

Persistable data is stored using module-scoped weak_map(player, t) variables, where t is any persistable type. When a player joins a game, their previously saved data is automatically loaded into all module-scoped variables of type weak_map(player, t).

using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
using { /Verse.org/Simulation }

# Global persistable variable storing player data
MySavedPlayerData : weak_map(player, int) = map{}

# Initialize data for a player if not already present
InitializePlayerData(Player : player) : void =
    if (not MySavedPlayerData[Player]):
        if (set MySavedPlayerData[Player] = 0) {}

Built-in Persistable Types

The following primitive types are persistable by default:

  • Numeric Types:

  • logic - Boolean values (true/false)

  • int - Integer values (currently 64-bit signed)
  • float - Floating-point numbers

  • Character Types:

  • string - Text values

  • char - Single UTF-8 character
  • char32 - Single UTF-32 character

  • Container Types:

  • array - Persistable if element type is persistable

  • map - Persistable if both key and value types are persistable
  • option - Persistable if the wrapped type is persistable
  • tuple - Persistable if all element types are persistable

Custom Persistable Types

You can create custom persistable types using the <persistable> specifier with classes, structs, and enums.

Classes must meet specific requirements to be persistable:

player_profile_data := class<final><persistable>:
    Version:int = 1
    Class:player_class = player_class.Villager
    XP:int = 0
    Rank:int = 0
    CompletedQuestCount:int = 0

Requirements for persistable classes:

  • Must have the <persistable> specifier
  • Must be <final> (no subclasses allowed)
  • Cannot be <unique>
  • Cannot have a superclass (including interfaces)
  • Cannot be parametric (generic)
  • Can only contain persistable field types
  • Cannot have variable members (var fields)
  • Field initializers must be effect-free (cannot use <transacts>, <decides>, etc.)

Structs are ideal for simple data structures that won't change after publication:

coordinates := struct<persistable>:
    X:float = 0.0
    Y:float = 0.0

Requirements for persistable structs:

  • Must have the <persistable> specifier
  • Cannot be parametric (generic)
  • Can only contain persistable field types (see Prohibited Field Types below)
  • Field initializers must be effect-free (cannot use <transacts>, <decides>, etc.)
  • Cannot be modified after island publication

Enums represent a fixed set of named values:

day := enum<persistable>:
    Monday
    Tuesday
    Wednesday
    Thursday
    Friday
    Saturday
    Sunday

Important notes:

  • Closed persistable enums cannot be changed to open after publication
  • Open persistable enums can have new values added after publication

Prohibited Field Types

Persistable types have strict restrictions on what field types they can contain. The following types cannot be used as fields in persistable classes or structs:

  • Abstract and Dynamic Types:

  • any - Cannot be persisted (too dynamic)

  • comparable - Abstract interface type
  • type - Type values cannot be persisted

  • Non-Serializable Types:

  • rational - Exact rational numbers (not persistable)

  • Function types (e.g., int -> int) - Functions cannot be serialized
  • weak_map - Weak references are not persistable
  • Interface types - Abstract interfaces cannot be persisted

  • Non-Persistable User Types

  • Non-persistable enums - Enums without <persistable> specifier cannot be used

  • Non-persistable classes - Classes without <persistable> specifier cannot be used
  • Non-persistable structs - Structs without <persistable> specifier cannot be used

Example

Initializing Player Data:

# Define a persistable player stats structure
player_stats := struct<persistable>:
    Level:int = 1
    Experience:int = 0
    GamesPlayed:int = 0

# Global persistent storage
PlayerData : weak_map(player, player_stats) = map{}

# Initialize or retrieve player data
GetOrCreatePlayerStats(Player : player) : player_stats =
    if (ExistingStats := PlayerData[Player]):
        ExistingStats
    else:
        NewStats := player_stats{}
        if (set PlayerData[Player] = NewStats):
            NewStats
        else:
            player_stats{}  # Fallback

JSON Serialization

Verse provides JSON serialization functions for persistable types, enabling manual serialization and deserialization of data. While the primary persistence mechanism uses weak_map(player, t) for automatic player data, JSON serialization can be useful for debugging, data migration, or integration with external systems.

Converts a persistable value to JSON string:

player_data := class<final><persistable>:
    Level:int = 1
    Score:int = 100

Data := player_data{Level := 5, Score := 250}
JsonString := Persistence.ToJson[Data]
# Produces: {"$package_name":"/...", "$class_name":"player_data", "x_Level":5, "x_Score":250}

Deserializes JSON string to typed value:

JsonString := "{\"$package_name\":\"/.../\", \"$class_name\":\"player_data\", \"x_Level\":10, \"x_Score\":500}"
if (Restored := Persistence.FromJson[JsonString, player_data]):
    # Restored.Level = 10
    # Restored.Score = 500

All serialized persistable objects include metadata fields:

{
  "$package_name": "/SolIdeDataSources/_Verse",
  "$class_name": "player_data",
  "x_Level": 5,
  "x_Score": 250
}

Metadata fields:

  • $package_name - Package path of the type
  • $class_name - Qualified class/struct name

Field names:

  • Prefixed with x_ in current format
  • Old format used mangled names like i___verse_0x123_FieldName

Type-Specific Serialization

Primitives:

int_ref := class<final><persistable>:
    Value:int

# Serialized as JSON number
JsonString := Persistence.ToJson[int_ref{Value := 42}]
# {"$package_name":"...", "$class_name":"int_ref", "x_Value":42}

Optional types:

optional_ref := class<final><persistable>:
    Value:?int

# None serialized as false
Persistence.ToJson[optional_ref{Value := false}]
# {..., "x_Value":false}

# Some serialized as object with empty key
Persistence.ToJson[optional_ref{Value := option{42}}]
# {..., "x_Value":{"":42}}

Tuples:

tuple_ref := class<final><persistable>:
    Pair:tuple(int, int)

# Serialized as JSON array
Persistence.ToJson[tuple_ref{Pair := (4, 5)}]
# {..., "x_Pair":[4,5]}

# Empty tuple
empty_tuple_ref := class<final><persistable>:
    Empty:tuple()

Persistence.ToJson[empty_tuple_ref{Empty := ()}]
# {..., "x_Empty":[]}

Arrays:

array_ref := class<final><persistable>:
    Numbers:[]int

Persistence.ToJson[array_ref{Numbers := array{1, 2, 3}}]
# {..., "x_Numbers":[1,2,3]}

Maps:

map_ref := class<final><persistable>:
    Lookup:[string]int

Persistence.ToJson[map_ref{Lookup := map{"a" => 1, "b" => 2}}]
# {..., "x_Lookup":[{"k":{"":"a"},"v":{"":1}}, {"k":{"":"b"},"v":{"":2}}]}

Enums:

day := enum<persistable>:
    Monday
    Tuesday

enum_ref := class<final><persistable>:
    Day:day

Persistence.ToJson[enum_ref{Day := day.Monday}]
# {..., "x_Day":"day::Monday"}

Default Value Handling

When deserializing, missing fields are automatically filled with their default values:

versioned_data := class<final><persistable>:
    Version:int = 1
    NewField:int = 0  # Added in v2

# Old JSON without NewField
OldJson := "{\"$package_name\":\"...\", \"$class_name\":\"versioned_data\", \"x_Version\":1}"

# Deserializes successfully with default for NewField
if (Data := Persistence.FromJson[OldJson, versioned_data]):
    Data.Version = 1
    Data.NewField = 0  # Uses default value

This enables forward-compatible schema evolution - new fields with defaults can be added without breaking old saved data.

Block Clauses During Deserialization

Block clauses do not execute when deserializing from JSON:

logged_class := class<final><persistable>:
    Value:int
    block:
        Print("Constructed!")

# Normal construction triggers block
Instance1 := logged_class{Value := 1}  # Prints "Constructed!"

# Deserialization does NOT trigger block
Json := Persistence.ToJson[Instance1]
Instance2 := Persistence.FromJson[Json, logged_class]  # No print

Block clauses are only executed during normal construction, not during deserialization. This means initialization logic in blocks won't run for loaded data.

Integer Range Limitations

Verse protects against integer overflow during serialization. Integers that exceed the safe serialization range cause runtime errors:

int_ref := class<final><persistable>:
    Value:int

# Safe range integers work fine
SafeData := int_ref{Value := 1000000000000000000}
Persistence.ToJson[SafeData]  # OK

# Overflow protection - runtime error for very large integers
var BigInt:int = 1
for (I := 1..63):
    set BigInt *= 2

# Runtime error: Integer too large for safe serialization
# Persistence.ToJson[int_ref{Value := BigInt}]

This prevents silent precision loss that could occur with floating-point representation of large integers.

Backward Compatibility

The serialization system maintains backward compatibility with older JSON formats:

Field name migration:

# Old format (V1) with mangled names
OldJson := "{\"$package_name\":\"...\", \"i___verse_0x123_Value\":42}"

# Deserializes correctly
Data := Persistence.FromJsonV1[OldJson, int_ref]

# Re-serializes with new format
NewJson := Persistence.ToJson[Data]
# {"$package_name":"...", "x_Value":42}

Best Practices

  • Schema Stability: Design your persistable types carefully, as they cannot be easily changed after publication. Consider versioning strategies for future updates.

  • Use Structs for Simple Data: For data that won't need inheritance or complex behavior, prefer persistable structs over classes.

  • Handle Missing Data: Always check if data exists for a player before accessing it, and provide appropriate defaults.

  • Atomic Updates: When updating persistent data, create new instances rather than trying to modify existing ones (Verse uses immutable data structures).

  • Consider Memory Usage: Persistent data is loaded for all players when they join, so be mindful of the amount of data stored per player.

Example: Player Profile System

using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }

# Player class enum
player_class := enum<persistable>:
    Warrior
    Mage
    Archer
    Rogue

# Achievement data
achievement := struct<persistable>:
    Name:string = ""
    Completed:logic = false
    CompletedDate:int = 0  # Timestamp

# Complete player profile
player_profile := class<final><persistable>:
    Username:string = "Player"
    Level:int = 1
    Experience:int = 0
    SelectedClass:player_class = player_class.Warrior
    TotalPlayTime:float = 0.0
    Achievements:[]achievement = array{}

# Global player profiles
PlayerProfiles : weak_map(player, player_profile) = map{}

# Profile management device
profile_manager := class(creative_device):

    OnBegin<override>()<suspends>:void =
        # Initialize all players
        AllPlayers := GetPlayspace().GetPlayers()
        for (Player : AllPlayers):
            InitializeProfile(Player)

    InitializeProfile(Player : player) : void =
        if (not PlayerProfiles[Player]):
            DefaultProfile := player_profile{}
            set PlayerProfiles[Player] = DefaultProfile

This demonstrates how to create and manage persistable player data, ensuring that player progress and achievements are maintained across game sessions.