Core / traits, size & health
The horse’s body
Speed, max health, jump strength, body size, and the disorders a horse expresses — all of it a pure function of the genotype. This page owns the machinery: the resolver, the baselines, how a gene contributes, the two kinds of lethal, and the server setting that governs them. The genes themselves are one page each.
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, a line’s numbers drifted
upward on luck rather than on choices, and nothing a player did to a pairing
was legible in the result. HorseStats is deleted. Every number
on this page is now a sum of allele weights.
One walk, one record
HorseTraits.resolve(genotype) walks
Genes.codeOrder() once, hands every
gene that implements TraitContribution a TraitBuilder,
and returns a Traits record.
public record Traits(double speed, double health, double jump, double scale,
List<Condition> conditions) {
Viability viability(); // derived: the worst severity in the list
Optional<Condition> lethalCondition();
}
Nothing is stored. HorseRecord has no
speed or health field any more — the same
reasoning that removed its sex field. A stored derived value can
only ever go stale against the alleles sitting next to it, and re-resolving on
demand means a change to a gene’s weight (or to the server setting below)
reaches horses that already exist.
There is deliberately no breed here
resolve takes a genotype, an epigenome and the health switch, and
nothing about the horse’s breed. A
breed pins one or more body axes to a
TargetBand, but that band is read exactly once — by
BreedFounder, which writes a point inside it onto a founder’s
allele copies as ordinary epigenetic numbers. A Thoroughbred still reliably
resolves near ×2 speed where two Swift copies on the plain
Gaussian would only reach ×1.2, because its copies genuinely carry those
percentages. The height band still exaggerates its above-baseline part
(BreedStatCurve.BIG_GAMMA = 3) so a draught breed genuinely towers
— real horse heights differ by too little to read (a Percheron founder ends
up ×1.0–1.38, a Quarter Horse ×0.96).
resolve overload
resolve(genotype, epigenome, BreedStatTargets, healthGenetics)
threaded the band through TraitBuilder.breedBand(StatAxis) on
every single resolution, and
BreedLineage.statTargets() handed a cross the
per-axis average of its two parents’ bands. So a Percheron bred to a
Falabella had its foal’s size recomputed into a mid-size band every time
anything looked at it, regardless of which alleles the foal actually
inherited. The overload, breedBand and
BreedStatTargets.average are all gone; see
breeds.
Determinism
No Rng, no epigenetics, no entity state. The same genotype always
resolves to the same body — on the server, on a reload, and in a unit test.
That is the determinism contract applied to the
body rather than the coat.
The baselines
A horse carrying nothing but wild types lands on these. They sit a little under vanilla’s midpoints on purpose: the variant alleles are what push a horse up through the vanilla range, so a bred line beats a wild-caught one and there is somewhere to go.
| stat | baseline | vanilla rolls | this model’s wild range |
|---|---|---|---|
| movement speed | 0.1875 | 0.1125 – 0.3375 | 0.1715 – 0.3135 |
| max health | 22.0 (11 hearts) | 15 – 30 | 18 – 26 |
| jump strength | 0.50 | 0.4 – 0.8 | 0.46 – 0.72 |
| body scale | 1.0 | — (vanilla has no size variation) | 0.88 – 1.10 |
The wild range is measured over 200 000 founder draws. It is deliberately narrower than vanilla’s at both ends: a wild-caught horse should be unremarkable, and both the ceiling and the floor should be things you find by breeding.
It bottoms out at MIN_HEALTH = 1.0, half a heart. A zero
max-health attribute is not “a very sick horse”, it is a
degenerate value — and killing a horse is the damage path’s job,
not the attribute’s. Speed and jump have floors for the same reason: a
zero-speed horse is stuck, not ill.
The genetic floor, MIN_SPEED = 0.02, is about a tenth of the
slowest vanilla horse — not a slow horse, a statue you can sit on. So
there is a second, entirely separate floor
(server/HorseSpeedFloor) applied to the
attribute and nowhere else, at the moment
applyTraitsToEntity writes it.
The genetics are untouched. A horse that resolves to
0.02 still resolves to 0.02, the family tree still
shows what its parents passed on, and the browser designer still shows the
genetic number — it is showing genetics. Only what the animal in the
world can be made to do changes. That separation is the point: the genome is
a model of inheritance and should not be bent to make the game playable, and
the attribute is the game and should not be allowed to become unplayable.
The floor is vanilla's own slowest horse —
AbstractHorse.generateSpeed with all three rolls at zero, read
through an access transformer
rather than written down. The brief was “just barely fast enough to
outrun a zombie”, and the honest way to hit that is not to read
the zombie: a zombie's MOVEMENT_SPEED is 0.23 and a
horse's is 0.1125–0.3375, and those are
not on the same scale. A ridden horse travels on the
player's movement model — its 0.1125 is about 4.8 blocks a
second, next to a walking player's 0.1 at 4.3 — while a
chasing zombie travels on the mob model, where 0.23 is a shamble.
Clamping a horse at a zombie's raw 0.23 would floor it
above the average vanilla horse, which is not a floor.
Vanilla's minimum is the right reference because it is a number with a
meaning: the bottom of what the game itself calls a horse. It sits below this
mod's own wild-type (BASE_SPEED = 0.1875), so the whole natural
range still varies and breeding away from a slow line is still work.
Everything the genetics can do below it is the part that was never
playable. Retuning is one expression in
HorseSpeedFloor.floor(), and the in-game check is literally
“race one against a zombie”.
How a gene contributes
public interface TraitContribution {
void contribute(AllelePair pair, Genotype genotype, TraitBuilder out);
}
/** Marker: this contribution is a disorder, and the server setting can suppress it. */
public interface HealthContribution extends TraitContribution { }
A capability a Gene may additionally implement, rather than
more methods on Gene that most genes would leave empty. A gene that
only does this — every performance, size and health gene — is an
allele list, a combination table and one method.
One interface, not four
The roadmap sketched four (StatContribution,
BodyContribution, ViabilityRule,
ConditionRule). They collapsed into one for two reasons. They all run
in the same single walk, so four of them would be four sinks threaded through one
loop; and the genes that need any of them mostly need several at once —
chondrodysplastic dwarfism is a stat change
and a size change and a condition, and splitting that across
three interfaces would scatter one gene’s answer over three methods that
have to agree.
Viability is not an interface at all. It is derived from the severity of the conditions reported, so a gene can never declare a horse lethal without saying what killed it.
Additive, and therefore order-independent
A contribution is either additive (addSpeed,
addHealth, addJump, addScale) or a
scale multiplier (multiplyScale).
build() applies every addition before every multiplication, and both
operations are associative, so the result does not depend on the order genes are
visited in — the same argument that keeps the coat’s phase-3
accumulator drift-free. Gene priority still fixes the code order; it
deliberately buys nothing here, so nobody is tempted to encode a dependency
between two genes as a priority number.
Among the natural loci, multipliers exist for one reason: dwarfism is a proportional change. A pony with chondrodysplasia should end up small twice over, not be dragged to the same absolute height as a draught horse with it. The natural speed, health and jump loci stay purely additive, which keeps a gene’s weight readable as “this allele is worth two hearts” rather than “it depends”.
The four magical body-stat genes — size,
speed, health and jump — are the deliberate exception. Each carries a
per-copy percentage that has to scale whatever the natural loci
settled on, so a magically fast pony is still slower than a magically fast
racehorse. They go through multiplyScaleUnclamped and the three
siblings beside it (multiplySpeedUnclamped,
multiplyHealthUnclamped, multiplyJumpUnclamped), every
one applied after all additions and bounded only by the MAGICAL_*
guards.
The per-horse twin 2026-09-04
public interface EpigeneticTraitContribution {
void contribute(AllelePair pair, Genotype genotype, GeneEpigenetics epigenetics, TraitBuilder out);
}
/** The trait-side twin of CoatBuildContext's two epigenetics accessors. */
public interface GeneEpigenetics {
Rng expressed(); // one locus, one result - almost everything
Rng copy(int slot); // a CODOMINANT gene, where both copies contribute
}
The plain interface is a pure function of the genotype, and for every natural gene
that is exactly right: p/p is one particular pony and always the same
one. It cannot express the four
magical body-stat genes at all, because the
point of them is that two horses can both be “big” (or fast,
or hardy, or springy) and one of them be twice the other — which needs a
number that varies between horses carrying identical alleles.
The Rng handed in is not a fresh die roll. It is
a SeededRng on the epigenetic seed of the allele copy that
expresses at this gene — the same seed the coat pipeline draws its
per-horse variation from. That seed is rolled once for a founder, stored on
the record, and inherited verbatim with the allele. So the same horse resolves
to the same body every time it is asked, on the server, after a reload and in
a unit test; a foal that inherits the copy inherits the number; and nothing is
stored that could go stale, because the trait is still resolved on
demand from the genotype and epigenome that were already there.
Two accessors, because a codominant gene needs both copies.
expressed() answers "what does this horse show at this locus",
which is right wherever one locus produces one result. The four magical
body-stat genes cannot use it: their two alleles each contribute a percentage
and they add, so asking for the expressed copy would count one allele
twice and the other not at all. That is the same split
mane colour needed on the coat side.
HorseTraits.resolve gained an epigenome parameter to feed it, and
HorseRecord.traits() / HorseRecords.traitsOf now pass
the record’s own. Asking a bare genotype
(resolve(genotype)) still works and is still the right call for a
question about a genotype rather than a horse — a punnett display,
a wiki table, a test — and answers with the midpoint of
what the genotype can produce, via MidpointRng. It is not random and
is not pretending to be; inventing an epigenome there would answer about a horse
nobody owns.
Scale has two stages, and only one is clamped
MIN_SCALE / MAX_SCALE (0.45 – 1.75) exist so that
no amount of stacking the real size loci and the dwarfisms can produce a horse
that is not a horse. A magical gene is allowed to produce exactly that, so
multiplyScaleUnclamped applies after that clamp and is
bounded only by MAGICAL_MIN_SCALE / MAGICAL_MAX_SCALE
(0.1 – 10) — a limit on absurdity rather than a limit on size, and the
exact counterpart of the coat’s uncapped phase-3 accumulator.
The two stages compose the way you would want: the natural loci settle the horse’s own size first and the magic multiplies whatever that turned out to be, so a magically enormous pony is still smaller than a magically enormous draught horse.
Speed, health and jump work the same way, minus the natural
clamp they never had: multiplySpeedUnclamped /
multiplyHealthUnclamped / multiplyJumpUnclamped take
the additive result and scale it, bounded only by
MAGICAL_MIN_FACTOR / MAGICAL_MAX_FACTOR (0.1 –
10). The MIN_HEALTH floor is still applied last, so no combination
of Frail and a disorder can resolve a horse to zero hearts.
The non-coat genes cost the coat nothing
All thirteen of them paint nothing — as do seven magical genes
(milk, verdant,
particle and the four
body-stat genes), on the same terms: every
combination is an
expression marked wildType. That is
not a loophole, it is what wildType means — changes nothing
about the coat. Three things follow, and together they are why thirteen new
genes were nearly free:
Gene.affectsCoat()is false, so they are excluded fromGenotype.coatCode()and two horses that differ only in them share one baked texture.GenotypeCatalog.distinctPairsOfcollapses every wild type into one group, so each locus contributes one catalogue entry however many alleles it has. Adding all thirteen left the catalogue of distinct coats exactly where it was — the number itself has moved since, but only ever because a gene that does paint was added.- The raw genotype count multiplied by 5 314 410. Those are all real, heritable, distinguishable horses — they just are not distinguishable by colour. Current figures are on the genetics model, which is the one place they are kept.
Conditions
A Condition is a named disorder, declared by a gene as a constant
next to the expression that produces it. It carries the same two pieces of prose
an expression does — a short name and a sentence — because the
surfaces that show it are the same surfaces that show an expression, and a player
who has just lost a foal deserves to be told which disorder took it.
| severity | meaning | who has it |
|---|---|---|
INFORMATIONAL | Named and shown; no mechanical cost. | Splash deafness (MITF / PAX3) |
IMPAIRING | Fewer hearts, usually with a smaller body or a lower jump. | ACAN dwarf, B4GALT7, silver MCOA |
LETHAL_AT_BIRTH | Born, named, filed in the pedigree, then dies. | EDNRB O/O,
PLOD1, RAPGEF5,
ST14, SHOX,
ACAN D1/D1 |
LETHAL_AT_CONCEPTION | No foal is produced at all. | MET |
The mechanical effect is not on the condition. A gene applies its own
stat changes through TraitBuilder in the same breath as it reports
the condition; the record is the label plus the one bit the breeding and death
code switches on. Keeping them apart is what lets two genes cause
“dwarfism” with different numbers without either pretending to be the
other.
Deafness and MCOA cost nothing that exists
A deaf horse and a horse with malformed eyes are both real and both worth telling
the player about, and the mod has no hearing or vision for a horse to lose. So
deafness is INFORMATIONAL — reported, no cost — while
MCOA is priced the way every sub-lethal disorder here is priced, in hearts. Both
are honest; neither pretends to a system the mod does not have.
The two shapes a disorder comes in
Sixteen health loci ship, and they divide into two classes that behave differently enough to be separate base classes.
RecessiveDisorderGene | DominantDisorderGene | |
|---|---|---|
| One copy | silent — a carrier, indistinguishable from normal | affected — there is no carrier state at all |
| Two copies | affected | affected, and worse |
| Can a founder be affected? | Never. The homozygote is simply absent from the founder table | Yes, heterozygous — the homozygote is still excluded |
| How you find out | after the fact, from a foal | by looking at the horse |
| Loci | the simple ones, plus ACAN’s five-allele variant of the same idea. Several colour loci carry a Condition as well — lethal white is a white pattern and a foal lethal at once | HYPP and PSSM1 |
“No founder is ever affected” was an invariant of this layer for as long as every disorder was recessive, and it rested on a fact about recessives specifically: a wild-caught horse is an adult that survived, so it carries at most one broken copy, and one copy is silent. A dominant has no silent copy. If founders could never be affected, the allele could never enter the world at all and the locus would be dead code.
So the invariant is now two narrower ones, and both are tested: no founder is ever born with a recessive disorder, and no founder is ever born dying — dominant or not. A wild horse may be sick; a wild horse that expires while you watch is a spawn nobody asked for.
HYPP is also the first locus that is both kinds of thing at once — a survivable heart reducer on one copy and a lethal on two — which is what made a second base class worth writing rather than bending the first.
Why the reference’s percentages are not the numbers
The disorder table this layer is built from gives each condition a health %, a speed % and a jump %. None of the shipped genes uses them as arithmetic, and that is deliberate: read literally against the 22-health baseline, HYPP’s “30% health” would make an affected but living horse worse than several of this mod’s outright lethals, which is plainly not what the table means.
So every locus is calibrated against the disorders that already ship — a survivable disorder costs 3 to 8 hearts’ worth, a lethal 11 to 18 — and the reference is used for the ordering of the disorders against each other. If the severity model on roadmap §3 ever lands, it does not get to inherit those percentages either.
The two kinds of lethal
Lethal at conception — no foal
The Mendelian draw happens exactly as it always does.
Genotype.breedWith knows nothing about viability and was not touched.
HorseBreedingHandler.applyBredFoal then reads the genotype it
just drew, and returns false; the caller cancels the
BabyEntitySpawnEvent. So the odds are the ordinary one-in-four for
two carriers, the draw stays a pure function of the two parents and the RNG, and
the check is one branch in one place.
Lethal at birth — born, then lost
The foal is born: it gets a name, a record and a place in the family
tree, and then dies over about four seconds.
LethalFoalHandler does it, and it stores nothing — being lethal
is a property of the genotype, so it re-reads it each second. A foal that logs out
mid-death still dies on the way back in.
- It has to out-damage the healing. Gated healing regenerates a hurt horse by 1 (or 2 in a herd) every 30 ticks. The damage is a fraction of the foal’s own max health (28%, floor 2) applied once a second, so it beats that comfortably at any health value and a player cannot save a foal by standing it next to a hay bale.
- Foals only. An adult is never killed by this. The only ways
to get an adult with a lethal genotype are the custom spawn egg and a
hand-written
/summon, and both are debug tools for looking at a coat — and lethal white is exactly the coat people want to look at. - Not in the horse dimension. Its pens are there to be looked at, not survived — and lethal white is exactly the coat people go there to look at.
- The damage source is
horsegenetics:genetic_defect, a real datapack damage type, so the death message reads as a genetic defect rather than an unexplained death.
A foal that silently drops dead reads as a bug; a pairing that silently produces nothing reads as a worse one. Both cases send a line naming the disorder, at the moment of birth (or refusal) rather than at death. That line is a statement about both parents at once, and it is the moment a pedigree stops being decoration.
The server settings
ServerConfig, and server-side for the same reason both times:
whether a foal dies has to be one answer for everyone, and so does how big a
horse is, because size moves the hitbox.
health.mode | fewer hearts | conditions shown | deaths |
|---|---|---|---|
FULL (default) | yes | yes | yes |
NO_DEATHS | yes | yes | no |
OFF | no | no | no |
health.mode cannot change
All the health genetics are built and inherited regardless. The genes are registered in every world, they occupy the same slots in the genotype code, they are drawn from the same founder tables and they pass to foals the same way. If the setting could change any of that, two players on different settings would be breeding different animals and a horse traded between them would change genotype on the way. All it governs is whether what a horse carries is allowed to affect the horse standing in front of you.
Mechanically, OFF is
HorseTraits.resolve(genotype, /*healthGenetics=*/false), which skips
every gene marked HealthContribution. The performance and size loci
are not health genetics and keep working in all three modes. A gene may paint a
coat and be a health contribution —
overo lethal white is a white pattern and a foal
lethal at once — and only the trait half is ever gated; the coat never is.
body.size — may the size loci resize the horse?
A boolean, true by default. The size loci resolve a body scale and
HorseRecords.applyTraitsToEntity writes it to
Attributes.SCALE; setting this false writes a flat
1.0 there instead. That is the whole mechanism — one
expression, in the one place listed in the table below.
The same sentence that sells the size system is the reason for the switch:
vanilla scales the model and the hitbox from SCALE. A
saddle, a lead, an arrow and a fence gap all meet a Falabella somewhere
other than where they meet a Percheron, and a player who would rather have
their tack sit where vanilla puts it can say so. It is
server-side precisely because it is a hitbox change: a
client that disagreed with the server about a horse’s size would be
aiming at a horse that is not there.
Nothing else is gated. The size genes are registered, drawn from the same
founder tables and inherited identically; Traits.scale() still
resolves to what the alleles say, so the info screen, the
inspect paper and the breeding debug line all keep reporting the real number. Only the entity is held at 1.0. The one other
route to SCALE — an effects
attribute verb naming scale — answers to the
same setting, and GeneAbilityHandler.clearAttributes takes a
standing modifier back off rather than leaving it frozen on the horse.
Like health.mode, a change reaches horses already in the world
when they next load: each horse re-resolves its body once per level load, from
the entity tick.
Reaching the game
HorseRecords.applyTraitsToEntity writes four attributes, and is
called on every horse join as well as at birth. Vanilla has just
randomised this horse’s speed, health and jump; this is where that gets
overwritten with what its alleles actually say.
| trait | attribute | notes |
|---|---|---|
| speed | MOVEMENT_SPEED | base value, floored at vanilla's slowest horse — the only place that clamp is applied |
| health | MAX_HEALTH | current HP clamped down, never healed for free on a reload |
| jump | JUMP_STRENGTH | tracked for the first time |
| scale | SCALE | vanilla scales the model and the hitbox from it — the one trait with a switch, body.size |
The roadmap flagged the size path as unverified, check before
designing. Attributes.SCALE exists in 26.1.2 and is on the
attribute supplier of every living entity
(LivingEntity.createLivingAttributes), so the whole size system
is one attribute write — no renderer-side scaling and no hand-written
hitbox change. JUMP_STRENGTH is there too.
What this is not
- No environmental noise. The roadmap left room for the random roll to survive as jitter on top of the genetic value. It did not: two horses with the same genotype are the same horse, full stop. Epigenetic variation (which would be inherited with the allele and still deterministic) is the obvious place to reopen this if the numbers ever feel too tidy.
- No per-part scaling. Friesian dwarfism should shorten the
limbs and leave the head;
SCALEis one number for the whole entity. See the roadmap. - No naked-foal coat. ST14’s hairless foal is reported, not drawn — phase 1 can only remove pigment, and a de-pigmented mane is a white mane.
- No gait. DMRT3 needs animation work and is still on the backlog.
- No CSNB. It rides on
LP/LP, and the leopard complex is not built. - No group traits.
TraitRule— two genes that only together trigger an outcome — is still the roadmap.
common/trait/,
neoforge-26.1.2/server/LethalFoalHandler.java,
neoforge-26.1.2/ServerConfig.java