Companion Dogs/Modding
Go to the Features manualGo to the Breeds manual
Join the Discord

Companion Dogs: Modding Manual

How to build your own add-on: a new breed, or a whole new species, as a separate Workshop mod that plugs into Companion Dogs.

This manual is for people who write mods. If you only want to play, read the other two manuals: Features for what the dog does, and Breeds for the breeds.

It assumes you can read Lua and that you have a rigged, animated model for your animal. The art pipeline is not covered here. Section 2 lists what the model file has to satisfy.

Everything named in this manual is a public contract and is meant to be called from outside the mod. Anything not named here is internal and can change in any release without notice.

1. What an add-on is

An add-on is a normal Workshop mod that declares require=CompanionDogs and calls one function at load time. It adds one breed (or several, if they share a body) and nothing else.

The base owns:

  • the animation state machine, the animation set and the skeleton
  • following, pathing, combat, hunting, herding, the sentinel, needs and upkeep
  • the whole user interface: the dog window, the radial, the context menu, the kennel, the map marker
  • taming, bonding, breeding, puppies and crossbreeds
  • the sandbox options, and the multiplayer replication

Your add-on owns:

  • the numbers that make your animal different from a Caramelo
  • its body model, its texture, its portrait and, if it has one, its moodle icon
  • where it is found in the world
  • its name and description, in every language you ship

Two colours that behave the same are still two breeds to the base (two names, two entries in the kennel); you write them from one shared table, the way CD: Cats does with its five coats.

2. What you need before you start

Your animal needs its own skinned .glb under media/models_X/Skinned/, with the complete set of 21 Rac_* animation clips. Clips are not inherited between model files: a model with fifteen clips gives you an animal that freezes the first time it is asked for one of the other six. The model also has to stay under 60 bones and have its top node at identity, or the engine throws once per frame and the animal renders as a black smear. Two more clips are optional, Rac_WalkLimpFront and Rac_WalkLimpBack (limping walk cycles); they only matter if you set limpAnim = true in the breed definition.

If you do not have a model yet, you can write and test everything else by pointing CD.applyDogModel at one of the base bodies. The animal looks like a Caramelo, but every system in this manual runs.

You also need a texture for the body and a portrait for the kennel window.

Decide whether your animal is a breed or a species before writing any code. A breed is a dog with different numbers. A species is an animal that lacks some of the jobs entirely. The choice changes which fields you write; section 6 covers it.

3. The files of an add-on

This is CD: Pug, the smallest published add-on, with the parts that matter:

CompanionDogsPug/
  42/
    mod.info
    icon.png
    poster.png
    media/
      models_X/Skinned/Pug_Body.glb
      scripts/models_pug.txt
      textures/Body/Pug.png
      textures/CDPortrait_pug.png
      lua/shared/CompanionDogsPug_Breed.lua
      lua/shared/Definitions/animal/PugDefinitions.lua
      lua/shared/Definitions/animal/CompanionDogsPug_Parts.lua
      lua/client/CompanionDogsPug_Moodle.lua
      lua/shared/Translate/EN/IG_UI.json
      lua/shared/Translate/PTBR/IG_UI.json
  common/

And the mod.info:

id=CompanionDogsPug
name=CD: Pug
description=Pugs for Companion Dogs. REQUIRES the Companion Dogs mod (0.6.8 or newer).
poster=poster.png
icon=icon.png
require=CompanionDogs
modversion=0.1.0
pzversion=42.19.0
versionMin=42.18.0

require=CompanionDogs is the important line. It guarantees that all of the base Lua has run before any of yours, in every phase: shared first, then client, then server. Without it your files can load first and every CD. call is a nil index.

versionMin cannot express "needs Companion Dogs 0.6.8". That field gates the game build only, and there is no mod.info field for a dependency version. The floor on the base is enforced by the guard in the next section and announced to players in your description. Write it there, in words, or the only symptom a player gets is an animal that never appears.

4. The version guard

Put this at the top of every Lua file of your add-on, with the number your add-on needs:

local CD = CompanionDogs
if not (CD and CD.registerBreed and (CD.API_VERSION or 0) >= 3) then return end

With the base missing or too old, the file returns on line two and the add-on does nothing: no breed, no spawn, no moodle, no log line.

The guard is per file, and each file guards on what it uses. PugDefinitions.lua needs the model helpers, so it guards on those:

if not (CompanionDogs and CompanionDogs.applyDogModel and CompanionDogs.DOG_SOUNDS) then return end

Do not create the global. CompanionDogs = CompanionDogs or {} in an add-on turns a missing base into a half built table, and every guard downstream passes when it should not.

5. Registering the breed

One call, in a shared file:

CD.registerBreed({
    key = "pug",
    engineBreed = "pug",
    typePrefix = "pug",
    nameKey = "IGUI_PD_Breed_pug",
    litter = { 1, 2 },
    xpMult = { scent = 1.5, combat = 1, obedience = 2.0, hunt = 1.2, herding = 1.0 },
    combatPower = 0.15,
    lethalityCurve = { min = 0.40, max = 1.3 },
    canKill = false,
    canKnockdown = false,
    combatStressMult = 0.9,
    panicThreshold = 0.70,
    bagMult = 0.6,
    sentinelMult = 1.35,
    barkNoiseMult = 1.5,
    alertModeLocked = true,
    loyaltyDecayMult = 0,
    geneRange = {
        strength       = { 0.00, 0.10 },
        aggressiveness = { 0.05, 0.20 },
        resistance     = { 0.00, 0.15 },
        stress         = { 0.55, 0.85 },
    },
    spawns = {
        { id = "pughouse", class = "house", suffix = "|pg", breed = "pug",
          chance = function() return CD.strayChancePerHouse() / CD.PUG_HOUSE_RARITY end },
    },
})

The call inserts the breed, registers its three animal types, rebuilds the breeding order so your animal takes part in crossbreeding, and registers the spawn steps.

A bad definition makes the call return nil instead of throwing, so the load goes on. Each rejection prints a named line in the log.

Required fields

Field What it is
key your breed identifier, unique across every mod. Do not change it after release.
typePrefix prefix of the three animal types: <prefix>pup, <prefix>female, <prefix>male. Picks the body mesh.
nameKey translation key of the display name.
xpMult learning speed per skill: scent, combat, obedience, hunt, herding. 1.0 is normal.
combatPower how hard it hits. The Caramelo is 0.20, a fighting breed is above 1.0.
lethalityCurve { min, max }, how the damage grows from Combat 0 to Combat 10.
geneRange birth range of the four genes: strength, aggressiveness, resistance, stress. Each is { low, high } inside 0 to 1.

Optional fields

Field Default What it does
engineBreed the key breed name given to the engine. It picks the texture. Unique per breed.
descKey IGUI_PD_BreedDesc_<key> description key. Several breeds can share one key.
litter base value { min, max } puppies per birth.
canKill true false means it wears zombies down but never lands the killing blow.
canKnockdown false true lets it knock a zombie over.
combatStressMult 1 how much stress a fight costs it.
panicThreshold base value stress level at which it stops fighting. panicImmune = true means it never stops.
bagMult 1 saddlebag capacity multiplier.
puppySize 0.6 absolute visual scale of the puppy. A small breed has to set it, or the puppy is born adult sized.
sentinelMult 1 multiplies the final sentinel radius.
barkNoiseMult 1 scales the radius and volume of the alarm bark. With the sandbox noise option off, no breed attracts zombies with a bark, whatever the value.
loyaltyDecayMult 1 multiplies the daily loyalty decay. 0 means the bond never fades.
alertModeLocked false the animal stays on full alert: the owner cannot set quiet or silent anywhere.
huntFetchLevel 6 Hunt level from which it carries the kill back to its owner.
huntDeliverTimeoutMin 5 game minutes it keeps trying to deliver before giving up.
distract off { <kind> = { chance = 0..1, ... } }. The breed goes after things on its own, with no mode and no level required.
idleAnimMs base value length of the idle animation window, in milliseconds. Set it when your clips are shorter than the dog ones, otherwise the loop restarts and cuts the gesture in the middle.
restAnim true false keeps the animal standing on Stay and Guard instead of lying down.
restPoses lie down a list of resting poses the animal draws from every time it settles, with optional transitions and idle variations. Needs clips and animation nodes of your own.
limpAnim false true makes a wounded animal limp while walking. Only set it when your model has the two optional clips Rac_WalkLimpFront and Rac_WalkLimpBack (1.0 s walk cycles with the same root motion as Rac_Walk). Without the clips the animal walks frozen in place.
bandSkin off { base, front, back, cutFront, cutBack }, five body texture names. The animal wears the cut variant while the wound bleeds and the banded one while it is dressed, on the wounded paw. Build the four variants with _dogrig/forge/_paw_band.py. Without it the animal still gets wounded, it just shows no mark.
voices dog sounds { bark, growl, idle, wildbark, pet, whine, eat, drink }.
diet the dog lists what the animal must not eat, what counts as meat for it, and what it eats on its own from a trough.
maleChance 0.5 share of the breed born male, 0 to 1.
sterileMale false true keeps males of the breed out of breeding.
species "dog" breeding is closed per species. Section 6.
skills, canBreed, huntMaxPrey everything on the structural blocks. Section 6.

Numbers and flags you add to the definition can be read back with CD.breedNumber(animal, field) and CD.breedFlag(animal, field), not with CD.getBreedDef(animal).field. They handle a missing field and keep working when the base changes.

Instinct: distract

distract = {
    prey = { chance = 0.25 },
},

A breed that declares it will, every so often, notice something and go after it, dropping whatever it was doing. It does this from Stay as well as Follow. While it lasts, orders come back refused with the distracted message the obedience roll already uses. Come here is the one command that gets through, and it cancels the distraction.

Every kind takes the same optional knobs. Anything you leave out uses the mod's defaults:

Field What it does
chance 0..1, rolled only when the kind's trigger found something. Writing 25 for "25%" is rejected with a named line in the log
radius tiles the trigger searches, when the kind searches at all
durationMin game minutes the window lasts before it expires on its own
cooldownMin game minutes before the same animal can be distracted again

The prey kind

The only kind the base mod ships. The animal goes after wild animals, and classes picks which ones count:

distract = {
    prey = { chance = 0.25, radius = 6, classes = { tiny = true, small = true } },
},

Two limits apply whatever you declare:

  • It never goes above your huntMaxPrey: a breed capped at small will not chase deer even if it declares large.
  • The level gate is skipped only for tiny. A freshly tamed animal with Hunt 0 already catches rodents. With small declared, the breed chases the rabbit at any level, but the kill needs the same Hunt level the Labrador needs.

The sandbox Hunting toggle only affects the kill: the animal still chases, but the prey gets away.

A kill made this way goes through the normal retrieval, so your breed's huntFetchLevel decides whether it carries the body back to the owner. At 0 the animal delivers from day one, which is how a cat brings you a dead rat. The delivery outlives the distraction window: once the prey is dead the animal takes orders again, and the trip home runs on its own huntDeliverTimeoutMin.

Writing your own kind

distract is a dispatcher. Register your own:

CD.registerDistraction("butterfly", {
    gate  = function(animal, cfg) return animal:isOutside() end,   -- optional, cheap pre-check
    find  = function(animal, cfg) return findButterflyNear(animal, cfg.radius) end,
    drive = function(animal, owner, d, cfg) return chaseIt(animal, d) end,
    stop  = function(animal, d) end,                               -- optional, cleanup
})

Then declare it on the breed like any other kind: distract = { butterfly = { chance = 0.10 } }.

gate runs before the mod's global scan budget. find runs after the budget and does the expensive search. drive returns true while it is still driving the animal and false when it is done, which closes the window. Your handler runs in its own pcall. If it throws, the mod logs a named line, unregisters that kind for the session and leaves the rest of the companion working.

Names reserved for future base kinds: drink, eat, play. You can register a kind under one of those names today, but the base takes the name over when it ships its own.

Resting poses

A resting animal lies down. restPoses replaces that single pose with a list the animal draws from every time it settles:

restPoses = {
    "cdRest",                                          -- the base lie-down
    { var = "cdSit", enter = "cdSitIn", exit = "cdSitOut",
      variation = { "cdSitGroom", "cdSitGroom2" } },
},

An entry is either the name of an animation variable or a table. var is the boolean that stays on for the whole pause. enter and exit are one-shot pulses played as the animal settles and as it gets up. variation is a list of one-shot pulses it plays now and then while it holds the pose, on the same clock as the idle variation. Only var is required.

The draw is uniform and happens on each entry into rest, so two entries mean 50% each and the animal can pick the same one twice in a row. It is not an alternation.

Each pulse lasts as long as idleAnimMs says for that variable, so pass the table form and give a length for every variable, including the idle ones you already had. Give the real length of the clip.

idleAnimMs = { cdIdle2 = 3800, cdIdle3 = 3800, cdSitIn = 1375, cdSitOut = 1417,
               cdSitGroom = 19833, cdSitGroom2 = 25167 },

The clips and the nodes are yours, not the base's. Each variable needs a node file in your add-on under media/AnimSets/raccoon/idle/, and the clip its m_AnimName points at has to exist in your model. A node pointing at a clip the model does not have fails silently and the animal just stands in the base idle. Give the pose node a m_ConditionPriority of 10 like the base ones, and a higher number to anything meant to play over it: 11 for a variation, 12 for the two transitions. A node plays its clip on a loop unless you say otherwise, so every node that is meant to play once -- both transitions and every variation -- needs <m_Looped>false</m_Looped>. Without it the clip restarts the moment it ends and the animal visibly snaps back and does it again, because the variable only goes off on the next server tick. Keep them in idle/ only. A pose node in pathfind/ plays a clip with no root motion and freezes the animal where it stands.

The voice

Without voices every breed uses the dog sounds, so a cat would bark. Register your own sounds first:

CD.registerVoices({
    CDCatMeow = "bark", CDCatGrowl = "bark", CDCatHiss = "bark", CDCatPurr = "bark",
    CDCatPet = "fx", CDCatMeowAmbient = "ambient",
})

Then point the breed at them:

voices = { bark = "CDCatMeow", growl = "CDCatGrowl", idle = "CDCatPurr",
           wildbark = "CDCatMeowAmbient", pet = "CDCatPet", whine = "CDCatHiss" },

Without CD.registerVoices the sounds still play, but they ignore the player's category slider and the game's effect volume, and nothing in the log points at it. A key you leave out of voices falls back to the dog sound.

whine is what the animal says when it gets hurt or falls ill. eat and drink are the feeding foley. The dog ones are loops the base stops when the animal is done, so if yours loop as well, add them to CD.SOUND_LOOPED and the base also stops them when the listener walks out of range. A base older than API 11 ignores the three keys and plays the dog sounds.

The third argument is the audible range of each sound in tiles. It should match the distanceMax you wrote in your own sound script:

CD.registerVoices(map, nil, { CDCatMeow = 22, CDCatGrowl = 10 })

On a server the base sends a sound only to the players inside that range. Leave the range out and the voice gets a generic range by category: a hiss that carries ten tiles goes out to everyone within thirty. Past distanceMax FMOD's inverse rolloff stops attenuating instead of going silent, so a loose .ogg keeps playing at distanceMin / distanceMax of its volume at any distance. Keep distanceMin small on your loud voices for the same reason.

The diet

Without diet every breed eats like a dog: the same food that poisons it, the same food that counts as meat, and the same food it grazes from a feeding trough. The field overrides those three lists for your breed only.

diet = {
    bad          = { Fruits = true },              -- fruit now makes it sick
    badParts     = { onion = false },              -- onion no longer does
    protein      = { Cheese = true },              -- cheese now counts as meat
    proteinParts = { tuna = true },                -- and so does anything with "tuna" in its type
    trough       = { Vegetables = false },         -- it will not graze vegetables
    troughItems  = { ["Base.CatFoodOpen"] = true },
},

The two forms exist because the game's own categories only go so far. Cheese is a real FoodType, so one key covers every cheese in the game and in any food mod that reuses the category. Tuna is not: the open tin is FoodType = Fish, together with every other fish, and the sealed tin declares no food type at all. badParts and proteinParts are how you reach those.

Every field is a table of <key> = true (add) or <key> = false (drop what the base had). A value that is not true or false is dropped with a named line in the log.

Field What its keys are
bad FoodType of food that makes the animal sick, costs it loyalty and gets a red entry in the feed menu
badParts a piece of the item type, lowercase, matched anywhere in it
protein FoodType that pays the animal's meat debt, the one the Weak moodle watches
proteinParts a piece of the item type, same matching as badParts
nonProtein FoodType you know is not meat. It only silences the log line the base writes for a food type it has never seen
trough FoodType and AnimalFeedType the animal eats by itself from a feeding trough
troughItems full item type, for an item the trough holds that has no feed type of its own

replace = true starts from an empty list instead of the dog one. Use it when your animal shares almost nothing with a dog; a cat is usually easier to write as a handful of additions and removals.

The three lists follow the animal, so a dog and a cat standing at the same trough eat different things from it. The mod's own bowl is the exception: it stores food points, not the item that filled it, so filling it goes by the base list for everyone. Nothing poisonous can be put in a bowl anyway.

Sex at birth

Every animal is drawn male or female when it spawns, half and half. maleChance is the share born male, from 0 to 1, and sterileMale keeps the males of the breed out of breeding.

maleChance = 0.00033,
sterileMale = true,

That pair is the calico cat. The orange and black mosaic is carried on the X chromosome, so a calico is almost always female, and the male that does turn up is XXY, about one in three thousand, and cannot father a litter. A male of a sterileMale breed is never picked as a partner, and the option to breed disappears from its context menu. The females breed normally.

A male of such a breed is tagged "Sterile" wherever the game shows its sex: the status panel, the context menu, the stray scan and the kennel, so the player knows why it does not breed.

Both fields are read from the breed definition, so nothing is written on the animal and an existing save needs no migration. The same draw decides the sex of a puppy at birth, so a breed that is female in the world stays female in the litters.

6. Turning a whole system off

The five skills are the five systems:

Skill The system it is
scent the sentinel: noticing zombies and warning you
combat fighting, the Guard mode and auto-protect
obedience the trick tree
hunt hunting, plus the foraging bonus it gives its owner
herding tending livestock in a pen

So a species declares what it does not have:

skills = { combat = false, herding = false },
canBreed = false,
huntMaxPrey = "tiny",

Absent or true means the animal has that skill.

Turning a skill off also stops it earning experience and removes the bar and its commands from the interface.

canBreed = false is separate from the table because breeding has no bar and no experience. The animal does not conceive, is never picked as a partner, and does not breed with its own kind either.

The species

An add-on that adds another animal, rather than another dog, declares it:

CD.registerSpecies({ key = "cat",
                     nounKey  = "IGUI_PD_SpeciesNoun_cat",
                     youngKey = "IGUI_PD_Young_cat" })

CD.registerBreed({ ..., species = "cat" })

The field is absent on every breed published so far, and absent means "dog".

Breeding is closed per species: a pair only conceives when both sides are the same species, in the command, in the passive hourly roll and in the debug tool. And the interface stops calling your animal a dog: nounKey is the bare noun that fills sentences like "This %1 already belongs to another survivor", and youngKey is the label the young of your species carries in the window, the context menu and the kennel, where a dog says Puppy.

Those two nouns only cover the sentences built around a %1. Every other sentence with the word dog written into it ("Inspect dog", "Your dog is hungry", "The dog barks on purpose") takes a per-species key from API 7 on: ship IGUI_PD_ScanStray_cat in your translation files and the base shows it instead of IGUI_PD_ScanStray whenever the animal on screen is a cat. A key you did not ship falls back to the dog's text. The keys that accept the suffix: ScanStray, ScanTitle, PauseGrowthTip, BadFood, AlertFullDesc, AlertQuietDesc, AlertLockedTip, HuntModeDesc, every Trick*Desc, TrickDistractDone, TrickNeedsBag, TrickGotoPick, TrickSniffNoItem, TrickFetchNoItem, AlertGroup, DogLost, every Refused_*, KennelStatusNoData and the Moodle_*_desc of the care moodles (Sick, Weak, Hunger, Thirst, Rested, Grief). Sentences with no animal on screen (the registry tab, "Befriend an animal first") are already species-neutral in the base. The cat ships all of them in fourteen languages; copy its IG_UI.json as the template.

The animal's group is only read by the debug spawn menu and the clip viewer, through IGUI_Animal_Group_<group>. Set t.group = "cat" after the behaviour helper and ship that key, or your species shows up there under the dog's name.

Declaring species without registering it is not fatal: your animal still refuses to breed outside its own species, because the gate compares the raw string. Only the two nouns fall back to the dog's, and the log says so by name.

On a base older than API 6 the field is ignored without a log line, and your animal breeds as a dog. If it must never cross with one, guard on API 6, or keep canBreed = false while the base is older, which is what the cat does.

huntMaxPrey caps the prey size inside a hunt that still exists; it does not replace hunt = false. It takes "tiny" (mice, rats, squirrels), "small" (rabbits, raccoons) or "large" (deer, the default, no cap). Your animal does not chase or point at prey above the cap. A value outside that ladder falls back to "large" and prints a named line in the log.

A blocked option disappears from the radial, the context menu and the window, and the server refuses it without a message. None of these fields adds a translation key, so explain the limits in your breed description.

7. The animal definitions

The engine binds the mesh to the animal type rather than the breed, so your breed needs three types of its own. The shortest path is to copy the file of an existing add-on and rename it.

The three pieces, in one shared file under Definitions/animal/:

AnimalDefinitions.stages["pug"] = {}
AnimalDefinitions.stages["pug"].stages = {}
AnimalDefinitions.stages["pug"].stages["pugpup"] = {}
AnimalDefinitions.stages["pug"].stages["pugpup"].ageToGrow = 3 * 30
AnimalDefinitions.stages["pug"].stages["pugpup"].nextStage = "pugfemale"
AnimalDefinitions.stages["pug"].stages["pugpup"].nextStageMale = "pugmale"
AnimalDefinitions.breeds["pug"].breeds["pug"].texture = "Pug"
AnimalDefinitions.breeds["pug"].breeds["pug"].textureMale = "Pug"
AnimalDefinitions.breeds["pug"].breeds["pug"].rottenTexture = "Raccoon_Rotting"
AnimalDefinitions.breeds["pug"].breeds["pug"].invIconMale = "CDDogPaw_64"
AnimalDefinitions.breeds["pug"].breeds["pug"].invIconMaleDead = "CDDogPawDead_64"
local pugfemale = {}
CD.applyDogModel(pugfemale, "Pug_Body")
CD.applyDogBehaviour(pugfemale)
pugfemale.female = true
pugfemale.babyType = "pugpup"
pugfemale.minSize = 1.5
pugfemale.maxSize = 2.0
pugfemale.minWeight = 5
pugfemale.maxWeight = 8
pugfemale.wildFleeTimeUntilDeadTimer = CD.STRAY_NO_BLEEDOUT
pugfemale.breeds = AnimalDefinitions.breeds["pug"].breeds
pugfemale.stages = AnimalDefinitions.stages["pug"].stages
pugfemale.genes = AnimalDefinitions.genome["dog"].genes
AnimalDefinitions.animals["pugfemale"] = pugfemale

The helpers the base gives you:

Helper What it fills in
CD.applyCompanionModel(t, "Body_Model") body model, skeleton, butchered textures and the animation set
CD.applyCompanionBehaviour(t) every engine behaviour flag a companion needs
CD.applyCompanionAvatar(t) the portrait camera for the animal window
CD.COMPANION_SOUNDS the shared footstep and foley table
CD.defineGenome("species") the genome of a new species, with the standard gene list (API 7)
CD.STRAY_NO_BLEEDOUT the value that stops a frightened stray from bleeding to death
CD.defineCompanionParts(typePrefix, engineBreed, meat) the butchering parts of all three stages. meat is optional: { item, minNb, maxNb, pupMinNb, pupMaxNb }, and without it the animal yields the base's dog meat. The engine multiplies both the count and the hunger of every piece by the carcass size, so a species with a large size (the cat uses 2.5 to 3.5 as a visual scale) needs its own item with a smaller base hunger and a low count

The species-neutral names arrived with API 7. The older ones, CD.applyDogModel, CD.applyDogBehaviour, CD.applyDogAvatar, CD.DOG_SOUNDS, CD.defineDogParts and CD.DogMoodles, are the same functions and tables under their first name. An add-on that wants to load on a base older than 0.7.3 guards on the old names and picks the new one when it exists: local applyModel = CD.applyCompanionModel or CD.applyDogModel.

The animation set has to stay "raccoon". A forked animation set does not load its state machine, and the animal stands frozen in place.

Reuse the genome. A dog breed points at the base's list: AnimalDefinitions.genome["dog"].genes. A new species calls CD.defineGenome("cat") once and gives every stage the table it returns. The engine only reads the genes list of each stage, and the genome key is a convention, so your species gets its own entry without a gene list of its own.

Do not declare mate. With it, the engine runs its own native mating inside any animal zone, which ignores the mod's breeding block and its sandbox options, and produces puppies with no bond. The base clears the field at boot, but do not rely on that.

Parts are mandatory. Three lines in their own file:

if CompanionDogs and CompanionDogs.defineDogParts then
    CompanionDogs.defineDogParts("pug", "pug")
end

Without them, butchering your animal crashes the vanilla code, because it indexes the parts definition by type and breed and hits a nil.

The portrait zoom in CD.applyDogAvatar is calibrated for a dog, and avatar zoom is magnification: the smaller the animal, the bigger the number. Divide the zoom by the size ratio against a dog and multiply the offsets by it, or your animal sits tiny at the bottom of its own portrait.

8. The model script

One file under media/scripts/ tells the game your model exists:

module Base
{
    model Pug_Body
    {
        mesh = Skinned/Pug_Body,
        shader = animalEffect,
        static = false,
        animationsMesh = PugAnim,
        attachment head
        {
            offset = -0.0061 0.1671 0.0507,
            rotate = -179.0 -5.0 95.0,
            bone = Dummy01,
        }
    }

    animationsMesh PugAnim
    {
        meshFile = Skinned/Pug_Body,
        keepMeshAnimations = true,
    }
}

keepMeshAnimations = true keeps the clips inside your model file usable. Without it the model loads and never animates.

The head attachment is where hats and the like sit. If you want the saddlebag to fit your animal, add saddlebags_l, saddlebags_r and saddlebags_c the same way, on the spine bone. Find those numbers by looking at the animal in game and nudging them. Values measured from the model put the bag in the geometric centre of the mesh, which is not where it looks right on the body.

9. Where the animal spawns

Strays are rolled per building, per chunk, the first time that chunk loads. You describe your rolls as a list of steps, either inside the breed definition as spawns, or with CD.registerStraySpawns(list).

spawns = {
    { id = "pughouse", class = "house", suffix = "|pg", breed = "pug", indoor = 85,
      chance = function() return CD.strayChancePerHouse() / CD.PUG_HOUSE_RARITY end },
    { id = "pugpetvet", class = "petvet", suffix = "|pv", breed = "pug",
      chance = function() return CD.PUG_PETVET_CHANCE * CD.dogSpawnMultiplier() end },
},
Key What it is
id name of the step, for your own logs
class the building class it rolls on
chance a function returning a percentage, so it can read the sandbox multiplier at roll time.
suffix key of the persistent store. Unique across all mods, and never changed.
breed the breed key to spawn
gate optional function returning a boolean, checked before the roll. The Husky uses CD.isWinter.
indoor percentage chance of being born inside the building instead of the street. Default 35.

Multiply your chance by CD.dogSpawnMultiplier(), or divide CD.strayChancePerHouse() by a rarity of your own. Both keep the player's sandbox setting working.

indoor is a preference: a building with no free interior tile in that chunk falls back to a tile outside.

The building classes

The base registers four:

Class Matches
house residential buildings that are not shops
police police stations and the like
petvet pet shops and veterinary clinics
farm farm buildings, and it is allowed outside town

If none of them fits, register your own:

CD.registerBuildingClass("mineshaft", function(def) return def:isX() end,
                         { skipUrbanGate = true })

The match function receives the building definition and runs inside a protected call, so an error in it does not stop the chunk load. skipUrbanGate lets the class roll in a chunk that is not urban, which a farm or a forest cabin needs. Do not set exclusive. The base classes are exclusive among themselves, and an add-on class matches on top of them.

Suffixes already taken

The suffix is part of the key under which "this building was already rolled" is saved. Two mods using the same suffix corrupt each other's rolls.

Every suffix starts with a vertical bar. These are already in use:

  • Base: the empty suffix, and g, h, hv, bc, gh, hh, bh
  • Rottweiler: rw, r
  • Doberman: db, dm, dh
  • Labrador: lb, lk
  • Pug: pg, pv
  • Malinois: ml, mm, mh
  • Cats: every suffix that starts with ct, cv or cs (one set per coat)

10. The breed moodle

A moodle is a client file. You append a table to CD.DogMoodles, and the base draws it, tracks it and cleans it up:

CD.DogMoodles[#CD.DogMoodles + 1] = {
    id = "shadow",
    breed = "pug",
    nameKey = "IGUI_PD_Moodle_Shadow",
    descKey = "IGUI_PD_Moodle_Shadow_desc",
    icon = "CD_Moodle_Shadow",
    fg = "CD_MoodShadowFG",
    tintR = 0.92, tintG = 0.52, tintB = 0.68,
    condition = function(player, dog)
        if not dog or dog:isDead() then return 0 end
        if CD.getBreed(dog) ~= "pug" then return 0 end
        if CD.isDisloyal(dog) then return 0 end
        return 1
    end,
    apply = function(player, dog, elapsedMin)
        CD.relieveMood(player, CD.moodleReliefPerMin() * elapsedMin)
    end,
}

condition returns the level, or 0 for "not showing". apply receives the game minutes since the last call, so multiply your effect by elapsedMin.

Ship the icon in six sizes, from 32 to 128 pixels. The interface picks the size by the player's scaling, and a missing size shows as an empty square. The icon appears in the mod's own strip, not among the vanilla moodles.

11. Hooks

Two lists of functions the base calls on the server. Append to them from a shared file. Each handler runs inside its own protected call, so an error in one add-on does not stop the base.

CD.onHuntDelivered, from API 2, is called right after the animal drops a kill at its owner's feet:

CD.onHuntDelivered[#CD.onHuntDelivered + 1] = function(animal, owner)
    -- server side, so this is the right place to write mod data
end

CD.onUpkeepStress, from API 3, is called every upkeep cycle, after the base has added the stress from unmet needs and before it is written. Return a number, which is added to the total:

CD.onUpkeepStress[#CD.onUpkeepStress + 1] = function(animal, needsStress)
    return CD.pugHeatRatio(animal) * CD.PUG_HEAT_STRESS_MAX
end

Upkeep recomputes and overwrites the value every cycle, so anything written from outside is lost on the next tick. Use the hook for a condition that comes from the environment, such as heat, cold or rain.

In multiplayer, the client never writes mod data. A client side write overwrites the server's copy on the next sync, and the symptom shows up minutes later somewhere unrelated. Do the work in a server file, or in one of these hooks.

The numbers in the SERVER TUNING panel are plain CD.* constants, and an add-on may assign one from a shared or server file at load. From API 9 the panel takes the value in effect when the server finishes loading as that knob's default: it stays until an admin types over it, the reset button returns to it, and the panel shows it as the default. On an older base the panel wrote the stock value back on every boot. Assign at load, not from a later event, or the next change to any knob puts the stock value back.

12. Translation keys

Ship one IG_UI.json per language folder, under media/lua/shared/Translate/<LANG>/. The base ships fourteen: EN, PTBR, CH, CN, DE, ES, FR, IT, JP, KO, RU, TH, UA, VI.

The keys a breed needs:

Key Where it shows
IGUI_PD_Breed_<key> the breed name, everywhere in the mod's interface
IGUI_PD_BreedDesc_<key> the description on the animal's card
IGUI_Breed_<engineBreed> the breed name in the game's own screens
IGUI_AnimalType_<type> one for each of your three types
your species keys the nounKey and youngKey you passed to CD.registerSpecies
your moodle keys the name and the description
{
    "IGUI_Breed_pug": "Pug",
    "IGUI_AnimalType_pugmale": "Pug (male)",
    "IGUI_AnimalType_pugfemale": "Pug (female)",
    "IGUI_AnimalType_pugpup": "Pug (puppy)",
    "IGUI_PD_Breed_pug": "Pug",
    "IGUI_PD_BreedDesc_pug": "The house dog: the first companion whose worth is not fighting."
}

A literal percent sign in a translated string crashes the screen that shows it, because the formatter reads it as a placeholder. A language file with a byte order mark does not compile, and the error names a different file than the broken one.

13. What the save remembers

Three of your identifiers are written into save files and read back forever:

  • key, the breed identifier stored on every animal of yours
  • typePrefix, which forms the animal types the engine stores
  • suffix, the key of the "already rolled" store, one per spawn step

None of the three can be renamed after release. Renaming a key orphans every living animal of that breed. Renaming a suffix makes every building in every existing save roll again, and a world played for months fills with your animal.

A new suffix is also how a new breed reaches worlds that are already running. CD: Cats gave its four new coats new suffixes, so they started appearing in existing saves, while the original black cat kept its old suffix and did not roll again.

Removing an add-on from a save that has living animals of that breed loses them. The engine discards an animal whose type it no longer knows. The base does not crash, and the bond and the kennel degrade cleanly. Say so on your Workshop page.

If your add-on is missing, the base falls back to the default breed for anything it is asked about, so a save opened without your mod is readable.

14. Before you publish

  • The guard is on every Lua file, and names the API you actually use.
  • The base version floor is written in your description, in words. versionMin cannot say it.
  • You read the log on first load. CD.registerBreed rejects in silence otherwise, and every rejection prints a named line.
  • CD.defineDogParts is called. Butcher one of your animals and see that it does not crash.
  • Your key, typePrefix and spawn suffixes are unique, and you will not need to change them.
  • A small breed sets puppySize, and you have seen a puppy to prove it.
  • If it is another species, CD.registerVoices is called and voices is filled in, whine included, or it barks and whines like a dog.
  • The moodle icon exists in all six sizes.
  • Every language folder you ship has every key. A missing key shows the raw key on screen.
  • You tested in multiplayer, not only in single player. Position, ownership and mod data all behave differently there.
  • Your Workshop page warns against removing the add-on from a save with living animals.

15. Common mistakes

Symptom What it is
"My animal never appears." Almost always the guard: the add-on needs an API the installed base does not have, so it returns early and registers nothing.
"It appears, but it is frozen." The animation set is not "raccoon", or the model is missing clips.
"It renders as a black smear and the log floods." The model has more than 60 bones.
"Butchering it crashes." CD.defineDogParts was not called.
"The puppy is born the size of an adult." A small breed did not set puppySize. The value is absolute and is applied again on every sweep, so the size range of the type does not cover it.
"Two of my breeds keep turning into each other." They share an engineBreed. The prefix picks the mesh and can be shared by a family. engineBreed picks the texture and tells the breeds of that family apart, so it has to be unique per breed.
"The sound plays but ignores the volume slider." CD.registerVoices was not called for that sound.
"It works in single player and misbehaves on a server." Something writes mod data on the client. Move it to the server.
"It fought a zombie even though I turned combat off." The base it runs on is older than API 5. That base does not know the field and ignores it. The guard has to block on this base instead of loading with the field missing.

Something in this manual wrong, or missing? Say so on Discord.