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
- Genetic-code combination — two parents’ codes produce a child’s.
- Stat inheritance — a foal’s speed and health, rolled from the parents. Random for now; Mendelian later.
- The horse record — the per-horse data bag: identity, sex, name, genetic code, parents, stats.
- The ancestry database — a server-wide store of every record, queryable for a horse’s ancestors.
- Breed inheritance — a foal’s breed label: same-breed parents breed true, two breeds make a named cross, and messier pairings collapse to “Mixed”.
- 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’:
- Pure A × pure A → A.
- Pure A × pure B → A × B cross (components sorted, so order never matters).
- The same cross × itself, or a cross × one of its own two breeds → that same cross.
- A cross × anything else (a third breed, a different cross) → Mixed. Mixed × anything → Mixed.
- Feral Mixed — a lone wild spawn, a
/summon, a spawn-egg horse — is absorbing, exactly like Mixed: any cross involving one produces a plain Mixed foal, feral × feral included. See why. - Spliced (A) — a line a
gene splice carrot has been let into —
is the pair
{A, splice}and combines by the rules above with no exception: spliced A × A stays spliced A, spliced A × B is Mixed, and the mark never washes out. See spliced lines.
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.
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.
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():
- One
nextBoolean()picks which of this parent’s two copies is passed on, one more picks the other parent’s — the same 2 drawsGenotype.breedWithmakes, so the allele half is bit-for-bit unchanged. - Each chosen allele arrives carrying that parent copy’s priority
verbatim and its numbers nudged by
drift. A foal that inherits its dam’s
Ainherits her bay shade — the polygenic half of how dark a bay she is — to well inside what anyone could see. - The two copies are re-aligned to
AllelePair’s canonical slot order; the epigenetics follow their own allele across the swap. - 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.
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 |
|---|---|
| half | 0.10% of its range |
| 90% | 0.35% |
| 99% | 0.70% |
| all but 1 in 10 000 | 1.4% |
| all but 1 in 1 000 000 | 2.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.priority — not
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:
- Embryonic lethal (MET, and the
milk locus's incompatible humours): the Mendelian
draw runs as usual,
applyBredFoalreads the genotype it produced, returnsfalse, and the caller cancels theBabyEntitySpawnEvent. No foal at all — the mare miscarries.Genotype.breedWithis untouched, so the odds stay the ordinary one-in-four for two carriers. - Lethal at birth (overo lethal
white and four recessives): the foal is born, named, and filed in
the pedigree, and then dies over a few seconds. The pedigree is fine with it
—
ancestorsOfalready tolerates ancestors it cannot find.
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.
- Half a heart off the dam, through the same
genetic_defectdamage type a lethal foal dies of. Enough to see on the health bar if the chat has scrolled away. It can kill a mare already on her last half heart, and that is left alone: breeding an animal that close to death is a decision. - A line to everyone within 32 blocks, not only to whoever held the wheat. The fact belongs to the pairing.
- The line describes the sign, never the disorder.
MiscarriageSignsholds one sentence per embryonic-lethalCondition, written so that two different lethals read differently without either naming a gene. That is the whole point: a pairing that comes to nothing is the only evidence a player ever gets that two of their horses share a recessive lethal, and printing the answer would retire the pedigree that exists to find it. A dozen pairings sorts a herd into two groups; nothing hands anyone the group's name.
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:
- to the log, always (whenever
DebugAnnounceis on — which since 0.3.2 means thedebug.announceserver config, on in dev and off in a normal install): both parents' and the foal's short forms, the foal's full code, its four resolved body numbers and viability, every condition, and thenBreedingReport.full— one line per registered gene givingdam A/B x sire C/D -> X/Y, whether that combination is new, what it expresses, and which parent supplied which copy where that can be said at all; - to chat, in a dev build only
(
!FMLEnvironment.isProduction()): the body numbers andBreedingReport.notable— the loci the foal expresses or landed on a combination neither parent had. Past a dozen of those it prints a count and points at the log.
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
| Overload | What 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.
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
| Field | Type | Notes |
|---|---|---|
id | UUID | The entity’s own getUUID(). |
firstName / lastName | String | The two halves of the registered name. Either may be blank — not both. |
barnName | Optional<String> | Free-form display override, clamped to 16 chars, owner-editable. |
geneticCode | String | A Genotype code — which now includes the sex locus, so sex() is derived from this and there is no separate field. |
motherId / fatherId | Optional<UUID> | Dam / sire, if bred. |
tamedBy / bredBy | Optional<String> | Usernames. |
generation | int | 0 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. |
parentStats | Optional<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:
- Nearest generation first — parents, then grandparents.
depthis how many generations:1= parents only.depth ≤ 0returns empty.- The horse itself is never included.
- Each ancestor appears at most once, even under inbreeding.
- An ancestor referenced by id but absent from the database is skipped, and that branch is not explored further.
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:
- Dirty-tracking:
record(...)only callssetDirty()when the record actually changed (HorseRecordis a value type, so an unchanged re-record compares equal and is a no-op). - Persistence:
SavedDataTypeidhorsegenetics:horse_ancestry. - Scope: server-global, not per-level —
server.getDataStorage(), so a parent stays lookup-able even when unloaded, in another dimension, or dead.
server/HorseRecords — the adapter
The only class that talks to both sides. No genetics, naming or lookup logic lives here.
| Method | Does |
|---|---|
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 / setTamedBy | Copy-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):
rngfromhorse.getRandom().- No real record →
apply(horse, newFounder(...)): random genotype (the sex locus among it), generated name, no parents. - Otherwise (bred or reloaded) → re-record into the global DB (idempotent) and re-set the custom name if missing.
- 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 tohealth.mode, reach horses that already exist. A newly founded horse is healed to its new max; a reloaded one is only clamped down. - Coat: if the coat attachment is still
UNASSIGNED,CoatGenerator.generaterolls 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):
- Require both parents and the child to be
Horse. 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.- Sex gate: same-sex parents →
event.setCanceled(true), no foal. - The
FEMALEparent is the dam, theMALEthe sire. childGenome = GeneticCodeCombiner.combine(genomeOf(dam), genomeOf(sire), rng).genomeOfreads 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.childGeneration = 1 + max(dam, sire).- Viability: the drawn genotype is resolved to
Traits. An embryonic lethal stops here —applyBredFoalreturnsfalseand the event is cancelled, so there is no foal. - Stats: nothing is rolled.
ParentStats.of(damTraits, sireTraits)is captured so the UI can colour the foal’s numbers against its parents’. - Name — see the schedule below.
bredBy= the causing player’s username, if any.- Auto-tame: if the dam is tamed, the foal is tamed to the
dam’s owner, and
tamedByis that owner’s username. apply(child, record)thenapplyTraitsToEntity(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.- If the foal is lethal at birth, a chat line
names the disorder.
LethalFoalHandlerdoes the rest. - 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:
| Foal | Name |
|---|---|
| 1 | dam.first + sire.last |
| 2 | sire.first + dam.last — the other combo |
| 3–6 | One 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
- Stick on an untamed horse → tame it.
- Clock on a foal → instant adult.
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:
HorseRecordSyncPayload(S→C) onPlayerEvent.StartTrackingand from everyHorseRecords.apply. The client stores it inClientHorseRecordCache, keyed by both entity id and record UUID.HorseScreenHooksadds one thing to the vanilla horse inventory screen: an i button, which opensHorseInfoScreen. It hangs off the window's left edge, level with the top — it used to sit inside the top-left corner, on top of the screen's own chrome, and the vanilla sprite is a fixed size with no spare pixels in it, so the only place a button of this mod's can go without covering something is outside. The grey side panel that used to carry the horse's numbers behind a collapsible tab is gone — it was a 128-pixel column trying to hold a page, and everything on it moved to the information screen's Overview tab. Nothing is duplicated and nothing draws over the vanilla screen, so a second horse-inventory mod is still not clobbered.The horse comes off the screen (
AbstractMountInventoryScreen.mount, opened by this mod's access transformer), not offplayer.getVehicle(). The two agree while you are riding and disagree completely when the screen was opened by shift-right-clicking a tamed horse, which vanilla handles without mounting — the old vehicle lookup returnednullthere and every one of this mod's additions came up blank.Shift-right-click works on a foal too, which vanilla does not do:
AbstractHorse.mobInteractbails out onisBaby()before it reaches the “tamed and sneaking, so open the inventory” branch, so a foal had no inventory screen and therefore no way to read its genes.HorseInteractionHandleropens it atEventPriority.LOWEST, so every other horse interaction in the mod gets first refusal on the click; the saddle slot excludes itself, because itsisActive()already askscanUseSlot(SADDLE), which is false for a baby.HorseInfoScreenis a seven-tab full-window overlay in the same dark chrome as the horse browser: Overview (name, the barn-name editor, sex, generation, breed, the four live body numbers, bond, conditions, and who bred / tamed / owns it), Genes, Health (each gene's own contribution to speed / health / jump / size, fromTraitBreakdown, plus the disorders), Coat, Other genes, Offspring, and Family tree, which hands straight off toFamilyTreeScreenand comes back. Epigenetic values are shown on Health, Coat and Other only — Genes is the index, and a wall of numbers under every row is what the other three tabs are for. Which tab a gene lands on is decided byGeneCategory, from the interfaces the gene implements, so a drop-in gene sorts itself.Genes is the short form plus a filtered locus list. The full genetic code is not on it: it runs to a thousand characters, the surfaces that read it are the custom spawn egg and the horse designer, and it was pushing the list it introduces below the fold. The filter button beside the count is on by default and hides every locus the horse is carrying nothing but the wild type at, keeping extension, agouti and shade whatever it says. It deliberately filters on the alleles and not the phenotype, so a carrier stays on the list — a heterozygote showing nothing is the single most interesting thing this screen can tell a breeder, and a filter that read the expression would throw exactly those rows away.
“Carrying nothing” is
Gene.atBaseline, which tests the gene's real alleles rather than the pair's two slots. That is not pedantry: a stallion's brindle pair readsn/Y, his one real allele plus the reserved slot he does not have, which is homozygous for nothing — so the slot-based test put a brindle row on every stallion in the game. Anything that asks "is this horse carrying something here" has to go throughatBaselinefor the same reason.Which tab a gene lands on is asked about the horse, not about the gene:
GeneCategory.of(gene, pair, genotype, epigenome)calls the non-coat channel and looks at the answer.KITimplements eye colour, but only its broad white outcomes claim an iris, and without this every horse alive carried aKITrow under Other genes. A gene that neither paints nor moves the body - the particle locus, diet, the cutie mark - stays on Other whatever its combination grants, because a silent carrier of one has nothing to say on a tab about colour.Health, Coat and Other genes put a hairline between entries, because an entry there is a gene row plus a wrapped description plus its epigenetic numbers and the blocks ran together. On Health the four headline numbers are green above the baseline and red below (
HorseTraits.BASE_*); size is left plain, because a draught horse is not a worse horse than a pony.- Offspring is the pedigree read downward: this horse's
foals, then their foals, each generation a wrapped row of little horses
under a rule.
HorseDatabase.descendantsOfreturns them grouped by generation - that is the only shape the answer is useful in - and lists each horse once, at the earliest generation that reaches it, so a mare bred back into her own line does not loop.It is the only tab that does not redraw from what the client already has: the walk is a pass over the whole ancestry table per generation, and each descendant comes back as a full
HorseRecordso its coat can be drawn from its own epigenome with no second round trip. Both of those make it a question worth asking deliberately, so it is asked on a Refresh press and the tab says whether what you are looking at was ever asked for. Capped in both directions byOffspringDataPayload; a generation cut at the cap says so. - A horse stands still while its information screen is open. The
AI is server-side and the screen is not, so the client says so:
InspectHorsePayloadonce a second while the screen is up, and once more when it closes.HorseInspectHoldkeeps it as a lease rather than a flag — it lapses three seconds after the last word from the client — because a boolean set on open and cleared on close is correct right up until the client crashes with the screen showing, and then that horse stands in a field forever with nothing able to say why. The freeze itself isInspectHoldGoalat priority 0, claimingMOVE,JUMPandLOOK, so stroll, panic, the bond follow and the herd are simply not running and nothing has to be undone when it lifts. A ridden horse is exempt. FamilyTreeScreenis a pedigree chart: the subject is the right-hand column, each column left is one chart-step older, out to great-grandparents. Within every pair the sire is the top box, the dam the bottom. Each box shows the display name, the horse term,by <breeder-or-tamer>, and a live 3D horse in the right coat and pose that turns to face the cursor. Clicking a known ancestor re-roots the tree. The chart shrinks to fit the window by default; afamilyTree.scrollBarconfig flag switches to full-size + scroll.
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
| Data | Where | Scope |
|---|---|---|
A horse’s HorseRecord | Entity NBT, via the HORSE_RECORD attachment | Save/reload, chunk unload, dimension change |
A horse’s coat {genotype code, epigenome code} | Entity NBT, via the HORSE_COAT attachment | Save/reload. The epigenome is what makes a non-deterministic coat stable across sessions, and what a foal inherits from |
| The ancestry table | HorseAncestryData SavedData | Per world — <save>/data/horsegenetics/horse_ancestry.dat |
| Generated coat textures | In-memory DynamicTextures in the client TextureManager | Session only; released on LoggingOut |
Known limitations
- No environmental noise on the stats. They are now purely genetic, so two horses with the same genotype are the same horse. That is deliberate; if the numbers ever feel too tidy, the place to reopen it is epigenetic variation (inherited with the allele, still deterministic), not a fresh die roll. See the horse’s body.
- Drift is untuned against real play. The curve is what the
owner specified and the distribution is unit-tested, but nobody has yet bred
thirty generations of anything to see whether a line actually develops
character at the rate it should. The two numbers to turn are
EpiDrift.SCALEandEpiDrift.REPLACE_CHANCE. - No inbreeding prevention, no generational effects, and no sex-linked loci yet — genes combine independently. The sex locus itself is built, which was the prerequisite; X-linked and Y-linked inheritance (the roadmap) is the remaining half and the one planned exception to independent assortment.
- Name-generation output is rough and slated for a rework. The rule —
<alpha> <space> <beta>— is fixed. HorseRecord.geneticCodeand the coat attachment’s genotype code both hold the genotype string. The coat derives from the record so they stay consistent; the redundancy is a likely future consolidation.ancestorsOfonly walks records present in the database; a gap in the chain ends that branch.- The family tree needs a fresh server request per re-centre; there is no local caching of “the whole tree”.
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.
common/genetics/, common/horse/,
common/name/, neoforge-26.1.2/{data,server,client,network}/