Skip to content

AnimalManager

AnimalManager (1101 lines in Unturned/Managers/AnimalManager.cs) manages the passive wildlife population of the Unturned world. It controls the lifecycle of all Animal instances: spawning from weighted tables within tiered spawn zones, pack-based flocking AI, wander-angle rotation, player proximity startle/panic/attack responses, and loot drops (meat and pelt) on death. Animals are organized into PackInfo groups that share a wander direction and average position calculation.

Unlike other manager classes, AnimalManager exposes no static delegate hooks. Custom animal behavior is implemented through the Animal component directly or by replacing animal prefabs. The dropLoot method is the most commonly called external API.

Source code location: Unturned/Managers/AnimalManager.cs

Architecture Overview

AnimalManager extends SteamCaller and manages a flat list of Animal instances organized into PackInfo groups. The architecture follows the standard sequenced-batch replication pattern used by ZombieManager and VehicleManager, with state updates batched and gated by a sequence number.

Key architectural components:

  • List<Animal> animals: Flat list of all animal instances.
  • List<PackInfo> packs: Pack groups that organize animals by spawn zone.
  • Sequence number (seq): Batched-state replication gate that discards out-of-order updates.
  • LevelAnimals: Separate class that loads spawn table data from Spawns/Fauna.dat.

Pack System

PackInfo

Animals are organized into PackInfo groups. Each pack has its own wander behavior:

csharp
public class PackInfo
{
    public List<AnimalSpawnpoint> spawns { get; private set; }
    public List<Animal> animals { get; private set; }
    private Vector3 wanderNormal;
    public float wanderAngle { get; set; }
}

The wander direction is computed as a 2D normal vector from the angle:

csharp
wanderNormal = new Vector3(Mathf.Cos(Mathf.Deg2Rad * wanderAngle), 0.0f,
    Mathf.Sin(Mathf.Deg2Rad * wanderAngle));

The pack's wanderAngle is initialized as Random.Range(0.0f, 360.0f) when the pack is created.

Average Point Calculations

Each pack has two average-point calculations:

  1. Average spawn point (getAverageSpawnPoint): the centroid of all spawn points assigned to the pack, cached at construction time (computed once, never recalculated).
  2. Average animal point (getAverageAnimalPoint): the centroid of all live animal positions in the pack, recalculated once per frame (cached via Time.frameCount).
csharp
public Vector3 getAverageAnimalPoint()
{
    if (Time.frameCount > avgAnimalPointRecalculation)
    {
        avgAnimalPoint = Vector3.zero;
        for (int animalIndex = 0; animalIndex < animals.Count; animalIndex++)
        {
            Animal animal = animals[animalIndex];
            if (animal == null) continue;
            avgAnimalPoint += animal.transform.position;
        }
        avgAnimalPoint /= animals.Count;
        avgAnimalPointRecalculation = Time.frameCount;
    }
    return avgAnimalPoint;
}

The average animal point is used to test whether the pack has drifted too far from its spawn area — if the average animal point diverges significantly from the average spawn point, the pack is steered back.

Flocking Behavior

Animals within a pack tend to move in the shared wander direction, creating the flocking behavior observable in-game. The wanderNormal is the same for all animals in a pack, so they drift as a group rather than scattering individually.

Spawning

giveAnimal (Admin/Plugin)

csharp
public static bool giveAnimal(Player player, ushort id)

Raycasts down from 16 units above the player's forward offset to find ground level, then calls spawnAnimal. Returns false if no suitable ground is found.

spawnAnimal (General)

csharp
public static void spawnAnimal(ushort id, Vector3 point, Quaternion angle)

First attempts to find a dead animal with the same ID to respawn it (via sendRevive), which avoids creating new Animal instances. If no dead animal is available, instantiates a new one, creates a temporary pack for it, and broadcasts via SendSingleAnimal.

Animal Revive and Respawn

When spawnAnimal finds a dead animal to respawn:

csharp
existingAnimal.sendRevive(point, Random.Range(0, 360f));

sendRevive repositions the animal and sets isDead = false. This is more efficient than destroying and recreating the GameObject.

Max Instances

The per-level-size instance limit is read from Provider.modeConfigData.Animals:

Level SizeConfig Key
TINYMax_Instances_Tiny
SMALLMax_Instances_Small
MEDIUMMax_Instances_Medium
LARGEMax_Instances_Large
INSANEMax_Instances_Insane

AI States and Replication

Four Primary States

StateRPCMethodDescription
StartleSendAnimalStartle / ReceiveAnimalStartlePlayStartleAnimation(animationIndex)Brief hesitation animation triggered by close approach (~10m)
AttackSendAnimalAttack / ReceiveAnimalAttackaskAttack(animationIndex)Move toward player and bite (dangerous animals: wolf, bear)
PanicSendAnimalPanic / ReceiveAnimalPanicaskPanic()Immediate flee at max speed (damage or sustained proximity)
State updateSendAnimalStates / ReceiveAnimalStatestellState(position, yaw)Batched position and yaw replication

Batched State Updates

State replication uses the same sequenced-batch pattern as zombies:

csharp
private static uint seq;
uint newSeq;
reader.ReadUInt32(out newSeq);
if (newSeq <= seq) return;
seq = newSeq;

Each animal in the batch is serialized with ushort index, compressed Vector3 position, and float yaw (degrees). The sequence number ensures out-of-order packets are discarded.

Replication Frequency

SendAnimalStates is sent at a fixed interval (every lastTick delta). The batch contains only animals that have moved since the last update:

  1. Fast-moving animals get updated every tick.
  2. Idle animals are excluded from batches (no state change to send).
  3. Out-of-order packets are discarded by the sequence gate.

Loot Drops

csharp
public static void dropLoot(Animal animal)

Two loot paths exist based on the animal asset:

  1. Reward table (animal.asset.rewardID != 0): resolves items through SpawnTableTool.ResolveLegacyId. Drop count is Random.Range(asset.rewardMin, asset.rewardMax + 1), clamped to [0, 100].
  2. Meat/pelt system: drops Random.Range(2, 5) of animal.asset.meat and animal.asset.pelt (if non-zero) as separate item spawns.

Serialization

Initial Replication

SendMultipleAnimals serializes every animal in the list with asset ID, position, yaw, and alive flag. Sent via SendInitialGlobalState when a client connects.

csharp
internal static void SendInitialGlobalState(ITransportConnection transportConnection)
{
    SendMultipleAnimals.Invoke(ENetReliability.Reliable, transportConnection,
        SendMultipleAnimals_Write);
}

The writer serializes:

ushort count
for each animal:
    ushort assetId
    Vector3 position (compressed)
    float yaw (degrees)
    bool isAlive

The client deserializes each animal via ReadSingleAnimal and calls manager.addAnimal(assetId, position, yaw, isAlive). If isAlive is false, the animal is immediately marked dead.

Dead/Alive State

SendAnimalAlive / SendAnimalDead replicate individual state transitions — used for revive and death events.

Animal Component Lifecycle

The Animal component tracks:

FieldTypePurpose
idushortAnimal asset ID
indexushortIndex in AnimalManager.animals
packPackInfoReference to the owning pack
assetAnimalAssetResolved asset reference
isDeadboolAlive/dead state

Level Data — Fauna.dat

Table Structure

LevelAnimals.load() reads Spawns/Fauna.dat:

byte version
byte tableCount
for each table:
    Color color
    string name
    ushort tableID
    byte tierCount
    for each tier:
        string tierName
        float chance
        byte spawnCount
        for each spawn:
            ushort animalID
ushort spawnpointCount
for each spawnpoint:
    byte type
    Vector3 point

Each table has:

  • Color and name (editor identification)
  • Table ID for spawn table resolution
  • Tier list: each tier has a name, weighted chance, and a list of animal IDs

Spawnpoints

Animal spawnpoints are stored as (type, position) pairs in the same file. Unlike zombie spawnpoints, animal spawnpoints are stored in a flat list, not a 2D region grid. The type field indexes into the table list.

Animal AI Behavior Details

Wander Behavior

Each animal pack has a wanderAngle initialized to a random direction. The pack's getWanderDirection() returns the normalized vector for this angle. Animals within the pack tend to move in this shared direction, creating the flocking behavior.

Startle, Panic, and Attack

StateTriggerEffect
StartleClose approach (~10m)Brief hesitation animation, then flee
PanicDamage or sustained proximityImmediate flee at max speed
AttackDangerous animal type (wolf, bear) + thresholdMove toward player and bite

The startle animation has an animationIndex parameter allowing different startle reactions per animal asset.

State Replication Frequency

The updates counter tracks how many state packets have been sent, useful for debugging replication issues. Idle animals are excluded from batches because they have no state change to send.

Plugin Patterns

AnimalManager has no static hooks, but plugins commonly:

