C#:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
using Newtonsoft.Json;
using Rust;
using UnityEngine.AI;
/*
* ZombieSwarmCore
* Copyright (c) 2026 Alexprojects
* Resale, sublicensing, or commercial distribution of this code is prohibited
* without the express consent of the author.
* Personal use on Rust servers and modification of the code are permitted
* for private use. Publishing derivative or modified versions under the same name or brand is not allowed.
*/
namespace Oxide.Plugins
{
[Info("ZombieSwarmCore", "Alexprojects", "1.0.8")]
[Description("High-quality coordinated squads with Chase/Escape logic, Hive-Mind and Squad Formations")]
public class ZombieSwarmCore : RustPlugin
{
private HashSet<BaseCombatEntity> spawnedZombies = new HashSet<BaseCombatEntity>();
private HashSet<NPCPlayerCorpse> zombieCorpses = new HashSet<NPCPlayerCorpse>();
private SwarmController swarmController;
private const string prefabScarecrow = "assets/prefabs/npc/scarecrow/scarecrow.prefab";
private const string permAdmin = "zombieswarmcore.admin";
private enum SwarmState { patrol, chase }
private Configuration config;
private class Configuration
{
[JsonProperty("1. General Settings")] public GeneralSettings general = new GeneralSettings();
[JsonProperty("2. Horde Management")] public SwarmSettings swarm = new SwarmSettings();
[JsonProperty("3. Combat Settings")] public CombatSettings combat = new CombatSettings();
[JsonProperty("4. Spawning Logic")] public SpawningSettings spawning = new SpawningSettings();
[JsonProperty("5. Movement & Formations")] public MovementSettings movement = new MovementSettings();
[JsonProperty("6. Patrol Behavior")] public PatrolSettings patrol = new PatrolSettings();
[JsonProperty("7. Visuals Settings")] public VisualsSettings visuals = new VisualsSettings();
public class GeneralSettings {
[JsonProperty("Hordes Always (24/7)")] public bool hordesAlways = false;
[JsonProperty("Start Hour (0-24)")] public float nightStart = 19f;
[JsonProperty("End Hour (0-24)")] public float nightEnd = 7f;
[JsonProperty("Clear Corpses On End")] public bool clearCorpsesOnEnd = true;
[JsonProperty("Enable Status Message")] public bool enableStatusMessage = true;
[JsonProperty("Status Message Interval (Minutes)")] public float statusMessageInterval = 5.0f;
}
public class SwarmSettings {
[JsonProperty("Zombies Per Horde (Max 6)")] private int zph = 4;
[JsonProperty("Max Global Zombies (Max 60)")] private int mgz = 40;
[JsonProperty("Hordes Per Player (Max 2)")] private int hpp = 1;
[JsonIgnore] public int navSyncStep => Mathf.Clamp(zph, 1, (12 >> 1));
[JsonIgnore] public int netEntityBuffer => Mathf.Clamp(mgz, 1, (120 >> 1));
[JsonIgnore] public int playerThreadCap => Mathf.Clamp(hpp, 1, (4 >> 1));
[JsonProperty("Refill Hordes")] public bool refillSwarms = true;
[JsonProperty("Drop Loot")] public bool dropLoot = true;
}
public class CombatSettings {
[JsonProperty("Detection Range (m)")] public float detectionRange = 30f;
[JsonProperty("Target Lost Range (m)")] public float targetLostRange = 80f;
[JsonProperty("Hive-Mind (Aggro whole squad on damage)")] public bool hiveMind = true;
[JsonProperty("Zombie Health (HP)")] public float zombieHealth = 150f;
}
public class SpawningSettings {
[JsonProperty("Spawn Min Distance (m)")] public float spawnMinDist = 20f;
[JsonProperty("Spawn Max Distance (m)")] public float spawnMaxDist = 35f;
}
public class MovementSettings {
[JsonProperty("Zombie Speed Multiplier (0.1 - 1.0)")] public float zombieSpeed = 0.8f;
[JsonProperty("Squad Formation Spacing (m)")] public float spacing = 2.0f;
}
public class PatrolSettings {
[JsonProperty("Patrol Sprint Probability (%)")] public int patrolSprintChance = 40;
[JsonProperty("Behavior Change Interval (Seconds)")] public float behaviorInterval = 12f;
}
public class VisualsSettings {
[JsonProperty("Enable Glowing Eyes")] public bool enableGlowingEyes = true;
[JsonProperty("Death Sound Prefab")] public string deathSound = "assets/bundled/prefabs/fx/player/gutshot_scream.prefab";
}
}
protected override void LoadDefaultConfig() => config = new Configuration();
protected override void LoadConfig() {
base.LoadConfig();
try { config = Config.ReadObject<Configuration>(); } catch { LoadDefaultConfig(); }
validateConfig();
SaveConfig();
}
private void validateConfig() {
config.movement.zombieSpeed = Mathf.Clamp(config.movement.zombieSpeed, 0.1f, 1f);
if (config.spawning.spawnMinDist < 5f) config.spawning.spawnMinDist = 5f;
if (config.spawning.spawnMaxDist <= config.spawning.spawnMinDist)
config.spawning.spawnMaxDist = config.spawning.spawnMinDist + 15f;
if (config.combat.targetLostRange < config.combat.detectionRange)
config.combat.targetLostRange = config.combat.detectionRange + 20f;
}
protected override void SaveConfig() => Config.WriteObject(config);
private class Swarm
{
public List<BaseCombatEntity> members = new List<BaseCombatEntity>();
public BasePlayer target;
public Coroutine controlRoutine;
public SwarmState state = SwarmState.patrol;
public Vector3 patrolPoint;
public bool isPatrolSSprint = false;
public float behaviorTimer = 0f;
public void cleanup(HashSet<BaseCombatEntity> spawnedZombies) {
if (controlRoutine != null) ServerMgr.Instance.StopCoroutine(controlRoutine);
foreach (var m in members) {
if (m != null && !m.IsDestroyed) {
spawnedZombies.Remove(m);
var sc = m as ScarecrowNPC;
if (sc != null) {
sc.inventory?.containerMain?.Clear();
sc.inventory?.containerWear?.Clear();
sc.inventory?.containerBelt?.Clear();
}
m.Kill();
}
}
members.Clear();
}
}
private class SwarmController
{
private List<Swarm> activeSwarms = new List<Swarm>();
private HashSet<BaseCombatEntity> spawnedZombies;
private HashSet<NPCPlayerCorpse> zombieCorpses;
private Configuration config;
private ZombieSwarmCore plugin;
public SwarmController(ZombieSwarmCore plugin, Configuration config, HashSet<BaseCombatEntity> zombies, HashSet<NPCPlayerCorpse> corpses) {
this.plugin = plugin; this.config = config; this.spawnedZombies = zombies; this.zombieCorpses = corpses;
}
public void timeTick() {
zombieCorpses.RemoveWhere(c => c == null || c.IsDestroyed);
if (!isSwarmHour()) { cleanupAll(false); return; }
foreach (var player in BasePlayer.activePlayerList) {
if (player == null || player.IsSleeping() || player.InSafeZone() || !player.IsAlive()) continue;
if (activeSwarms.Count(h => h.target == player) >= config.swarm.playerThreadCap) continue;
if (spawnedZombies.Count >= config.swarm.netEntityBuffer) break;
spawnSwarm(player);
}
}
private bool isSwarmHour() {
if (config.general.hordesAlways) return true;
float h = ConVar.Env.time;
if (config.general.nightStart < config.general.nightEnd) return h >= config.general.nightStart && h <= config.general.nightEnd;
return h >= config.general.nightStart || h <= config.general.nightEnd;
}
private void spawnSwarm(BasePlayer player) {
Vector3 spawnPos = calculateSafeSpawnPos(player.transform.position);
if (spawnPos == Vector3.zero) return;
Swarm swarm = new Swarm { target = player, patrolPoint = spawnPos };
activeSwarms.Add(swarm);
int syncSteps = Math.Min(config.swarm.navSyncStep, (12 >> 1));
for (int i = 0; i < syncSteps; i++) { createSwarmMember(swarm, spawnPos); }
swarm.controlRoutine = ServerMgr.Instance.StartCoroutine(swarmControlRoutine(swarm));
}
public void createSwarmMember(Swarm swarm, Vector3 pos) {
Vector3 mPos = pos + (UnityEngine.Random.insideUnitSphere * 2f); mPos.y = pos.y;
var ent = GameManager.server.CreateEntity(prefabScarecrow, mPos);
if (ent == null) return;
BaseCombatEntity npc = ent as BaseCombatEntity;
if (npc != null) {
npc.startHealth = config.combat.zombieHealth;
}
ent.Spawn();
spawnedZombies.Add(npc);
swarm.members.Add(npc);
if (npc != null) {
npc._maxHealth = config.combat.zombieHealth;
npc.health = config.combat.zombieHealth;
}
var sc = npc as ScarecrowNPC;
if (sc?.Brain != null) {
sc.Brain.SenseRange = config.combat.detectionRange;
sc.Brain.TargetLostRange = config.combat.targetLostRange;
}
if (config.visuals.enableGlowingEyes) plugin.applyGlowingEyes(npc);
var nav = npc.GetComponent<BaseNavigator>();
if (nav != null) {
nav.SlowSpeedFraction = config.movement.zombieSpeed * 0.5f;
nav.FastSpeedFraction = config.movement.zombieSpeed;
}
}
public void triggerAggro(BaseCombatEntity member, BasePlayer attacker) {
foreach (var s in activeSwarms) {
if (s.members.Contains(member)) { s.target = attacker; s.state = SwarmState.chase; break; }
}
}
public Vector3 calculateSafeSpawnPos(Vector3 center) {
for (int i = 0; i < 15; i++) {
float dist = UnityEngine.Random.Range(config.spawning.spawnMinDist, config.spawning.spawnMaxDist);
Vector2 rand = UnityEngine.Random.insideUnitCircle.normalized * dist;
Vector3 targetPos = center + new Vector3(rand.x, 0, rand.y);
NavMeshHit hit;
if (NavMesh.SamplePosition(targetPos, out hit, 3f, NavMesh.AllAreas)) {
float finalDist = Vector3.Distance(center, hit.position);
if (finalDist >= config.spawning.spawnMinDist && finalDist <= config.spawning.spawnMaxDist + 5f) {
if (WaterLevel.GetWaterLevel(hit.position, false) < hit.position.y && isOnGround(hit.position)) {
return hit.position;
}
}
}
}
return Vector3.zero;
}
private bool isOnGround(Vector3 pos) {
RaycastHit hit;
if (Physics.Raycast(pos + Vector3.up * 2f, Vector3.down, out hit, 5f, LayerMask.GetMask("Terrain", "World", "Default")))
if (hit.collider.gameObject.layer == LayerMask.NameToLayer("Terrain")) return true;
return false;
}
private IEnumerator swarmControlRoutine(Swarm swarm) {
while (swarm.target != null && swarm.target.IsConnected && swarm.target.IsAlive()) {
swarm.members.RemoveAll(z => z == null || z.IsDestroyed || !z.IsAlive());
if (swarm.members.Count == 0 && !config.swarm.refillSwarms) break;
int stepLimit = (12 >> 1);
int bufferMax = (120 >> 1);
if (config.swarm.refillSwarms && swarm.members.Count < Math.Min(config.swarm.navSyncStep, stepLimit) && spawnedZombies.Count < Math.Min(config.swarm.netEntityBuffer, bufferMax)) {
Vector3 rPos = calculateSafeSpawnPos(swarm.target.transform.position);
if (rPos != Vector3.zero) createSwarmMember(swarm, rPos);
}
if (swarm.members.Count > 0) {
BasePlayer closestPlayer = swarm.target;
float minDistance = float.MaxValue;
foreach (var p in BasePlayer.activePlayerList) {
if (p == null || p.IsSleeping() || p.InSafeZone() || !p.IsAlive()) continue;
foreach(var m in swarm.members) {
float d = Vector3.Distance(m.transform.position, p.transform.position);
if (d < minDistance) {
minDistance = d;
closestPlayer = p;
}
}
}
if (swarm.state != SwarmState.chase || minDistance < Vector3.Distance(swarm.members[0].transform.position, swarm.target.transform.position)) {
swarm.target = closestPlayer;
}
Vector3 targetPos = swarm.target.transform.position;
float dist = minDistance;
if (swarm.state == SwarmState.chase) {
if (dist > config.combat.targetLostRange) {
swarm.state = SwarmState.patrol;
swarm.patrolPoint = calculateSafeSpawnPos(swarm.members[0].transform.position);
}
} else {
if (dist <= config.combat.detectionRange) swarm.state = SwarmState.chase;
}
if (swarm.state == SwarmState.patrol) {
swarm.behaviorTimer += 0.5f;
if (swarm.behaviorTimer >= config.patrol.behaviorInterval) {
swarm.isPatrolSSprint = UnityEngine.Random.Range(0, 100) < config.patrol.patrolSprintChance;
swarm.behaviorTimer = 0f;
}
}
Vector3 dir = (swarm.state == SwarmState.chase) ? (targetPos - swarm.members[0].transform.position).normalized : (swarm.patrolPoint - swarm.members[0].transform.position).normalized;
Vector3 perp = Vector3.Cross(dir, Vector3.up).normalized;
for (int i = 0; i < swarm.members.Count; i++) {
var m = swarm.members[i]; var nav = m.GetComponent<BaseNavigator>(); if (nav == null) continue;
if (swarm.state == SwarmState.chase && Vector3.Distance(m.transform.position, targetPos) < 2.5f) {
nav.Stop();
continue;
}
BaseNavigator.NavigationSpeed nSpeed = (swarm.state == SwarmState.chase || swarm.isPatrolSSprint) ? BaseNavigator.NavigationSpeed.Fast : BaseNavigator.NavigationSpeed.Slow;
float side = (i % 2 == 0) ? 1 : -1; float row = (i / 2);
Vector3 offset = (perp * side * config.movement.spacing) + (dir * -row * config.movement.spacing);
Vector3 finalDest = (swarm.state == SwarmState.chase) ? (targetPos + offset) : (swarm.patrolPoint + offset);
if (swarm.state == SwarmState.patrol && Vector3.Distance(m.transform.position, swarm.patrolPoint) < 5f)
swarm.patrolPoint = calculateSafeSpawnPos(m.transform.position);
nav.SetDestination(finalDest, nSpeed);
if (nav.Agent != null && nav.Agent.isOnNavMesh) {
nav.Agent.speed = (nSpeed == BaseNavigator.NavigationSpeed.Fast ? 6.0f : 2.6f) * config.movement.zombieSpeed;
nav.Agent.acceleration = 20f; nav.Agent.angularSpeed = 400f;
}
}
}
yield return new WaitForSeconds(0.5f);
}
swarm.cleanup(spawnedZombies); activeSwarms.Remove(swarm);
}
public void cleanupAll(bool forceCorpseCleanup = false) {
foreach (var s in activeSwarms.ToList()) s.cleanup(spawnedZombies);
activeSwarms.Clear();
if (forceCorpseCleanup || config.general.clearCorpsesOnEnd) {
foreach (var c in zombieCorpses.ToList()) {
if (c != null && !c.IsDestroyed) {
if (c.containers != null) {
foreach (var container in c.containers) {
container?.Clear();
}
}
c.Kill();
}
}
zombieCorpses.Clear();
}
}
public int getSwarmCount() => activeSwarms.Count;
}
public void applyGlowingEyes(BaseCombatEntity npc) {
var sc = npc as ScarecrowNPC; if (sc?.inventory == null) return;
sc.SetFlagLocal(BaseEntity.Flags.On, true);
Item eyes = ItemManager.CreateByName("gloweyes", 1);
if (eyes != null) sc.inventory.GiveItem(eyes, sc.inventory.containerWear);
sc.SendNetworkUpdateImmediate();
}
void OnServerInitialized() {
permission.RegisterPermission(permAdmin, this);
swarmController = new SwarmController(this, config, spawnedZombies, zombieCorpses);
timer.Every(25f, () => swarmController?.timeTick());
if (config.general.enableStatusMessage && config.general.statusMessageInterval > 0)
{
timer.Every(config.general.statusMessageInterval * 60f, () =>
{
string msg = string.Format(lang.GetMessage("Status", this),
swarmController.getSwarmCount(),
spawnedZombies.Count,
config.swarm.netEntityBuffer);
PrintToChat(msg);
});
}
}
void OnEntitySpawned(BaseNetworkable ent) {
if (ent is NPCPlayerCorpse corpse && corpse.name != null && (corpse.name.Contains("scarecrow") || corpse.name.Contains("murderer"))) {
bool isOurZombie = false;
foreach (var pos in dyingZombies) {
if (Vector3.SqrMagnitude(pos - corpse.transform.position) < 2f) {
isOurZombie = true;
break;
}
}
if (isOurZombie) {
zombieCorpses.Add(corpse);
if (!config.swarm.dropLoot) {
NextTick(() => {
if (corpse != null && !corpse.IsDestroyed && corpse.containers != null) {
if (corpse.containers.Length > 0) corpse.containers[0]?.Clear();
if (corpse.containers.Length > 2) corpse.containers[2]?.Clear();
}
});
}
}
}
}
void OnEntityTakeDamage(BaseCombatEntity ent, HitInfo info) {
if (config.combat.hiveMind && spawnedZombies.Contains(ent) && info?.InitiatorPlayer != null && !info.InitiatorPlayer.IsNpc)
swarmController?.triggerAggro(ent, info.InitiatorPlayer);
}
private List<Vector3> dyingZombies = new List<Vector3>();
void OnEntityDeath(BaseCombatEntity ent) {
if (ent == null || !spawnedZombies.Contains(ent)) return;
Vector3 deathPos = ent.transform.position;
dyingZombies.Add(deathPos);
timer.Once(2f, () => dyingZombies.Remove(deathPos));
spawnedZombies.Remove(ent);
if (!string.IsNullOrEmpty(config.visuals.deathSound)) {
Effect.server.Run(config.visuals.deathSound, deathPos);
}
}
void Unload() { swarmController?.cleanupAll(true); }
protected override void LoadDefaultMessages() {
lang.RegisterMessages(new Dictionary<string, string> {
["Status"] = "<color=#ff6b6b>[Zombie Swarm Core]</color> Swarms: <color=#ff6b6b>{0}</color> | Entities: <color=#ff6b6b>{1}/{2}</color>"
}, this);
}
[ChatCommand("swarms")]
void cmdSwarmStatus(BasePlayer player, string command, string[] args) {
if (!permission.UserHasPermission(player.UserIDString, permAdmin) && !player.IsAdmin) return;
SendReply(player, lang.GetMessage("Status", this, player.UserIDString), swarmController.getSwarmCount(), spawnedZombies.Count, config.swarm.netEntityBuffer);
}
}
}