Verse Wiki — the Verse handbook for Blueprint authors
Chapter 8 · Lesson 28

<persistable> and Cross-Session Data: Make Your World Remember Every Player

Lesson 26 gave you the skeleton of a world; Lesson 27 gave you the craft of putting abilities into it. Chapter 8's last step is time: making data outlive a single session. In plain terms, every player gets their own SaveGame — data lives in a special player-keyed table (declared at the module layer, outside all your classes), and it survives players logging off and coming back next week, coins and progress intact. By the end you'll have built a real cross-session visit counter. 💾

1. Why Persistence: A Variable's Memory Lasts One Session

Take stock of Chapter 8 so far: Lesson 26 settled how a world is composed — entities are containers, components are abilities; Lesson 27 had you write a component by hand and make a platform move on its own. Skeleton: done. Abilities: done. One thing is still missing — memory.

Start with a thought experiment: your world has a coin system, and a player grinds all afternoon to bank 999 coins. The next day he logs on, thrilled — coins: zero. Why? Because that Coins variable you created in the variables panel only lives in this session's memory: the moment the session ends, the variable is wiped along with its value. Every variable we've created so far — including that component's @editable fields last lesson — has this goldfish memory, like a Blueprint with no SaveGame wired up, restarting from default values every match.

Persistence solves exactly this: hand the data to the engine and hang it on the player's account. For a persistent world or a live multiplayer experience this is not optional — a finished match or a dropped connection should not give the world amnesia. The official design is per-player, per-module — each player owns their own independent save under each script set (a module — think of it as this project's Verse code). Before a player joins again, the engine loads that save first; if the load fails, the player is even temporarily barred from joining — that's not a bug, it's a deliberate safeguard: better to make a player wait than to let a blank slate overwrite their old save.

The best part: enabling persistence in Verse takes almost nothing new — no Save Game node, no file I/O. You just have to put a special save table (a weak_map variable, details soon) in the right place. The location itself is the switch. That declarative flavor is the biggest gap between this lesson and Blueprint's SaveGame, and we settle that account properly in the Blueprint Cross-Reference section.

(Everything in this lesson runs on UEFN today — still the only place Verse genuinely runs. But read what follows as "Verse's persistence model" rather than "a UEFN feature": when this language spreads with UE6, the rules won't change just because the editor did.)

2. weak_map(player, t): Location Is the Switch

There is exactly one rule for setting up persistent data: outside all your Blueprint classes (that layer is called module scope), create a variable of type weak_map(player, t) — picture it as a save table keyed by player: each player gets one slot in the table, and the slot holds whatever you want to store (type t, which must be persistable — next section covers which types qualify). No Save checkbox to tick, no extra nodes to wire; as long as this table lives outside the classes, the engine saves it for you automatically:

coin_storage.verse
# ▢ Declaration: must be at module scope (outside the class!)
var PlayerCoins:weak_map(player, int) = map{}

# ▢ Read: subscript access can fail, so it lives in a failure context
if (Coins := PlayerCoins[Player]):
    Print("You have {Coins} coins")

# ▢ Write: set can fail too, so wrap it in an if as well
if (set PlayerCoins[Player] = 100) {}

Reading and writing this save table are both operations that can fail — the same "this wire might not connect" situation you know from the failure-context lessons: the player may have already left, the table may not even have a slot for them yet, so a miss is a perfectly normal dead end, not an error. That's why in the code above the read is wrapped as if (Coins := PlayerCoins[Player]) — it works like a Branch: found takes the "print the coins" wire, not-found quietly takes the other one; the write is the same story — the set in if (set PlayerCoins[Player] = 100) {} is your Set node (wiring the new value 100 into this player's slot), and it can fail too, so it stays wrapped in the Branch; with nothing else to do on success, you close it off with an empty pair of braces {}.

Also memorize this save table's three can'ts: you can't count its slots (no Length), you can't run a ForEach over it slot by slot, and its grip on players is "weak" (once a player leaves, their slot can be reclaimed at any time). In other words, "pull out every player who ever saved and loop over them" is flatly impossible — you can only check players who are currently present, e.g. grab the in-session player array with last lesson's GetPlayspace().GetPlayers(), then ForEach through it and look each one up in the table. For server-wide leaderboards and similar needs, you'll want a different approach (see the extras at the end of this lesson).

3. What Can Go in a Save: Persistable Types and <persistable> Classes

Not every value can be stuffed into a persisted weak_map. Here is the official list of persistable types:

Category Types Condition
Primitive types logic, int, float, string, etc. Usable as-is
Composite types enum, option, array, map, tuple Every element / key / value inside must also be persistable
Custom types Classes with the <persistable> specifier Must be class<final><persistable>, constant fields only

While we're here, let's get the name straight: <persistable> is an angle-bracket specifier — a switch written in the angle brackets after the class name, in the same gang as <final> (no Child Blueprint Classes allowed) and <override>; don't confuse it with @editable (that Instance Editable eye icon on a variable), which is an attribute written above the field and starting with @. Persistable classes come with two hard rules: they must carry <final> (no Child Blueprint Classes), and fields may only be constants — not a single mutable (var) field allowed, and every field's own type must be persistable too.

If the fields are welded-shut constants, how do you "change" anything? The official pattern is replace, not mutate: read out the player's current save instance → assemble a brand-new instance from its old values (with the new value filled into the field you want to change) → then use a Set node to write the whole new instance back into the save table. The snippet below has two keywords carved out — fill them back in to feel the full routine:

player_profile.verse
# Data that will evolve across versions goes in a class (Section 5 explains why)
player_profile := class<final><____>:
    Level:int = 1
    Coins:int = 0

var Profiles:____(player, player_profile) = map{}

# Fields are immutable: update = read the old one, build a new one, swap it whole
AddCoins(Player:player, Amount:int):void =
    if (Old := Profiles[Player]):
        NewProfile := player_profile{ Level := Old.Level, Coins := Old.Coins + Amount }
        if (set Profiles[Player] = NewProfile) {}

Why put up with all this for constant fields? Because save data has to stay alive across versions and sessions for years. Immutability means every write is a complete, self-consistent snapshot — you can never end up with a half-modified save.

4. Hands-On: A Cross-Session Visit Counter

Time to bolt the last three sections together into a minimal but complete persistence feature: counting how many times each player has visited. Returning player: count +1. New player: record a 1. Click "Run next step" and follow a returning player whose save from last session reads 2 — watch the data get read, updated, and written back.

visit_counter_device.verse
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }

# Module scope: the persistent visit count, one entry per player
var VisitCount:weak_map(player, int) = map{}

visit_counter_device := class(creative_device):

    OnBegin<override>()<suspends>:void =
        for (Player : GetPlayspace().GetPlayers()):
            WelcomePlayer(Player)

    WelcomePlayer(Player:player):void =
        var NewCount:int = 1
        if (Old := VisitCount[Player]):
            set NewCount = Old + 1
        if (set VisitCount[Player] = NewCount) {}
        Print("Visit number {NewCount} for this player")
Output log

Click "Run next step" to watch the code execute line by line.

Note that WelcomePlayer only greets the players already present at OnBegin (that is, Event BeginPlay). Real multiplayer experiences also get mid-session joiners — that's exactly what PlayerAddedEvent from Lesson 25 is for: hook it up like a Bind Event to your handler, and when a new player joins, call the same WelcomePlayer on them inside the callback. Same idea, start to finish.

5. Common Pitfalls and Version Evolution: Types Lock the Moment You Publish

Persistence pitfalls come in two flavors: the ones you hit while writing code, and the ones that only blow up after publishing. First, the coding ones:

Now the post-publish ones: the moment you publish, "what type each slot of this save table holds" is locked for good. Every publish after that runs a backward-compatibility check, and if old and new don't line up, the publish fails outright (blocking you just like a Compile error). This is why Section 3 recommended wrapping evolving data in a class — of all the persistable types, only class (your Blueprint class) leaves a door open for adding things later:

What you want to do after publishing Allowed? Countermeasure
Change the value type from int to float, or reshape a struct / tuple ✗ Fails the compatibility check Switch to a class as the carrier at the planning stage
Add a new field to a persistable class ✓ The one and only evolution path The new field must have a default value; old saves pick up the default automatically on load
Delete a persistable class you no longer use ✗ Publish fails Retire the old definition in place in the code; open a new weak_map for the new system
Delete a player's save data ✗ True deletion is impossible Reset only: write the defaults back