PatternImplementation
Spawn animal at playergiveAnimal(player, animalId)
Respawn dead animalsIterate animals, check isDead, call spawnAnimal
Modify pack behaviorReplace/wrap Animal component methods
Custom loot tablesListen for dropLoot calls and override item drops
Animal count trackingSubscribe to animals list mutations via custom wrapper

Animal Spawning Internals

spawnAnimal In Detail

csharp
public static void spawnAnimal(ushort id, Vector3 point, Quaternion angle, bool isAdmin = false)
{
    // Step 1: Try to revive a dead animal first
    for (int i = 0; i < animals.Count; i++)
    {
        Animal animal = animals[i];
        if (animal != null && animal.id == id && animal.isDead)
        {
            // Revive: reposition and restore
            animal.sendRevive(point, angle.eulerAngles.y);
            SendAnimalAlive.Invoke(ENetReliability.Reliable,
                Provider.GatherRemoteClientConnections(),
                writer => {
                    writer.WriteUInt16(animal.index);
                    writer.WriteClampedVector3(point);
                    writer.WriteFloat(angle.eulerAngles.y);
                });
            return;
        }
    }

    // Step 2: No dead animal found — instantiate new one
    if (animals.Count >= GetMaxInstances()) return;

    // Check spawn region validity
    byte x, y;
    if (!Regions.tryGetCoordinate(point, out x, out y)) return;

    // Instantiate prefab
    Animal newAnimal = manager.addAnimal(id, point, angle.eulerAngles.y, true);

    // Create transient pack
    PackInfo pack = new PackInfo();
    pack.animals.Add(newAnimal);
    newAnimal.pack = pack;
    newAnimal.index = (ushort)animals.Count;
    animals.Add(newAnimal);

    // Broadcast to clients
    SendSingleAnimal.Invoke(ENetReliability.Reliable,
        Provider.GatherRemoteClientConnections(),
        writer => {
            writer.WriteUInt16(id);
            writer.WriteClampedVector3(point);
            writer.WriteFloat(angle.eulerAngles.y);
        });
}

Max Instance Enforcement

csharp
private static int GetMaxInstances()
{
    switch (Level.info.size)
    {
        case ELevelSize.TINY:   return Provider.modeConfigData.Animals.Max_Instances_Tiny;
        case ELevelSize.SMALL:  return Provider.modeConfigData.Animals.Max_Instances_Small;
        case ELevelSize.MEDIUM: return Provider.modeConfigData.Animals.Max_Instances_Medium;
        case ELevelSize.LARGE:  return Provider.modeConfigData.Animals.Max_Instances_Large;
        case ELevelSize.INSANE: return Provider.modeConfigData.Animals.Max_Instances_Insane;
        default: return 0;
    }
}

The instance limit prevents unlimited animal spawning via admin commands or plugin calls.

Pack Behavior Internals

Wander Steering

Each pack's wander behavior uses a steering algorithm:

csharp
// Called each frame per pack
private void UpdatePackWander(PackInfo pack)
{
    Vector3 avgPos = pack.getAverageAnimalPoint();
    Vector3 avgSpawn = pack.getAverageSpawnPoint();

    // Distance from home check
    Vector3 delta = avgPos - avgSpawn;
    delta.y = 0; // Ignore height

    if (delta.sqrMagnitude > 1600f) // 40m from home
    {
        // Steer back toward spawn area
        Vector3 homeDir = (-delta).normalized;
        pack.wanderAngle = Mathf.Atan2(homeDir.z, homeDir.x) * Mathf.Rad2Deg;
    }
    else
    {
        // Random drift within home range
        pack.wanderAngle += Random.Range(-5f, 5f) * Time.deltaTime;
    }
}

The 40-meter radius check keeps packs from wandering too far from their spawn zone.

Flocking Cohesion

Animals within a pack share the same wanderNormal direction. This creates the cohesive flocking effect without implementing a full boids algorithm:

  1. All pack members get the same wanderAngle.
  2. Each animal moves in that direction at its own speed.
  3. The average position centroid is frame-cached (recalculated once per frame) to avoid redundant vector math.

State Replication Details

SendAnimalStates Serialization

csharp
internal static readonly ClientStaticMethod<byte[]> SendAnimalStates = ...;

