Start here

Breeding & pedigree

The single source of truth for the breeding, horse-record, pedigree and stat-inheritance system. Layer 1 (common/, pure Java) owns the rules; layer 2 (neoforge-26.1.2/) only translates — it never decides how codes combine, what an ancestor is, or the band a foal’s stats come from.

What “breeding” covers

  1. Genetic-code combination — two parents’ codes produce a child’s.
  2. Stat inheritance — a foal’s speed and health, rolled from the parents. Random for now; Mendelian later.
  3. The horse record — the per-horse data bag: identity, sex, name, genetic code, parents, stats.
  4. The ancestry database — a server-wide store of every record, queryable for a horse’s ancestors.
  5. Breed inheritance — a foal’s breed label: same-breed parents breed true, two breeds make a named cross, and messier pairings collapse to “Mixed”.
  6. Integration — attaching a record to a live Horse, persisting it, pushing stats onto attributes, syncing to clients, and the in-game surfaces.

Breed labels combine (BreedLineage.combine)

Every horse carries a breed token on its HorseRecord (breed, optional). A wild herd is a pure breed; a lone spawn is unknown. HorseBreedingHandler.applyBredFoal sets the foal’s from its two parents’:

A pure breed also pins the four magical body-stat genes to the breed’s target bands, so record.traits() and HorseRecords.traitsOf resolve a Thoroughbred near ×2 speed and a Falabella near ×0.45 scale. A cross averages its two parents’ bands per axis (only where both pin it); Mixed / Feral Mixed pin nothing and fall back to the ordinary bounded-Gaussian draw. Full detail: the breeds page.

Verified in-game

2026-08-30: breeding two horses produces a foal with stats between the parents, a correctly combined genetic code, and — for a pairing’s first foal — a name combining both parents.
2026-09-01: FamilyTreeScreen is correct in full (nodes, per-node coats, shrink-to-fit layout). A clock on a tamed foal ages it to adult without seating the player on it.


Layer 1 — the domain model

Genotype.breedWith(Genotype other, Rng)

Mendelian segregation: for each gene the child takes one allele from each parent, drawn 50/50 within that parent’s pair. That is 2 nextBoolean() per gene, genes in codeOrder() — 96 draws for the 48 built-in genes; true means that parent’s first allele.

Each resulting pair is put in AllelePair’s canonical slot order, so breedWith is symmetric: E/E-A/A-… × e/e-a/a-… always gives E/e-A/a-….

Sex-linked loci are the one exception

A gene that declares X_LINKED or Y_LINKED (the mode; today only brindle) does not get the sire’s copy by a coin flip — it is decided by the foal’s own sex. On an X-linked locus the sire gives his X-borne allele to a filly and his Y — i.e. nothing — to a colt; Y-linked is the mirror. The dam is unchanged in both. The sex locus is priority 1, so the foal’s sex is always already drawn by the time any other gene is reached.

The second coin is still flipped and thrown away. Two nextBoolean() per gene is an invariant a great deal leans on — the golden coats, the gamete-bias equivalence test, every claim that adding a gene shifts the random stream by a known amount — and a locus that quietly consumed one would make the stream depend on a foal’s sex. Which parent is the dam is read off the parents’ own sex loci, since breedWith is otherwise symmetric and nothing else has ever needed to know.

What the foal then looks like is its genes’ combination tables, nothing more: one W22 gives a white foal because every combination carrying it maps to KIT’s masking dominant-white expression, and one Hlr/n gives an ordinary-looking foal because that combination maps to a wild type. There is no dominance rule in the middle.

The homozygous-only magical loci

A single Hlr is an invisible carrier; only Hlr/Hlr shows, and milk, the LUT locus and the four magical body-stat genes are built the same way. Those are the genes you have to breed for rather than spot, and each page has the frequencies.

Seal brown has no allele — it is a high roll of bay’s epigenetic leg and face heights.

Genome.breedWith(Genome other, Rng) — the one a foal actually uses

A genotype says which alleles a horse carries. It does not say what those particular copies carry, and every copy carries a priority and a set of literal numbers — one per value the gene declares. Those live in an Epigenome, aligned slot-for-slot with the genotype’s pairs. Genome is the two together, and it exists because only a breeding pass that draws both at once can keep that alignment true.

Per gene, in codeOrder():

  1. One nextBoolean() picks which of this parent’s two copies is passed on, one more picks the other parent’s — the same 2 draws Genotype.breedWith makes, so the allele half is bit-for-bit unchanged.
  2. Each chosen allele arrives carrying that parent copy’s priority verbatim and its numbers nudged by drift. A foal that inherits its dam’s A inherits her bay shade — the polygenic half of how dark a bay she is — to well inside what anyone could see.
  3. The two copies are re-aligned to AllelePair’s canonical slot order; the epigenetics follow their own allele across the swap.
  4. Priority tie-break: if both copies arrived holding the same priority, one extra nextBoolean() bumps the second copy a single step up or down (AlleleEpigenetics.deconflict), clamped inside [1, MAX]. A horse never carries the same priority twice at one gene.
Two passes, and why

Pass 1 locks every allele — two nextBoolean() per gene, in a fixed order, whatever the carrots say. Pass 2 then does all the epigenetic work: drift, and the fresh roll a splice carrot’s substituted gamete needs. Nothing in pass 2 can move a foal’s genotype, which is the guarantee that matters.

There is no longer an exact draw count. It used to be “2 nextBoolean() per gene plus 1 per tie”, which let an unfed carrot be bit-for-bit a plain breeding; drift spends a couple of draws per stored value on top, so that guarantee cannot hold and is not claimed.

Genetic drift

Inheritance used to be exactly verbatim, which meant a lineage’s numbers were frozen the day its founder was caught and no amount of breeding could produce a horse outside what the wild population already contained. Every scalar now takes a tiny nudge on its way to a foal.

The magnitude is drawn from a two-sided exponential whose scale is EpiDrift.SCALE of the value’s design span — so the larger the drift, the exponentially less likely it is:

Of every scalar passed on…moves less than
half0.10% of its range
90%0.35%
99%0.70%
all but 1 in 10 0001.4%
all but 1 in 1 000 0002.1%

So the overwhelming majority of foals are, to the eye and to the stat readout, exactly their parents — “breeds true” here means imperceptible, not bit-identical — while the tail stays open. A horse carries a few hundred of these numbers, so roughly one foal in thirty has one value that moved a visible amount. Most pairs breed true; chaos stays possible.

Drift may leave the range founders are rolled in. A value is held only inside its hard safety bound — the one that stops a horse breaking the model — never inside its design range. That is deliberate: the design range is where wild horses start, not a ceiling a breeder can never pass. A line bred for three hundred generations is allowed to be somewhere no wild horse has ever been.

Noise seeds and categories do not nudge. There is no “slightly different” splash shape or body site — nudging either replaces the thing rather than adjusting it. Both instead get a EpiDrift.REPLACE_CHANCE chance per breeding of being re-rolled outright, so a lineage keeps its exact markings essentially forever and a foal that does change is a real event rather than a boundary accident.

Priority does not drift either — it is an ordinal deciding which copy expresses, and a horse whose expressed copy silently swapped between generations would be inexplicable.

What epigenetic priority is for

This is AlleleEpigenetics.prioritynot Gene.priority(), which is the fixed per-gene constant Genes sorts codeOrder() on (so breeding rolls each gene in a stable order and a gene only ever sees lower-priority genes). When the two copies are different alleles the expressed one is the canonically first — an arbitrary but fixed choice, not a claim that one allele wins. When they are the same both express, so the tie goes to the higher epigenetic priority. That is its only job; it is a full-range int for headroom. It selects a copy, never an order.

Founders

A founder — wild spawn, /summon, a dimension pen horse — gets a fresh Epigenome.random: an independent priority and an independent roll of every declared value on every copy, from that value’s own declared distribution, deconflicted the same way. That is the only place epigenetics are ever invented. CoatGenerator.generate is the founder path, and a foal must not go through it. A horse of a breed goes through BreedFounder, which is the same path plus the breed’s stat targets written onto the copies — and the only place a breed ever touches a number.

Stats are inherited, not rolled

HorseStats.rollFoalStat is gone

A foal’s speed and health used to be drawn uniformly from [0.75 × min(parents), 1.5 × max(parents)] — an uncapped random walk with no genetics in it at all. Two full siblings could differ by a factor of two, and “breeding for speed” was breeding for luck. The class is deleted.

