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 queriesIAutosaveService<T>is for hot runtime mutations- root/self-owned profile rows should use
MarkDirty(profile) - explicit
ownerIdis 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:
Continue the Series