Back to blog
Your FirstPart 4 of 9

Sunday, April 19, 2026

Your First Auto Save

cover

Starting Point

The last post gave us a durable PlayerProfileVault.

That is a good start, but saving directly to the database every time the player moves or gains a little gold is the wrong runtime shape.

This post upgrades the profile flow to autosave:

  • hot-path updates stay cache-first
  • only dirty entities are flushed
  • disconnects still force a safe save

1. Mark the Model for Autosave

Update Persistence/PlayerProfileVault.cs:

using Altruist;
using Altruist.Gaming.Autosave;
using Altruist.UORM;

namespace SpaceGame.Persistence;

[Autosave(60, AutosaveCycle.Seconds, BatchSize = 100)]
[Vault("player_profiles")]
[VaultUniqueKey(nameof(Username))]
public sealed class PlayerProfileVault : VaultModel
{
    [VaultColumn("username")]
    public string Username { get; set; } = "";

    [VaultColumn("display_name")]
    public string DisplayName { get; set; } = "";

    [VaultColumn("level")]
    public int Level { get; set; } = 1;

    [VaultColumn("gold")]
    public int Gold { get; set; } = 100;

    [VaultColumn("last_x")]
    public float LastX { get; set; }

    [VaultColumn("last_y")]
    public float LastY { get; set; }
}

Now the framework can create IAutosaveService<PlayerProfileVault> for this model automatically.

2. Split Creation from Runtime Mutation

Create Services/PlayerRuntimeProfileService.cs:

using Altruist;
using Altruist.Gaming.Autosave;
using SpaceGame.Persistence;

namespace SpaceGame.Services;

[Service]
public sealed class PlayerRuntimeProfileService
{
    private readonly IAutosaveService<PlayerProfileVault> _autosave;
    private readonly IVault<PlayerProfileVault> _vault;

    public PlayerRuntimeProfileService(
        IAutosaveService<PlayerProfileVault> autosave,
        IVault<PlayerProfileVault> vault)
    {
        _autosave = autosave;
        _vault = vault;
    }

    public async Task<PlayerProfileVault> LoadOrCreateAsync(
        string profileId,
        string username,
        string displayName)
    {
        var cached = await _autosave.LoadAsync(profileId);
        if (cached != null)
            return cached;

        var existing = await _vault
            .Where(x => x.StorageId == profileId)
            .FirstOrDefaultAsync();

        if (existing != null)
            return existing;

        var created = new PlayerProfileVault
        {
            StorageId = profileId,
            Username = username,
            DisplayName = displayName,
            Level = 1,
            Gold = 100,
        };

        await _vault.SaveAsync(created);
        return created;
    }

    public void TrackPosition(PlayerProfileVault profile, PlayerPrefab player)
    {
        profile.LastX = player.Transform.Position.X;
        profile.LastY = player.Transform.Position.Z;
        _autosave.MarkDirty(profile);
    }

    public void AwardGold(PlayerProfileVault profile, int amount)
    {
        profile.Gold += amount;
        _autosave.MarkDirty(profile);
    }

    public Task FlushProfileAsync(string profileId)
    {
        return _autosave.FlushByOwnerAsync(profileId);
    }
}

This is the key rule:

  • IVault<T> is for first creation and deeper queries
  • IAutosaveService<T> is for hot runtime mutations
  • root/self-owned profile rows should use MarkDirty(profile)
  • explicit ownerId is for grouped child records that flush under some other root

3. Use Autosave in the Portal

Update Portals/GamePortal.cs:

private readonly PlayerRuntimeProfileService _runtimeProfiles;

public GamePortal(
    IGameSessionService gameSessionService,
    IAltruistRouter router,
    PlayerRuntimeProfileService runtimeProfiles,
    ILoggerFactory loggerFactory)
    : base(gameSessionService, router)
{
    _runtimeProfiles = runtimeProfiles;
    _logger = loggerFactory.CreateLogger<GamePortal>();
}

Load the profile on join:

var profileId = message.Name.ToLowerInvariant();
var profile = await _runtimeProfiles.LoadOrCreateAsync(
    profileId,
    message.Name,
    message.Name);

session.SetContext(clientId, profile);

Mark runtime movement as dirty instead of immediately saving:

[Gate("input")]
public Task OnInput(PlayerInputPacket packet, string clientId)
{
    var session = _gameSessionService.GetSession(clientId);
    var profile = session?.GetContext<PlayerProfileVault>(clientId);
    var player = GetPlayer(clientId);
    if (profile == null || player == null)
        return Task.CompletedTask;

    player.SetInput(packet);
    _runtimeProfiles.TrackPosition(profile, player);
    return Task.CompletedTask;
}

And flush on disconnect:

public override async Task OnDisconnectedAsync(string clientId, Exception? exception)
{
    var session = _gameSessionService.GetSession(clientId);
    var profile = session?.GetContext<PlayerProfileVault>(clientId);
    if (profile != null)
        await _runtimeProfiles.FlushProfileAsync(profile.StorageId);

    await base.OnDisconnectedAsync(clientId, exception);
}

4. Why This Is Better Than Direct Saves

With this change:

  • movement can update profiles many times without hammering the DB
  • the cache always has the latest runtime copy
  • disconnect still forces a safe write

This is the first place where the project starts feeling like a real game backend instead of an ordinary CRUD app.

Next Up

Now that we have durable and runtime-friendly player data, we can layer real gameplay state on top of those already-synchronized players: