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.

What this replaced

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.

common/trait/Traits.java
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).

This used to be a fourth 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.

statbaselinevanilla rollsthis model’s wild range
movement speed0.18750.1125 – 0.33750.1715 – 0.3135
max health22.0 (11 hearts)15 – 3018 – 26
jump strength0.500.4 – 0.80.46 – 0.72
body scale1.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.

Health never resolves to zero

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.

And no horse is too slow to get away from a zombie

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 horseAbstractHorse.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.11250.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

common/trait/TraitContribution.java
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

common/trait/EpigeneticTraitContribution.java
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.

This does not weaken determinism

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:

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.

severitymeaningwho has it
INFORMATIONALNamed and shown; no mechanical cost. Splash deafness (MITF / PAX3)
IMPAIRINGFewer hearts, usually with a smaller body or a lower jump. ACAN dwarf, B4GALT7, silver MCOA
LETHAL_AT_BIRTHBorn, named, filed in the pedigree, then dies. EDNRB O/O, PLOD1, RAPGEF5, ST14, SHOX, ACAN D1/D1
LETHAL_AT_CONCEPTIONNo 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.

RecessiveDisorderGeneDominantDisorderGene
One copysilent — a carrier, indistinguishable from normalaffected — there is no carrier state at all
Two copiesaffectedaffected, and worse
Can a founder be affected?Never. The homozygote is simply absent from the founder tableYes, heterozygous — the homozygote is still excluded
How you find outafter the fact, from a foalby looking at the horse
Locithe 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 onceHYPP and PSSM1
A dominant disorder must reach founders

“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.

The chat line is the feature

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.modefewer heartsconditions showndeaths
FULL (default)yesyesyes
NO_DEATHSyesyesno
OFFnonono
What 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.

Why anyone would turn it off

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.

traitattributenotes
speedMOVEMENT_SPEEDbase value, floored at vanilla's slowest horse — the only place that clamp is applied
healthMAX_HEALTHcurrent HP clamped down, never healed for free on a reload
jumpJUMP_STRENGTHtracked for the first time
scaleSCALEvanilla scales the model and the hitbox from it — the one trait with a switch, body.size
Answered: the entity-scale question

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

Source: common/trait/, neoforge-26.1.2/server/LethalFoalHandler.java, neoforge-26.1.2/ServerConfig.java