A horse’s body — speed, max health, jump strength, body size and the disorders it expresses — is now a pure function of its genotype, resolved by HorseTraits.resolve. So a foal inherits its stats the same way it inherits its colour: through the alleles, and nowhere else. There are no speed or health fields on HorseRecord at all, the same way there is no sex field — a stored derived value can only ever go stale against the alleles beside it.

Full machinery, the baselines, the per-gene weights and the two kinds of lethal: the horse’s body. What matters on this page is that breeding draws nothing extra — the same two nextBoolean() per gene that always decided the coat now decide the body too.

Breeding can now fail, or produce a foal that dies

Two of the new health loci change the shape of a pairing:

Both are governed by the server’s health.mode setting, which never changes what a horse carries or passes on.

The miscarriage — and why it does not name the gene

An embryonic lethal used to be a silent non-event: two horses that would breed with anything except each other, for no visible reason, which is indistinguishable from a bug. It is now something that happens to the mare, in LethalFoalHandler.announceMiscarriage, and in every build — it is gameplay, not a diagnostic.

A condition with no sign written for it falls back to a generic sentence rather than failing, so a new lethal gene works the day it is added — it simply reads like every other one until somebody writes it a line. The log always names the condition, because a dev reading a log is debugging rather than playing. Condition.description() also still names it, which is what the horse information screen shows for a horse you already have in front of you: an animal you can inspect tells you everything, an event you witnessed tells you what it looked like.

Every draw, written down (dev builds)

A Mendelian draw is invisible, and the only evidence afterwards is one foal — which is a single sample of a distribution. “Every foal came out chestnut” and “five foals happened to come out chestnut” look identical from the paddock. BreedingDebug.reportDraw runs on every pairing, before the viability check so a miscarriage still leaves a record, and writes:

BreedingReport lives in common/ and recomputes nothing: it reads the three genotypes that already exist, so it cannot disagree with what was actually bred. It deliberately does not claim a copy's parent when both parents could have supplied it — the draw records nothing about that, and a guess dressed as a fact is worse than a question mark.

GeneticCodeCombiner

OverloadWhat it carries
combine(String motherCode, String fatherCode, Rng)Alleles only. The string-level seam, so a caller never touches Genotype. A foal bred through this would have its epigenetics re-rolled from scratch.
combine(Genome mother, Genome father, Rng)Alleles and the priority / seed on each copy the foal received. This is what the game calls.

Both throw IllegalArgumentException on a malformed code and are symmetric — argument order only decides which parent id is recorded as mother vs father.

Sex

MALE / FEMALE. Vanilla horses have no sex; this is mod-assigned, used to decide dam and sire, to gate breeding (one of each), and for display. Sex.label(boolean adult) gives the horse term: stallion / mare for adults, colt / filly for foals.

Sex is a gene, and the enum is derived

The enum is now a reading of the sex locus (horsegenetics.sex, X/Y, priority 1), not a stored field: HorseRecord has no sex component and the codec has no "sex" key. HorseRecord.sex() reads it out of geneticCode, via Genotype.sexOf(String) (which skips a full parse, because the info panel asks per frame).

So a foal’s sex is inherited, not rolled — the dam is X/X and gives an X, the sire is X/Y and gives one or the other 50/50, all of it through the same breedWith draw as every other gene. The only place sex is chosen is a founder, via Genotype.withSex(Sex).

HorseRecord

FieldTypeNotes
idUUIDThe entity’s own getUUID().
firstName / lastNameStringThe two halves of the registered name. Either may be blank — not both.
barnNameOptional<String>Free-form display override, clamped to 16 chars, owner-editable.
geneticCodeStringA Genotype code — which now includes the sex locus, so sex() is derived from this and there is no separate field.
motherId / fatherIdOptional<UUID>Dam / sire, if bred.
tamedBy / bredByOptional<String>Usernames.
generationint0 for a foundation horse; a child of two foundations is 1; otherwise 1 + max(dam, sire), fixed at birth. Not the family-tree column depth.
parentStatsOptional<ParentStats>Low/high of the two parents’ resolved speed and health at birth, so the UI can colour this foal’s numbers. Absent for founders. It stays a stored snapshot even though both parents’ numbers are derivable, because a parent can be dead, sold or simply forgotten by the ancestry DB by the time anyone looks.
String displayName();   // barnName if present, else "firstName lastName", stripped
String attribution();   // bredBy if present, else tamedBy - "who is this horse's human"
boolean hasName();      // any name part non-blank, or a barn name set

Traits traits();        // speed / health / jump / scale / conditions, resolved
                        // from geneticCode on demand - never stored

// factories
static HorseRecord founder(id, first, last, genome);              // generation 0
static HorseRecord bred(id, first, last, genome, motherId, fatherId, generation);
// no sex argument: it rides in the genome. A founder that wants a particular
// sex hands in genome.withSex(sex); a foal's is inherited.

Sex sex();              // read back off the sex locus in geneticCode

// copy helpers
withNames, withBarnName, withTamedBy, withBredBy, withGenome, withParentStats

The constructor null-checks required fields, normalizes null Optionals, strips and clamps the barn name, and clamps generation to ≥ 0. It no longer rounds anything: there are no numeric stat fields left to round.
(ceilSpeed / ceilHealth went with them.)

ParentStats.rankSpeed(v) / rankHealth(v) return 1 above both parents, 0 between, -1 below both.

HorseDatabase and InMemoryHorseDatabase

void    record(HorseRecord r);
Optional<HorseRecord> lookup(UUID id);
boolean forget(UUID id);
List<HorseRecord> ancestorsOf(UUID id, int depth);
int     offspringCount(UUID a, UUID b);

ancestorsOf(id, depth)

A breadth-first walk up the mother/father links:

offspringCount(a, b)

Scans every stored record for one whose two parents are exactly a and b, order-independent. It feeds the foal-name schedule, so it must count prior foals only — which holds because a foal’s own record is not stored until after its name is chosen.

forget(id)

For a horse deleted outright rather than dying: the horse dimension clears its pens on exit, and without this every visit would leave hundreds of throwaway records in the save forever. It deliberately does not repair records naming the forgotten horse as a parent — ancestorsOf already skips ancestors it cannot find, so a foal bred in the dimension and brought home keeps working with a missing parent node.


Layer 2 — integration

data/HorseRecordCodecs

MapCodec / Codec for HorseRecord, kept out of the layer-1 type so it stays free of DataFixerUpper. NBT keys: id, first_name, last_name, barn_name, genetic_code, mother_id, father_id, tamed_by, bred_by, generation, speed, health, parent_stats.

There is no sex key any more: sex is a gene, so it is already inside genetic_code. The record has 14 components — still under RecordCodecBuilder.group’s limit of 16. Also exposes STREAM_CODEC / LIST_STREAM_CODEC for the sync payloads.

data/ModAttachments.HORSE_RECORD

A NeoForge Data Attachment whose value type is the layer-1 HorseRecord itself, registered with the map codec, so it persists in entity NBT with no hand-rolled save/load.

Its default is a sentinel: HorseRecord.founder(holder.getUUID(), FEMALE, "", "", "eeaa") — built via the AttachmentType.builder(Function<IAttachmentHolder, T>) overload so the default can read the holder’s own UUID. Its blank name is how the code distinguishes “never assigned” from a real record.

data/HorseAncestryData

extends SavedData implements HorseDatabase — a thin wrapper holding an InMemoryHorseDatabase. What it adds:

server/HorseRecords — the adapter

The only class that talks to both sides. No genetics, naming or lookup logic lives here.

MethodDoes
of(horse) / hasRealRecord(horse)Read the attachment; hasName().
apply(horse, record)Write the attachment, set the custom name visible, record into the global DB, and send a HorseRecordSyncPayload to trackers.
newFounder(horse, rng)Founder record: random genotype (sex included — it is a gene now) and a random name. Nothing is copied off the entity any more: whatever vanilla randomised its attributes to is about to be overwritten by what its alleles say.
newFounder(horse, rng, genotype) / (horse, rng, sex, genotype)Founder record on a fixed genotype (a dimension pen). The epigenome is still rolled. The 4-arg form writes the sex into the genotype with withSex.
newFounder(horse, rng, genome)Founder record on a fixed genome — epigenome included, so nothing is rolled but the name. The custom spawn egg is the caller: its editor previews a live 3D horse, and a preview the spawn then re-rolls is not a preview.
traitsOf(horse) / traitsOf(record)Resolve the body from the genotype, honouring the server’s health.mode. See the horse’s body.
applyTraitsToEntity(horse, traits, fullHeal)Push the resolved body onto four attribute base values: MOVEMENT_SPEED, MAX_HEALTH, JUMP_STRENGTH and SCALE. fullHeal sets current HP to the new max (newborn); otherwise HP is only clamped down, so nothing is healed for free on a reload.
offspringCount(contextHorse, damId, sireId)From the server-global ancestry data, else 0.
rename / setBarnName / setTamedByCopy-and-apply helpers.
rng(horse) / names()Small utilities. (randomSex is gone — the founder draw rolls the sex locus.)

Runtime flows

A. Wild spawn → founder record

HorseGeneticsEventHandler.onHorseJoin on EntityJoinLevelEvent (server side, any Horse) calls ensureRecordAndCoat(horse):

  1. rng from horse.getRandom().
  2. No real record → apply(horse, newFounder(...)): random genotype (the sex locus among it), generated name, no parents.
  3. Otherwise (bred or reloaded) → re-record into the global DB (idempotent) and re-set the custom name if missing.
  4. Every horse, every join → applyTraitsToEntity(...). Vanilla has just randomised this horse’s speed, health and jump; this is where that gets overwritten with what its alleles say. Doing it on every join rather than storing it is what lets a re-tuned gene, or a change to health.mode, reach horses that already exist. A newly founded horse is healed to its new max; a reloaded one is only clamped down.
  5. Coat: if the coat attachment is still UNASSIGNED, CoatGenerator.generate rolls a founder epigenome, stores {genotype code, epigenome code}, and sends the coat sync packet.

Step 4 means the coat is always derived from the record’s genetic code plus the persisted epigenome — a bred foal’s coat matches the genes it actually inherited and stays the same across reloads. The pixels are generated client-side; see the pipeline.

The horse-dimension pens pre-apply a founder record before adding the entity, so each pen ends up with exactly one mare and one stallion; onHorseJoin then sees a real record and only fills in the coat.

B. Real breeding → combined record

HorseBreedingHandler.onBabySpawn on BabyEntitySpawnEvent (fired before the foal is added to the world):

  1. Require both parents and the child to be Horse.
  2. ensureParentRecord(parent) for each — a parent that predates the mod gets a founder record now, and its stats are backfilled from its entity so the roll has real numbers.
  3. Sex gate: same-sex parents → event.setCanceled(true), no foal.
  4. The FEMALE parent is the dam, the MALE the sire.
  5. childGenome = GeneticCodeCombiner.combine(genomeOf(dam), genomeOf(sire), rng). genomeOf reads the parent’s coat attachment; a parent whose attachment is missing or has drifted founds a fresh epigenome now and keeps it, so the foal still inherits real allele copies.
  6. childGeneration = 1 + max(dam, sire).
  7. Viability: the drawn genotype is resolved to Traits. An embryonic lethal stops here — applyBredFoal returns false and the event is cancelled, so there is no foal.
  8. Stats: nothing is rolled. ParentStats.of(damTraits, sireTraits) is captured so the UI can colour the foal’s numbers against its parents’.
  9. Name — see the schedule below.
  10. bredBy = the causing player’s username, if any.
  11. Auto-tame: if the dam is tamed, the foal is tamed to the dam’s owner, and tamedBy is that owner’s username.
  12. apply(child, record) then applyTraitsToEntity(child, childTraits, fullHeal=true) so the foal spawns at the max health its own genotype gives it — which for an affected foal is not many hearts.
  13. If the foal is lethal at birth, a chat line names the disorder. LethalFoalHandler does the rest.
  14. The foal’s coat attachment is written here, not at join time. It has to be: the inherited epigenetics can only be read while both parents are in hand, and flow A’s founder path would re-roll them. Flow A then sees an assigned attachment and leaves it alone.

Steps 5–12 are factored into HorseBreedingHandler.applyBredFoal(child, damHorse, damGenome, damRecord, sireGenome, sireRecord, breeder, rng) so a second entry point can reuse them: the stallion seed jar (server/StallionSeedJarHandler) builds the foal Horse itself, passes the mare’s live genome and a synthetic sire HorseRecord reconstructed from the jar’s stored_genome component (a GenomeSample + the donor’s UUID / name / stats — his sex is in the stored genotype), and adds the foal after. Same Mendelian + carrier-faithful draw, drawn at impregnation — no gamete is frozen at collection. There is no gestation delay yet and no breeding-carrot gate; the foal is immediate, exactly as flow B.

The foal-name schedule

HorseNames.breedNth(dam, sire, priorFoals, generator, rng), where priorFoals counts how many foals this exact pairing has already produced (from the server-global ancestry data, so it survives reloads). The point is to stop a pairing churning out the same two names forever:

FoalName
1dam.first + sire.last
2sire.first + dam.last — the other combo
3–6One half kept from a parent (either parent, either half), the other a fresh random word
7+A fully random generateParts name

HorseNames.breed — the old 50/50 one-half-each — stays for callers and tests that do not track a foal count.

C. Paper inspector

Holding paper and right-clicking a horse dumps to chat: name, id, the horse term (stallion / mare / colt / filly), generation, genetic code, speed, health, jump and size — all resolved from the genotype — any conditions it expresses with their descriptions, a vs parents line (above both / between / below both) when parentStats is present, bred by, tamed by, sire and dam resolved to names, and ancestors (3 gen).

D. Name-tag rename

Right-clicking a horse with a real record with any name tag — enchanted, anvil-renamed, or plain — opens the rename window (client/HorseRenameScreen): two fields, first name and last name, prefilled with the horse’s current registered name. Confirming sends RenameHorsePayload; the server re-checks range, that a name tag is still in hand and that the two parts are not both blank, then applies the name and consumes one tag (unless creative). Cancelling consumes nothing. The interaction is cancelled on both sides so vanilla does not also set the entity’s custom name.

E. Barn name

A free-form display override, editable from the horse inventory screen (an edit box plus a “Set” button). The server checks the player is within 8 blocks, then sets it — blank clears, otherwise clamped to 16 characters. When set, it is what displayName() returns.

F. Horse-dimension right-clicks

EntityInteract fires on both sides

Any item used on a tamed horse is otherwise turned into a mount by vanilla, so the event must be cancelled on the client too or the client predicts a mount and rubber-bands. Do the state mutation server-only; cancel on both.

G. Tamer tracking

Wild-horse taming resolves inside the horse’s own tick, after the bucking logic — not during the interact packet — so it cannot be caught in the interaction handler. Instead, every ~40 ticks any tamed horse whose record has no tamedBy gets its owner’s username recorded. Once set, the check short-circuits.

H. Client sync, the information screen, family tree

The record attachment is server-only, so it is pushed to clients:

Ancestor coats are a stand-in

The epigenome lives on the entity, not in the record, so the ancestry database cannot reproduce a dead ancestor’s exact coat. FamilyTreeScreen draws ancestors from Epigenome.fromSeed(record UUID) — stable and plausible, but not necessarily the real one. Moving the epigenome onto HorseRecord would fix it.


Persistence summary

DataWhereScope
A horse’s HorseRecordEntity NBT, via the HORSE_RECORD attachmentSave/reload, chunk unload, dimension change
A horse’s coat {genotype code, epigenome code}Entity NBT, via the HORSE_COAT attachmentSave/reload. The epigenome is what makes a non-deterministic coat stable across sessions, and what a foal inherits from
The ancestry tableHorseAncestryData SavedDataPer world<save>/data/horsegenetics/horse_ancestry.dat
Generated coat texturesIn-memory DynamicTextures in the client TextureManagerSession only; released on LoggingOut

Known limitations

Literal epigenetics and drift are the newest things here

Not seen in-game at all. Unit tests cover the mechanism — an inherited allele keeps its copy’s numbers to within one generation of drift, the epigenetics follow their allele when the pair is reordered, a tie gets deconflicted, and the drift distribution matches its declared curve. What is unproven is the live path: that a foal really looks like the parent it took the allele from, and that a cross of two extreme breeds really does spread rather than average. The checks are on To be verified.

Source: common/genetics/, common/horse/, common/name/, neoforge-26.1.2/{data,server,client,network}/