From 3a2d9446743e99a1e6824a8008260bc1b085b4e8 Mon Sep 17 00:00:00 2001 From: Arufonsu <17498701+Arufonsu@users.noreply.github.com> Date: Sun, 1 Feb 2026 14:41:05 -0300 Subject: [PATCH 1/3] performance: optimize packet broadcasting by caching entity snapshots - Add snapshot caching for entity stats, vitals, statuses and player equipment - Cache per-(player,npc) aggression to skip unchanged NpcAggressionPacket sends - Cache per-(player,map) visible map-items hash to avoid regenerating identical MapItemsPacket data - Skip redundant broadcasts when cached state matches current values - Clear all snapshots when entities leave map/layer/instance (including player/npc aggression) - Batch vital and status updates to only include entities with actual changes - Equipment broadcasts now only send when items change These changes significantly reduce network bandwidth and server CPU in high-entity scenarios (raids, events, crowded maps) by eliminating unnecessary/duplicate packet transmissions to clients while preserving first-send guarantees and correctness. Behavior is fully backwards-compatible; clients see the same game state with less packet noise and lower latency impact from server-side broadcasting. --- .../Networking/PacketSender.cs | 304 ++++++++++++++++-- 1 file changed, 283 insertions(+), 21 deletions(-) diff --git a/Intersect.Server.Core/Networking/PacketSender.cs b/Intersect.Server.Core/Networking/PacketSender.cs index 26285249c0..34227dd7d1 100644 --- a/Intersect.Server.Core/Networking/PacketSender.cs +++ b/Intersect.Server.Core/Networking/PacketSender.cs @@ -12,7 +12,6 @@ using Intersect.Framework.Core.GameObjects.PlayerClass; using Intersect.Framework.Core.GameObjects.Resources; using Intersect.Framework.Core.GameObjects.Variables; -using Intersect.Framework.Core.Network.Packets.Security; using Intersect.Framework.Core.Security; using Intersect.GameObjects; using Intersect.Models; @@ -27,7 +26,6 @@ using Intersect.Server.General; using Intersect.Server.Localization; using Intersect.Server.Maps; -using Intersect.Utilities; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -53,6 +51,30 @@ public static void ResetMetrics() SentBytes = 0; } + //Tracks last sent stats per entity so we can skip redundant packet updates. + private sealed record EntityStatsSnapshot(int[] Stats); + + private static readonly Dictionary _lastEntityStatsSnapshot = new Dictionary(); + + // Tracks last sent vitals per entity for map‑wide batch updates. + private sealed record EntityVitalSnapshot(long[] Vitals, long[] MaxVitals, long CombatTimeRemaining); + + private static readonly Dictionary _lastEntityVitalSnapshot = new Dictionary(); + + // Tracks last sent equipment item ids per player. + private static readonly Dictionary _lastPlayerEquipmentSnapshot = new Dictionary(); + + // Tracks last sent statuses per entity so we can skip redundant packet updates. + private sealed record EntityStatusSnapshot(StatusPacket[] Statuses); + + private static readonly Dictionary _lastEntityStatusSnapshot = new Dictionary(); + + // Tracks last sent NPC aggression per (player, npc) pair. + private static readonly Dictionary<(Guid PlayerId, Guid NpcId), NpcAggression> _lastNpcAggressionSnapshot = new Dictionary<(Guid PlayerId, Guid NpcId), NpcAggression>(); + + // Tracks last sent map item state per (player, map) to avoid regenerating and resending already sent item packets. + private static readonly Dictionary<(Guid PlayerId, Guid MapId), int> _lastMapItemsSnapshot = new Dictionary<(Guid PlayerId, Guid MapId), int>(); + //PingPacket public static void SendPing(Client client, bool request = true) { @@ -620,6 +642,24 @@ public static void SendNpcAggressionToProximity(Npc en) var players = mapInstance.GetPlayers(); foreach (var pl in players) { + if (pl == null) + { + continue; + } + + var key = (PlayerId: pl.Id, NpcId: en.Id); + var currentAggro = en.GetAggression(pl); // Returns NpcAggression now + + if (_lastNpcAggressionSnapshot.TryGetValue(key, out var lastAggro)) + { + if (lastAggro.Equals(currentAggro)) + { + // No change in aggression towards this player; skip. + continue; + } + } + + _lastNpcAggressionSnapshot[key] = currentAggro; SendNpcAggressionTo(pl, en); } } @@ -640,24 +680,28 @@ public static void SendNpcAggressionTo(Player player, Npc npc) public static void SendEntityLeaveMap(Entity en, Guid leftMap) { SendDataToMapInstance(leftMap, en.MapInstanceId, new EntityLeftPacket(en.Id, en.GetEntityType(), en.MapId)); + ClearEntitySnapshotCache(en); } //EntityLeftPacket public static void SendEntityLeave(Entity en) { SendDataToProximityOnMapInstance(en.MapId, en.MapInstanceId, new EntityLeftPacket(en.Id, en.GetEntityType(), en.MapId)); + ClearEntitySnapshotCache(en); } //EntityLeftPacket public static void SendEntityLeaveLayer(Entity en, Guid mapInstanceId) { SendDataToProximityOnMapInstance(en.MapId, mapInstanceId, new EntityLeftPacket(en.Id, en.GetEntityType(), en.MapId)); + ClearEntitySnapshotCache(en); } //EntityLeftPacket public static void SendEntityLeaveInstanceOfMap(Entity en, Guid mapId, Guid mapInstanceId) { SendDataToProximityOnMapInstance(mapId, mapInstanceId, new EntityLeftPacket(en.Id, en.GetEntityType(), en.MapId)); + ClearEntitySnapshotCache(en); } //EntityLeavePacket @@ -772,6 +816,88 @@ public static void CacheGameDataPacket() CachedGameDataPacket = new GameDataPacket(gameObjects.ToArray(), CustomColors.Json()); } + private static void ClearEntitySnapshotCache(Entity en) + { + if (en != null) + { + _lastEntityStatsSnapshot.Remove(en.Id); + _lastEntityVitalSnapshot.Remove(en.Id); + _lastPlayerEquipmentSnapshot.Remove(en.Id); + _lastEntityStatusSnapshot.Remove(en.Id); + ClearNpcAggressionSnapshot(en); + } + } + + private static void ClearNpcAggressionSnapshot(Entity en) + { + if (en == null) + { + return; + } + + // If this entity is an NPC, drop all entries referencing that NPC. + if (en is Npc npc) + { + var npcId = npc.Id; + var keysToRemove = _lastNpcAggressionSnapshot + .Where(kvp => kvp.Key.NpcId == npcId) + .Select(kvp => kvp.Key) + .ToArray(); + + foreach (var key in keysToRemove) + { + _lastNpcAggressionSnapshot.Remove(key); + } + } + + // If this entity is a Player, drop all entries referencing that player. + if (en is Player player) + { + var playerId = player.Id; + var keysToRemove = _lastNpcAggressionSnapshot + .Where(kvp => kvp.Key.PlayerId == playerId) + .Select(kvp => kvp.Key) + .ToArray(); + + foreach (var key in keysToRemove) + { + _lastNpcAggressionSnapshot.Remove(key); + } + } + } + + private static int ComputeVisibleMapItemsHash(Player player, Guid mapId) + { + if (player == null) + { + return 0; + } + + if (!MapController.TryGetInstanceFromMap(mapId, player.MapInstanceId, out var mapInstance)) + { + return 0; + } + + var hash = new HashCode(); + + foreach (var item in mapInstance.AllMapItems.Values) + { + if (!item.VisibleToAll && item.Owner != player.Id) + { + continue; + } + + hash.Add(item.TileIndex); + hash.Add(item.UniqueId); + hash.Add(item.ItemId); + hash.Add(item.BagId); + hash.Add(item.Quantity); + hash.Add(item.Properties); + } + + return hash.ToHashCode(); + } + /// /// Sends a global chat message to every user online. /// @@ -945,40 +1071,108 @@ public static EntityVitalsPacket GenerateEntityVitalsPacket(Entity en) //EntityVitalsPacket public static void SendMapEntityVitalUpdate(MapController map, Entity[] entities, Guid mapInstanceId) { - // Generate a list of vitals to send to our users! + if (map == null || entities == null || entities.Length == 0) + { + return; + } + var data = new List(); + foreach (var entity in entities) { - data.Add(new EntityVitalData() + if (entity == null) { - Id = entity.Id, - Type = entity.GetEntityType(), - Vitals = entity.GetVitals(), - MaxVitals = entity.GetMaxVitals(), - CombatTimeRemaining = entity.CombatTimer - Timing.Global.Milliseconds - }); + continue; + } + + var vitals = entity.GetVitals(); + var maxVitals = entity.GetMaxVitals(); + var combatRemaining = entity.CombatTimer - Timing.Global.Milliseconds; + + var newSnapshot = new EntityVitalSnapshot(vitals, maxVitals, combatRemaining); + + if (_lastEntityVitalSnapshot.TryGetValue(entity.Id, out var oldSnapshot)) + { + if (oldSnapshot.Vitals.SequenceEqual(newSnapshot.Vitals) && + oldSnapshot.MaxVitals.SequenceEqual(newSnapshot.MaxVitals) && + oldSnapshot.CombatTimeRemaining == newSnapshot.CombatTimeRemaining) + { + // Nothing relevant changed, skip this entity. + continue; + } + } + + _lastEntityVitalSnapshot[entity.Id] = newSnapshot; + + data.Add( + new EntityVitalData + { + Id = entity.Id, + Type = entity.GetEntityType(), + Vitals = vitals, + MaxVitals = maxVitals, + CombatTimeRemaining = combatRemaining + } + ); } - // Send the data to the surroundings! - SendDataToProximityOnMapInstance(map.Id, mapInstanceId, new MapEntityVitalsPacket(map.Id, data.ToArray())); + // Only send if at least one entity actually changed. + if (data.Count > 0) + { + SendDataToProximityOnMapInstance(map.Id, mapInstanceId, new MapEntityVitalsPacket(map.Id, data.ToArray())); + } } public static void SendMapEntityStatusUpdate(MapController map, Entity[] entities, Guid mapInstanceId) { - // Generate a list of statuses to send to our users! + if (map == null || entities == null || entities.Length == 0) + { + return; + } + var data = new List(); + foreach (var entity in entities) { - data.Add(new EntityStatusData() + if (entity == null) { - Id = entity.Id, - Type = entity.GetEntityType(), - Statuses = entity.StatusPackets() - }); + continue; + } + + var statuses = entity.StatusPackets(); // Existing API + + var newSnapshot = new EntityStatusSnapshot(statuses); + + if (_lastEntityStatusSnapshot.TryGetValue(entity.Id, out var oldSnapshot)) + { + if (oldSnapshot.Statuses.SequenceEqual(newSnapshot.Statuses)) + { + // No status change; skip this entity. + continue; + } + } + + _lastEntityStatusSnapshot[entity.Id] = newSnapshot; + + data.Add( + new EntityStatusData + { + Id = entity.Id, + Type = entity.GetEntityType(), + Statuses = statuses + } + ); } - // Send the data to the surroundings! - SendDataToProximityOnMapInstance(map.Id, mapInstanceId, new MapEntityStatusPacket(map.Id, data.ToArray())); + // Only send if at least one entity actually changed. + if (data.Count > 0) + { + SendDataToProximityOnMapInstance( + map.Id, + mapInstanceId, + new MapEntityStatusPacket(map.Id, data.ToArray()) + ); + } } //EntityStatsPacket @@ -989,7 +1183,26 @@ public static void SendEntityStats(Entity en) return; } - SendDataToProximityOnMapInstance(en.MapId, en.MapInstanceId, GenerateEntityStatsPacket(en), null, TransmissionMode.Any); + // Build current stats array (same logic as GenerateEntityStatsPacket) + var stats = new int[Enum.GetValues().Length]; + for (var i = 0; i < stats.Length; i++) + { + stats[i] = en.Stat[i].Value(); + } + + var newSnapshot = new EntityStatsSnapshot(stats); + + if (_lastEntityStatsSnapshot.TryGetValue(en.Id, out var oldSnapshot)) + { + if (oldSnapshot.Stats.SequenceEqual(newSnapshot.Stats)) + { + // No visible stat changes; skip the packet update entirely. + return; + } + } + + _lastEntityStatsSnapshot[en.Id] = newSnapshot; + SendDataToProximityOnMapInstance(en.MapId, en.MapInstanceId, new EntityStatsPacket(en.Id, en.GetEntityType(), en.MapId, stats), null, TransmissionMode.Any); } //EntityVitalsPacket @@ -1127,6 +1340,25 @@ public static void SendMapItemsToProximity(Guid mapId, MapInstance mapInstance) // Send all players on a map instance and its surrounding instances a map item update. foreach (var player in mapInstance.GetPlayers(true)) { + if (player == null) + { + continue; + } + + var key = (PlayerId: player.Id, MapId: mapId); + var newHash = ComputeVisibleMapItemsHash(player, mapId); + + if (_lastMapItemsSnapshot.TryGetValue(key, out var lastHash)) + { + if (lastHash == newHash) + { + // Visible item set unchanged for this player on this map; skip. + continue; + } + } + + _lastMapItemsSnapshot[key] = newHash; + player.SendPacket(GenerateMapItemsPacket(player, mapId)); } } @@ -1295,6 +1527,36 @@ public static void SendPlayerEquipmentTo(Player forPlayer, Player en) //EquipmentPacket public static void SendPlayerEquipmentToProximity(Player en) { + if (en == null) + { + return; + } + + var slots = Options.Instance.Equipment.Slots.Count; + var equipment = new Guid[slots]; + + for (var i = 0; i < slots; i++) + { + if (en.Equipment[i] == -1 || en.Items[en.Equipment[i]].ItemId == Guid.Empty) + { + equipment[i] = Guid.Empty; + } + else + { + equipment[i] = en.Items[en.Equipment[i]].ItemId; + } + } + + if (_lastPlayerEquipmentSnapshot.TryGetValue(en.Id, out var last)) + { + if (last.Length == equipment.Length && last.SequenceEqual(equipment)) + { + // Nothing changed, skip entirely. + return; + } + } + + _lastPlayerEquipmentSnapshot[en.Id] = equipment; SendDataToProximityOnMapInstance(en.MapId, en.MapInstanceId, GenerateEquipmentPacket(null, en), null, TransmissionMode.Any); SendPlayerEquipmentTo(en, en); } From 8510c7785edcdbe7d3dedcb09967cdd7e737ed0b Mon Sep 17 00:00:00 2001 From: Arufonsu <17498701+Arufonsu@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:08:34 -0300 Subject: [PATCH 2/3] minor change (by reading panda's question) Signed-off-by: Arufonsu <17498701+Arufonsu@users.noreply.github.com> --- Intersect.Server.Core/Networking/PacketSender.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Intersect.Server.Core/Networking/PacketSender.cs b/Intersect.Server.Core/Networking/PacketSender.cs index 34227dd7d1..7f36d87ff1 100644 --- a/Intersect.Server.Core/Networking/PacketSender.cs +++ b/Intersect.Server.Core/Networking/PacketSender.cs @@ -708,6 +708,7 @@ public static void SendEntityLeaveInstanceOfMap(Entity en, Guid mapId, Guid mapI public static void SendEntityLeaveTo(Player player, Entity en) { player.SendPacket(new EntityLeftPacket(en.Id, en.GetEntityType(), en.MapId)); + ClearEntitySnapshotCache(en); } //EventLeavePacket @@ -1252,6 +1253,7 @@ public static void SendEntityAttack(Entity en, int attackTime, bool isBlocking = public static void SendEntityDie(Entity en) { SendDataToProximityOnMapInstance(en.MapId, en.MapInstanceId, new EntityDiePacket(en.Id, en.GetEntityType(), en.MapId)); + ClearEntitySnapshotCache(en); } //EntityDirectionPacket From 161efab1fc097ed9bcf6821d30bf7ac13f5a72d8 Mon Sep 17 00:00:00 2001 From: Arufonsu <17498701+Arufonsu@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:21:46 -0300 Subject: [PATCH 3/3] chore: use switches for gameobject branches Signed-off-by: Arufonsu <17498701+Arufonsu@users.noreply.github.com> --- .../Networking/PacketSender.cs | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Intersect.Server.Core/Networking/PacketSender.cs b/Intersect.Server.Core/Networking/PacketSender.cs index 7f36d87ff1..d6b318d9de 100644 --- a/Intersect.Server.Core/Networking/PacketSender.cs +++ b/Intersect.Server.Core/Networking/PacketSender.cs @@ -765,14 +765,16 @@ public static void SendGameData(Client client) continue; } - if ((GameObjectType)val == GameObjectType.Shop || - (GameObjectType)val == GameObjectType.Event || - (GameObjectType)val == GameObjectType.PlayerVariable || - (GameObjectType)val == GameObjectType.ServerVariable || - (GameObjectType)val == GameObjectType.GuildVariable || - (GameObjectType)val == GameObjectType.UserVariable) + switch ((GameObjectType)val) { - SendGameObjects(client, (GameObjectType)val, null); + case GameObjectType.Shop: + case GameObjectType.Event: + case GameObjectType.PlayerVariable: + case GameObjectType.ServerVariable: + case GameObjectType.GuildVariable: + case GameObjectType.UserVariable: + SendGameObjects(client, (GameObjectType)val, null); + break; } } } @@ -796,22 +798,20 @@ public static void CacheGameDataPacket() //Send massive amounts of game data foreach (var val in Enum.GetValues(typeof(GameObjectType))) { - if ((GameObjectType)val == GameObjectType.Map) + switch ((GameObjectType)val) { - continue; - } - - if ((GameObjectType)val == GameObjectType.Shop || - (GameObjectType)val == GameObjectType.Event || - (GameObjectType)val == GameObjectType.PlayerVariable || - (GameObjectType)val == GameObjectType.ServerVariable || - (GameObjectType)val == GameObjectType.GuildVariable || - (GameObjectType)val == GameObjectType.UserVariable) - { - continue; + case GameObjectType.Map: + case GameObjectType.Shop: + case GameObjectType.Event: + case GameObjectType.PlayerVariable: + case GameObjectType.ServerVariable: + case GameObjectType.GuildVariable: + case GameObjectType.UserVariable: + continue; + default: + SendGameObjects(null, (GameObjectType)val, gameObjects); + break; } - - SendGameObjects(null, (GameObjectType)val, gameObjects); } CachedGameDataPacket = new GameDataPacket(gameObjects.ToArray(), CustomColors.Json());