reckfellENGINE

World snapshots: IDs, data and references

Restore several objects without partially applying an invalid file.

On this page

What this example saves

Position, health and one reference between existing level objects. A snapshot contains format, level and records with id, position, health, link. Coordinates are global; position is an array of three numbers. Assign door and switch IDs in the editor and retain them between runs.

Do not use GetInstanceId() or hierarchy paths as persistent IDs: the former changes between processes, the latter when restructuring the level. Do not give every prefab instance the same ID.

Prepare the level

  1. Create an Entity3D with WorldDemo.cs. Add two child Entity3D objects named Door and Switch, both with SavedActor.cs.
  2. Set Door.SaveId = door and Switch.SaveId = switch. Give them distinct positions and Health in 0…100. Add meshes and a camera to observe them, or inspect properties through the Remote Inspector.
  3. Add WorldSnapshot.cs and FileDemo.cs to the project. Do not attach FileDemo to this level; only its static methods are used.
  4. Add Input Map actions save_game on F2 and load_game on F3. Build, run and press F2. Expect Save world: Ok.
  5. Change object positions and health through the Remote Inspector, then press F3. Values return and Output prints Load world: Ok. Stop and restart the game; F3 should restore the same slot.

Code sets the initial Switch.LinkedActor → Door link. Loading resolves the reference by ID rather than storing a C# object address.

SavedActor.cs
C#
using Reckfell;

public partial class SavedActor : Entity3D
{
    [Export] public string SaveId { get; set; } = "";
    [Export] public int Health { get; set; } = 100;
    public SavedActor? LinkedActor { get; set; }
}
WorldSnapshot.cs
C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Reckfell;

public static class WorldSnapshot
{
    private sealed class Snapshot
    {
        public required int Format { get; set; }
        public required string Level { get; set; }
        public required List<Record> Objects { get; set; }
    }
    private sealed class Record
    {
        public required string Id { get; set; }
        public required float[] Position { get; set; }
        public required int Health { get; set; }
        public required string Link { get; set; }
    }
    private static readonly JsonSerializerOptions Options = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
    };

    private static bool ValidTarget(SavedActor actor) =>
        ReckfellObject.IsInstanceValid(actor) && actor.IsInsideTree() &&
        !actor.IsQueuedForDeletion() && !string.IsNullOrWhiteSpace(actor.SaveId) && actor.SaveId.Length <= 64;

    private static bool ValidActor(SavedActor actor) => ValidTarget(actor) &&
        actor.GlobalPosition.IsFinite() && actor.Health >= 0 && actor.Health <= 100;

    private static int Depth(Entity entity)
    {
        int depth = 0;
        for (Entity? parent = entity.GetParent(); parent is not null; parent = parent.GetParent())
            depth++;
        return depth;
    }

    public static Error Capture(string level, IReadOnlyList<SavedActor> actors, out string json)
    {
        json = "";
        if (string.IsNullOrWhiteSpace(level) || level.Length > 128 || actors.Count > 1000)
            return Error.InvalidData;
        var ids = new HashSet<string>(StringComparer.Ordinal);
        foreach (SavedActor actor in actors)
            if (!ValidActor(actor) || !ids.Add(actor.SaveId))
                return Error.InvalidData;
        var records = new List<Record>();
        foreach (SavedActor actor in actors)
        {
            string link = "";
            if (actor.LinkedActor is not null)
            {
                if (!ValidActor(actor.LinkedActor) || !actors.Contains(actor.LinkedActor))
                    return Error.InvalidData;
                link = actor.LinkedActor.SaveId;
            }
            Vector3 p = actor.GlobalPosition;
            records.Add(new Record { Id = actor.SaveId, Position = [p.X, p.Y, p.Z], Health = actor.Health, Link = link });
        }
        json = JsonSerializer.Serialize(new Snapshot { Format = 1, Level = level, Objects = records }, Options);
        if (json.Length > 1024 * 1024)
        {
            json = "";
            return Error.InvalidData;
        }
        return Error.Ok;
    }

    public static Error Apply(string json, string level, IReadOnlyList<SavedActor> actors)
    {
        if (json.Length > 1024 * 1024 || actors.Count > 1000 || string.IsNullOrWhiteSpace(level))
            return Error.InvalidData;
        Snapshot? snapshot;
        try
        {
            snapshot = JsonSerializer.Deserialize<Snapshot>(json, Options);
        }
        catch (JsonException)
        {
            return Error.InvalidData;
        }
        if (snapshot is null || snapshot.Format != 1 || snapshot.Level != level ||
            snapshot.Objects is null || snapshot.Objects.Count != actors.Count)
            return Error.InvalidData;
        var current = new Dictionary<string, SavedActor>(StringComparer.Ordinal);
        foreach (SavedActor actor in actors)
            if (!ValidTarget(actor) || !current.TryAdd(actor.SaveId, actor))
                return Error.InvalidData;
        var restored = new HashSet<string>(StringComparer.Ordinal);
        foreach (Record record in snapshot.Objects)
        {
            if (record is null || string.IsNullOrWhiteSpace(record.Id) ||
                !restored.Add(record.Id) || !current.ContainsKey(record.Id) ||
                record.Position is null || record.Position.Length != 3 ||
                record.Position.Any(v => !float.IsFinite(v)) ||
                record.Health < 0 || record.Health > 100 || record.Link is null ||
                (record.Link != "" && !current.ContainsKey(record.Link)))
                return Error.InvalidData;
        }
        foreach (Record record in snapshot.Objects.OrderBy(record => Depth(current[record.Id])))
        {
            SavedActor actor = current[record.Id];
            actor.GlobalPosition = new Vector3(record.Position[0], record.Position[1], record.Position[2]);
            actor.Health = record.Health;
            actor.ResetPhysicsInterpolation();
        }
        foreach (Record record in snapshot.Objects)
            current[record.Id].LinkedActor = record.Link == "" ? null : current[record.Link];
        return Error.Ok;
    }
}
WorldDemo.cs
C#
using Reckfell;

