Starting Point
At this point in the series, you already have:
- a working multiplayer portal
- real player entities in a shared world
- sync patches for movement and facing
- durable player profile data from vault and autosave
Now we add the first system that makes those players feel alive: resource regeneration.
The goal is simple:
- each player has
HP,SP, andMP - those values are synced like any other entity state
- stamina and mana can be spent immediately
- health, stamina, and mana recover over time
1. Extend the Player Prefab
Update World/PlayerPrefab.cs:
using System.Numerics;
using Altruist.Gaming;
using Altruist.Gaming.ThreeD;
using Altruist.Networking;
using Altruist.ThreeD.Numerics;
namespace SpaceGame.World;
[Synchronized(20, SyncUnit.Hz)]
[WorldObject("player")]
public sealed class PlayerPrefab : WorldObjectPrefab3D, ISynchronizedEntity
{
public string Name { get; set; } = "";
public int Level { get; set; } = 1;
public int AttackPower { get; set; } = 24;
public int DefensePower { get; set; } = 8;
private int _health = 100;
private int _stamina = 100;
private int _mana = 100;
private float _hpBuffer;
private float _spBuffer;
private float _mpBuffer;
[Synced(0, SyncAlways: true)]
public uint EntityId => VirtualId;
[Synced(1)]
public float PosX => Transform.Position.X;
[Synced(2)]
public float PosY => Transform.Position.Z;
[Synced(3)]
public float FacingYaw { get; set; }
[Synced(4)]
public int Health => _health;
[Synced(5)]
public int Stamina => _stamina;
[Synced(6)]
public int Mana => _mana;
public PlayerPrefab(uint entityId, string name, Transform3D transform)
{
VirtualId = entityId;
Name = name;
Transform = transform;
}
public bool SpendStamina(int amount)
{
if (amount <= 0 || _stamina < amount)
return false;
_stamina -= amount;
return true;
}
public bool SpendMana(int amount)
{
if (amount <= 0 || _mana < amount)
return false;
_mana -= amount;
return true;
}
public void TakeDamage(int amount)
{
if (amount <= 0)
return;
_health = Math.Max(0, _health - amount);
}
public bool Regenerate(float dt)
{
bool changed = false;
changed |= Restore(ref _health, ref _hpBuffer, regenPerSecond: 2f, maxValue: 100, dt);
changed |= Restore(ref _stamina, ref _spBuffer, regenPerSecond: 12f, maxValue: 100, dt);
changed |= Restore(ref _mana, ref _mpBuffer, regenPerSecond: 8f, maxValue: 100, dt);
return changed;
}
private static bool Restore(ref int value, ref float buffer, float regenPerSecond, int maxValue, float dt)
{
if (value >= maxValue)
{
buffer = 0f;
return false;
}
buffer += regenPerSecond * dt;
int whole = (int)MathF.Floor(buffer);
if (whole <= 0)
return false;
buffer -= whole;
int next = Math.Min(maxValue, value + whole);
if (next == value)
return false;
value = next;
return true;
}
}
The important part is that Health, Stamina, and Mana are just more synced fields on the same player prefab. They do not need a separate packet system.
2. Expand the Spawn Snapshot
The client still needs the first full snapshot on spawn, so update PlayerSpawnPacket in Packets/GamePackets.cs:
public struct PlayerSpawnPacket : IPacketBase
{
[JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
[JsonPropertyName("entityId")] public uint EntityId { get; set; }
[JsonPropertyName("name")] public string Name { get; set; }
[JsonPropertyName("position")] public float[] Position { get; set; }
[JsonPropertyName("facingYaw")] public float FacingYaw { get; set; }
[JsonPropertyName("health")] public int Health { get; set; }
[JsonPropertyName("stamina")] public int Stamina { get; set; }
[JsonPropertyName("mana")] public int Mana { get; set; }
public PlayerSpawnPacket()
{
MessageCode = 1002;
EntityId = 0;
Name = "";
Position = new float[2];
FacingYaw = 0;
Health = 100;
Stamina = 100;
Mana = 100;
}
}
And update the portal helper:
private static PlayerSpawnPacket ToSpawnPacket(PlayerPrefab player)
{
return new PlayerSpawnPacket
{
EntityId = player.EntityId,
Name = player.Name,
Position = new[]
{
player.Transform.Position.X,
player.Transform.Position.Z
},
FacingYaw = player.FacingYaw,
Health = player.Health,
Stamina = player.Stamina,
Mana = player.Mana
};
}
3. Add a Tiny Test Packet
Create a packet we can use to spend resources on demand:
public struct UseSkillPacket : IPacketBase
{
[JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
[JsonPropertyName("staminaCost")] public int StaminaCost { get; set; }
[JsonPropertyName("manaCost")] public int ManaCost { get; set; }
[JsonPropertyName("selfDamage")] public int SelfDamage { get; set; }
public UseSkillPacket()
{
MessageCode = 1004;
StaminaCost = 15;
ManaCost = 10;
SelfDamage = 0;
}
}
This is just a test harness for the tutorial. In a real game, these costs would come from combat, sprinting, spells, skills, or zone damage.
4. Regenerate in an Engine Cycle
Create Services/PlayerRegenService.cs:
using Altruist;
using SpaceGame.World;
namespace SpaceGame.Services;
[Service]
public sealed class PlayerRegenService
{
private readonly IGameSessionService _gameSessions;
private readonly IAltruistRouter _router;
public PlayerRegenService(
IGameSessionService gameSessions,
IAltruistRouter router)
{
_gameSessions = gameSessions;
_router = router;
}
[Cycle(2, CycleUnit.Seconds)]
public async Task TickAsync()
{
foreach (var player in _gameSessions.FindAllContexts<PlayerPrefab>().ToList())
{
if (!player.Regenerate(0.5f))
continue;
await _router.Synchronize.SendAsync(player);
}
}
}
[Cycle(2, CycleUnit.Seconds)] schedules the method on the Altruist engine twice per second, so you do not need a manual BackgroundService, PeriodicTimer, or cancellation loop.
This is enough for the first version:
- twice per second, look at all connected players
- if any resource changed, emit a sync patch
- clients merge the patch exactly the same way they already do for movement
5. Spend Resources in the Portal
Update Portals/GamePortal.cs with a test gate:
[Gate("use-skill")]
public async Task OnUseSkill(UseSkillPacket packet, string clientId)
{
var player = GetPlayer(clientId);
if (player == null)
return;
bool changed = false;
if (packet.StaminaCost > 0)
changed |= player.SpendStamina(packet.StaminaCost);
if (packet.ManaCost > 0)
changed |= player.SpendMana(packet.ManaCost);
if (packet.SelfDamage > 0)
{
player.TakeDamage(packet.SelfDamage);
changed = true;
}
if (changed)
await _router.Synchronize.SendAsync(player);
}
Now the loop is complete:
- a gameplay action changes synced resources immediately
- regen refills them over time
- both paths reuse the same sync contract
6. Update the Client Patch Logic
Extend the client-side spawn and patch handling:
if (envelope.messageCode === 1002) {
players.set(envelope.entityId, {
id: envelope.entityId,
name: envelope.name,
x: envelope.position[0],
y: envelope.position[1],
facingYaw: envelope.facingYaw,
health: envelope.health,
stamina: envelope.stamina,
mana: envelope.mana
});
return;
}
if (envelope.messageCode === 3) {
const patch = envelope.message.data;
const player = players.get(patch.EntityId);
if (!player) return;
if ("PosX" in patch) player.x = patch.PosX;
if ("PosY" in patch) player.y = patch.PosY;
if ("FacingYaw" in patch) player.facingYaw = patch.FacingYaw;
if ("Health" in patch) player.health = patch.Health;
if ("Stamina" in patch) player.stamina = patch.Stamina;
if ("Mana" in patch) player.mana = patch.Mana;
}
The client does not need a separate “regen packet.” It just keeps merging sync deltas by EntityId.
7. Test It
Once connected, trigger a resource change:
ws.send(JSON.stringify({
messageCode: 4,
event: "use-skill",
staminaCost: 25,
manaCost: 15,
selfDamage: 10
}));
What you should see:
- an immediate sync patch lowering
Health,Stamina, andMana - follow-up sync patches every few ticks as the regen service restores them
That is the exact feeling we want from a living entity system. State changes are immediate, but recovery is systemic.
Why This Step Matters
This is where the project stops being “a synced transform demo” and starts becoming a game simulation.
From here, HP/SP/MP can drive:
- combat costs
- sprint drain
- spell casting
- death and respawn rules
- quest conditions
And because the values already live on the synced player entity, every later system can reuse them.
Next Up
Now that the world contains players with real synchronized state, we can add entities that think:
Continue the Series