• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orion-ecs / keen-eye / 20587004827

30 Dec 2025 02:01AM UTC coverage: 69.377% (-0.5%) from 69.855%
20587004827

push

github

tyevco
Add entity hierarchy replication

Server side:
- Add SendHierarchyChange() to broadcast parent changes to all clients
- Add SendHierarchySnapshot() to send all parent-child relationships
- Include hierarchy in full snapshot flow for late joiners
- Use HierarchyChange (0x18) message type

Client side:
- Handle HierarchyChange message to apply SetParent()
- Look up child and parent by network ID
- Support parentNetworkId=0 for orphaning (no parent)

Protocol:
- Add HierarchyChange message type (0x18)
- Add WriteHierarchyChange/ReadHierarchyChange helpers

Game code calls SendHierarchyChange() when parent changes on server.
Late joiners receive all current hierarchy relationships after snapshot.

3493 of 4493 branches covered (77.74%)

Branch coverage included in aggregate %.

0 of 50 new or added lines in 4 files covered. (0.0%)

245 existing lines in 8 files now uncovered.

20186 of 29638 relevant lines covered (68.11%)

1.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

46.01
/src/KeenEyes.Network/Systems/NetworkServerSendSystem.cs
1
using KeenEyes.Network.Components;
2
using KeenEyes.Network.Protocol;
3
using KeenEyes.Network.Serialization;
4
using KeenEyes.Network.Transport;
5

6
namespace KeenEyes.Network.Systems;
7