public partial class WorldDemo : Entity3D
{
    private const string Path = "user://io-demo/world.json";
    private const string Level = "harbor";
    private SavedActor[] _actors = [];

    public override void _Ready()
    {
        _actors = [GetEntity<SavedActor>("Door"), GetEntity<SavedActor>("Switch")];
        _actors[1].LinkedActor = _actors[0];
    }

    public Error Save()
    {
        Error error = WorldSnapshot.Capture(Level, _actors, out string json);
        return error == Error.Ok ? FileDemo.WriteText(Path, json) : error;
    }

    public Error Load()
    {
        Error error = FileDemo.ReadText(Path, out string json);
        return error == Error.Ok ? WorldSnapshot.Apply(json, Level, _actors) : error;
    }

    public override void _UnhandledInput(InputEvent @event)
    {
        if (@event.IsActionPressed("save_game"))
            ReckfellApi.Print($"Save world: {Save()}");
        else if (@event.IsActionPressed("load_game"))
            ReckfellApi.Print($"Load world: {Load()}");
        else
            return;
        GetViewport().SetInputAsHandled();
    }
}

Validate before changing the world

Apply() first validates the entire JSON: version, level, complete unique ID set, three finite coordinates, health range and every link target. Only then does it change values, applying parents before descendants so global coordinates do not shift again. Another pass resolves links. An unknown ID, unsupported schema or corrupt field returns InvalidData without partially modifying the level.

The list must contain ready, nonqueued objects in the tree. Call these methods on the main thread while gameplay is not changing the object roster. Limits are 1000 objects, 64 characters per ID, 128 characters for a captured level name and 1,048,576 JSON characters. The file helper separately limits reads to 1 MiB of UTF-8 bytes. Serialization uses System.Text.Json; code defines property names and required fields instead of relying on implicit Variant conversions.

Extend for your game

This is a fixed roster of existing objects. It does not spawn or remove dynamic entities, switch levels, save inventory, physics velocities, quests or rotation. Define those explicitly in your schema. For dynamic objects, add a permitted kind from a prefab registry; create every missing instance before resolving links and remove objects absent from the snapshot. Do not load arbitrary prefab paths from a file without checking the registry.

For several levels, first validate the file and its level, load the correct level and await SceneChanged, then restore objects. Teleporting a physics character also needs interpolation reset and a consistent velocity policy. PackedEntity.Pack() does not replace a progress schema; it packs stored hierarchy properties, not arbitrary state of every game system.

WorldDemo writes directly. Use backup-based writing for a production slot. On read failure, report the error and retain the old slot; do not automatically Save() initial values. Schema changes need explicit migration, not a new engine version.

Diagnostics

ResultCorrection
FileNotFoundSave a slot first; compare the user:// directory
InvalidData while savingEmpty/duplicate ID, invalid health, nonfinite position or a reference outside the list
InvalidData while loadingVersion, level, field types, ID roster and links must match the contract
Object restores to the wrong placeThese are global coordinates; do not apply them as local coordinates
Gameplay immediately overwrites loaded valuesSuspend it during restoration and refresh system state after loading
Reckfell Engine documentationBack to top ↑
QUICK LINKSGetting startedEntity hierarchyC# & .NET 10Scenes and prefabs
Reckfell guides and full API K
reckfellENGINE
RECKFELL ACCOUNT

Sign in

Sign in to your Reckfell account.

One account for the entire Reckfell platform.