Two more things to jot down in your notebook at the planning stage: the number of persistent variables per project (that is, these save tables built outside your classes and keyed by player) is capped — currently at 4. That cap has shifted historically (raised from 2 to 4), so check the official docs of the day before you build. Also, verifying "cross-session persistence" in a UEFN editor test session often produces the false conclusion that it "doesn't work" — real cross-session behavior must be judged from a published version (a private one is fine); editor-session behavior varies between releases, so don't draw conclusions from it.

6. Blueprint Cross-Reference

You have done saving before in Blueprint — it just had a completely different shape. This table lines the two up item by item; the differences column is the point.

What you do in Blueprint How it's written in Verse Difference
Create a SaveGame object Blueprint and declare the variables to store A persistable class: class<final><persistable> Verse's persistable class may hold constant fields only; a SaveGame object's variables are freely mutable
CreateSaveGameObject to build a save instance player_profile{ Level := 1, Coins := 0 } to construct a new instance Both are "make a piece of data"; on the Verse side it's an ordinary class construction expression
SaveGameToSlot (slot name + user index) if (set PlayerCoins[Player] = 100) {} The biggest gap: Verse has no "save" action at all — writing into the table is the save
LoadGameFromSlot plus a Cast back to your save class if (Coins := PlayerCoins[Player]): No Cast, and no None to guard: a miss is simply "this wire doesn't connect", caught by the failure context
DoesSaveGameExist to detect a new player The else branch of that same if Verse merges "does it exist" and "get it out" into a single decision
You manage slot names and user indices yourself The key is player; the engine partitions by account There is no slot concept, so there is no "misspelled the slot name" class of bug
GameInstance variables: survive level changes, die with the process A module-scoped weak_map(player, t) GameInstance is cross-level; a persistent variable is cross-session, cross-day, cross-version — a different order of magnitude
Changing your SaveGame class's shape: you write version numbers and migration code The backward-compatibility check at publish time blocks it automatically Verse turns "you might have broken old saves" from a runtime problem into a publish-time one

Read that table through and the real difference collapses to one sentence: Verse's persistence is declarative. In Blueprint, a save is a chain of nodes you must wire by hand — create the object, fill the fields, SaveGameToSlot, then LoadGameFromSlot next time, Cast, null-check. Miss any link and data quietly vanishes. In Verse you only have to get the data's shape right: a table keyed by player, declared outside the classes, holding persistable types. Get the shape right and saving is automatic — there is no save button, and therefore no "forgot to save" bug.

There is a price, and it's a real one: declarative means you give up control of timing. When data actually lands and how often is the engine's call, and you cannot reach in. In exchange you inherit a batch of new constraints — constant fields, types locked at publish, a cap on the number of save tables, no iteration. The whole pitfall list in section 5 is, at bottom, the invoice for handing over control.

One mapping to get right: Blueprint's closest thing to "cross-session data" is the SaveGame object, not a GameInstance variable. GameInstance only guarantees data survives a level change; close the process and it's gone. What genuinely outlives today is what got written to disk or a backend. Verse's persistent variables correspond to the latter.

7. Challenge Gauntlet

Time to inspect your save system. Three challenges, zero penalty for wrong answers, retry as often as you like.

For VisitCount, a weak_map(player, int), to genuinely persist across sessions, where should it be declared?

player_profile is a class<final><persistable>. You want to add 10 to a player's Coins. The right move is?

Your project is already published. Which of these save-structure changes passes the backward-compatibility check?

Further Reading

Level up · EXTRA

Designing Saves as a Production System

A close read of Epic's Verse Persistence Best Practices: load-failure protection, the 256 KB budget, and a pre-launch checklist.

Open the extra →

Technique · EXTRA

The Persistable Class You Can't Delete After Publishing

A version-evolution war story: why old persistable classes must not be deleted, and the retirement plan of opening a parallel weak_map.

Open the extra →

Deep dive · EXTRA

End-to-End Build: A Persistent Stats System

Epic's Persistent Player Statistics tutorial plus a comprehensive community guide — turning this lesson's knowledge into a shippable feature.

Open the extra →