8
/// <summary>
9
/// Server system that sends state updates to clients.
10
/// </summary>
11
/// <remarks>
12
/// Runs in LateUpdate phase after game logic has executed.
13
/// </remarks>
14
public sealed class NetworkServerSendSystem(NetworkServerPlugin plugin) : SystemBase
1✔
15
{
16
    private readonly byte[] sendBuffer = new byte[4096];
1✔
17

18
    // Track last sent component values per entity for delta detection
19
    private readonly Dictionary<Entity, Dictionary<Type, object>> lastSentState = [];
1✔
20

21
    // Track bytes sent this tick for bandwidth limiting
22
    private int bytesSentThisTick;
23

24
    /// <inheritdoc/>
25
    public override void Update(float deltaTime)
26
    {
27
        // Advance tick
28
        if (!plugin.Tick(deltaTime))
1✔
29
        {
30
            return; // Not time for a network tick yet
1✔
31
        }
32

33
        // Check for clients that need full snapshots
34
        foreach (var client in plugin.GetConnectedClients())
1✔
35
        {
36
            if (client.NeedsFullSnapshot)
1✔
37
            {
38
                plugin.SendFullSnapshot(client.ClientId);
1✔
39
                client.NeedsFullSnapshot = false;
1✔
40
            }
41
        }
42

43
        var serializer = plugin.Config.Serializer;
1✔
44
        var config = plugin.Config;
1✔
45

46
        // Calculate bytes per tick budget
47
        var bytesPerTick = config.EnableBandwidthLimiting
1✔
48
            ? config.MaxBandwidthBytesPerSecond / config.TickRate
1✔
49
            : int.MaxValue;
1✔
50
        bytesSentThisTick = 0;
1✔
51

52
        // Collect entities that need updates and sort by priority
53
        var entitiesToUpdate = new List<(Entity entity, float priority, bool needsFullSync)>();
1✔
54

55
        foreach (var entity in World.Query<NetworkId, NetworkState>())
1✔
56
        {
57
            ref var networkState = ref World.Get<NetworkState>(entity);
1✔
58

59
            // Accumulate priority over time
60
            networkState.AccumulatedPriority += deltaTime;
1✔
61

62
            if (ShouldSendEntity(entity, ref networkState, serializer))
1✔
63
            {
64
                entitiesToUpdate.Add((entity, networkState.AccumulatedPriority, networkState.NeedsFullSync));
1✔
65
            }
66
        }
67

68
        // Sort by priority (higher first), with full sync entities always at front
69
        entitiesToUpdate.Sort((a, b) =>
1✔
70
        {
1✔
71
            // Full sync entities have highest priority
1✔
72
            if (a.needsFullSync != b.needsFullSync)
×
73
            {
1✔
74
                return a.needsFullSync ? -1 : 1;
×
75
            }
1✔
76
            return b.priority.CompareTo(a.priority);
×
77
        });
1✔
78

79
        // Send entity updates within bandwidth budget
80
        foreach (var (entity, _, _) in entitiesToUpdate)
1✔
81
        {
82
            ref var networkState = ref World.Get<NetworkState>(entity);
1✔
83
            ref readonly var networkId = ref World.Get<NetworkId>(entity);
1✔
84

85
            // Check bandwidth budget
86
            if (config.EnableBandwidthLimiting && bytesSentThisTick >= bytesPerTick)
1✔
87
            {
88
                // Don't reset priority for entities we couldn't send
89
                break;
×
90
            }
91

92
            var bytesBefore = bytesSentThisTick;
1✔
93
            SendEntityUpdate(entity, networkId, ref networkState);
1✔
94
            networkState.LastSentTick = plugin.CurrentTick;
1✔
95
            networkState.AccumulatedPriority = 0; // Reset priority after sending
1✔
96

97
            // Stop if we've exceeded budget (but we already sent this message)
98
            if (config.EnableBandwidthLimiting && bytesSentThisTick > bytesPerTick)
1✔
99
            {
100
                break;
×
101
            }
102
        }
103

104
        // Pump the transport to flush outgoing data
105
        plugin.Transport.Update();
1✔
106
    }
1✔
107

108
    private bool ShouldSendEntity(Entity entity, ref NetworkState state, INetworkSerializer? serializer)
109
    {
110
        // Always send if needs full sync
111
        if (state.NeedsFullSync)
1✔
112
        {
113
            return true;
1✔
114
        }
115

116
        // If no serializer, we can only send spawn/despawn
117
        if (serializer is null)
×
118
        {
119
            return false;
×
120
        }
121

122
        // Check if any replicated component has changed
123
        if (!lastSentState.TryGetValue(entity, out var entityState))
×
124
        {
125
            // Never sent this entity - needs update
126
            return true;
×
127
        }
128

129
        // Compare current state to last sent state using delta masks for efficiency
130
        foreach (var (type, value) in World.GetComponents(entity))
×
131
        {
132
            if (!serializer.IsNetworkSerializable(type))
×
133
            {
134
                continue;
135
            }
136

137
            if (!entityState.TryGetValue(type, out var lastValue))
×
138
            {
139
                // New component - needs update
140
                return true;
×
141
            }
142

143
            // Use dirty mask for delta-supported types, fallback to Equals for others
144
            if (serializer.SupportsDelta(type))
×
145
            {
146
                if (serializer.GetDirtyMask(type, value, lastValue) != 0)
×
147
                {
UNCOV
148
                    return true;
×
149
                }
150
            }
151
            else if (!Equals(lastValue, value))
×
152
            {
UNCOV
153
                return true;
×
154
            }
155
        }
156

UNCOV
157
        return false;
×
UNCOV
158
    }
×
159

160
    private void SendEntityUpdate(Entity entity, NetworkId networkId, ref NetworkState state)
161
    {
162
        var writer = new NetworkMessageWriter(sendBuffer);
1✔
163
        var serializer = plugin.Config.Serializer;
1✔
164

165
        if (state.NeedsFullSync)
1✔
166
        {
167
            // Send full entity state
168
            writer.WriteHeader(MessageType.EntitySpawn, plugin.CurrentTick);
1✔
169

170
            var owner = World.Has<NetworkOwner>(entity)
1✔
171
                ? World.Get<NetworkOwner>(entity)
1✔
172
                : NetworkOwner.Server;
1✔
173

174
            writer.WriteEntitySpawn(networkId.Value, owner.ClientId);
1✔
175

176
            // Write all replicated components (full serialization)
177
            WriteReplicatedComponentsFull(entity, ref writer, serializer);
1✔
178

179
            state.NeedsFullSync = false;
1✔
180
        }
181
        else
182
        {
183
            // Send delta update (only changed fields within components)
UNCOV
184
            writer.WriteHeader(MessageType.ComponentDelta, plugin.CurrentTick);
×
UNCOV
185
            writer.WriteUInt32(networkId.Value);
×
186

187
            // Write components with delta encoding
UNCOV
188
            WriteReplicatedComponentsDelta(entity, ref writer, serializer);
×
189
        }
190

191
        var span = writer.GetWrittenSpan();
1✔
192
        bytesSentThisTick += span.Length;
1✔
193
        plugin.SendToAll(span, DeliveryMode.UnreliableSequenced);
1✔
194

195
        // Update last sent state for delta tracking
196
        SaveSentState(entity, serializer);
1✔
197
    }
1✔
198

199
    private void WriteReplicatedComponentsFull(Entity entity, ref NetworkMessageWriter writer, INetworkSerializer? serializer)
200
    {
201
        if (serializer is null)
1✔
202
        {
203
            writer.WriteComponentCount(0);
1✔
204
            return;
1✔
205
        }
206

207
        // Collect all replicated components
208
        var toSend = new List<(Type, object)>();
×
UNCOV
209
        foreach (var (type, value) in World.GetComponents(entity))
×
210
        {
UNCOV
211
            if (serializer.IsNetworkSerializable(type))
×
212
            {
UNCOV
213
                toSend.Add((type, value));
×
214
            }
215
        }
216

UNCOV
217
        writer.WriteComponentCount((byte)toSend.Count);
×
UNCOV
218
        foreach (var (type, value) in toSend)
×
219
        {
UNCOV
220
            writer.WriteComponent(serializer, type, value);
×
221
        }
222
    }
×
223

224
    private void WriteReplicatedComponentsDelta(Entity entity, ref NetworkMessageWriter writer, INetworkSerializer? serializer)
225
    {
UNCOV
226
        if (serializer is null)
×
227
        {
228
            writer.WriteComponentCount(0);
×
UNCOV
229
            return;
×
230
        }
231

232
        // Get last sent state for delta comparison
UNCOV
233
        lastSentState.TryGetValue(entity, out var entityLastState);
×
234

235
        // Collect components that have changed
UNCOV
236
        var toSend = new List<(Type type, object current, object? baseline)>();
×
UNCOV
237
        foreach (var (type, value) in World.GetComponents(entity))
×
238
        {
UNCOV
239
            if (!serializer.IsNetworkSerializable(type))
×
240
            {
241
                continue;
242
            }
243

244
            object? lastValue = null;
×
UNCOV
245
            if (entityLastState is not null)
×
246
            {
UNCOV
247
                entityLastState.TryGetValue(type, out lastValue);
×
248
            }
249

250
            // Check if changed using delta mask or equality
251
            bool hasChanged;
UNCOV
252
            if (lastValue is null)
×
253
            {
UNCOV
254
                hasChanged = true; // New component
×
255
            }
256
            else if (serializer.SupportsDelta(type))
×
257
            {
UNCOV
258
                hasChanged = serializer.GetDirtyMask(type, value, lastValue) != 0;
×
259
            }
260
            else
261
            {
UNCOV
262
                hasChanged = !Equals(lastValue, value);
×
263
            }
264

265
            if (hasChanged)
×
266
            {
UNCOV
267
                toSend.Add((type, value, lastValue));
×
268
            }
269
        }
270

UNCOV
271
        writer.WriteComponentCount((byte)toSend.Count);
×
272

273
        // Write each component with delta encoding where supported
UNCOV
274
        foreach (var (type, current, baseline) in toSend)
×
275
        {
276
            // Use delta serialization if we have a baseline and the type supports it
UNCOV
277
            if (baseline is not null && serializer.SupportsDelta(type))
×
278
            {
UNCOV
279
                writer.WriteComponentDelta(serializer, type, current, baseline);
×
280
            }
281
            else
282
            {
283
                // Fall back to full serialization
UNCOV
284
                writer.WriteComponent(serializer, type, current);
×
285
            }
286
        }
UNCOV
287
    }
×
288

289
    private void SaveSentState(Entity entity, INetworkSerializer? serializer)
290
    {
291
        if (serializer is null)
1✔
292
        {
293
            return;
1✔
294
        }
295

UNCOV
296
        if (!lastSentState.TryGetValue(entity, out var entityState))
×
297
        {
UNCOV
298
            entityState = [];
×
UNCOV
299
            lastSentState[entity] = entityState;
×
300
        }
301

302
        // Save current state of all replicated components
UNCOV
303
        foreach (var (type, value) in World.GetComponents(entity))
×
304
        {
UNCOV
305
            if (serializer.IsNetworkSerializable(type))
×
306
            {
307
                // Store a copy of the value (boxing creates a copy for value types)
UNCOV
308
                entityState[type] = value;
×
309
            }
310
        }
UNCOV
311
    }
×
312

313
    /// <summary>
314
    /// Clears tracking state for an entity (call when entity is despawned).
315
    /// </summary>
316
    /// <param name="entity">The entity to clear.</param>
317
    public void ClearEntityState(Entity entity)
318
    {
UNCOV
319
        lastSentState.Remove(entity);
×
UNCOV
320
    }
×
321
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc