Quote:
Originally Posted by Spirited
I'm really not sold on using a game engine pattern on a game server due to unnecessary sharding and complexity. You have multiple race condition issues, especially when multiple players are involved... for example: damaging a player when that player should have zero health, but it hasn't been updated yet by the health system.
|
Each tick is a snapshot of the simulation state. It starts with processing the packets that arrived in the queue. Packet Handlers just create components. They do not do any logic beyond that.
Code:
...
case MsgActionType.ChangeFacing:
{
var dir = new DirectionComponent(ntt.Id, msg.Direction);
ntt.Add(ref dir);
break;
}
case MsgActionType.ChangeAction:
{
var emo = new EmoteComponent(ntt.Id, (Emote)msg.Param);
ntt.Add(ref emo);
break;
}
case MsgActionType.Jump:
{
var jmp = new JumpComponent(ntt.Id, msg.JumpX, msg.JumpY);
ntt.Add(ref jmp);
break;
}
...
after all the packets have been processed, the systems run their Update() for every entity that has the required components. Eg. the Jump System using 4 threads because jumps do not affect eachother
Code:
public sealed class JumpSystem : PixelSystem<PositionComponent, JumpComponent, DirectionComponent>
{
public JumpSystem() : base("Jump System", threads: 4) { }
protected override bool MatchesFilter(in PixelEntity ntt) => ntt.Type != EntityType.Item && ntt.Type != EntityType.Npc && base.MatchesFilter(in ntt);
public override void Update(in PixelEntity ntt, ref PositionComponent pos, ref JumpComponent jmp, ref DirectionComponent dir)
{
if(jmp.ChangedTick == PixelWorld.Tick)
{
var dist = (int)Vector2.Distance(pos.Position, jmp.Position);
var direction = CoMath.GetDirection(new Vector2(pos.Position.X, pos.Position.Y),new Vector2(jmp.Position.X, jmp.Position.Y));
dir.Direction = direction;
jmp.Time = CoMath.GetJumpTime(dist);
// Console.WriteLine($"Jumping to {jmp.Position} | Dist: {dist} | Time: {jmp.Time:0.00}");
}
pos.Position = Vector2.Lerp(pos.Position, jmp.Position, jmp.Time);
pos.ChangedTick = PixelWorld.Tick;
// Console.WriteLine($"Time: {jmp.Time:0.00}");
jmp.Time -= deltaTime;
if(jmp.Time <= 0)
{
pos.Position = jmp.Position;
ntt.Remove<JumpComponent>();
}
}
}
after the jump system processed every jumping entity and moved it, the Viewport System has to update and sync visible entities - using a single thread because the quadtree is shared and not threadsafe.
Code:
public sealed class ViewportSystem : PixelSystem<PositionComponent, ViewportComponent>
{
public ViewportSystem() : base("Viewport System", threads: 1) { }
protected override bool MatchesFilter(in PixelEntity ntt) => ntt.Type != EntityType.Item && ntt.Type != EntityType.Npc && base.MatchesFilter(in ntt);
public override void Update(in PixelEntity ntt, ref PositionComponent pos, ref ViewportComponent vwp)
{
if (pos.ChangedTick != PixelWorld.Tick)
return;
Game.Grids[pos.Map].Move(in ntt, ref pos); // one thread because of shared quadtree
vwp.Viewport.X = pos.Position.X - vwp.Viewport.Width / 2;
vwp.Viewport.Y = pos.Position.Y - vwp.Viewport.Height / 2;
vwp.EntitiesVisibleLast.Clear();
vwp.EntitiesVisibleLast.AddRange(vwp.EntitiesVisible);
vwp.EntitiesVisible.Clear();
Game.Grids[pos.Map].GetVisibleEntities(ref vwp);
if (ntt.Type != EntityType.Player)
return;
for (var i = 0; i < vwp.EntitiesVisible.Count; i++)
{
var b = vwp.EntitiesVisible[i];
// todo if visible last contains b continue
NetworkHelper.FullSync(in ntt, in b);
}
}
}
then it continues processing things like damage, health, death, drops, respawns and eventually the last step will be to sync the state to the client - on a single thread because its very trivial and fast
Code:
public sealed class NetSyncSystem : PixelSystem<NetSyncComponent>
{
public NetSyncSystem() : base("NetSync System", threads: 1) { }
protected override bool MatchesFilter(in PixelEntity ntt) => ntt.Type == EntityType.Player && base.MatchesFilter(ntt);
public override void Update(in PixelEntity ntt, ref NetSyncComponent c1)
{
SelfUpdate(in ntt);
if (ntt.Type != EntityType.Player)
return;
ref readonly var vwp = ref ntt.Get<ViewportComponent>();
for (var x = 0; x < vwp.EntitiesVisible.Count; x++)
{
var changedEntity = vwp.EntitiesVisible[x];
Update(in ntt, in changedEntity);
}
}
public static void SelfUpdate(in PixelEntity ntt) => Update(in ntt, in ntt);
public static void Update(in PixelEntity ntt, in PixelEntity other)
{
ref readonly var syn = ref other.Get<NetSyncComponent>();
ref readonly var net = ref ntt.Get<NetSyncComponent>();
ref readonly var pos = ref ntt.Get<PositionComponent>();
ref readonly var dir = ref ntt.Get<DirectionComponent>();
if (syn.Fields.HasFlag(SyncThings.Walk))
{
ref readonly var wlk = ref other.Get<WalkComponent>();
if(wlk.ChangedTick == PixelWorld.Tick)
{
var walkMsg = MsgWalk.Create(other.Id, wlk.Direction, wlk.IsRunning);
ntt.NetSync(in walkMsg);
}
}
if(syn.Fields.HasFlag(SyncThings.Jump))
{
ref readonly var jmp = ref other.Get<JumpComponent>();
if(jmp.CreatedTick == PixelWorld.Tick)
{
var jumpMsg = MsgAction.Create(0, ntt.Id, pos.Map, (ushort)pos.Position.X, (ushort)pos.Position.Y, dir.Direction, MsgActionType.Jump);
ntt.NetSync(in jumpMsg);
}
}
if (syn.Fields.HasFlag(SyncThings.Health) || syn.Fields.HasFlag(SyncThings.MaxHealth))
{
ref readonly var hlt = ref other.Get<HealthComponent>();
if(hlt.ChangedTick == PixelWorld.Tick)
{
var healthMsg = MsgUserAttrib.Create(ntt.Id, hlt.Health, MsgUserAttribType.Health);
var maxHealthMsg = MsgUserAttrib.Create(ntt.Id, hlt.MaxHealth, MsgUserAttribType.MaxHealth);
ntt.NetSync(in healthMsg);
ntt.NetSync(in maxHealthMsg);
}
}
if (syn.Fields.HasFlag(SyncThings.Level))
{
ref readonly var lvl = ref other.Get<LevelComponent>();
if(lvl.ChangedTick == PixelWorld.Tick)
{
var lvlMsg = MsgUserAttrib.Create(ntt.Id, lvl.Level, MsgUserAttribType.Level);
ntt.NetSync(in lvlMsg);
}
}
if (syn.Fields.HasFlag(SyncThings.Experience))
{
ref readonly var exp = ref other.Get<ExperienceComponent>();
if(exp.ChangedTick == PixelWorld.Tick)
{
var expMsg = MsgUserAttrib.Create(ntt.Id, exp.Experience, MsgUserAttribType.Experience);
ntt.NetSync(in expMsg);
}
}
}
}
Quote:
|
... due to unnecessary sharding and complexity.
|
It is alot less complex than comet. When I read comet code I immediately thought that its convoluted and complicated. Mind you I am a senior dev with 15 years of experience, i do not doubt you know what you are doing and I'm also sure it works well, but its doing everything in a very smart way instead of a very simple one. It takes alot of effort to read and understand the code. With ECS you have KISS absolutism. Only Systems modify state and each system has a single job. You know where everything happens. Something is not syncing to the client? NetSyncSystem. Items do not drop? Drop System. Drops do not despawn? LifetimeSystem.
You work in a single file most of the time. Components contain no logic, just data and are immutable most of the time, for example
Code:
[Component]
public struct DirectionComponent
{
public readonly int EntityId;
public uint ChangedTick;
public Direction Direction;
public DirectionComponent(int entityId, Direction direction = Direction.South)
{
EntityId = entityId;
Direction = direction;
ChangedTick = PixelWorld.Tick;
}
public override int GetHashCode() => EntityId;
}
So the only position where a bug can appear is inside a system.
Sharding comes for free. Worlds are self-contained. Take it or leave it, if you need it its available with near zero effort. Performance profiling comes for free. Systems can be timed, you can know where your bottleneck is without 3rd party tools. There's no allocations during runtime (other than strings) so the GC rarely runs and when it does its very quick. You do not need any timers as you always have the gametime reference and the delta times. You can even throttle systems and skip ticks easily if you need more performance, eg. updating the viewport on every even or odd tick only.
I'll leave you with an interesting talk about ECS for Roguelikes, its about simulating worlds and less obvious things - eg complex behaviors with component reuse and remixing.