// Server-side write
SendAnimalStates.Invoke(ENetReliability.Unreliable,
    Provider.GatherRemoteClientConnections(),
    writer => {
        uint currentSeq = ++seq;
        writer.WriteUInt32(currentSeq);

        ushort count = 0;
        // Count animals that need updates
        for (int i = 0; i < animals.Count; i++)
        {
            Animal animal = animals[i];
            if (animal != null && !animal.isDead && animal.hasStateChanged)
                count++;
        }
        writer.WriteUInt16(count);

        // Write each animal's state
        for (int i = 0; i < animals.Count; i++)
        {
            Animal animal = animals[i];
            if (animal != null && !animal.isDead && animal.hasStateChanged)
            {
                writer.WriteUInt16(animal.index);
                writer.WriteClampedVector3(animal.transform.position);
                writer.WriteFloat(animal.yaw);
                animal.hasStateChanged = false;
            }
        }
    },
    k_ESteamNetworkingSendType_NoNagle);

Client-Side Receive

csharp
[SteamCall(ESteamCallValidation.ONLY_FROM_SERVER)]
public void ReceiveAnimalStates(in ClientInvocationContext context)
{
    uint newSeq = context.reader.ReadUInt32();
    if (newSeq <= seq) return; // Discard out-of-order
    seq = newSeq;

    ushort count = context.reader.ReadUInt16();
    for (ushort i = 0; i < count; i++)
    {
        ushort index = context.reader.ReadUInt16();
        Vector3 position = context.reader.ReadClampedVector3();
        float yaw = context.reader.ReadFloat();

        Animal animal = GetAnimalByIndex(index);
        if (animal != null)
            animal.tellState(position, yaw);
    }
}

The NoNagle send type is used because animal state updates are high-frequency (every tick for moving animals) and benefit from low latency over packet coalescing.

Loot Drop System Detail

Reward Table Resolution

csharp
public static void dropLoot(Animal animal)
{
    // Path 1: Reward table (preferred)
    if (animal.asset.rewardID != 0)
    {
        ushort resolvedId = SpawnTableTool.ResolveLegacyId(
            animal.asset.rewardID, EAssetType.ITEM,
            (error) => UnturnedLog.error($"Animal {animal.asset.name} reward table error: {error}"));

        int dropCount = Random.Range(animal.asset.rewardMin,
            Math.Min(animal.asset.rewardMax + 1, 101)); // Clamped to [0,100]

        for (int i = 0; i < dropCount; i++)
        {
            ItemManager.dropItem(
                new Item(resolvedId, EItemOrigin.NATURE),
                animal.transform.position + Random.insideUnitSphere * 0.5f,
                false, true, true);
        }
        return;
    }

    // Path 2: Meat/pelt system
    int meatCount = Random.Range(2, 5); // 2-4 meat pieces
    for (int i = 0; i < meatCount; i++)
    {
        if (animal.asset.meat != 0)
            ItemManager.dropItem(
                new Item(animal.asset.meat, EItemOrigin.NATURE),
                animal.transform.position + Random.insideUnitSphere * 0.5f,
                false, true, true);
    }

    // Pelt (if configured)
    if (animal.asset.pelt != 0)
    {
        int peltCount = Random.Range(2, 5);
        for (int i = 0; i < peltCount; i++)
        {
            ItemManager.dropItem(
                new Item(animal.asset.pelt, EItemOrigin.NATURE),
                animal.transform.position + Random.insideUnitSphere * 0.5f,
                false, true, true);
        }
    }
}

Drop Scatter

The Random.insideUnitSphere * 0.5f ensures dropped items scatter in a 0.5m radius from the death point, preventing item stacking and making the loot feel natural.

Animal Asset Configuration

AnimalAsset Fields

FieldTypePurpose
idushortAsset ID for lookup and spawning
meatushortItem ID for meat drops
peltushortItem ID for pelt drops
rewardIDushortSpawn table ID for reward drops
rewardMinushortMinimum reward drop count
rewardMaxushortMaximum reward drop count
speedRunfloatMovement speed when fleeing
speedWalkfloatMovement speed when wandering
healthushortMaximum health
attackDamagefloatDamage per bite

Common Issues

  1. Pack desync: When animals in a pack die and respawn at different times, they may end up in different packs temporarily. The spawn system handles this by creating transient single-animal packs.

  2. Instance cap during events: Server events that spawn many animals simultaneously can hit the per-level-size instance cap. Plugins should check animals.Count < GetMaxInstances() before spawning.

  3. Loot table errors: If rewardID references an invalid spawn table, SpawnTableTool.ResolveLegacyId returns 0, and no loot drops. The error is logged to console.

  4. Pose calculation on death: Dead animals stop updating their position in state batches but remain in the animals list. This ensures they can be revived without losing their index.

Document history