Start here
The genetics model
Alleles are objects, not letters in a string. A horse carries one
AllelePair per registered Gene, plus an
epigenome — a priority and a set of literal numbers riding on
each allele copy. Everything below lives in
common/genetics/ and imports nothing from Minecraft.
The pieces
| Type | What it is |
|---|---|
Gene | An interface: the alleles a locus defines, how they segregate, and how the locus changes the coat. One singleton instance per gene. |
Allele | One allele of one gene. Identified by <modauthor>.<gene>.<token>, e.g. horsegenetics.splash.Spl. |
AllelePair | The two copies a horse carries at one gene — an unordered combination, put in a canonical slot order on construction so a pair equals its reverse. |
Expression | One outcome a gene can produce: a name, a human-readable description, whether it is a wild type, whether it masks, and the paint function. Replaces DominancePattern entirely. |
FounderTable | How common each allele combination is in the wild population, as percentages. Replaces the old per-allele “1 in N” frequency. |
Genotype | One AllelePair per registered gene. Round-trips through a code string. |
AlleleEpigenetics | (int priority, EpiValues values) — the heritable payload on one copy of one allele. |
EpiSchema / EpiValue | What a gene declares it writes on a copy (Gene.epiSchema()): each value’s name, kind, design range and founder distribution. |
EpiValues | The literal numbers themselves, read by name. EpiRoll rolls a founder’s; EpiDrift nudges them at each breeding; EpiCodec writes and reads them as text. |
Epigenome | One AlleleEpigenetics per copy, aligned slot-for-slot with the genotype’s pairs. |
Genome | Genotype + Epigenome. The thing a foal actually inherits. breedWith takes an optional per-parent GameteBias — what a breeding carrot does. |
GameteBias / CarrotEffect | A per-parent modifier on the breeding draw (re-roll epigenetics / prefer the dominant or recessive copy / substitute a pair for one gene), and the sealed set of carrot effects that fold into one. NONE is bit-for-bit the plain draw. |
GeneRarity | Six tiers. Gene.rarity() (default UNCOMMON), hasGeneCarrot() and spliceTable() are the §19 metadata the carrot economy and the gene database read. |
Genes | The registry. Three orderings: codeOrder(), naturalOrder(), magicalOrder(). |
GenotypeCatalog | Every visually distinct genotype the registry can produce, in a stable order. |
GeneSpec / SpecGene | A gene described as a JSON file rather than a class, and the Gene that answers everything from it. Indistinguishable from a hand-written gene everywhere below this table. |
The genotype code string
A Genotype is held internally as a map keyed by gene, and
serialises to one gene-keyed segment per gene, in
Genes.codeOrder(). Each segment is
<geneKey>=<a>/<b> — the full
Gene.key(), then the two alleles in the gene’s own
declaration order; segments joined by -, alleles by
/:
horsegenetics.extension=E/e-horsegenetics.agouti=A/a-horsegenetics.silver=z/z-horsegenetics.matp=Cr/N- …
Allele tokens can be any run of characters —
Spl, Cr, prl, Mzeb,
Blupnk — not single letters. The =,
/ and - separators (and the gene key, which
contains none of them) keep that unambiguous. The forward-looking rule for
third-party genes is stricter: alphanumeric
tokens, 1–128 characters, wild type named n.
Genotype.parse looks each segment's gene up by
key, not by position. A registered gene with no
segment reads as its wild type; a segment naming a gene that is
not registered is dropped; the empty
string is the all-wild-type genotype. A bad allele
token on a known gene is still a hard error. So adding or removing a
gene is just a coat regeneration — no padding, no version field, no
positional / legacy fallback. What happens when a player
installs a gene mod is still an open
question.
Gene priority and the derived orderings
Every Gene declares an int priority() — a
fixed constant of the gene, no default. Genes keeps a single
order: every registered gene, built-in and data-driven
alike, sorted on (priority, key). The three public views are a
result of that one sort, never a source:
codeOrder() / all() is the whole list,
naturalOrder() is it filtered to isNatural(),
magicalOrder() to the magical genes. Ties break alphabetically
by key. A data-driven natural gene at priority 45 lands between the
built-in MATP (40) and champagne (50) — loaded genes are not
appended.
Bands are a convention: 0–99 natural, 100+
magical — the phase (isNatural()), not the
number, splits the two coat passes, and Genes.register only
warns when a gene sits outside its band. Within the natural band,
low numbers set pigment absolutely, higher numbers dilute
(agouti's absolute points must precede PigmentField.dilute).
Built-in priorities were chosen to reproduce the pre-rewrite hand-written
order, so no coat changed. This is not
AlleleEpigenetics.priority, which selects a seed and never an
order.
Alleles, combinations and expressions
A gene has any number of alleles. A horse carries two, and
order does not matter, so a gene with n alleles has
n(n+1)/2 combinations — three for two alleles, six
for three, 465 for a thirty-allele KIT. Every
combination produces some result, and the gene says which by
declaring its distinct results as Expressions and mapping any
pair to one of them with expressionOf(AllelePair).
“Dominant” and “recessive” are shorthand for which combinations happen to share a result. They only describe a two-allele locus, and they cannot say what a third allele does without inventing a fourth word. The combination table says all of it directly, and says it for any number of alleles:
- several combinations landing on one outcome is what
“dominant” meant —
E/EandE/eboth wild type; - only the double-variant landing on a non-wild-type outcome is
what “recessive” meant —
Hlr/nis a carrier because it maps to a wild type, not because of a label; - two variant alleles each with their own outcome and a third for the pair of them is codominance — MATP is exactly that.
None of them needs a name, and none of them needs a special case.
What an expression carries
| Field | What it means |
|---|---|
id | The gene’s slug for this outcome, unique within the gene and stable. Two expressions are equal when their ids match, and the catalogue dedups allele pairs by it. |
name | Short display name — “Sabino-white”. |
description | One human-readable sentence: what this combination does to the horse. What the gene dictionary, the tooltips and these wiki pages show. |
wildType | This combination changes nothing. No painter, skipped by the composer, excluded from the texture key, reads as “absent” in the genome display. A property of the combination, not of an allele. |
masks | While this shows, no other gene is visible — dominant white, frame lethal white. Also per-combination: w/w does not mask, W/w does. |
deterministic | Every horse with this combination is painted byte-for-byte identically. A false anywhere forces per-horse texture generation. |
| the painter | Either a phase-1 restrict (a natural gene) or a phase-3 tint (a magical one) — never both, because a gene is one or the other. Both are pure: handed read-only views, they return a contribution. |
private final Expression WILD = Expression.wildType("No white markings.");
private final Expression SABINO = Expression.of("sabino1", "Sabino 1")
.describe("Tall jagged stockings, a splash of white up the belly, ...")
.varies()
.restrict((ctx, coat) -> paintSabino(ctx, coat, 1));
private final Expression SABINO_WHITE = Expression.of("sabino-white", "Sabino-white")
.describe("Ninety per cent white or more, with a few coloured flecks ...")
.varies()
.restrict((ctx, coat) -> paintSabino(ctx, coat, 2));
@Override
public Expression expressionOf(AllelePair pair) {
return switch (pair.count(SB1)) {
case 2 -> SABINO_WHITE;
case 1 -> SABINO;
default -> WILD;
};
}
A gene may declare more than one wild type when different
silent combinations deserve different wording — MATP’s
prl/N is a “pearl carrier”, distinct in the gene
dictionary from the plain N/N even though the two horses look
identical. The catalogue collapses every wild type into one entry, because
“changes nothing” is one look.
The genotype-aware question
A handful of genes depend on another gene: agouti paints black points, so it
does nothing at all on a chestnut horse. Those override
expressionIn(pair, genotype), which defaults to
expressionOf(pair) and is what the coat pipeline actually calls.
An override may only return one of the gene’s own declared expressions
— typically a wild type, meaning “suppressed here”.
A data-driven gene can do this too now. An expression in a
gene file may carry a needs block
naming another locus and a copy count, and SpecGene implements
expressionIn and coatDependsOn off it. That is the whole of
the addition: no new concept, just a way to reach the hook the leopard complex has
used since long before the file format existed.
Accretion is the gene built on it — one locus
decides whether there is a pale field and what colour it is, and
Accretion Field, which paints nothing at all
and which every horse carries, decides whether it takes the topline or the underside.
Founder frequency, per combination
Gene.founderTable(FounderContext) returns a
FounderTable: a weight per allele combination,
as percentages that should sum to 100. One
nextFloat() per gene per founder picks a bucket, drawn in
codeOrder(), so a founder’s genotype is reproducible from
the RNG stream. Founders only — breeding never
consults it.
This replaces the “1 in N per allele” frequency each gene used to
hand-roll, for two reasons. It has no meaning past two
alleles — “1 in 45 per allele” says nothing about
a locus with thirty of them. And it hid the number the author cares
about: a per-allele chance only implies a homozygote rate,
where a per-combination table lets you set it directly. The two
splash loci are the clearest case — a
majority of MITF founders carrying one SW1 and
none at all homozygous is a shape no allele frequency can
produce, because Hardy-Weinberg’s heterozygote share peaks at 50% and
always brings homozygotes with it.
The table is sparse: a combination left out simply never
turns up in the wild. That is what lets a thirty-allele locus declare the
dozen combinations that actually occur instead of all 465. Weights that miss
100 are normalised proportionally with a warning, not an error.
FounderTable.hardyWeinberg(variant, baseline, p) is a
convenience that computes the three numbers a two-allele author would
otherwise work out by hand — it is what the old “1 in N”
meant, not a second model.
The multi-allele overload is
hardyWeinberg(Map<Allele, Double>, Predicate<AllelePair>),
and KIT is why it exists: thirty-six combinations, four
of them ruled out, is not a table anyone can hand-write without a typo nobody would
ever see. Excluded combinations are dropped and the rest rescaled,
which is also the biology — a lethal is absent from the adult population you
are observing.
FounderContext carries the part of the genotype already rolled,
so a gene’s frequency may depend on what the horse already is
(“champagne twice as likely on a chestnut”). Genes roll in
codeOrder(), so a gene can only read genes with a
lower priority; asking about a later one throws rather than quietly
handing back a wild type that would make the bug invisible.
The forty-eight registered genes
The table is in codeOrder() — the one
(priority, key) sort every ordering is derived from — so it
doubles as the list of the numbers the built-ins occupy. Low numbers set pigment
absolutely, higher ones dilute; 80–99 is the non-coat sub-band
and 100+ is magical.
Six of them are white-pattern loci, and they are six because
real white patterning is six genes. Only alleles at exactly the same
locus share a gene here: sabino and the numbered W series are
all KIT variants, so they are one gene; splash lives on
MITF and PAX3, so it is two; and tobiano
(an inversion near KIT, not a KIT variant), roan
(region-mapped, causal change unresolved) and frame (EDNRB) each
keep their own. That is what lets a horse be tobiano and
W20 and splash at two loci — and what stops it
being sabino and dominant white at once.
| Pri. | Gene | Alleles | Outcomes | In the wild | Phase |
|---|---|---|---|---|---|
| 1 | sex | X / Y | mare, stallion — both wild types‡ | 50 / 50, no Y/Y | neither (paints nothing) |
| 10 | extension | E / e | wild, chestnut | 25 / 50 / 25 | natural |
| 20 | agouti | A / a | wild, bay | 25 / 50 / 25 | natural |
| 30 | silver | Z / z | wild, silver | 1/60 per allele | natural |
| 32 | mushroom | Mu / mu | wild, mushroom-carrier, mushroom | 1/34 per allele | natural |
| 34 | dun | D / d1 / d2 | wild, primitive-marks, dun§ | 1/24 D, 1/10 d1 | natural |
| 36 | brindle | Brn / n / Y● | wild, brindle-carrier, brindle | 2% per X — 2% of stallions, 0.04% of mares | natural |
| 40 | MATP | Cr / prl / N | wild, pearl-carrier, single-cream, classic-pearl, double-dilute | 1/30 Cr, 1/22 prl | natural |
| 50 | champagne | Ch / c | wild, champagne | 1/40 per allele | natural |
| 55 | grey | G / g | wild, grey | 1/16 per allele | natural |
| 60 | tiger eye | TE1 / TE2 / N | wild, tiger-eye-carrier, tiger-eye-amber, tiger-eye-yellow | 1/70 per allele — but 26% of Puerto Rican Paso Finos | natural (eyes only) |
| 68 | natural zebra | Zeb / n | wild, shadow-striped, zebra-striped | 1/60 per allele | natural (codominant) |
| 70 | roan | Rn / rn | wild, roan | 1/30 per allele | natural |
| 72 | tobiano | To / to | wild, tobiano | 1/50 per allele | natural |
| 73 | leopard complex | LP / lp | wild, mottled, varnish-roan, leopard, fewspot, blanket, snowcap, semi-leopard¶¶ | 1/40 per allele | natural |
| 74 | EDNRB — frame | O / N | wild, frame, lethal-white (masks)† | 1/55 per allele; no O/O founder | natural |
| 76 | KIT — white spotting | W22 / W13 / W10 / W5 / W23 / SB1 / W20 / N | wild, minimal-white, modest-white, sabino, broad-white, extensive-white, near-white, dominant-white (masks)* | W20 6%, SB1 2.2%, the rest under 1% each; four homozygotes cannot occur | natural |
| 78 | MITF — splash | SW3 / SW1 / SW5 / N | wild, splash, splash-bold, splash-extensive | SW1 4%, SW5 0.6%, SW3 0.4%; no SW3/SW3 | natural |
| 79 | PAX3 — splash | SW2 / SW4 / N | wild, splash, splash-bold | 90% SW2/N, 1% SW4/N; heterozygotes only | natural |
| 80–92 | the thirteen non-coat genes — see the table below | ||||
| 93 | PATN1 | PATN1 / n | wild only — a silent modifier¶¶ | 0 unless the founder rolled LP | neither (paints nothing) |
| 94 | PATN2 | PATN2 / n | wild only — a silent modifier¶¶ | 0 unless the founder rolled LP | neither (paints nothing) |
| 112 | mane colour | Mnsld / Mnstrp / n | wild, solid, striped, solid-striped¶ | 2.0% / 1.5% per allele | magical |
| 114 | tail colour | Tlsld / Tlstrp / n | wild, solid, striped, solid-striped¶ | 2.0% / 1.5% per allele | magical |
| 116 | healer | Hlr / n | wild, healer-carrier, healer | 9% per allele | magical |
| 120 | magic zebra | Mzeb / n | wild, zebra | 1/100 per allele | magical |
| 130 | milk | Watr / Lava / n | mares-milk, milk-carrier, water-milk, lava-milk, milk-lethal — all wild types‡ | 8% / 6% per allele; no Watr/Lava | neither (paints nothing) |
| 140 | magic body size | Big / Small / n | six combinations, six outcomes — codominant, and all wild types‡ | 80% carry one copy; no homozygote is ever wild | neither (paints nothing) |
| 141 | magic speed | Swift / Sluggish / n | six combinations, six outcomes — codominant, all wild types‡ | 80% carry one copy; no homozygote is ever wild | neither (paints nothing) |
| 142 | magic health | Hardy / Frail / n | six combinations, six outcomes — codominant, all wild types‡ | 80% carry one copy; no homozygote is ever wild | neither (paints nothing) |
| 143 | magic jump | Springy / Leaden / n | six combinations, six outcomes — codominant, all wild types‡ | 80% carry one copy; no homozygote is ever wild | neither (paints nothing) |
| 150 | particle | 40 variants / n | wild + 40 single + 46 codominant double — all wild types‡ | 0.1% per variant allele | neither (paints nothing) |
| 160 | light | Lthf / Ltmn / Lteye / n | wild + six regions, codominant‡‡ | 0.5% per variant allele | magical |
| 180 | verdant | mush / moss / grass / n | wild, verdant-carrier, mycelium, moss, grass — all wild types‡ | 6% / 7% / 8% per allele | neither (paints nothing) |
| 190 | LUT | n / Blupnk / Grnpnk | wild, a carrier wild type per palette, bluepink, greenpink | ~1 in 60 carry each copy; no homozygote is ever a founder, and a mixed pair is the wild type | magical — swaps the phase-2 gradient (no phase-1 or phase-3 paint) |
| 196 | cutie mark | Cutmrk / n | wild, cutie-mark-carrier, cutie-mark — all wild types‡ | 6% per allele (recessive) | neither — a client render layer draws a 1-3 item emblem on both flanks over everything |
| 690 | extreme white dominant | EWD / n | wild, white-dominant | 1/320 per allele | neither — it paints nothing and changes the fold instead |
The hundred-odd data-driven genes take their slots by the same rule and are not listed here, because a hundred rows of numbers typed into a wiki page is exactly the thing that goes stale. The paint order draws every gene that paints, highest first, from the registry at run time. It is the place to look for what covers what.
● Brindle is the model’s only sex-linked gene.
Its Y is not an allele a horse can express — it is the
reserved placeholder a hemizygous stallion’s spare slot holds, meaning
“this locus is not on the Y”. That is what lets a genotype stay one
two-slotted AllelePair per gene with no structural change, and it is why
a stallion carrying brindle displays as X-Brn rather than beside a fake
second copy. See its page.
¶ Mane and tail colour are the model’s first genes whose
heterozygote paints with two allele copies at once, one colour
each, which is why they need
CoatBuildContext.epigeneticsForCopy rather than "the copy that
expresses". ‡‡ Light is the clearest codominance in the
model: three variants, each dominant to the wild type and to none of
each other, so a horse shows everything it carries — ten combinations,
seven outcomes, and no ordering of three alleles that could describe it.
* KIT is the locus that broke the last of the old
model. Eight alleles, thirty-six combinations, and four of them
homozygotes that are thought nonviable — so canOccur rules
them out and the founder table is the survivors, renormalised. It also
settles a genotype the old model allowed and biology does not: a horse can
be homozygous sabino or dominant white, never both, because they
are alleles of one gene competing for two slots. See
its page.
† O/O is one of the model’s two kinds of
lethal. An embryonic lethal is a horse that never existed
(canOccur false, no pen, no founder) — KIT’s
four W homozygotes, and MET’s
met/met. Lethal white is the other kind: the foal is born,
so it occurs, it has its own masking all-white outcome and it gets a pen —
it is simply never a founder, because a founder is an adult that survived. The
death is modelled; see the horse’s body and
its page.
¶¶ The leopard complex was the model’s first cross-locus
gene, and accretion is the second —
the first to do it from a file rather than from Java.
LP reads
PATN1 and PATN2 in expressionIn to pick
one of eight outcomes (leopard, fewspot, spotted blanket, snowcap, semi-leopard,
or the plain LP looks). The modifiers paint nothing on their own, so
they carry Gene.affectsCoat() false and would be left out of the
texture key — LP.coatDependsOn() names them and
coatCode() folds their alleles in, but only for a horse that actually
has LP. A founder draws a variant PATN copy only if it
already rolled LP. See its page.
§ Dun is the locus with two dominance orders.
Dilution reads D > d1 = d2 but marking reads
D = d1 > d2, so d1 draws the dorsal stripe
without diluting anything — a horse with primitive markings that is
not a dun. No single dominance label covers both orders, which is the
clearest case yet for the table. See its page.
‡ Sex was the first gene that never paints, and is no
longer the only one. Both its outcomes are wild types, so it costs the catalogue
nothing and is left out of the texture key; it is registered at priority 1 so a
future sex-linked gene can read a sex that
is already resolved, and so a foal’s sex is inherited rather
than rolled. See its page.
The thirteen that never paint
Every outcome of every gene below is a wild type, which here means
“changes nothing about the coat”. So affectsCoat()
is false for all of them, they are out of the texture key, and the
catalogue collapses each locus to a single entry —
thirteen genes for zero catalogue growth, which is the sex
locus’s trick reused. What they do instead goes through
common/trait/: the machinery is on
the horse’s body, and each gene has its
own page. Their order among themselves is arbitrary, because none of them can
reach another through the coat.
| Pri. | Gene | Alleles | In the wild | What it moves |
|---|---|---|---|---|
| 80 | MSTN | C / T | p(C) = 0.35 | codominant: each C is +0.020 speed and −2 health |
| 81 | PDK4 | A / G | p(A) = 0.25 | +0.018 speed per copy |
| 82 | CKM | T / C | p(T) = 0.20 | +0.015 speed per copy |
| 83 | RYR2 | J / n | p(J) = 0.20 | +0.09 jump strength per copy |
| 84 | LCORL | L / n | p(L) = 0.30 | height: +0.05 scale, +0.010 speed, +0.02 jump per copy |
| 85 | HMGA2 | p / N | p(p) = 0.25 | pony: −0.06 scale, −0.008 speed, −0.02 jump, +2 health per copy |
| 86 | ACAN | D1 / D2 / D3 / D4 / N | 0.4% per variant; affected combinations excluded | dwarfism. Five alleles, fifteen combinations; affected is “no working copy left”, so D1/D4 counts. D1/D1 is lethal at birth, every other D/D is scale ×0.70 and −6 health |
| 87 | B4GALT7 | d / N | 4.0% carriers | Friesian dwarfism — scale ×0.75, −5 health. The one disorder a horse lives with |
| 88 | PLOD1 | ffs / N | 2.6% carriers | fragile foal syndrome — lethal at birth |
| 89 | RAPGEF5 | efih / N | 1.4% carriers | EFIH — lethal at birth; the most severe and the rarest |
| 90 | ST14 | nfs / N | 1.8% carriers | naked foal syndrome — lethal at birth. The bare coat is not drawn |
| 91 | SHOX | sa / N | 2.0% carriers | skeletal atavism — lethal at birth. Pseudoautosomal, so it segregates like an autosome |
| 92 | MET | met / N | 3.0% carriers | lethal at conception — canOccur false, and the pairing produces no foal at all |
| 93 | SCN4A | H / N | 1.2% affected — there is no carrier | HYPP. Dominant, and the first locus that is both a survivable disorder and a lethal: H/N is −6 health and rideable, H/H is lethal at birth. Founders can be born H/N, because a dominant that never reached a founder could never exist |
| 94 | GYS1 | P / N | 2.5% affected — there is no carrier | PSSM1, tying-up. Dominant and neither combination is lethal, which makes it the mildest locus here and by far the one a player is most likely to meet |
| 95 | PPIB | herda / N | 3.4% carriers | HERDA, fragile skin — −7 health, survivable. The highest carrier rate of any recessive here, because the real thing hides in the working lines a breeder would choose |
| 96 | PRKDC | scid / N | 2.8% carriers | SCID — lethal at birth. No immune system at all |
| 97 | MYO5A | lfs / N | 2.2% carriers | lavender foal syndrome — lethal at birth. The only lethal whose real presentation includes a colour, and it is deliberately not drawn |
| 98 | TOE1 | ca / N | 2.4% carriers | cerebellar abiotrophy — survivable, and the heaviest jump penalty in the mod. Progressive in reality; flat here, because there is no age model |
| 99 | CVM | cvm / N | 1.6% carriers | cervical vertebral malformation — lethal at birth. No confirmed causal variant; one locus is the mod's simplification, and the page says so |
| 100 | GBE1 | gbed / N | 2.0% carriers | GBED — lethal at birth, and the largest heart reduction in the mod |
| 101 | Megaesophagus | meg / N | 1.7% carriers | lethal at birth, and the gentlest of them on the numbers — the failure is mechanical, not systemic. Also has no confirmed causal variant |
No founder is ever affected. Every health founder table lists only
the clear horse and the carrier, because a wild-caught horse is an adult that
survived — so the only way to meet a disorder is to breed two
carriers. Four colour genes carry one as well:
silver Z/Z is MCOA,
EDNRB O/O is lethal white, and a
homozygote at either splash locus (MITF /
PAX3) is deaf — one condition with two causes,
de-duplicated by id.
Seal brown has no allele of its own. Neither has blood bay
nor liver bay: all four bays are bands of one
shade score, which the
shade locus, MC1R dosage,
ASIP dosage and a per-horse roll add up between them.
Agouti declares the four as outcomes and picks
between them by reading the rest of the genotype.
Cream and pearl are one gene, and so are dominant white
and sabino: they are allelic in real horses (MATP and
KIT respectively) and are now one locus each,
which kills the impossible Cr/Cr-and-prl/prl
genotype the old two-gene model allowed. See
its page. Dun is the other
three-allele locus, and the one that shows why a dominance label
could never have worked: see its page.
Sex-linked inheritance
Every gene in the model is autosomal except one.
Gene.inheritance() declares AUTOSOMAL (the default),
X_LINKED or Y_LINKED, and
brindle is the gene that made it worth building: a
stallion has one X, so he carries one copy and can never be a
carrier, while a mare needs two. That asymmetry produces pedigree shapes no
autosomal gene can.
The reserved slot
A genotype stays one AllelePair per gene, two slots,
always. A hemizygous horse fills the slot it does not really have with a
reserved placeholder allele the gene declares:
| Mode | Mare X/X | Stallion X/Y |
|---|---|---|
AUTOSOMAL | two real alleles | two real alleles |
X_LINKED | two real alleles | one real allele + reserved Y |
Y_LINKED | two reserved X | reserved X + one real allele |
That is what keeps the change small. Genotype is untouched,
epigenome alignment is untouched (the reserved slot simply
carries epigenetics nothing reads), the code string keeps its shape, and parsing needs
no special case because the placeholder is a declared allele like any other.
The placeholder must be declared at the end of alleles() that
its slot sorts to — Y last on an
X-linked gene, X first on a
Y-linked one — because AllelePair canonicalises on
declaration order. Genes.register refuses a gene that gets this wrong,
since the failure otherwise is not an exception but a gene that quietly misbehaves for
exactly one sex.
What changes in breeding
- The sire's copy is decided by the foal's own sex rather than
drawn. The sex locus is priority 1, so it is always already resolved by the time
any other gene is reached. On an
X-linked locus the sire gives hisX-borne allele to a filly and hisY— nothing — to a colt;Y-linked is the mirror. The dam is unchanged in both. - 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 stream by a known amount — and a locus that quietly consumed one would make the random stream depend on a foal's sex. Gene.sexConsistentdrops combinations no horse of either sex could carry, so the catalogue does not enumerate them and the random splice cannot roll them.Gene.realAlleles(pair)— two for a diploid horse, one for a hemizygous one, none for a mare at aY-linked locus — is what a sex-linked gene'sexpressionOfcounts. “Every real copy isBrn” covers recessive in mares and always shows in stallions in one sentence.- The founder table has to be sex-aware, and a hemizygous one is written out rather than derived from Hardy-Weinberg — Hardy-Weinberg is a statement about diploid loci and this one is not.
On screen a hemizygous copy carries an X- / Y- prefix
(X-Brn), unambiguous on sight because - is the gene
separator in a code string and can never appear inside an allele token. The code
string itself is unaffected.
Epigenetics ride on the allele, not on the horse
A genotype says which alleles a horse carries. It does not say what those particular copies are carrying, and every copy carries two things:
- a priority — an int in
[1, Integer.MAX_VALUE], used only for the homozygote tie-break below; - a set of literal numbers — one per value the gene
declares in its
epiSchema(): how much white a splash covers, how much bigger a magically large horse is, how far up the legs a bay’s points climb.
So a horse carrying A/a has one set of numbers on its A
and another on its a, and a foal that inherits the A
inherits that copy’s numbers. Bay point heights run in
families rather than being reinvented every generation.
A copy carried a single long, and every gene recovered its
numbers by replaying a PRNG off it in a documented draw order.
That was compact and completely unreadable: “how much jump does this
copy add” could only be answered by running the gene, and the
position of a draw was the meaning of each number — so
inserting one while tuning a gene silently rewrote every horse in the save.
Values are stored literally and read by name now. It costs a great deal more space (an epigenome code went from tens of characters per gene to a couple of hundred, and is only written for the genes that actually vary), and it buys a genotype a player can read, edit and select on. That was the owner’s call and the reason for the whole rewrite.
Three kinds of value, because drift cannot treat them alike
| Kind | What it is | How drift treats it |
|---|---|---|
SCALAR | A magnitude — how much white, how much bigger, how wide. | Nudged continuously. Nudging it a hair makes a horse a hair different. |
SEED | The long behind a noise field — the shape of a splash, a streak, a set of spots. | Never nudged. There is no “slightly different” splash, so it is replaced whole or not at all — and almost never. |
CATEGORY | An index into a list the gene owns — a particle body site, an iris wedge. | Never nudged, for the same reason: a boundary nudge would silently move a horse’s particles from its mane to its tail. |
Colours are three SCALAR channels (<name>_r /
_g / _b) rather than one packed int, so a line’s
colour drifts gradually instead of jumping. A founder’s is still rolled
bright — hue over the whole circle at a held-up saturation and
value — because a magical mane that rolled a muddy olive reads as a bug.
Founder distributions are declared, not flattened
A value names the distribution its founders are rolled from: uniform, a bounded Gaussian, or a power curve. That matters because the genes were deliberately skewed — ednrb’s cover is a power curve so most splash horses carry a little white and a few carry a lot; the magical stat deltas are Gaussian so a single copy is usually subtle. Rolling everything uniformly would have been simpler and would have quietly changed the character of the wild population on every one of them.
Which copy expresses
| Case | Expressed copy |
|---|---|
| Two different alleles | The canonically first copy — the earlier-declared allele. An arbitrary but fixed choice, not a claim about which allele “wins”: what the combination does is its expression, which reads both. |
| Homozygote | Both copies express, so the tie goes to the higher priority. |
That is what Epigenome.expressed(gene, genotype) answers, and its
values are what CoatBuildContext.epigeneticsFor(geneKey) returns.
Epigenetic priority (AlleleEpigenetics.priority) picks
which copy’s values express. It can never move a gene in
the processing queue, and by design it never will — if it could,
two horses with the same genotype and the same values could diverge on
priority alone and silently share a coat cache entry. It also never
drifts: it is an ordinal deciding a tie, not a
magnitude, and a horse whose expressed copy silently swapped between
generations would be inexplicable. Gene processing
order is a separate per-gene constant, Gene.priority()
(Genes sorts on (priority, key)). See
Philosophy §2 and
§ Gene priority above.
The epigenome code string
One <geneKey>=<copy>/<copy> segment
per gene that declares a schema, joined by ;.
Each copy is p:<priority> followed by that gene’s named
values: a plain number, # for a hex noise seed,
[a|b|c|d] for a per-leg value.
horsegenetics.body_size=p:1734,delta:0.34215/p:9871,delta:0.31049;horsegenetics.ednrb=p:44,seed:#8f3a21c4b70e1d55,face:0.61,cover:0.4113,…
Two things changed shape here. Genes join with ; rather than
-, because values are signed now and a hyphen can no longer double
as a delimiter. And the majority of genes write nothing at all
— a gene whose behaviour is fixed by its alleles declares an empty schema,
which is what keeps the code readable now that each stored gene carries a
couple of hundred characters instead of a sixteen-digit seed.
Parsing is tolerant the same way the genotype code is: an unregistered gene’s segment is dropped, and anything the schema declares but the text omits — a gene, or one value inside a gene — is rolled deterministically from the gene key and the value’s name, so every horse in the world agrees on it.
Every value is quantised to what the text can write the moment it is created, so a horse’s numbers in memory are exactly what its code says. That is not tidiness: a value that lost a digit on the way to disk would give a horse a subtly different coat after a reload than before it, and the two would fork the texture cache without ever looking different enough to notice.
Epigenome.fromSeed(long) replays a whole epigenome from a single
long — a reproducible test and tooling horse, used by the
golden-coat suite, the coat sample sheet and the designer preview. It is
not a stand-in for a horse whose epigenome was not stored;
FamilyTreeScreen used it that way and drew ancestors that never
existed, and that fallback is gone.
Founders roll; foals inherit
- Founder (wild spawn,
/summon, a dimension pen horse) —Epigenome.random(rng): an independent priority and an independent roll of every declared value on every copy, deconflicted. Reached throughCoatGenerator.generate, which is the founder path, and throughBreedFounderfor a breed. - Foal —
Genome.breedWith. Every inherited allele brings its parent copy’s priority verbatim and its numbers nudged by drift. A foal must never go through the founder path.
If both copies arrive on the same priority, one extra
nextBoolean() bumps the second ±1
(AlleleEpigenetics.deconflict), clamped to [1, MAX]
— so a horse never carries a tie. Full inheritance detail is on the
breeding page.
What the texture key captures
CoatData.textureKey() is the genotype’s coat code, plus
@<fingerprint hex> only when the horse is
non-deterministic.
Epigenome.visibleFingerprint(genotype) digests only the
expressed values of genes that are both visible and
non-deterministic, so epigenetics a horse cannot show do not fork the cache.
It folds in every stored number rather than a single seed — getting that
wrong does not throw, it silently forks the cache or collides two horses onto
one texture.
Genotype.coatCode() is the full code minus every gene that can never
paint anything — Gene.affectsCoat(), derived as “is any of
my outcomes not a wild type?”. Today the sex
locus is the only such gene, and leaving it out is what stops a mare and a
stallion of the same colour baking two identical textures. It is not
a persistence format: it is lossy on purpose and nothing parses it back.
/** Is every gene's contribution identical on every horse with this genotype? */
public boolean isDeterministic();
/** Does any *visible* gene need per-horse randomness? -> bake per horse. */
public boolean hasVisibleNonDeterministic();
Gene.isVisible(pair, genotype) and
Gene.isDeterministic(pair, genotype) both see the whole
genotype — both derive from expressionIn(pair, genotype),
which is how agouti knows it is invisible on a chestnut.
Claiming determinism you do not have poisons the coat cache for every horse sharing your genotype: the first horse to bake wins, and the others render its coat. This is the single most damaging mistake a third-party gene can make.
The genotype catalogue
GenotypeCatalog enumerates every visually distinct
genotype the registry can produce. Nothing is hard-coded — it falls out of
codeOrder(), each gene’s allele list and each gene’s
expression table, so registering a gene widens it on its own — the catalogue is
built lazily and thrown away whenever the registry changes, which is what makes a
drop-in gene show up in the catalogue without
anything else being told about it.
allPairsOf(gene) | Every unordered pair a horse can carry — all n(n+1)/2 of them (ee, Ee, EE), minus any the gene rules out with canOccur. Only the sex locus does: there is no Y/Y horse. |
|---|---|
distinctPairsOf(gene) | One representative per distinct Expression — the homozygous pair where the group has one. Every wild type is one group, because “changes nothing” is one look. |
totalGenotypes() | The raw product — every distinct heritable genotype. |
size() | The reduced catalogue — how many of them are distinct to look at. |
get(i) / entries() | Read an entry. Nothing is materialised — size() is arithmetic and get(i) reads a mixed-radix odometer, because a two-million-entry List<Genotype> is hundreds of megabytes. entries() is a lazy view: streaming it still walks all of them, so sample it or ask the arithmetic. |
Two reductions, both read straight off the expression table with no
dominance metadata in the middle: pairs landing on the same expression
collapse to one, and a gene on a masks expression keeps exactly
one entry — that combination with every other gene at a wild type,
which is why a masking combination keeps just one pen instead of hundreds of
thousands. Ordering is a mixed-radix odometer whose first gene is
the fastest-varying digit, so the catalogue reads
Nothing in the game reads it any more. It used to drive the horse dimension, one pen per entry; that corridor went back to random pens on 2026-09-04, because at two million entries the corridor was longer than the world. The catalogue stays because the tests read it and a punnett display will — the reduction is a statement about the genes, not about the dimension.
Phenotype, coarsely
Genotype.phenotype() gives a coarse CoatPhenotype
(CHESTNUT / BLACK / BAY /
WHITE; everything else ignored). It is only a
family-tree fallback now — foals and adults alike are fully generated
through the coat pipeline.
common/src/main/java/com/example/horsegenetics/common/genetics/
Genes that never paint
Twenty-four of the registry’s forty-eight built-in genes change nothing
about a horse’s colour: the sex locus, the six
performance and size loci, the seven health loci, the two
leopard-complex modifiers
(PATN1, PATN2 — read by LP, but
never painting themselves), and seven magical genes
— milk,
verdant,
particle, and the four
magical body-stat genes (size, speed,
health and jump). Every combination they can produce is
an Expression marked wildType.
That reads oddly for a gene that can kill a foal, and it is exactly right:
wildType means “changes nothing about the
coat”. It is the property the coat pipeline asks about, and
nothing else. What these genes do instead travels on
the trait system — speed, max health, jump
strength, body scale, and the disorders a horse expresses.
Three consequences follow, and together they are why the non-painting genes are nearly free:
Gene.affectsCoat()is false, so they are excluded fromGenotype.coatCode(). Two horses that differ only in them share one baked texture.GenotypeCatalog.distinctPairsOfcollapses every wild type into one group, so each contributes one catalogue entry however many alleles it has — ACAN’s five alleles and fifteen combinations included, and verdant’s four — and particle’s forty, 861 combinations collapsing to one entry — and each leopard-complex modifier’s three combinations collapsing to one. The catalogue stands at 11 098 128 386 distinct coats. The particle locus and the four body-stat genes left it untouched; the two genes that do change the coat — the two-outcome LUT locus and now the leopard complex (coarseexpressionOf: wild orvarnish-roan) — each doubled it. That tippedGenotypeCatalog.size()pastInteger.MAX_VALUE, so it (andget()) arelongnow; nothing in production indexes the catalogue.- The raw genotype count is
42 875 903 837 175 872 297 619 456 000 000. All real, all heritable,
all distinguishable — just not by colour. (The leopard complex’s
three loci, three combinations each, multiplied it by 27.)
It is a
BigIntegernow, and has to be: the particle locus pushed the product past 263, and the figure this page carried until then — 3 028 898 126 035 238 912 — was alongthat had already wrapped. The true count before particle was 94 874 633 669 214 259 200 000.
The magical utility and body-stat genes stretched the phrase further than the health loci did, and it still holds. A lava-bearing horse is emphatically not “the same as an ordinary horse”, and a ten-times giant is the most visible thing on the field — but neither changes a coat pixel, and the coat pipeline is the only thing that asks. The cost is real and is logged as a gap: milk and verdant are invisible until you put a bucket under the horse or watch the floor, where healer deliberately draws a stripe so you can see what it does.
An expression still carries a name and a description when it is a wild type, which
is what lets a carrier outcome say something useful
(MATP’s pearl-carrier, every health
locus’s …-carrier) without pretending to change the coat.