Reference / everything about making one
Making a gene
A gene is a JSON file you drop in a folder, or — for the few
things the format cannot say — a Java class. This page is the
whole job in one place: what a gene is, how to get from a picture of a marking to a
rule that draws it, every key in the file, every mask and op with its parameters,
the effects vocabulary for behaviour beyond the coat, the Java escape
hatch, a prompt that hands the work to a model, and what to re-bake afterwards.
It used to be four pages — Creating a gene, The gene file format, Writing a gene and Gene effects — and the cost was not duplication so much as no entry point: the traps were on one page, the parameter that caused them on another, and which one you needed depended on knowing the answer already. The tabs below are that material, unchanged except where two pages disagreed. The wider trait architecture that the effects page carried — auras, resource pools, memory, goals, spawn variants, and the catalogue of divergences from the spec — is not here, because none of it runs: it is on the trait & effect architecture.
What a gene is here
A gene is a JSON file. Not a class, not code — a file you drop in
.minecraft/phc/genes/, restart, and wild horses start carrying. It
breeds Mendelian, takes its own segment in the genotype code, and stores its own
per-horse numbers, all without you writing a line of Java. The full format is on
the gene file format; this page is about the part that
is hard, which is not the format.
The hard part is that you are not drawing. You are writing down a rule that is true at every point on a horse, and then finding out what that rule looks like. Those are different skills, and the gap between them is where all the time goes.
Every layer is where × what
One layer has a list of masks and one op. The masks fold together into a single number per point on the horse — call it coverage, 0 to 1 — and the op is applied scaled by it. That is the whole engine. Coverage scales the effect rather than switching it on, which is why edges come out soft unless you work at making them hard.
So “a white patch on the shoulder” is: a mask that says shoulder,
a mask that says patch-shaped, and an op that says white. Masks
multiply by default; you can also MAX, MIN, ADD
or SUBTRACT them, and any one of them can be inverted.
The shape
A header, then two tables:
expressions— one entry per distinct outcome, each naming the allele combinations that produce it, a human-readable description, and what it paints. This is why the format has nodominancefield: which combinations share an outcome is the whole of what dominance used to say, and saying it directly works for any number of alleles. See the genetics model.founders— the share of wild horses carrying each combination, as percentages. Replaces the old per-allelewildOdds.
An expression paints with a list of layers. Each layer is
where (its masks, folded into one coverage
number per texel) times what (its op, applied
scaled by that number). Because coverage scales the effect rather than gating it,
every edge is soft by construction — the thing each hand-written gene has to
remember separately. (CoatRegions.whitenLowerLeg is the standing
counter-example: it cuts hard, so every sock it draws ends in a perfect ring, which
is why no built-in gene calls it any more.)
{
"format": 3,
"key": "mymod.silver",
"name": "Silver dapple",
"phase": "natural",
"priority": 45,
"alleles": [
{ "token": "Z", "label": "Silver (Z)" },
{ "token": "z", "label": "Wild-type (z)" }
],
"expressions": [
{
"id": "silver",
"name": "Silver dapple",
"description": "Black pigment diluted to chocolate, with red left alone.",
"when": [ "Z/Z", "Z/z" ],
"layers": [
{
"name": "dilute the black, leave the red",
"masks": [ { "type": "ALL" } ],
"op": { "type": "DILUTE", "keepRed": 1.0, "keepBlack": 0.45, "blackTint": 0.18 }
}
]
},
{
"id": "wild",
"name": "Wild type",
"description": "Black pigment is left alone.",
"wildType": true
}
],
"founders": { "Z/Z": 0.027778, "Z/z": 3.277778, "z/z": 96.694444 }
}
Most genes are one shape and one number. Silver dapple is “multiply
eumelanin by 0.45 and leave phaeomelanin alone”; a sock is “drive
both pigments to zero up the bottom 40% of each leg, jittered”. Neither
needs a class. Build it in the
gene creator — which previews it on
a 3D horse over any base coat — export the JSON, drop it in
.minecraft/phc/genes/, restart. Every key is on
The file and every mask and op on
Masks & ops.
Go to In Java when you need something the format cannot express: reading another gene's dose (cream reads pearl), or rewriting both pigment channels together against a reading of the coat (grey's remap onto the gradient's neutral column). Those are still classes, and always will be — the point of the file path is that you rarely reach for one.
Genes is an open registry for data-driven genes:
Genes.register(SpecGene) appends after the forty-one built-ins,
sorted by declared priority then key, so registration order never
decides how a horse looks. A Java gene from another mod still has to
be added to this repository — the mod-bus registration hook, the
declarative builder and the abstract base classes are
designed and on the roadmap. Everything
else on this page — the interface, the contract, the pipeline, the
determinism rules — is live and applies to both kinds of gene.
Writing a breed is a different job and a much smaller one. A breed is a JSON file — a slice of the gene pool plus some metadata — and needs no Java at all. See the breed file format and the breed designer. This page is about genes.
Defining a new shape
Start by asking what kind of thing the marking is, because the mask list is organised by kind rather than by appearance:
| If the marking is… | Reach for |
|---|---|
| a region of the horse — the topline, the lower legs, the face | AXIS, PARTS, CENTERLINE |
| countable marks with bare coat between them | SPOTS, RINGS |
| a field that fills the horse and leaves a web | DAPPLES, PATCHES |
| the same field at several scales at once — a detailed edge, veining, lace | FRACTAL |
| lines that wander, fork and taper | STROKES, or FRACTAL shape: "ridged" |
| grain, ticking, dust | SPECKLE |
| something that repeats evenly — scallops, a row of teeth, a string of lights | WAVES |
| polygons that tile with a channel between them | CRACKLE |
| a particular shape — a bolt, a brand, a curl you can point at | PATH |
| the rim of the model’s own boxes — a wireframe, piping along a seam | EDGE |
| defined by the pigment underneath — find the black points | PIGMENT |
| defined by what the coat looks like — the white bits, the pale bits | LUMA |
| an either/or settled once per horse, the same everywhere on it | CHOICE |
Compose before you invent. Most shapes that look new are two masks multiplied. A crescent is a ring with a wedge missing. A halo round a circle of bare coat — Lasertae’s eclipse — is a big soft disc times a small sharp one inverted. A peacock eye is three concentric discs where the middle one is simply not painted. None of those needed a new mask.
You need a genuinely new mask only when the measurement is different, not the
tuning. DAPPLES and SPOTS both measure distance to a cell's
centre, so both draw round things however hard you push them; a giraffe needed the
distance to the wall between two cells, and that is why CRACKLE
exists. Everything noise-derived wanders and never repeats; even lobes and a string of
evenly spaced lights are periodic, and that is why WAVES exists. Adding
one is a five-file contract — do read it.
Three masks are worth knowing about before you decide you are stuck, because each of them is a whole class of shape that composition could not reach:
FRACTALisPATCHESat several scales at once. One octave is aPATCHESmask; each further one adds finer detail to the same blobs without changing how much of the horse clears the threshold, sooctavesis a detail knob and nothing else. Itsshape: "ridged"is the reason to reach for it — it keeps only the surfaces where the field crosses its midpoint, which turns those blob outlines into lines that fork, taper and pinch out. Wound tight that is lace, and nothing else here draws lace. Two or three octaves, not six: by the fourth the finest detail is under a texel and the line breaks up into speckle.PATHis the shape you drew. Every other mask says what a shape is made of and lets the horse decide where it lands; this one carries control points, stroked as a line or filled as an outline, extruded through the horse in one of three planes. It is the honest answer to “a lightning bolt on the shoulder” — a bolt is not a field, and faking one out ofSTROKESwas never going to work. Reach for it when the marking is a particular shape rather than a kind of shape, and not before: a path is a thing you decided, so it is the same on every horse.CHOICEis the only branch in the vocabulary. The mask fold has noifin it — a knob can move a boundary but cannot pick between two of them.CHOICEdraws one integer per horse and answers the same everywhere on it, so giving each outcome its own layer gated on the same seed with a differentismakes exactly one fire.
Coverage and priority — a rule of thumb, not a rule
The more of a horse a gene covers, the lower its priority should be. A gene that repaints the whole coat goes near the bottom of the paint order; a gene that draws one small mark goes near the top; everything else sorts itself in between by roughly how much horse it claims.
The reasoning is one sentence long. Priority is the order the genes paint in, and a later gene paints over an earlier one — so a gene that covers everything and paints late erases every gene under it, and a gene that covers a hand's width and paints early gets erased by anything that covers more. Sorted by coverage, a horse carrying five markings shows five markings. Sorted at random, it shows whichever one happened to be biggest and last.
This is not a contract, nothing checks it, and violating it deliberately is a
normal thing to do — say so in the gene's notes and move on.
Rising sun is the standing example: it covers
the entire horse in ribbons and sits at priority 620, in the top third of the
order and above nearly everything. That is exactly backwards by this rule and it
is right, because a rising sun is a poster printed on the horse rather
than a marking the horse has, and a poster that other markings draw over is not
a poster.
The other honest reason to break it: a gene that reads the coat rather
than just painting on it — anything using
LUMA or PIGMENT — has to
run after whatever it is reading, however much or little it covers.
Fielded draws wisps out of the edges of existing
white and is at 600 for that reason, not because of its coverage.
The bands the mod already uses are the rule of thumb made concrete, and a new gene is usually best slotted next to whatever it most resembles rather than reasoned out from scratch:
| Roughly | Covers | Examples |
|---|---|---|
100–199 | the whole coat, as a replacement ground | nymphaline, contour, suit |
200–299 | large fields and regions — half a horse, a saddle, a shroud | moonwing, dragon shroud |
300–399 | repeating figures over a region — rosettes, scales, veins | iridescent rosette, fish scales |
400–499 | fine overlays — dust, speckle, sheen | oil sheen, frostfall |
600+ | anything that reads the coat, and the deliberate exceptions | fielded, invert, rising sun |
Priorities are unique per gene and the number itself means nothing beyond its place in the order, so leave gaps. Every band above has room in it, and a gene that lands between two others is doing the only thing the number is for.
Scale — the thing that wastes an evening
One body unit is two texels. The adult barrel is 22 units long and the sheet is 128×128, so a feature under about half a unit is arithmetically present and invisible on the horse. This is the single commonest way a gene comes out blank, and it does not look like a scale problem — it looks like the mask not working.
It bites hardest when a description is in centimetres. A horse is about 160 cm at the withers, so a unit is roughly 8 cm and a “3 cm ring wall” is a third of a pixel. Translate for the texture, not for the animal.
| Mask | Below this it will not read |
|---|---|
SPOTS | radius 0.5, spacing 1.5 |
RINGS | radius 1.3, thickness 0.7, and keep softness under half the thickness |
STROKES | spacing 3.0 — strokes need bare coat between them or they resolve as a wash rather than as lines |
SPECKLE | spacing 0.5 |
A band along a pitched part — space: "local"
Six parts are tilted: the neck, the head, the muzzle, the mane, the
ears and the tail. For all of them, the box the pipeline measures against is the
rest-pose bounding box, which is not the part — the adult neck is a
4×12×7 cuboid pitched 30°, and its AABB comes out 4×13.9×12.1,
nearly twice as deep. So an ordinary space: "part" band on Y across the
neck is a horizontal slice: it crosses the crest and the throat alike, which
is a collar and not a stripe. That is exactly the bug that put
goth’s hood on the underside of the neck.
space: "local" takes the pitch back out before normalising, so each
fraction runs along one edge of the actual cuboid. On the neck and the head that gives
you the two things body space cannot say:
"axis": "X"is across the part’s depth — low is the crest (the mane sits just off that face) and the poll, high is the throat. A band here is a stripe running the whole length of the neck."axis": "Y"is along the part’s length — a band here is a true collar, square to the neck rather than to the world.
On an unpitched part the box is its own AABB and local is
exactly part, to the last bit — so there is never a reason to worry
about which to use on a leg or the barrel.
The texel grid is the bounding box, so a pitched part’s corner texels sit off
the ends of its own cuboid: on the adult neck local X spans
−0.72 to 1.72 and Y spans −0.23 to 1.23, and nothing clamps them. A band
that wants “everything from the crest to here” should therefore start at
-1.0 rather than at 0.0, which is what the crest genes do.
Before this space existed, a band along the crest was faked with a
WAVES mask whose sawtooth was longer than the horse
(wavelength 90) at an amplitude of 77.94, so the displacement never
wrapped and the band came out as a tilted plane at
2A/λ = tan(60°). It worked, and it was the neck’s own
geometry written out by hand in eleven gene files that
HorseSkinGeometry knew nothing about. It was also only ever right in
space: "units": amplitude is in whatever
space says while wavelength never is, so the six genes
that copied the trick into space: "part" displaced their band twenty
to forty normalised spans off the horse and selected nothing at all. If
you meet those two numbers in an old file, they mean
{"space": "local", "axis": "X"}.
Where the parts actually are
For a mask that needs real coordinates rather than a normalised
space: "part" band. Adult mesh, in body units.
| Part | X (tail→nose) | Y (hoof→withers) | Z (centre→right) |
|---|---|---|---|
BODY | 7.5 – 29.5 | 11 – 21 | −5 – 5 |
NECK | 22.1 – 34.2 | 13.8 – 27.7 | −2 – 2 |
HEAD | 28.1 – 36.7 | 24.2 – 32 | −3 – 3 |
MUZZLE | 34.2 – 41 | 21.7 – 28.5 | −2 – 2 |
MANE | 20.9 – 30.6 | 18.2 – 33 | −1 – 1 |
TAIL | 0 – 10.5 | 5.9 – 20 | −1.5 – 1.5 |
| a leg | 4 wide | 0 – 11 | 1 – 5, either sign |
These are read off HorseSkinGeometry.bounds and will move if the mesh
does — ask it rather than trusting the table if something looks a unit out.
Body space is the full story.
Look at it. Then look at it again
The single most useful habit here: when a marking lands somewhere unexpected, do not reason about the coordinates. Replace the gene's layers with three or four bands of primary colour over the range you are unsure about, bake, and look at the horse. It takes one render and it is right every time.
This is not a counsel of perfection; it is written down because reasoning failed three
times in one day. A Y band on the neck turns out to be a collar,
because the neck is pitched. The tilted plane that replaced it was calibrated to the
neck's pitch and should have been calibrated to its long axis — the complementary
angle — so the second attempt was wrong too. And
PIGMENT channel: "darkness" reads about 0.84 on a bay's barrel against
0.95 on its points, so a threshold meaning “the black bits” quietly selects
the whole horse. Each was settled in a minute by painting bands and looking.
Six parts are tilted — neck, head, muzzle, mane, ears, tail — and for
all of them space: "part" measures against the rest-pose bounding box
rather than against the part, which is why a band on Y comes out as a
collar. space: "local" takes the pitch back out first, so
axis: "X" is across the part's depth (low is the crest and the poll,
high is the throat) and axis: "Y" runs along its length. Fractions run
outside 0..1 on a pitched part — a crest band starts at -1.0,
which is not a typo. The full note is on the
format page, and it also decodes the wavelength: 90 /
amplitude: 77.94 saw-wave trick if you meet it in an older file.
What has not changed is the habit. local removes one known
confusion; it does not make the next surprise reasonable-about. Paint the bands.
Two tools separate the two failures that look identical — a gene that painted
nothing, and a gene that painted where the camera cannot see.
./gradlew :common:bakeGeneIcons answers does it look right (and
python intake/tools/sheet.py 8 stitches the icons into one contact sheet,
which is the only practical way to look at every gene at once).
node intake/tools/coverage.mjs <gene.json> answers did it paint
at all, and where, per layer and per part.
Look at it, and know which failure you have
A gene that painted nothing and a gene that painted somewhere the camera cannot see are the same picture and opposite bugs. Two tools separate them:
./gradlew :common:bakeGeneIcons | One three-quarter snapshot of every data-driven gene on a standard bay, through the real pipeline. python intake/tools/sheet.py 8 stitches them into one contact sheet, which is the only practical way to look at eighty genes. Answers does it look right. |
|---|---|
node intake/tools/coverage.mjs <gene.json> | Per-layer, per-part coverage percentages, through the creator's engine. Answers did it paint at all, and where. |
node intake/tools/fit-svg.mjs <gene.json> --write | Brings a traced drawing under the SVG point ceiling and reports how much of the drawing that moved. Answers the parser says this flattens to 5260 points. |
python intake/tools/founders.py | Rewrites every imported gene's founder table from its rarity tier, budgeted across the whole set. Founder rates compound, so they are not a per-gene judgement call: twenty genes at “a few percent each” once took the share of wild horses showing something magical from a half to 0.86. |
The traps, in the order they will catch you
- Region first, shape last. A mask list is a fold starting from 1.
Putting the shape mask first and then
MAXing a region onto it gives youmax(shape, 1)on that region — the region floods and the shape is gone. Fold the region into one number, then multiply the shape in. This one has been made three times, and the loader now refuses a first mask that combines byMAXorADDrather than let it happen a fourth. - What a mask’s
partsmeans depends on where it sits. The first mask defines the layer’s region: outside its parts the layer has coverage 0 and aninvertdoes not bring it back. Every later mask only modifies that region, so outside its own parts it is simply skipped — which is what lets a layer say “this ground, minus the throat” without the cut deleting the ground.PARTSis exempt, because there the list is the mask rather than a restriction on it. widthmust stay well underspacingonSTROKES. Neighbouring strokes touch and the whole field resolves as a wash. Zebra's Coat shipped with strokes ten body units wide at a three-unit pitch and rendered as a solid white neck.- A soft mask can still have a hard edge, if the op has one.
PALETTEgives each cell of its own lattice one colour and neighbouring cells meet at a wall. Gamma's blooms had several texels of fade and still looked cut out, because the walls ran across them. RAMPvaries colour, not coverage. An ombre that should fade out needs its coverage to fade too, or the far end just gets the ramp's first stop at full strength.smoothstepwithtobelowfromis not a reversed ramp — it is a hard step in the original direction. Two genes were written as though it reversed; the loader now refuses it, and checks the whole range of any knob feeding either end rather than just the numbers as written.- A layer cannot see what the layer above it painted. Every layer of a
magical gene is handed the same accumulator — the coat as it was before this
gene started — so “paint X, then
LUMA-mask on X” reads the horse the gene inherited. Write the second mask as the union of both conditions instead. (Phase 1 is the other way round:RESTRICTre-reads the pigment between layers, which is what lets aPIGMENTmask chain.) PIGMENTandLUMAare different questions. Pigment is melanin — two levels that mean nothing until the gradient chart turns them into a colour, and the LUT locus can hand a horse a chart whose black corner is violet. If you mean “the parts that look black”, askLUMA. A natural gene may not useLUMAat all and neither may an emissive layer, and the loader says so with the reason rather than reading zero.- A texel is half a body unit. The adult barrel is 22 long. A radius of 0.4 is under a pixel; a shape with internal structure needs to be several units across before any of that structure survives.
- In
PATH’sspace: "body", the spine is 0.62. The box it normalises against runs from the hoof to the ear tips, so 0.33 is the underline and anything above 0.62 is neck, mane and head. Points at 0.7 are not high on the flank — they are floating above the back, and nothing appears on the barrel at all.
Header
- format
- 3. A file from another format is refused rather than half-read. Format 2 → 3 only added the four optional metadata blocks below; a format-2 file just needs its version number bumped.
- key
modid.gene, lower case, unique. Namespaced so two mods can't collide.- name
- Display name. Defaults to the part after the dot.
- phase
naturalormagical. See below — never both.- priority
- Processing order — lower runs first. Sorted into the one
(priority, key)order alongside the built-ins (sex 1, extension 10 … PAX3 79; magical 100+), so a natural gene at 45 lands between the built-in MATP and champagne. Registration order is ignored. Natural band0–99, magical100+(out of band only warns); within the natural band, put an absolute pigment-setter low and a dilution higher. - alleles
- Any number, two or more. The last one is the population baseline — a genotype code with no segment for this gene reads as two copies of it. A token may be any run of characters except
/and-, which separate alleles and genes in a genotype code. - knobs
- Optional. See numbers that vary per horse.
- expressions
- The outcomes. Required — see below.
- founders
- The wild population. Required — see below.
Prose and gameplay metadata (format 3, all optional)
These feed the systems in the carrot family and the gene database. A gene that omits them still loads; the runtime has a fallback for each.
- blurb
- One to three plain sentences describing the locus as a whole — the glanceable summary the in-game browser and the research-paper tooltip show. Falls back to a built-in table (empty for a gene not in it).
- rarity
- One of
common/uncommon/rare/epic/legendary/mythic. Drives the gene-carrot recipe cost (the tier→item table — iron / gold / diamond / emerald / netherite ingot / nether star — lives on the recipe side, not here) and research-paper loot weighting. A gene that omits it defaults to uncommon, the gold-ingot tier. - carrot
{ "enabled": true, "behaviour": "heterozygous" | "homozygous", "flavour": ["item id", …] }. Setenabled: falseto have no Known Gene Splice carrot (sex and the recessive lethals do).behaviourchooses whether feeding the carrot treats the parent asn<Gene>(default) or<Gene><Gene>for that gamete.flavouris the extra recipe ingredients.- splice
- A distribution over this gene’s own combinations, exactly the shape of
founders— what the Unknown Gene Splice carrot rolls when it lands on this gene. Kept separate so the two can differ: an author can guarantee a heterozygote, forbid a homozygote, keep it always wild type. A gene that omits it gets a uniform draw over its viable pairs. - notes
- An array of paragraphs (a bare string is taken as one) — prose about the gene, for a person reading the file. This is not
blurb: a blurb is one to three sentences sized for a tooltip and has to stay that size, whilenotesis where everything else goes — where the idea came from, why a number is that number, what was tried and abandoned, what it collides with. It used to have nowhere to live (JSON has no comments) so it ended up on a hand-written wiki page and drifted from the gene the first time either moved.:common:bakeGeneWikiPagesprints it on the gene's own page under About this gene, exactly as written, so it is written once, in the file. Most genes have none, and that is the right amount for most genes. Each expression takes its ownnotesfor the same reason one level down. - preview
{ "base": "bay", "expression": "flames" }, either half or neither. What the gene’s one baked picture is of — the icon on the landing page and the coat its preview window opens on. Normally this is measured: every base coat and every allele combination is baked and the loudest wins, so no list goes stale when a gene starts or stops painting, and almost every gene should leave it alone. Declare it when the loudest outcome is not the recognisable one — Flametouched’s homozygote is a whole-horse ember gradient and shouts down theflamesthe gene is named for, and Patina moves more texels on a tobiano than it does on the plain bay that reads better.baseis a base-coat key (bay,black,chestnut,tobiano,splash,sabino);expressionis one of this gene’s own expression ids, and the loudest combination landing on it is the one photographed.
Expressions — one entry per outcome
| Field | Meaning |
|---|---|
id | A slug, unique within the gene, stable. The catalogue dedups combinations by it and the wiki keys tables on it. |
name | Short display name. Defaults to the id. |
description | One human-readable sentence: what a horse with this combination looks like. What the gene dictionary and the tooltips show. |
notes | Optional. Paragraphs of prose about this one outcome, for a reader of the file rather than for a player — which layer is doing the work, why it is drawn this way, what it collides with. description is the sentence a player sees; this is everything you would otherwise have put in a comment JSON does not have. Printed on the gene’s generated page under the outcome’s own heading, as written. Same field as the gene-level notes, one level down. |
when | Which combinations land here. Two shapes, below. Omit it on exactly one entry to make that the catch-all. |
needs | Optional. A condition on another locus — what makes a data-driven gene polygenic. See below. |
wildType | true = this combination changes nothing. Carries no layers and no effects; skipped by the composer, excluded from the texture key, reads as absent in the genome display. |
masks | true = while this shows, no other gene is visible. Dominant white does this. |
varies | Override the “non-deterministic if the gene has knobs” default. Declaring determinism you do not have poisons the coat cache — see Writing a gene. |
layers | What it paints. |
effects | Optional Minecraft behaviour — see below. An expression with effects but no layers is fine: it changes the horse, just not its coat. |
when: which combinations
| Written | Means |
|---|---|
"when": [ "Cr/Cr", "Cr/prl" ] | Exactly these combinations. Either order works — "prl/Cr" resolves to the same entry. |
"when": { "Cr": 1, "prl": 0 } | Every combination with those copy counts. Compact when a locus has many alleles. |
| omitted | The catch-all: whatever no other entry claimed. At most one per gene. |
The loader enumerates every one of the gene's n(n+1)/2 combinations and checks each is claimed by exactly one expression. A gap is an error naming the unclaimed combinations; an overlap is an error naming both claimants; a catch-all with nothing left to catch is an error too. The whole value of declaring the table is that it cannot quietly be wrong — a horse nobody can explain is worse than a file that will not load.
needs: reading a second locus
An entry may carry a needs block naming another gene and how many copies
of one of its alleles the horse has to have. That entry then
overrides the combinations in its own when, but only
when the second locus agrees:
{
"id": "top",
"when": [ "Acr/Acr", "Acr/Acc", "Acr/n" ],
"needs": { "horsegenetics.accretion_field": { "Atp": 1 } },
"layers": [ ... ]
}
Several entries may name the same combination this way. The first in file order whose
condition holds is the outcome, and the ordinary entry underneath — the one with
no needs — is what a horse gets when none of them does. So the
table stays total whatever the other locus says, which is the property worth keeping:
an entry with needs is exempt from the exactly-once count precisely
because it never leaves a gap.
Gene.expressionIn and Gene.coatDependsOn have existed
since the leopard complex started reading PATN1 and PATN2, both of which are real
loci that paint nothing themselves. All needs adds is a way to say
the same thing without writing a Java class, and it is deliberately the narrow
version: a copy count at another locus, and nothing conditional on the
phenotype there.
Accretion is the gene built on it — one locus decides whether the pale field exists and what colour it is, and Accretion Field decides whether it takes the topline or the underside. The second gene is inherited by every horse alive and shows nothing on its own, which is what lets two underside-marked parents throw a topline foal.
The gene named is not checked at load: files load in an order
nobody controls, and the gene may live in another mod or not exist at all. One
that never resolves simply never matches, which is what a soft dependency wants.
The gene creator does not offer needs and does not preserve it —
this is hand-edited JSON, like every other multi-outcome gene.
Worked example: three alleles
"alleles": [ { "token": "Cr" }, { "token": "prl" }, { "token": "N" } ],
"expressions": [
{ "id": "double-dilute", "name": "Double dilute",
"description": "Cremello, perlino or smoky cream.",
"when": [ "Cr/Cr", "Cr/prl" ],
"layers": [ /* ... */ ] },
{ "id": "single-cream", "name": "Single cream",
"description": "Palomino, buckskin or smoky black.",
"when": { "Cr": 1, "prl": 0 },
"layers": [ /* ... */ ] },
{ "id": "classic-pearl", "name": "Classic pearl",
"description": "An apricot body with sepia points.",
"when": [ "prl/prl" ],
"layers": [ /* ... */ ] },
{ "id": "wild", "name": "Wild type",
"description": "No dilution.",
"wildType": true }
],
"founders": {
"Cr/Cr": 0.111111, "Cr/prl": 0.303030, "Cr/N": 6.141414,
"prl/prl": 0.206612, "prl/N": 8.374656, "N/N": 84.863177
}
The catch-all sweeps up prl/N and N/N, the two
combinations that genuinely do nothing. That is what “pearl is
recessive” used to mean, said as the table.
Outcomes, and how often
A gene lists its alleles, then an expressions table with one entry per
distinct outcome, each naming the allele combinations that produce it. There is no
dominance field, because saying which combinations share an outcome is what
dominance was for. Then founders says how common each combination is in
the wild, as percentages.
Two conventions worth copying. Most magical genes come in a pair — a white form and a coloured form on a second allele, fully recessive, drawing the same shape in a hue the line carries. And carrier-only combinations are left out of the founder table: a wild horse does not get to roll a pair that shows nothing.
Do not pick the percentages by hand. They compound: twenty genes at
“a few percent each” once took the share of wild horses showing something
magical from about a half to 0.86. Declare a rarity tier instead and let
python intake/tools/founders.py derive the table and budget the whole set
together — it also enforces the carrier convention above, giving a recessive
gene’s heterozygote a flat zero rather than its Hardy-Weinberg share.
Founders — the wild population
An object of combination to percentage. The shares should sum to 100; if they do not they are normalised proportionally with a warning, not an error. A combination left out simply never turns up in the wild — which is how a gene forbids a lethal homozygote among founders, and what no per-allele frequency could ever say.
One nextFloat() per gene per founder picks a bucket, so a
founder's genotype is reproducible from the RNG stream. Founders
only: breeding never consults this table.
Natural or magical, never both
A natural gene only pushes red / black pigment down, in phase 1, before the coat resolves to colour — dilutions and white markings. A magical gene only adds signed RGB, in phase 3, after it resolves, and can therefore paint over anything including a dominant-white horse. The two op families are separate and the loader refuses a file that mixes them; a gene that wants both registers as two genes. The reasoning is in Philosophy §6: natural is reserved for genes that exist in real life.
Three forms, where the palette is narrow
The common shape of a magical marking here is a white form and a
recessive coloured one — the same layers twice, with
"hue": "$hue" where the white one names #ffffff. It
is a good shape and it is one form short. A marking whose palette is narrow
— one or two fixed colours, or an idea that is really a
shape — should ship three: white, black,
coloured.
White and coloured are not opposites, they are two points on one axis with the whole of the other end unused. Every one of these markings already knows how to draw itself in a single flat tone; the black form is that same drawing with the light taken out of it, and it costs one allele and no new vocabulary. It also lands on a set of horses the white form never reads well on — the palominos, cremellos, greys and dominant whites, where a white marking on a white coat is a marking nobody can see.
The dominance series
Black sits directly under white and above coloured, in declaration order, which is the order these files already read as a series. Put that way, adding one to an existing gene changes nothing that already existed — every pair that had an outcome keeps it, and only the pairs carrying the new token are new.
| Slot | Allele | Shows on |
|---|---|---|
alleles[0] | white / pale | every pair carrying it |
alleles[1] | black | every pair carrying it and no white |
alleles[2] | coloured | the recessive — usually the homozygote alone |
alleles[3] | n | wild type |
The token convention is the coloured one with its last letter swapped for
k — Bpc → Bpk,
Mnc → Mnk. b is not used because
it reads as “bay” in a genotype string full of real allele names.
Writing the black form: re-tone, do not repaint
Copy the white expression and move every achromatic target into the dark end of the scale, keeping the order and the spacing of the tones it had. Leave every saturated target alone: that colour is the gene rather than its lightness, and greying it is deleting the gene instead of darkening it. Moonwing is the clearest case — the black form is a graphite wing carrying the same iridescent dust and the same gold binding, because only the wing itself was ever white.
max(r,g,b) - min(r,g,b), and the line is about
0.12. HLS saturation reads backwards at exactly the
values that matter here, because its denominator collapses near white: a
pearl six counts off the top of the scale, #f4f0e8, scores 0.35
and would be protected as though it were a colour. That is not hypothetical
— it is how the first pass of this change shipped a black moonwing
byte-for-byte identical to the pearl one.
Both achromatic forms get as many steps as the coloured one
This is the half that is easy to skip. A coloured form that separates its layers into a dark ring, a mid field and a bright core, against a white form that paints all three off-white, is not two versions of one marking — it is the good one and the flat one. Where the coloured form spans a noticeably wider range of lightness than the white form does, regrade the white form onto the coloured form’s own per-layer ordering, in greys, and then derive the black form from that. Pavonem is the example: its coloured form makes the eye’s pupil the darkest thing on the horse, and its white form had painted the pupil the same white as the spots around it, so the eye had no pupil at all.
A ring with no interior structure has nothing to tell the eye where its edges are — which is the same finding as iridescent rosette’s, and the reason this is written down rather than left to taste.
When not to
Not every gene wants this. Where the colour is the gene — coral bloom’s sea-blue and gold, a marking whose whole idea is that it is ember, or gold, or verdigris — a grey version is a different marking wearing its name. The test is the one in the heading of this section: is the palette narrow? If the gene names one or two fixed colours and the rest is shape, it wants three forms. If it names a palette and the palette is the point, it wants two.
Founders are not a judgement call either way: declare the rarity tier and let the budget be shared out. A third allele takes a share of the gene’s existing budget rather than adding to it, so three forms are not three times as many magical horses.
Numbers that vary per horse
Anywhere the format takes a number, it also takes one of these:
| Written | Means |
|---|---|
0.4 | A constant. |
"$extent" | The value of a knob declared in knobs. |
{ "min": 0.1, "max": 0.9 } | An inline range — an anonymous knob, written where it is used. |
{ "perDose": [0, 0.4, 0.9] } | One value per number of copies of the first-declared allele. A convenience for scaling one outcome by dose; a genuinely different outcome should be its own expression instead. |
A knob is stored on the allele copy that expresses, by name, and read back — so the same horse rebuilds the same coat every session, and a foal that inherits the copy inherits the look. That is the determinism contract, and it holds for data-driven genes exactly as it does for hand-written ones.
A knob is an epigenetic value: SpecValues.schema turns the
list below straight into the EpiSchema a Java gene declares by hand.
That is why data-driven genes needed almost no work when epigenetics moved from a
single opaque seed to stored numbers — the format already described its
varying numbers by name and with a range, which is exactly the right shape. See
the genetics model.
"knobs": [
{ "name": "barSeed", "type": "seed" },
{ "name": "sock", "min": 0.25, "max": 0.6, "per": "leg", "spread": 0.18 },
{ "name": "extent", "min": 0.2, "max": 1.0, "dial": true }
]
- type: "seed"
- An opaque
longfeeding a noise mask'sseed. Drift never nudges one — there is no “slightly different” noise field — so a lineage keeps the shape of its markings. - per: "leg"
- Four independent stored numbers, one per leg, across a range widened by
spread. A horse's four socks come out near each other but never level — the shapeBayCoathand-rolls, in one line. - dial: true
- This knob is how much of itself the gene is showing. At most one per gene, per-horse, never a seed. It is drawn, stored and read like any other knob and costs nothing at paint time — what it buys is that the tools can find it. See below.
The dial — naming the knob that means “how much”
Most patterned genes have one knob that is really the answer to how strongly does this horse show it: how far the roaning reaches, how wide the blaze is, how much of the barrel the spots cover. The format could always express that — declare a knob, point every threshold at it — but nothing could find it, because “this one is the important one” lived only in the author's head.
"dial": true says it out loud, and two things follow:
- The gene creator grows a slider under the preview that pins it — 0%, 50%, 100%, or anywhere between. Before, the only way to see a gene at its weakest was to re-roll horses until one landed near the bottom of the range, which is a poor way to judge a marking you are still drawing.
- A
PATHmay carry a minimal shape that it morphs toward as the dial falls.
The number a mask sees is the knob's own value, exactly as before
("$extent"). What the dial adds is a normalised reading
beside it — 0 at the knob's min, 1 at its max
(SpecValues.dial) — because “how much of itself” is a
fraction, and a raw draw is in whatever units the parameter wanted. A gene with no
dial reads 1: it shows all of itself.
Why “dial” and not the obvious words.
expressions is already the table of outcomes — which
allele combinations look like what — and strength is already a
parameter on every colour op, for how hard it pulls. Both were tried and both read
as the other thing. This is the handle the tools turn, so it is named after being
a handle.
Renaming a knob orphans it: it reads as a fresh roll on the next parse, and horses carrying the gene change look. Adding or re-ordering knobs is free — values are stored by name. That used to be the other way round, and inserting a knob mid-list silently repainted every horse carrying the gene.
A per: "leg" knob changed shape with stored values. It used to draw
one base for the horse and then jitter each leg around it, which made the four
legs correlated and no single leg a number anyone could edit; now each leg is its
own stored value and the spread is folded into the declared range.
Same look, four editable numbers instead of one and a wobble.
Write down why, in the file
A gene file carries three pieces of human-readable text, and they are for three different readers. Keeping them apart is the difference between a gene somebody can pick up in a year and a gene that is a wall of numbers.
| Field | Who reads it | How long |
|---|---|---|
blurb | A player, in a tooltip and the in-game gene browser. What the locus does, at a glance. | One to three sentences. It has to stay that size — it is rendered somewhere small. |
an expression’s description | A player again: what a horse with this combination looks like. | A sentence or a short paragraph. |
notes, on the gene and on each expression | The next person to open the file — possibly you. | As long as it needs to be. Usually nothing. |
notes is the one that did not exist until it was needed. JSON has no
comments, so everything an author actually knew — why the wavelength is 90 and
not 45, which two layers have to stay in that order, what the source description asked
for and where this build knowingly does something else — had nowhere to go except
a hand-written wiki page, where it drifted from the gene the first time either moved.
Now it lives beside the layers it is about, and
:common:bakeGeneWikiPages prints it on the gene’s own page —
the gene-level block under About this gene, an expression’s block under
that outcome’s heading in What it paints. Write it once, in the file.
It is an array of paragraphs, or a bare string for one; a gene with nothing worth
saying omits it, and most do.
"blurb": "Two arcs sweeping round the barrel, like the bands on a moth's wing.",
"notes": [
"The source drew these as rings at explicit centres. There is no centred-ring mask,
so each band is a narrow WAVES band on a wavelength longer than the barrel - one
long curve across the flank rather than a closed circle.",
"The look is close and the mechanism is not the same. Worth knowing before anyone
tries to tighten the arc."
]
A good test for which field a sentence belongs in: if it would confuse a player who
has just caught the horse, it is a note.
A gene file cannot change the horse’s eyes, and should not want to
There is no mask, no op and no effect that reaches an eye, and that is deliberate rather than an omission. A horse’s eyes are thirteen ordinary heritable loci — iris colour, sclera colour and the two halves of sectoral heterochromia, once per eye, plus the glows and the third eye. What colour a horse’s eyes are is something it inherits, not something a coat pattern decides. See Eye colour.
The natural genes that used to paint an iris — cream, champagne, tiger eye, the four white loci — no longer do either. They request an allele at the eye loci, and it is written onto the foal at birth, so a splashed white horse really carries the blue and really passes it on.
If your gene needs a particular eye, ship a breed with it — a breed can pin any locus, the eye loci included — or tell players which eye alleles to breed onto it. Four hand-written Java genes do still paint eyes directly; they predate the loci and they are not a pattern to copy.
The effects block
An expression may carry an effects array alongside its
layers: the Minecraft-specific things it makes the horse
do — walk on water, glow, trail particles, hand back a filled bucket.
Every entry takes an optional when condition and an optional
minDose. The vocabulary is closed and it is documented in full on the
Effects tab, which is also where the four-step contract for
adding a verb lives.
"effects": [
{ "type": "traversal", "flag": "walk_on_water", "when": { "flag": "adult" } },
{ "type": "glow", "light": 12, "parts": [ "HAIR" ] }
]
What happens when a file is wrong
Validation is strict on purpose: an unknown key is an error naming the key and listing the legal ones, not a setting that silently does nothing. A file that fails is logged and skipped — one typo costs you that gene and no other, and never stops the game booting. A key that collides with a registered gene is reported the same way.
Adding or removing a gene changes the genotype code's shape, so horses saved before the change will not load. That is expected while the mod has no save compatibility (the standing "no legacy code" rule) — but it is the one thing to know before adding a gene to a world you care about.
Masks — where
Each mask returns coverage in [0, 1] per texel. Terms fold together by
their combine (MULTIPLY by default, or MAX,
MIN, ADD, SUBTRACT), starting from 1 —
so add a term to narrow the area and switch it to MAX to widen
it. "invert": true flips a term.
The first term may not be MAX or ADD, and the parser
says so. Coverage starts at 1, so either of those in first position returns 1 whatever
the mask found, and the layer paints the entire horse - which looks precisely like a
mask that is merely too generous, and is not.
Every mask also takes parts: a list of part names
(BODY, NECK, HEAD, MUZZLE,
MANE, TAIL, LEFT_EAR …) and group
aliases (ALL, LEGS, FRONT_LEGS,
HIND_LEGS, EARS, HAIR, FACE,
POINTS, BARREL).
| Type | Parameters | What it is for |
|---|---|---|
ALL | — | Everywhere this horse has skin. |
PARTS | parts | Named boxes — a mane stripe, black ears. |
AXIS | axis (Y/X/Z), space (part/body/units/local), from 0, to 1, softness 0.15 | A soft band along the horse. space: "part" normalises inside each part, so to: 0.4 is the lower 40% of every leg — socks. X runs tail to nose, Y hoof to withers, Z centre to the horse's right. space: "local" is the one to reach for on a pitched part — see the note below. |
CENTERLINE | halfWidth 1.0, softness 0.35, offset 0 | A stripe down the middle. A blaze on FACE; a dorsal stripe if you multiply it by a high AXIS band on BARREL. |
STRIPES | seed, spacing 3.0, duty 0.45, warp 1.0 | BodyStripes — parallel bands with a chevron slant. Body units; the adult barrel is 22 long. The built-in striped genes each have their own field now (why); this is the one a data-driven gene gets. |
DAPPLES | seed, spacing 3.5, warp 0.45, edge0 0.35, edge1 0.78 | BodyNoise.cellDistance — dapples, rosettes, appaloosa spots. |
PATCHES | seed, scale 6.0, threshold 0.5, softness 0.12 | Big irregular blobs — pinto, tobiano, roan patching. |
NOISE | seed, scale 8.0, low 0, high 1 | Shading rather than a shape — sooty, countershading. |
FRACTAL | seed, scale 6.0, octaves 3, lacunarity 2.13, gain 0.5, warp 0, shape (fbm/ridged/billow), threshold 0.5, softness 0.12 | The same field as PATCHES, at several scales at once. One octave is PATCHES; each further octave adds finer detail to the same shapes. shape: "ridged" is what makes it worth having — it turns the blob outlines into lines that fork, taper and pinch out, which at a high threshold is lace. See the note below. |
PATH | plane (side/top/front), space (body/units), points, pointsMin, curve, closed, fill, width 1.0, softness 0.25 | A shape you drew. Control points in a plane, stroked as a line or filled as an outline, extruded through the horse. The only mask that carries a shape instead of a rule for making one — for a bolt, a crescent, a brand, a curl on a particular shoulder. See the note below. |
SVG | d, transform, viewBox, plane (side/top/front), space (body/units), originU/originV 0, sizeU/sizeV 1, fit (meet/slice/none), align (xMidYMid…), flipY true, fill true, fillRule (nonzero/evenodd), width 1.0, cap (butt/round/square), join (miter/round/bevel), miterLimit 4, dash 0, gap 0, dashOffset 0, softness 0.25 | An SVG path, drawn. PATH with the whole grammar behind it instead of a polyline. See the note below. |
FAN | plane, space, originU 0.5, originV 0.5, spacing 0.4, duty 0.5, twist 0, inner 0, outer 0, softness 0.15 | Bars that radiate from a point, widening as they travel — a sunburst, the gills under a mushroom cap, the bars on a butterfly’s wing. WAVES takes its phase from a straight line, so its bars are parallel, evenly spaced and all pointed the same way everywhere in the region, whatever its wavelength; this takes it from the angle around a pivot, so both the direction and the gap grow with distance from it. spacing is an angle — the arc one cycle covers a single body unit out, so a full turn holds 2π/spacing bars and 0.4 is about sixteen of them. A number that reads like a body-unit spacing puts three bars on the whole horse. twist rotates the fan’s zero with distance, which is the difference between a sunburst and a pinwheel; inner and outer bound it in body units, and outer: 0 means it never stops. |
NORMAL | axis (Y/X/Z), space (body/local), round 0, from -1, to 1 | Which way the surface faces, dotted with a body axis — a rim light along the topline, a wash that only takes on the upward planes, the curvature sheen on a shell. The second mask after EDGE that asks about the model rather than about position, and it asks the other question: not where a box stops but which way its face is pointed. round is the parameter that matters. A Minecraft horse is boxes, so the flat face normal takes exactly three values on any axis and a sheen keyed to it reads as three hard zones; above 0 it blends toward the normal an ellipsoid of the same box would have, which is continuous. It is not iridescence — the coat is a baked texture and nothing here knows where a camera is, so a colour run through this mask shifts with the surface and not with the viewer. That is the structural half of the effect and it is the honest half. Carapace Sheen is the gene it was added for. |
CHOICE | seed, options 2, is 0 | A choice made once per horse — constant across the whole body, and fully on or fully off. |
The SVG mask, and why it is not just a longer PATH
So the mask takes the file’s own vocabulary and keeps the parts of it that carry meaning.
Every path command:
It costs what
The two flags that default on.
Placing it.
Getting a drawing in.
A traced drawing will be over the point ceiling. Every curve flattens
to sixteen segments whatever its size, so a trace of a few hundred tiny curves costs
thousands of points and the parser refuses it (
Like | ||
octaves buys detail and nothing else — on purpose
A fractal field is several samples of the same noise at rising frequency and falling
amplitude, added together. The usual way to add them up is to divide by the total
amplitude, which makes the result a weighted mean — and a mean of
independent samples is narrower than one sample, more so with every octave. On such a
field, raising
This one divides by the root of the summed squares instead, which
preserves the spread rather than the sum. So
The other two knobs are worth knowing.
Lace is two or three octaves, not six. How wide a
| ||
Drawing on a horse: the plane, the space, and the one number that catches everybody
In
body space, the topline is 0.62 — not 1.0
This is the same trap that once put a splash waterline above the underline — see
It is still a mask, so everything composes as usual: multiply a
| ||
CHOICE is the only mask that does not read where you are
Every other mask is a function of position on the horse.
It exists because the mask fold has no branch in it. A knob can move a
boundary but cannot choose between two of them, so a gene that has to pick
which quadrant goes pale had no way to say so. Give each outcome its own layer
and gate them on the same seed with different
It replaces a trick, and the trick is worth knowing about because it
reads as deliberate noise if you meet it in an older file:
quarter used to sample | ||
What a mask’s parts means depends on its position
A mask that names
Both halves were wrong until 2026-09-08, in opposite directions, and each had shipped
genes depending on the wrong behaviour. The
| ||
PIGMENT | channel (darkness/red/black/total), from 0.5, to 1.0, spread 0 | Coverage read off the coat the earlier genes left — find the black points, then paint them. darkness is 0.55·red + 0.95·black, the reading grey uses. Pick the channel for the question you are asking. darkness answers “how dark does this look”, and it saturates: a bay’s barrel already reads about 0.84 and its black points 0.95, so a threshold up at 0.7 selects the whole horse rather than the points, and one measured against a chestnut’s 0.55 works fine. If what you mean is “the black points” — the socks, the muzzle, the mane — ask black instead, where a bay’s body sits at 0.2–0.45 and its points at 0.85 and up. Integration was reading darkness and treating every horse as one enormous source. spread grows whatever this mask selected by that many body units: the largest coverage found inside the radius wins. It is applied after invert, which is what lets one parameter serve both directions — an inverted mask over white grows the white (Fielded’s wisps run out of the edges of it), and a plain one over darkness grows the dark (Integration’s spots spread out of the black points). That is the only way to ask for “beside something the horse already has” — the coat is one number per texel, and being next to a region is not a property any one of them holds. The radius is in body units and the test is done in body space — the texel window is only a bound on the search, because a texture atlas packs unrelated parts side by side and a disc measured in texels reaches across a UV seam. |
LUMA | channel (dark/light/white/saturation/red/green/blue), from 0.5, to 1.0, spread 0 | Coverage read off what the coat looks like — the colour phase 2 resolved through the gradient chart, plus whatever the magical genes before this one painted. Magical genes only, and not in an emissive layer: the natural phase is what decides the pigment the chart is handed, and the overlay pass has already spent the accumulator, so the parser refuses it in both rather than let it quietly read zero.
Every reading comes off the composited appearance, so a texel the natural phase left bare reads as the white template rather than as the transparent black underneath it — which is what makes “the white markings” mean what it says. A layer cannot see what the layer above it painted. Phase 3 hands every layer of a gene the same accumulator — the coat as it was before this gene started — so a |
EDGE | width 0.6, softness 0.25 | The rim of each body part’s box, on the face the texel sits on — a wireframe of the horse. It is the one mask that asks about the model rather than about body space: every other shape here is a field sampled at a point, and a field has no idea where the horse stops. A Minecraft horse is a handful of boxes, and the line where two of a box’s faces meet is the only thing in this geometry that reads as an edge to a viewer. So this measures, in the two axes that span the face, how far the texel is from the nearest of that face’s four boundaries — the third axis is the face’s own normal and is constant across it, so measuring against it would make every texel equally “on the edge”.
Know before using it: it outlines every box, not the silhouette. A leg gets a rectangle round each of its faces, including the one buried in the barrel. That is the honest thing for a mask with no visibility test, and on a boxy model it is also what a wireframe is supposed to look like. |
SPOTS | seed, spacing 4.0, radius 0.9, vary 0.5, chance 1.0, stretch 1.0, axis (X/Y/Z), shape (round/heart), mirror false, arc 1.0, angle 0, offsetX/offsetY/offsetZ 0, softness 0.25 | Countable round or oval marks with bare coat between them — freckles, stars, leopard spots. DAPPLES fills the horse with cells and leaves a web; this leaves most of it bare and puts marks on it. Drop chance to thin the field out, raise stretch to turn the marks into ovals along axis. shape: "heart" swaps the disc for a heart standing upright with its point down — measured in the (x, y) plane, so it is right way up on the flank and reads as a lozenge seen edge-on over the topline. mirror samples the lattice at |z| instead of z, so the far flank gets the near flank’s spots in the same places: body space has a signed z, so without it a scatter is independent on the two sides, which is right for a paint marking and wrong for anything that reads as a design. Angler’s string of lights is the case for it.
The three Every layer of one compound element must share its |
RINGS | seed, spacing 6.0, radius 2.0, thickness 0.6, vary 0.4, chance 1.0, arc 1.0, offsetX/offsetY/offsetZ 0, softness 0.2 | The same scatter drawn hollow — rosettes, wormholes, halos. arc below 1 opens the ring into a crescent, and each ring opens a different way, so a field of them does not read as printed. |
SPECKLE | seed, spacing 0.7, size 0.45, density 0.5, clumping 0, clumpScale 7.0, softness 0.3 | Fine stipple with no boundary anywhere — dust, ticking, speckling. A texel is about 0.5 body units, so spacing under that is scattered single pixels. clumping lets a slow field push the density around, which is the difference between even static and drifts of it. |
STROKES | seed, spacing 3.0, length 12.0, axis (X/Y/Z), width 0.8, curl 0.35, softness 0.25 | width has to stay well under spacing, or neighbouring strokes touch and the field resolves as a wash rather than as lines — Zebra’s Coat shipped with a width of up to 10 body units against a spacing of 3 and came out as a solid white neck. Tapering, curving lines that fork and pinch out — scratches, riblines, brindle bars, wisps. It is the level set of the noise field sampled in a frame stretched along axis, which is why the strokes end in points and wander rather than sitting parallel like a comb. width and softness are in body units, not fractions: the mask divides by the field\u2019s own gradient so a stroke is the width you asked for everywhere along its run, rather than a hairline where the noise is steep and a blot where it is flat. |
SPIRAL | seed, radius 4.0, turns 2.0, width 0.5, axis (Z/X/Y), offset 0, softness 0.2 | One closed spiral per named part — the filigree curl. The only mask that draws a figure rather than a field, so it takes its centre from the part's bounds; offset slides it along the part's long axis. |
WAVES | seed, axis (X/Y/Z), across (Y/X/Z), shape (sine/triangle/saw), space (part/body/units/local), from 0, to 1.0, wavelength 8.0, amplitude 0.5, spacing 0, phase 0, softness 0.15 | An AXIS band whose edges are displaced by a periodic wave running along another axis — a boundary scalloped into even lobes. Mind the names: axis is the direction the wave travels and across is the axis the band sits on — the opposite of what axis means on AXIS. A topline band with lobes hanging down is "axis": "X", "across": "Y"; written the natural way round it comes out as a band across the front of the barrel with lobes pointing at the tail, which is how Ooze Drip and Ribbon Drip both arrived. It is the one shape in this vocabulary that repeats on purpose: everything else that curves here curves because noise bent it, and noise never comes back to the same place twice. from, to, amplitude and spacing are in whatever space says, exactly as on AXIS; wavelength is always in body units. Leave spacing at 0 for one edge (Waves’ lobed field), or raise it to repeat the band into parallel ribbons that rise and fall together (Angler’s strings of lights), and then phase decides whether they stay in step. shape picks the waveform: sine scallops, triangle folds the edge into teeth with straight sides, and saw ramps and then cuts back square into a row of spears all raked one way. Those two are the only way to ask this vocabulary for an edge made of straight lines — everything noise-derived rounds off however far the softness is wound down, which is why Cleave’s boundary could not be drawn before. |
GOO | seed, axis (X/Y/Z), across (Y/X/Z), space (part/body/units/local), from 0.75, to 1.6, spacing 4.0, drop 3.0, width 1.6, bulb 1.5, vary 0.6, chance 0.75, wobble 0.5, sag 0.6, softness 0.1 | A band whose edge sags into separate drips, each hanging from a heavy bead and filleted where it leaves the band — paint poured along a line and running off it. It reads axis and across the same way WAVES does, and the drips hang from the from edge away from to. It is not a displaced line but a distance field — a half-plane, a capsule per drip, a disc at each tip, smooth-unioned — which is why it can do what WAVES cannot: every drip draws its own length, width and position from the seed, and chance leaves gaps. Reach for it whenever the thing running is a liquid: a sine has every lobe the same length and, at the two or three texels a lobe gets on a barrel, reads as a row of triangles (which is exactly how Ooze Drip arrived). Only from and to are in space; every other length is in body units, because a drip is a round shape and has to stay round. Put to well past the end of the part — the whole band shifts with wobble, and one that stops at 1.0 leaves bare stripes along the top of the back. sag bites an arc out of the edge between each pair of drips, because a ruled line with beads on it reads as a wire rather than a spill. An end face is carried round the corner — the back of the barrel has one along value over its whole surface, so without that it would take one drip's answer for the lot and come out a slab, which is what the rump did on the first build. |
CRACKLE | seed, scale 5.0, gap 0.5, warp 0.35, chance 1.0, softness 0.08, measure (wall/centroid), vertexWeight 0 | Polygons that tile, each filled solid, separated by a channel of even width — a giraffe, a cracked glaze, a dry lake bed. DAPPLES and SPOTS both draw round marks however hard they are pushed, because both measure to a cell’s centre; this measures to the wall between two cells, which is what makes the edges straight and the corners meet three at a time. warp pushes the polygons out of true (0 is a regular tiling), gap is the channel width, and softness is small by default because the point of the mask is the one hard edge in the set. Qular is the gene built on it.
The same 3D slicing is why a
|
All noise masks sample in body space, so a pattern crosses a part
seam without a join — see Body space. Leaving
seed out is fine: the gene gets a stable per-layer default, different
for every gene and every layer, so two layers don't come out on top of each other.
Ops — what
Natural
| Type | Parameters | What it does |
|---|---|---|
DILUTE | keepRed 1, keepBlack 1, blackTint 0 | The dilution move. blackTint feeds a share of the removed eumelanin back in as red — without it a "diluted" black point stays on the gradient's jet-black column and reads as a void. Cream, champagne, pearl and silver are all this one op. |
RESTRICT | red 0, black 0 | Take a share of one pigment away, leaving the other alone. |
SET_PIGMENT | red, black | Drive pigment to a level. (0, 1) is a black point. Omit a channel to leave it alone. For a white marking reach for WHITEN instead — see below. |
WHITEN | amount 1 | Mix white hair in: 1 is bald white and the transparent path, a fraction is a roan fleck. Multiplied by the mask coverage, so a soft-edged mask fades correctly on its own. |
Why white has an op of its own, and SET_PIGMENT to (0, 0) does not do.
A black horse is (red = 1, black = 1): it stores a full load of red
pigment that the black is simply masking — the gradient's whole bottom row is
#000000, so at black = 1 the red cannot be seen and nowhere
else is that true. Driving both channels to zero together unmasks it on the way out and walks the
sample down the gradient's gold diagonal, so the soft edge of every patch comes out
#5F330B then #885517 — chocolate and tan, around a marking on an
otherwise jet-black horse.
WHITEN holds the visible red constant instead of the stored red, and on a
black horse that is zero, so the fade runs down the gradient's red = 0
column — the one neutral ramp in the chart. Shades of grey, which is what white hairs
through black hairs look like. On a chestnut nothing was masked and the op collapses to
red * keep, so strawberry roan is unchanged.
Magical
| Type | Parameters | What it does |
|---|---|---|
TINT | red, green, blue (percent, signed), opacity 100 | Adds signed colour. The accumulator is an uncapped int, so -200 on all three channels reads black over any coat and +300 on blue is blue whatever else the horse carries. That headroom is the point: your gene never has to know what else is there. |
TOWARD | color, hue −1, saturation 0.8, lightness 0.55, strength 100, opacity 100 | Walks the texel toward a colour, reading what it already looks like — so one number lands the same on a black mane and a cremello one, and keeps the strand shading. The pink-hair move. |
FLAT | color, hue −1, saturation 0.8, lightness 0.55, opacity 100 | Flat paint that replaces what is under it. Order-dependent, so reserve it for a masks expression that must look identical on any base. |
RAMP | colors (or hue 0 + hueSpan 60), saturation 0.8, lightness 0.55, axis (X/Y/Z/noise/cell/cellId), space (part/body/units/local), from 0, to 1, seed, scale 5.0, strength 100, opacity 100 | TOWARD with the colour running along an axis — a spectral tail, an aurora band, a mane fading root to tip. Give it colors for named stops (it interpolates between them, so the result is continuous rather than banded), or leave colors out and it sweeps hueSpan degrees of the wheel from hue.
Three of the six axes are not axes. A straight line through the horse is monotonic by construction — the colour sweeps once from one end to the other and never comes back — and several markings are simply not like that. |
PALETTE | colors (or hue 0 + hueSpread 40), saturation 0.8, lightness 0.55, seed, scale 5.0, strength 100, opacity 100 | TOWARD with the colour drawn per cell — opal, nebula, galaxy. Adjacent cells take unrelated entries and meet at a wall, which is exactly what separates "iridescent" from "gradient"; RAMP is the other one. |
INVERT | amount 100, opacity 100 | The photographic negative of whatever the texel already looks like - white to black, orange to blue, amount: 50 to flat grey. The only colour op that is a function of the texel rather than a walk toward a colour, which is why it is also the only one whose place in the paint order really matters: put it late, or it inverts a horse that is not finished yet. |
Giving a gene the horse's own colour
All four colour ops take hue, saturation and
lightness as an alternative to a literal color. That
matters more than it sounds: color is a constant, and a constant
cannot be the thing a horse drew for itself. hue is an
ordinary number parameter, so it can point at a knob — and one knob is the
difference between a gene that paints teal spots and a gene that paints spots of
whatever colour this line of horses runs to.
"knobs": [ { "name": "glowHue", "min": 0, "max": 360 } ],
...
"op": { "type": "TOWARD", "hue": "$glowHue", "saturation": 0.85, "lightness": 0.6 }
On TOWARD and FLAT, hue defaults to
−1, which means "not set" — those ops paint color
until the hue is 0 or above. (A negative sentinel rather than "is the key
present", because the gene creator carries every parameter whether or not you
touched it, and a presence test would preview one colour and export another.) On
RAMP and PALETTE the switch is the colors
list instead: name stops and it uses them, leave it out and it sweeps the hue.
Glow
A layer may carry "emissive", and what it carries is how
brightly it glows: true for all of it,
0.35 for a smoulder, "$burn" or the gene's
dial for a glow that is different on every horse. Lit texels
are drawn a second time at full brightness over the finished coat, so they render
at night as if lit. Glow is a property of the layer, not of the op,
because it is orthogonal to colour: the same TOWARD that paints a
teal spot paints a lit teal spot with one number moved, and a gene that wants a
lit core inside an unlit bloom writes two layers rather than two ops.
The level a texel ends up at is its coverage times the layer's number. A hard-edged mask therefore lights its shape evenly, and a soft-edged one fades its glow out exactly where it fades its paint out — you do not draw the falloff twice.
This used to be a bit per texel, cut at a coverage of 0.5. The cut
was a stand-in for this: it existed so a soft mask would not bloom a glow two
body units wider than the shape that drew it, and it paid for that with a
hard line across a soft edge. A level does the job the cut was approximating,
because the emissive pass blends rather than replaces
(BlendFunction.TRANSLUCENT) — 0.4 is four tenths of the
full-bright colour over the texel as the world lit it, which is a dimmer
rather than a darker colour.
Two genes lighting one texel take the brighter of the two, not the sum. There is nothing above full bright to spend an overlap on, and summing would make a horse carrying two faint glows brighter than one carrying a real one.
The hardest mask in the layer governs the falloff. Masks fold by
MULTIPLY, so a soft AXIS times a SPECKLE
is as hard as the speckle, and a glow behind it goes from nothing to its full
level over almost no distance. That is usually what a gene wants - ember veins'
filament is a FRACTAL at softness 0.04 because a vein of light
should be a line - and it does mean the level and the falloff are two
separate decisions. If you want a glow that fades, every mask in the layer has to
be soft, not just one of them.
A glow has no colour of its own. The emissive texture is
written with whatever colour the coat ended up being at that texel, so the
level says how much and the layer's op says what colour. Two
layers at the same level can therefore read very differently:
Tron's two haloes are both
"emissive": 0.25 at strength 34, and the gradient one
is visibly the stronger on a horse because its RAMP sweeps 300°
of hue where the solid one is flat (owner-confirmed 2026-09-12). If a glow looks
too faint, look at what the layer is painting before reaching for the level.
Three limits, all load errors rather than surprises. A natural gene cannot glow
— pigment does not. An emissive layer cannot use a PIGMENT or
LUMA mask: glow is decided in the overlay pass, after the texture is
baked, and there is neither a pigment field nor a colour accumulator left there to
read — split the layer if you need both. And a constant outside
[0, 1] is refused rather than clamped, because it always means
somebody thought the number was a light level.
The creator does not render the glow. It writes it, validates it and keeps it through a round-trip, but the preview beside it is a flat sheet with no lighting to be brighter than — so a glow is one of the few things you have to go and look at in game. See what to look at.
Where the numbers actually live
The parameter table above is generated from the same declaration the game validates
against — SpecSchema in Java, mirrored in the creator's
js/schema.js. The creator omits any setting left at its default, so
those two defaults must match exactly;
wiki/gene-creator/tools/check-parity.mjs compares them, along with the
creator's whole preview engine, against what the real Java engine produces.
Behaviour beyond the coat
An effect is a Minecraft-specific thing a gene makes the horse do
— walk on water, resist fire, trail particles, be milked for a fluid, watch you
after dark — as opposed to a coat pixel. This tab is the complete reference:
every verb that exists, the triggers and conditions that gate them, and the
four-step contract for adding one. The vocabulary is
closed and the parser has no per-verb code: a verb is one
declaration on AbilityType, which is what keeps adding one from being a
parser change.
A gene file's effects block and a hand-written Java gene reach the
game the same way. A built-in implements AbilityContribution and
returns the same GeneAbility records the block parses into;
HorseAbilities.activeFor collects both. The alternative — a
second vocabulary for built-ins — was worse in every direction: the
translator written twice, and a behaviour available to a Java gene silently
unavailable to a file. Sharing it means a new verb lands once and both kinds get
it, which is exactly what happened with healing and
spread: written for built-in genes, usable from JSON the same day.
One asymmetry survives. minDose exists because a gene file has no
expression language and needs some way to say “only if homozygous”. A
hand-written gene has the whole of Java and answers that directly, so it returns
the list it means and leaves minDose at 1.
Every effects block used to be hand-written JSON. The
gene creator now has a form for it: one
card per effect, every verb and parameter below, the trigger shapes, a
minDose toggle, and a when built from the condition
flags. Its verb table is a mirror of AbilityType and
check-parity.mjs compares the two, so a verb, parameter or default
that changes on one side and not the other is a build failure rather than a gene
that plays differently from how it was authored.
The one thing the form declines to edit is a nested
when. It handles a flat all / any of
(optionally negated) flags, which is every condition any shipped gene uses;
anything deeper is preserved byte-for-byte and shown read-only, because silently
flattening a condition is worse than not editing it.
What has actually been seen in a running game is a fraction of what parses. Each verb below says whether the translator executes it; verification is the authority on which ones a person has watched work, and is the page to read before claiming any of this runs.
How to read this tab
- Live
- Parses in
common/and executes in the translator. Untested in a running game, but the code path exists end to end. - Parses only
- Validated at load and carried on the record, but the translator does
nothing with it. Logged once via
warnUntranslated. - Unbuilt
- Defined in the architecture spec, absent from the gene layer. Writing it in a gene file is a load error, because validation is strict and the key is not in the allowed set.
[[…]]- A placeholder for a class, file, method or constant that does not exist
yet, or that the author of this revision could not read. Every one is a
note to fill in against the source tree — treat a
[[…]]left standing as an unanswered question, not as documentation.
The model
The spec's core shape is three levels deep:
Horse → has a set of Traits
Trait → a named bundle of Components
Component → (Trigger, Condition, Selector, Effect)
Four axes describe any behaviour:
| Axis | Question | Example | In the gene layer |
|---|---|---|---|
| Trigger | When does it fire? | when a rider mounts | Partial — four triggers, see Triggers |
| Condition | Is it active right now? | daytime and sky-visible | Partial — boolean flags, see Conditions |
| Selector | Who or what does it act on? | undead within 8 blocks | Unbuilt — every effect acts on self or rider |
| Effect | What happens? | deal 2 damage, set flee flag | Six of the spec's verbs, see Effect verbs |
Adding new behaviour should mean writing a JSON file. Writing Java means you have hit a genuinely new primitive — which should be rare, and is an architectural decision rather than a task.
Genes and traits
The spec talks about traits; the gene file talks about a gene's
effects array. Today these are the same thing under two names: a
gene that expresses contributes its effects to the horse, and the set of effects
in play is the union across expressing genes. The mapping:
| Spec concept | Gene layer today | Note |
|---|---|---|
| Trait | A gene's effects array | No trait ID, no cost, no relations. A gene is an implicit anonymous trait. |
| Trait set on a horse | HorseAbilities.activeFor(genotype) | Assembled from visible gene pairs rather than from a stored trait list. |
| Component | One entry in effects | Carries its own when and trigger; there is no component grouping above it. |
| State attachment | [[per-horse attachment class? — yield cooldowns are stored somewhere per horse]] | The spec's State is a full record; the gene layer stores far less. See State. |
| Dose | minDose | Not in the spec. Genetics-specific: the spec's trait model has no notion of allele copies. |
The consequence worth noticing: the horse has no trait list. Trait relations, the trait budget, discovery state and granted traits all assume a stored, addressable set of trait IDs. None of that can be built until genes carry IDs and the horse carries the assembled set. It is the first structural prerequisite for most of the unbuilt half of this page.
The effects block
An expression in a gene’s JSON may carry an
effects array alongside its layers — so a
homozygote and a heterozygote can grant entirely different behaviour by being
different outcomes, with nothing anywhere comparing doses. Each entry is an
object with a type and that type’s own fields, plus two
things every effect accepts:
- when
- A condition — the effect is only active while it holds. Omitted = always. Evaluated by the translator against the live horse, every tick (or at interaction time for a yield).
- minDose
1(default — any expressing copy) or2(homozygous variant only). Largely superseded by the expression table — a homozygote can be its own outcome with its own effects list. Lets a gene gate the louder half of its effect on two copies without an expression language.
"effects": [
{ "type": "traversal", "flag": "walk_on_water", "when": { "flag": "adult" } },
{
"type": "emitter", "shape": "trail", "anchor": "feet",
"trigger": { "on_move": {} },
"particle": "minecraft:dust", "color": "#1ec8ff", "chance": 0.7
}
]
Validation is strict, the same as the rest of the format: an unknown
type, an unknown key inside an effect, or a value outside its
allowed set is a load error that names the offender and lists the legal choices.
A bad gene file is skipped with a log line, never fatal.
Fields the spec puts on every component that the block does not accept yet:
| Field | Status | What it would do |
|---|---|---|
selector | Unbuilt | Choose targets other than self / rider. See Selectors. |
| Scaling block in place of a literal | Unbuilt | { "base": 1.0, "scale_by": "bond", "curve": "ease_out", "max": 3.0 }. See Trait scaling. |
cost | Unbuilt | Pool cost for a triggered effect. See Resource pools. |
cooldown | Live on yield only | The spec keys cooldowns per ability ID; the gene layer keys them per [[yield cooldown key? — gene id, or gene+index?]]. |
Triggers
The firing vocabulary. Every component that is not continuous declares a trigger.
In the gene layer, emitter and yield take one; write it
as an object with one key, or — for the two that take no argument — a
bare string.
Live today
| Trigger | Fires |
|---|---|
"continuous" | Every tick when allows. The default if an emitter omits trigger is on_move, not this. |
"on_move" | Every tick the horse is moving on the ground under its own power. |
{ "interval": 40 } | Every N ticks (horse.tickCount % N == 0). |
{ "on_interact": "minecraft:bucket" } | A player right-clicks the horse holding that item. "" = any item. The only trigger a yield accepts; an emitter may not use it. |
Note the divergence: the spec writes on_interact(item_or_tag). The
gene layer accepts an item ID only, so #minecraft:buckets will not
match. Tag support is [[item tag resolution helper? — needs a
translator-side tag lookup]].
The full vocabulary
Everything below is unbuilt unless marked. Adding one is
three edits, and a trigger is shared across every
effect that takes one — so keep the set tiny and prefer
on_change-style generality over a verb per game event.
| Group | Triggers | Status |
|---|---|---|
| Time-based | continuous · interval(ticks) · on_cooldown_expire(ability_id) | First two live; the third needs Ability. |
| Lifecycle | on_spawn · on_tame · on_death · on_load (chunk enters ticking range) | Unbuilt |
| Rider | on_ride · on_dismount · on_rider_input(key) | Unbuilt. on_rider_input also needs a network packet: [[keybind + payload class?]] |
| Movement | on_move · on_jump · on_land(fall_distance) · on_sprint_start · on_sprint_end · on_step(distance) | on_move live; rest unbuilt |
| Interaction | on_interact(item_or_tag) · on_sneak_interact(item_or_tag) · on_interact_fail · on_feed(item_or_tag) | on_interact live (item IDs only); rest unbuilt |
| Combat | on_hurt(source_predicate) · on_hurt_rider · on_hurt_owner · on_damage_dealt · on_kill(selector) | Unbuilt |
| World | on_entity_spawn_nearby(selector, radius) · on_item_pickup · on_block_break · on_block_place | Unbuilt |
| Edge detection | on_change(condition_source) | Unbuilt — see below |
on_change
This one is doing a lot of work and is worth calling out. Biome change, dimension change, weather change, light-level threshold crossing, mood transition, bond tier-up, day/night flip and pool-empty are all the same trigger: watch a condition source, fire on the edge. One implementation, one cached previous value per watched source, and every future condition source gets an edge trigger for free.
{ "trigger": { "on_change": "biome", "direction": "any" } }
{ "trigger": { "on_change": "is_raining", "direction": "rising" } }
Building it needs somewhere to cache the previous value per horse per watched
source: [[previous-value map — on the state attachment, or a
translator-side cache?]]. The spec puts edge detection and its bookkeeping
in pure common/, which means the cache is keyed by source name and
fed a fresh ContextSnapshot rather than reading the horse.
Damage hooks are triggers
There is no separate hook system, and adding one would be a mistake to catch in
review. on_hurt fires with the incoming damage in context, and the
damage-modifying verbs (cancel_damage, reduce_damage,
reflect_damage) are ordinary effects valid only in that context.
Conditions
A composable predicate evaluated against a context snapshot. Every other component
accepts an optional condition — spelled when in a
gene file.
Live today
when is a predicate on the live horse. It is boolean
today — the spec’s 0–1 scalar model is unbuilt. Shapes:
{ "flag": "sex_female" }
{ "flag": "in_water", "negate": true }
{ "all": [ { "flag": "tamed" }, { "flag": "day" } ] }
{ "any": [ { "flag": "raining" }, { "flag": "submerged" } ] }
{ "not": { "flag": "has_rider" } }
Flags (each mapped to game state by the translator):
sex_female · sex_male · tamed ·
untamed · adult · baby ·
full_health (at the horse’s own max, so a
genetically frail mare is milkable at 4 hearts) ·
has_rider · in_water · submerged ·
on_ground · on_fire · day ·
night · raining · thundering ·
sky_visible
Adding one is two one-liners.
The scalar model unbuilt
Conditions in the spec evaluate to a scalar 0.0–1.0, not a
boolean. Boolean contexts treat > 0.5 as true. This lets effects
ramp — a sun-powered horse gains speed gradually through the morning rather
than snapping at dawn. curve remaps a scalar through a named easing
function for non-linear response.
Migrating to it is a change of return type on
GeneAbilityHandler.flagHolds and everything that calls it, plus a
decision about which effects read the magnitude rather than the truth value.
emitter.chance and attribute.amount are the obvious first
consumers. The curve library itself is [[curve registry? — named
easing functions, does not exist]].
Condition sources
The spec’s full source list. Sixteen boolean flags exist today; the rest are unbuilt. Where a flag covers part of a source, it is named in the third column.
| Group | Sources | Today |
|---|---|---|
| World | time of day · moon phase · weather · sky access · light level · dimension · biome or biome tag · block underfoot · fluid underfoot · in spawn chunks | day, night, raining, thundering, sky_visible |
| Self | health fraction · pool threshold · has trait · has active ability · standing-still duration · velocity · is tamed · is in water/lava | tamed, untamed, in_water, submerged, on_ground, on_fire |
| Rider and owner | rider present · rider’s held item · rider sneaking · rider sprinting · rider flying · owner online · owner sleeping · owner within range | has_rider |
| Genetics | — | sex_female, sex_male, adult, baby. Not in the spec; a gene-layer addition. |
| Entity state | entity_state(selector, predicate) where predicate is has_effect(id) · has_tag(tag) · is_glowing · is_invisible · is_on_fire · is_baby · health_below(pct) · count(op, n) | Unbuilt — needs Selectors |
| Combinators | all · any · not · threshold(source, op, value) · curve(source, curve_id) | all, any, not, plus negate on a flag |
Folding entity checks into one source with a predicate sub-vocabulary matters: they compose with any selector, so “an undead within 8 blocks is on fire” and “my owner has Regeneration” use the same code path.
Integration conditions
Sources like claimed-chunk membership or region-protection state belong in optional
compat submodules that register additional condition sources at load. They are not
built-ins, and a gene referencing an unregistered source is disabled with a
load-time warning rather than crashing. The registration seam is
[[condition source registry? — today the list is the constant
AbilityType.CONDITION_FLAGS, which is closed and cannot be extended at load]]
— note that today’s constant-list design actively blocks this and would
have to become a registry first.
Effect verbs
Keep this list closed. It is the main lever on long-term maintainability — everything else composes these. Fourteen exist; the rest are the spec’s vocabulary, recorded here so a new verb is checked against them before it is written.
The ones that exist
traversal
Holds a movement / survival flag up while when is true.
| Field | Default | Meaning |
|---|---|---|
flag | required | One of walk_on_water, walk_on_lava, fire_immune, fall_immune, underwater_breathing, water_averse. |
Wired today: walk_on_water (surface buoyancy — an
approximation, not a solid plane), fire_immune,
fall_immune, underwater_breathing.
walk_on_lava and water_averse parse but aren’t
executed.
The spec lists three more flags the gene layer does not accept:
flight, water_movement_efficiency and phasing.
water_movement_efficiency is a magnitude rather than a boolean and lives on
attribute here; flight and phasing are
simply absent. See Divergences.
{ "type": "traversal", "flag": "fire_immune" }
attribute
A temporary attribute modifier, present while when holds, removed when it stops.
| Field | Default | Meaning |
|---|---|---|
attribute | required | movement_speed, jump_strength, max_health, armor, armor_toughness, knockback_resistance, step_height, safe_fall_distance, scale, water_movement_efficiency. |
op | add | add, multiply_base, multiply_total — vanilla modifier operations. |
amount | 0 | Signed modifier amount. |
scale is the one entry in that list a world may switch off. It
resizes the model and the hitbox exactly as the size loci do, so it answers to
the same server setting they do —
body.size. With that false
the modifier is never applied, and one already standing is taken off on the next
reconcile rather than left frozen on the horse.
{ "type": "attribute", "attribute": "movement_speed", "op": "multiply_total",
"amount": 0.15, "when": { "flag": "in_water" } }
There is no swim_speed. There never was one to
translate to: vanilla has no such attribute. What it has is
water_movement_efficiency — the share of its land speed a mob
keeps in water, which is Depth Strider’s attribute and is what “how
fast does this horse swim” means. The list was corrected when
magic swim speed was built and needed it
to work.
The modifier is transient and its id is derived from the gene and
the attribute, so re-applying it every tick is idempotent — a transient
modifier whose id is already present is replaced, not stacked. The removal half is
the interesting one: when when goes false the ability is skipped
before it reaches the apply path, so nothing would ever take the modifier
off. GeneAbilityHandler.clearAttributes therefore runs unconditionally
after the ability loop and strips every modifier in the mod’s namespace the
horse is not currently asking for.
The spec’s merge rule also calls for a cap on the total per
attribute, and that is still unbuilt: [[per-attribute cap config
key? — no config surface for this yet]].
emitter
Spawns particles (a dynamic light kind is defined but not wired).
| Field | Default | Meaning |
|---|---|---|
kind | particle | particle or light. |
shape | point | point, ring, trail, burst. Controls spread and count. |
anchor | feet | Where on the horse it comes from — see the sites below. |
trigger | on_move | A trigger. An emitter can’t use on_interact. |
particle | minecraft:dust | Particle id. Any registered id with no parameters of its own works; the parameterised ones are listed below. An id this build has never heard of falls back to coloured dust. |
color | #ffffff | #rrggbb. Used by the particle types that take a colour. |
color2 | #ffffff | #rrggbb. The second colour, for the one particle that fades between two. |
count | 1 | Particles per firing, 1–16. A trickle or a plume. |
data | 0.0 | A normalised [0, 1) number standing in for whatever else a particle takes. |
chance | 1.0 | Per-fire probability, in (0, 1]. |
cycle | 0 | Ticks for one lap of the hue circle, 0–12000. 0 = the colours above are the colours; anything larger is a rainbow and color / color2 are ignored. |
A rainbow: the cycle parameter
Every other field on an emitter is a constant. cycle is the one that
is a function of time: set it and the translator recomputes color and
color2 from the clock on every firing, walking the hue circle round
once per cycle ticks and putting color2 a twelfth of a
turn ahead of color — so a
dust_color_transition fade reads as “red going orange”
rather than as two unrelated colours in one puff.
{ "type": "emitter", "shape": "trail", "anchor": "hooves",
"particle": "minecraft:dust_color_transition", "count": 2, "chance": 0.5,
"cycle": 120 }
It is a parameter rather than a verb of its own because everything else about a rotating trail — the particle, the anchor, the count, the chance — is the emitter it already was, and a whole verb whose only difference is where two ints come from would be a copy of this one. Rainbow dust is its first user, and draws the number epigenetically so no two rainbow horses turn at the same speed.
The hue is counted off the horse’s own tick count rather than the world’s, so two cycling horses side by side are not in lockstep.
Anchors, and the five body sites
The first four anchors are single points, and were all the verb had while its only
users were a trail at the feet and an aura round the body. The five
body sites came in with the
particle locus, where which part of the horse
a trail comes off is a heritable fact and so has to be a real place on the
animal rather than one spot with a wide spread. A site covering several points
picks one of them per particle, so a firing of four off
hooves genuinely comes off different hooves.
| Anchor | Where |
|---|---|
feet | The ground under the horse’s centre. The default. |
body | Mid-height, on the centre line. |
head | Eye height, forward of the chest. |
eyes | Eye height, on the centre line. |
spine | A point along the topline, withers to croup. |
hooves | One of all four, at ground level. |
front_hooves | One of the front pair. |
back_hooves | One of the back pair. |
tail | Behind the horse, at topline height. |
Positions come from the live bounding box and yaw, not from
HorseSkinGeometry — these are world
positions rather than texels, and the box has already had the horse’s scale
attribute applied, so a magically enormous horse
trails from its own hooves rather than from where an ordinary horse’s would be.
Which particles read color, color2 and data
Most particles are a SimpleParticleType and ignore all three, so the
translator looks them up in the registry by id rather than keeping a name table
that would go stale. The handful that carry their own parameters take them from
these fields, which is what the three exist for — write all three and let the
particle decide.
| Particle | Reads |
|---|---|
dust | color |
dust_color_transition | color → color2 |
effect, instant_effect | color |
entity_effect, tinted_leaves | color |
shriek | data as a delay, 0–60 ticks |
sculk_charge | data as a roll angle |
vibration | data as travel time; the destination is the horse |
block | neither — it carries a sculk state |
{ "type": "emitter", "shape": "burst", "anchor": "body",
"trigger": { "interval": 40 },
"particle": "minecraft:happy_villager", "chance": 1.0 }
The spec’s emitter is wider on three axes:
| Field | Spec | Gene layer |
|---|---|---|
kind | light · particle · sound · block_trail | particle live, light parses only. No sound or block trail. |
shape | point · ring · cone · sphere · trail · burst | cone and sphere missing. |
anchor | entity · feet · head · eyes · forward_offset · impact_point · target | body in place of entity; the last three missing (two of them need triggers or selectors that don’t exist). |
color_source | fixed · dye_slot · bound(state_field) | Fixed hex, plus cycle — a clock-bound hue, which is the first colour source here that is not fixed. |
pattern | Sound sequence; see sound signalling | Unbuilt. |
shape and anchor do most of the work and are worth
keeping as parameters rather than letting them split into emitter types: a
hoofprint is particle + point + feet, a landing shockwave is
particle + ring + impact_point, a forward light beam is
particle + cone + forward_offset, a warning flash is
light + burst + entity. color_source is the same
principle applied to colour: a dye-driven glow and a mood-driven glow are one
emitter with a different binding, and both read State.
mob_effect
Keeps a mob effect topped up on self or rider while when holds — the “aura on self” pattern.
| Field | Default | Meaning |
|---|---|---|
effect | required | Mob effect id, e.g. minecraft:dolphins_grace. |
target | self | self or rider. |
amplifier | 0 | 0-based amplifier. |
refresh | 40 | Re-apply every N ticks (at least 1). |
{ "type": "mob_effect", "effect": "minecraft:water_breathing",
"when": { "flag": "submerged" } }
This is the spec’s apply_effect with the target list clamped to
two relationships instead of a selector. When selectors arrive, the honest move is
to widen target rather than add a second verb — and at that
point max_targets becomes mandatory, because
every radius effect has a target cap.
How the translator runs it: on the refresh beat
(tickCount % refresh == 0) it resolves the id against the mob-effect
registry — an unknown id is logged once and skipped — picks the
target (self, or the controlling passenger for rider,
nothing if the saddle is empty), and re-applies a hidden, ambient,
icon-less MobEffectInstance of duration refresh + 20.
When when goes false the re-apply stops and the effect decays within
that window — there is no explicit removal. Suntouched
is the worked example (minecraft:glowing on self).
yield
Something the horse hands back on a right-click. Fires on on_interact only.
| Field | Default | Meaning |
|---|---|---|
trigger | on_interact (any item) | An {"on_interact": "item id"} trigger. Bare "" = any held item. |
consumes | "" | Item id taken from the player’s hand, or "" for nothing. |
produces | "" | Item id handed back. Recognised set: buckets, bottles, egg, slime ball. |
cooldown | 0 | Per-horse cooldown, ticks. Stored on HorseCooldownsAttachment keyed "yield:<geneKey>" — a game time that survives a restart, not a static map. |
denied_damage | 0 | The else branch. When a matching interaction’s when fails, a yield with an empty produces and a non-zero denied_damage hurts the player this much instead (a stallion’s kick is 1). |
denied_message | "" | Shown with the denial (a foal’s “nothing to give”). Fed through Component.translatable, so a built-in gene passes a lang key and an author passes literal text — both render. |
The interaction is eaten so vanilla doesn’t also read it as a mount; a non-creative player loses the consumed item. A producing yield whose cooldown is still running sends a “nothing to give yet” message rather than a silent cancel.
The else branch is a second yield entry with an empty
produces and its own when: milk declares a
{sex_male, adult} yield with denied_damage: 1 and a
{baby} yield with a message. This is what a bare yield
could not express — “and do this instead”.
{ "type": "yield",
"trigger": { "on_interact": "minecraft:bucket" },
"consumes": "minecraft:bucket", "produces": "minecraft:water_bucket",
"cooldown": 24000,
"when": { "all": [ { "flag": "sex_female" }, { "flag": "tamed" }, { "flag": "full_health" } ] } }
{ "type": "yield",
"trigger": { "on_interact": "minecraft:bucket" },
"denied_damage": 1, "denied_message": "The stallion kicks you.",
"when": { "flag": "sex_male" } }
The spec’s Yield is the same system with three fields the gene layer lacks:
Yield {
trigger, condition, cooldown,
consumes: item?,
output: items | fluid | effect | entity | block | structure | area_cloud,
roll_table: id?
}
| Gap | Effect |
|---|---|
output kinds | Only a single item ID from a recognised set. No fluid, effect, entity, block, structure or area cloud output. |
roll_table | No weighted or conditional outputs. [[loot/roll table id resolution? — would this reuse vanilla loot tables or a mod table registry?]] |
| Non-interact triggers | The spec unifies interval production, container extraction and death drops. Today only right-click works, so “produces an egg every 20 minutes” is unexpressible. |
glow
Makes the carrier a light source and/or marks coat regions as full-bright — the “this horse shines” verb. The two halves are independent.
| Field | Default | Meaning |
|---|---|---|
light | 0 | World light level 0–15 the horse emits. 0 = no dynamic light. |
parts | [] | Coat regions rendered full-bright over the base coat — part names or a group like "HAIR". Empty = none. |
{ "type": "glow", "light": 12, "parts": [ "HAIR" ] }
Dynamic light is a server-side trick, not a shader: the
translator keeps one minecraft:light block trailing the horse,
moving it only when the horse changes block and removing it on death / unload
(EntityLeaveLevelEvent) or when when goes false. It is
skipped in the read-only horse dimension, only placed into air, and a server
crash can orphan one block (the same caveat the portal plots carry). A real
mixin-based dynamic light would be cleaner; this needs no coremod.
Emissive parts are pure client render.
GeneticCoatTextureFactory bakes a second texture — the coat
colour on the named parts, transparent elsewhere — and
client/EmissiveCoatLayer (a twin of vanilla’s
HorseMarkingLayer) redraws the model through it with
RenderTypes.eyes(…) at full brightness. That pass
blends (BlendFunction.TRANSLUCENT), which is what
lets a coat layer's glow be a level rather than a bit: the
texel's intensity is written as the second texture's alpha, so a partly
lit texel is that fraction of full bright over the coat as the world lit it. A
part named by the glow verb has no level of its own and is written at
full alpha. The emissive colour is
whatever the coat layers already painted there, so
Suntouched’s gold TOWARD
layer and its glow.parts stay in sync by construction. It is the
only effect verb with a client-render component — adding another such verb
means a render layer, not just a translator case.
parts introduced a new parameter kind, PARTS, on
AbilityType (parsed through PartGroups.expand, the same
expander the coat masks use) — it is reusable by any future verb that needs
a body-region list.
healing
An aura that mends what stands near the horse — the first verb in the vocabulary that reaches anything other than the horse and its rider.
| Field | Default | Meaning |
|---|---|---|
target | players | Who the aura catches: players, rider, self, animals. |
radius | 3 | Reach in blocks, 1–16. A real sphere, not the scan box. |
amount | 1 | Health points restored per beat — two per heart. |
interval | 40 | Ticks between beats, at least 1. |
max_targets | 8 | Most entities one beat may reach, 1–64 — the cap every radius effect is required to carry. |
{ "type": "healing", "target": "players", "radius": 3, "amount": 1, "interval": 40 }
Beats are counted off the horse’s tick count, not the
world’s, so a stable full of healers does not pulse in lockstep —
which matters less for the look than for not doing all the work on one tick. A
target already at full health is skipped, and each heal spits one
HEART particle so the aura is visible while it works.
Built for healer, which is a hand-written gene — the verb is in the shared vocabulary rather than a private hook precisely so a gene file can use it too.
spread
Ground cover creeping outward from the hooves.
| Field | Default | Meaning |
|---|---|---|
cover | required | One vocabulary word, from three families. Converts a block: mycelium, moss, grass, melt. Plants above the ground: sapling_oak, sapling_birch, sapling_spruce, sapling_jungle, sapling_acacia, sapling_dark_oak, mushroom, flower. Plants nothing: bonemeal, which hurries along what is already there. |
radius | 2 | Reach in blocks, 1–8. |
chance | 0.5 | Per-beat probability, in (0, 1]. |
interval | 40 | Ticks between beats, at least 1. |
{ "type": "spread", "cover": "moss", "radius": 2, "chance": 0.35, "interval": 60 }
cover is a vocabulary word, not a block id, and that
is the interesting decision. “Spreading moss” is not one conversion:
it is a small family of them plus a rule about what it will and will not eat, and
that judgement needs the block registry. So the gene says which of three, and the
translator owns the lists — which also means a pack cannot accidentally
write a gene that eats a block somebody built with.
At most one block per beat, and a random candidate near the horse rather than the nearest eligible one. Both are design: this is a horse changing the ground it walks over, not a terraforming tool, and picking randomly makes the edge of a spread ragged instead of a perfect expanding disc.
Every word in the planting family goes through
BlockState.canSurvive before it is written, and that one call is
load-bearing rather than tidy. A mushroom placed into daylight pops off on the
next tick; a flower on the wrong ground never existed. Both look
exactly like a broken gene, and a gene that silently does nothing is
the failure mode this project keeps rediscovering — it cost two sessions
on the spreading genes alone, where the answer each time turned out to be the
floor rather than the code.
The dark oak is the case that proves it, because
canSurvive is not enough on its own. A dark oak sapling
can stand anywhere an oak can; it simply cannot grow without
four of itself in a square. When the vocabulary had one word
(sapling) and the translator picked a species from
pos.hashCode(), one planting in six was permanent litter —
measured overnight, a dark oak sat as a sapling for forty minutes while its
oak neighbour had been a tree since a minute after planting, and one position
logged ten failed growth attempts in three quarters of an hour. The fix is
two-part and both halves matter: the species belongs to the
allele, so a horse plants one thing and its saplings accumulate, and
sapling_dark_oak clusters — it looks for a
free spot beside one already standing before falling back to anywhere, so the
square completes on its own.
bonemeal is the odd one and worth its own sentence: it is the only
word that answers “what does this block do next” rather than
“what does this block become”, so it does not go through the conversion
table at all. It refuses crops — wheat, carrots, potatoes,
beetroot, stems, nether wart, cocoa, berries — which is not a detail but the
whole reason it was allowed to exist: dryad had
rejected bone meal in its own javadoc as “a horse which auto-farms every crop
you own”, and the carve-out answers that objection exactly rather than
overruling it. It also rolls the block’s own
isBonemealSuccess, so it is no faster than a player with a stack.
Built for verdant; the planting and fertilising halves for dryad.
charges
Extra uses of somebody else’s yield before its cooldown bites.
| Field | Default | Meaning |
|---|---|---|
kind | required | The yield kind this grants extra uses of. |
extra | 1 | Additional uses per cooldown window, 1–64. |
{ "type": "charges", "kind": "milk", "extra": 2 }
It names a kind, not a gene, and that indirection is doing real work rather than being tidy. The gene that produces a thing and the gene that decides how often are different loci, and neither can see the other’s epigenome — a gene is handed its own values and nobody else’s, deliberately. So a design where the producing gene asked “how voluminous am I” could not have read the number at all.
Naming a kind is the only version that works, and it pays off: one volume locus governs every gene that declares the kind, including ones written later. Charges from several genes add. They divide the cooldown rather than banking uses, so “three times a day” means three fillings spread across the day rather than three at dawn — which needs no state of its own, since the stamp already on the cooldown attachment is enough.
Built for magic milk volume, which governs milk’s milk, water and lava at once.
breath
Multiplies how long the horse lasts under water.
| Field | Default | Meaning |
|---|---|---|
factor | 1.0 | Multiplier on the air supply — 2 lasts twice as long, 0.5 half. 0.05–40. |
{ "type": "breath", "factor": 2.5 }
The graded counterpart of the underwater_breathing
traversal flag, which is absolute. The two compose the way you would expect: a
horse that cannot drown does not care how slowly it would have.
Vanilla takes one point of air per submerged tick, so the translator hands back
1 - 1 / factor each tick — positive for a long-breathed horse,
negative for a short-breathed one, one expression for both directions. Air is an
int and that is not, so the fraction accumulates per horse.
Vanilla’s oxygen_bonus attribute was rejected for this: it
reaches one direction only, and it rolls a die every tick, so two horses with
identical genotypes would drown at different times.
Built for magic water breathing.
on_death
What happens to the ground where the horse died.
| Field | Default | Meaning |
|---|---|---|
effect | required | One of lava, water, explode. |
{ "type": "on_death", "effect": "explode" }
Deliberately not about items. What a horse leaves behind is
item_drop’s question, and folding the two together would make
“drops diamonds and leaves a crater” impossible to express as the two
independent loci it is.
The two fluids are placed only into a block that is genuinely free — air, or
something a fluid would wash away anyway. The alternative reading turns a novelty
verb into a griefing tool nobody can see coming, since the carrier looks like any
other horse. Nothing here fires in the horse dimension, like glow and
spread.
Built for magic on death.
item_drop
What the horse leaves behind.
| Field | Default | Meaning |
|---|---|---|
drop | required | One of vanilla, diamonds, spawn_egg, enchanted_sword, meat. |
min | 1 | Fewest items, 0–64. |
max | 1 | Most items, at least min. |
{ "type": "item_drop", "drop": "diamonds", "min": 2, "max": 5 }
Every value replaces the vanilla drop except meat,
which is added beside it. The asymmetry belongs to the genes rather than to the
verb: a horse that leaves diamonds instead of its leather is a different animal,
and a meaty one is the same animal with more on it — which is why the two
are separate loci and why a diamond horse can also be a meat horse.
spawn_egg carries the horse’s own genotype and
epigenome, so what comes back is a clone rather than a horse with the same
alleles. It reuses the editor’s preset egg item rather than introducing one
of its own.
Built for magic item drop and magic meat.
mob_aura
How mobs feel about the horse.
| Field | Default | Meaning |
|---|---|---|
mode | required | repel keeps hostiles outside the radius; attract makes those inside it prefer the horse to anything else. |
radius | 8 | Reach in blocks, 1–32. |
interval | 20 | Ticks between beats, at least 1. |
max_targets | 12 | Most entities one beat may reach, 1–64. |
{ "type": "mob_aura", "mode": "repel", "radius": 10 }
Repel is a shove, not a wall. A hostile inside the radius has its target cleared if it was the horse or its rider, and is pushed outward. That is deliberately weaker than a real avoidance goal, and the reason is lifecycle: a goal would have to be added to and taken off every mob that ever walked past, and a horse whose aura stopped expressing would leave the goal behind on everything it had met.
Built for magic mob aura.
night_temper
How the horse feels about other creatures after dark.
| Field | Default | Meaning |
|---|---|---|
mood | required | aggressive — it goes for them; flee — it runs from them. |
towards | required | Who it feels that about: players, passive (animals), hostile (monsters), or all — both plus players. |
radius | 16 | How far it notices, in blocks, 1–48. |
interval | 20 | Ticks between scans, at least 1. |
max_targets | 8 | Most entities one scan may consider, 1–64 — the target cap every radius effect carries. |
{ "type": "night_temper", "mood": "aggressive", "towards": "hostile", "radius": 20 }
The night is in the verb, not in a when. A temperament
that merely happened to be gated on darkness would need that gate written onto every
allele of every gene using it, and the point of the locus this was built for is that
the animal is one thing by day and another after sunset — so the verb says so
once. There is no day_temper, and if one is ever wanted it is a second
verb rather than a flag on this one.
The translator carries it but does not act on it in the ability pass:
NightBehaviourHandler reads it after dark and installs the goals, which
is why GeneAbilityHandler's branch for it is deliberately empty.
Built for magic night temper.
night_watch
What the horse does about the nearest player after dark.
| Field | Default | Meaning |
|---|---|---|
mode | required | One of stare, approach, line_of_sight, unseen, behind — see below. |
radius | 10 | The distance the mode is measured against, in blocks, 1–48: what approach closes to, and how far the others look. |
silent_steps | true | Suppress the horse's footfall sound while the mode is active. |
{ "type": "night_watch", "mode": "behind", "radius": 12 }
The five modes are a deliberate progression, from not caring where you are to being
directly behind you: stare never moves and does not need line of sight;
approach closes to radius first; line_of_sight
watches only while it can actually see you; unseen tries to stand where
you cannot see it; behind closes to arm's length behind your
back.
Like night_temper it is installed by
NightBehaviourHandler rather than executed in the ability pass, and it
runs as a goal (NightWatchGoal) so it competes with the horse's other
behaviour properly instead of teleporting it about.
Built for magic night watch.
combat
What the horse hits for, in health points — two per heart.
| Field | Default | Meaning |
|---|---|---|
damage | 3 | Health points per hit, 0–200. |
{ "type": "combat", "damage": 7.5 }
A vanilla horse has no attack at all; this mod already gave them an
ATTACK_DAMAGE attribute and a melee goal so a wild one could kick
back (HorseAggroHandler), so this verb has only to move the number.
The number is absolute rather than a modifier, so a reader of a horse’s sheet sees the damage it deals and not an adjustment to a baseline they would have to look up. The translator writes it as a base value, and only when it differs.
Built for magic fighter.
The full verb vocabulary
Everything not marked live is unbuilt. Before proposing a new verb, check it is not a parameter on one of these — and check the excluded list, which records the ones already rejected.
Entity
| Verb | Status | Notes |
|---|---|---|
apply_effect | Live — mob_effect (self / rider) | Mob effect to self, rider or selected |
emit_light / emissive render | Live — glow | Dynamic minecraft:light block + full-bright coat parts. New to the gene layer; see Divergences. |
remove_effect | Unbuilt | |
transfer_effect | Unbuilt | Move effects between self and target |
damage | Unbuilt | |
heal | Unbuilt | |
modify_attribute | Partial — attribute, parses only | Temporary or condition-gated |
set_ai_flag | Unbuilt | Flee, no-target, follow |
teleport | Unbuilt | Self or target, with a destination resolver |
impulse | Unbuilt | Push, pull, launch |
spawn_entity | Unbuilt | Supports copying the source entity’s data for clones |
spawn_area_cloud | Unbuilt | Lingering effect cloud |
set_entity_tag | Unbuilt | Add or remove a scoreboard tag; cross-mod integration surface |
Damage-context unbuilt
Valid only under on_hurt triggers.
| Verb | Notes |
|---|---|
cancel_damage | |
reduce_damage | Flat or percentage |
reflect_damage | Fraction back to the source |
World unbuilt
| Verb | Notes |
|---|---|
transform_block | Predicate → new state |
place_block | |
break_block | Respects block tags and protection checks |
place_structure | Small configured feature |
set_weather | Config-gated |
strike_lightning | |
set_time | Config-gated, default off |
set_moon_phase | Config-gated, default off |
The config gates are [[config keys for world-mutating verbs? — no
config surface exists; needs a server-config spec before any of these ship]].
Output and feedback
| Verb | Status | Notes |
|---|---|---|
give_item | Partial — yield’s produces | To inventory, to player, or drop in world |
consume_item | Partial — yield’s consumes | From horse inventory or rider hand |
give_experience | Unbuilt | |
play_sound | Unbuilt | The worked example in Adding a new effect builds exactly this |
queue_sound_sequence | Unbuilt | Pattern-based; see Structured sound signalling |
spawn_particles | Live — emitter | |
emit_light | Parses only — emitter with kind: light | |
send_message | Unbuilt | channel: chat | action_bar | title | subtitle |
open_screen | Unbuilt |
State and control unbuilt
| Verb | Notes |
|---|---|
set_state | Writes a state field; the general-purpose lever |
modify_pool | Add to or drain a resource pool |
grant_trait | With optional duration |
revoke_trait | |
modify_cooldown | Set, reset or offset an ability cooldown |
store_waypoint | |
remove_waypoint | |
recall_waypoint | |
navigate_to | Position resolver → pathfinding target |
deny_spawn |
set_state deliberately absorbs a large family of one-off verbs.
Setting a follow target, a guard anchor, a patrol route, a model scale, an emissive
overlay, a particle colour or a visual flag are all writing a state field that
some other system reads. Goals read navigation state; the renderer reads
visual state; emitters read colour state. Adding a fifth verb for each would put
the same operation in five places. Reach for set_state before
adding a verb — but note it cannot be built before
State exists as a real attachment.
How it runs today
- Effects are neither natural nor magical. The coat pipeline’s natural / magical split is about pigment vs. RGB; effects touch neither. A gene can be natural and carry effects, or magical and carry effects.
- When they run.
traversal,attribute,emitterandmob_effectare evaluated every tick byGeneAbilityHandler(EntityTickEvent.Post), gated bywhenand the trigger.yieldruns fromGeneYieldHandleronPlayerInteractEvent.EntityInteract. - Which abilities are in play is
HorseAbilities.activeFor(genotype)incommon/: for every loaded spec gene whose pair is visible, every effect whoseminDosethe horse meets. The translator then evaluateswhenper tick. - Multiple genes. Traversal flags OR together. Emitters all fire independently. Attribute modifiers stack (once wired, keyed per gene+attribute so they don’t duplicate). Yields have independent per-horse cooldowns.
- Cheap when idle. Both handlers return immediately if no
loaded gene declares any effect (
HorseAbilities.anyLoaded()). - Every effect must be perceivable. A trait a player
can’t observe is a bug (the architecture’s rule). An
emitteror a visible coat layer is the usual signature; a bareattributewith no other tell is borderline.
Merge policy
A horse carries several genes at once, so resolution rules are defined up front rather than discovered through bug reports. The spec’s table, with what the gene layer actually does:
| Component | Spec rule | Today |
|---|---|---|
| Attributes | Additive modifiers, vanilla operation semantics; cap the total per attribute | Stacking is specified but unexecuted; no cap |
| Auras | All stack; deduplicate identical (selector, effect) pairs | No auras |
| Traversal flags | Boolean OR, except mutually exclusive pairs resolved by trait priority | Boolean OR. No priority, so walk_on_water + water_averse is undefined: [[which wins? — needs a decision and a test]] |
| Emitters | All run; light level takes the maximum, not the sum | All run. Light unwired, so the max rule is untested. |
| Abilities | Cooldowns keyed per ability ID, never global | No abilities; yield cooldowns are per-horse per-[[key?]] |
| Goals | Priority = base priority + trait weight; document the numeric bands | No goals |
| Pools | A pool declared by multiple traits takes the widest bounds and the sum of regen/decay rates | No pools |
| Diet | A rejection anywhere overrides any acceptance | No diet table |
| Yields | Independent cooldowns; no shared budget unless explicitly configured | Matches |
| Damage effects | cancel_damage wins over reductions; reductions apply multiplicatively; reflection uses post-reduction damage | No damage effects |
| Granted traits | Merge normally, but never re-trigger on_tame or other lifecycle triggers | No granted traits |
Trait budget. Each trait carries a cost, and spawn
rules and breeding enforce a cap so no horse ends up running fourteen auras. The
cap is config, not code. Genes carry no cost today, and breeding does
not count effects: [[budget config key + enforcement point in the breeding
path?]]. Traits granted temporarily via grant_trait bypass the
budget but carry an expiry.
Worked example: Waterborn
horsegenetics.waterborn ships loaded. Its coat half is a magical
STRIPES × TOWARD layer (neon-blue mane and
tail); its effects half is a traversal
(walk_on_water, adults only), an emitter (blue dust
trail on the move), and a yield (a tamed mare hands back a water
bucket for an empty one). The full file and a line-by-line walkthrough are on
the architecture page.
Read against the spec, Waterborn is a trait with three components, no relations, no cost, no discovery block and a boolean condition on each component. That is a fair picture of the whole gene layer today: the component shapes are right and the machinery around them is missing.
Adding a new effect
The design goal is that an effect is a module: everything about its shape lives in one declaration, and the JSON parser has no per-effect code. Adding one is four edits, and only one of them is in shared code.
The four steps
1. A record on GeneAbility
In common/genetics/spec/GeneAbility.java, add a record to the sealed
interface. Its components are your parameters, then Condition when
and int minDose (every effect carries those). Add a
Trigger component if the effect is event-driven. Keep it a plain
data record — no Minecraft imports, no logic.
/** Play a sound at the horse when the trigger fires. */
record Sound(Trigger trigger, String sound, double volume, double pitch,
Condition when, int minDose) implements GeneAbility {}
2. One declaration on AbilityType
In common/genetics/spec/AbilityType.java, add a
register(new AbilityType(...)) field. This is the single source for
the JSON type name, the parameter list (each with a kind, a default,
and a doc string), any range validation, and how to build the record. The parser
reads all of it generically — you do not touch
GeneSpecParser.
public static final AbilityType SOUND = register(new AbilityType("sound",
List.of(
Param.trigger("trigger", new Trigger.Interval(20), "when it plays"),
Param.required("sound", "sound event id, e.g. 'minecraft:entity.horse.ambient'"),
Param.num("volume", 1.0, "0..1+"),
Param.num("pitch", 1.0, "0.5..2.0")),
v -> {
double pitch = v.num("pitch");
if (pitch <= 0) throw v.bad("pitch must be positive, got " + pitch);
return new GeneAbility.Sound(v.trigger("trigger"), v.str("sound"),
v.num("volume"), pitch, v.when, v.minDose);
}));
Parameter kinds (AbilityType.Kind):
| Helper | Kind | JSON → builder sees |
|---|---|---|
Param.str(name, fallback, doc) | STRING | a string; v.str(name) |
Param.required(name, doc) | STRING, no default | as above, but omitting the key is an error |
Param.choice(name, choices, fallback, doc) | CHOICE | a lower-cased string checked against choices; v.str(name) |
Param.requiredChoice(name, choices, doc) | CHOICE, no default | as above, required |
Param.num(name, fallback, doc) | NUMBER | a double; v.num(name) / v.intOf(name) |
Param.bool(name, fallback, doc) | BOOL | a boolean; v.bool(name) |
Param.color(name, fallback, doc) | COLOR | #rrggbb parsed to 0xRRGGBB; v.color(name) |
Param.trigger(name, fallback, doc) | TRIGGER | a parsed Trigger; v.trigger(name) |
No kind exists yet for a selector or a
scaling block; both would be new kinds when those land
([[Param.selector / Param.scalable — not written]]).
The builder gets a Values bag with v.when /
v.minDose already filled and v.bad("...") for a scoped
error. Range checks (like emitter’s chance in
(0,1]) go here, not in the parser.
3. A branch in the NeoForge translator
In neoforge-26.1.2/…/server/GeneAbilityHandler.java add a
case to the switch (ability) in onHorseTick
for a tick / triggered effect, or handle it in
server/GeneYieldHandler.java if it fires on interaction. Read the
record’s fields, check the trigger, and make the game calls. Use
GeneAbilityHandler.conditionHolds(ability.when(), horse, record) to
respect when; it’s already applied for you in the tick loop.
case GeneAbility.Sound s -> {
if (firesThisTick(s.trigger(), horse)) {
level.playSound(null, horse.getX(), horse.getY(), horse.getZ(),
soundEvent(s.sound()), SoundSource.NEUTRAL,
(float) s.volume(), (float) s.pitch());
}
}
If you can’t implement it yet, call warnUntranslated("sound", …)
— it logs once so a gene author knows the verb parsed but does nothing. That
is what attribute did for months, and what
traversal:walk_on_lava still does.
4. A section on this page
Add an <h3> under The ones that exist
with the parameter table and a JSON example, bump the count in that heading, and
update the verb’s row in The full verb vocabulary
from Unbuilt to its new status. If the verb is not in the
spec’s vocabulary, add a row to
Divergences from the spec saying so and why.
Rules an effect must follow
- The set stays closed. Reach for a parameter on an existing
verb before adding a verb. A new verb needs a one-line justification that it
is a genuinely new kind of thing, not a special case of
emitter/attribute/yield. - Check it against the spec first. If the spec already names the verb, use its name and its shape. If the spec put it on the excluded list, the answer is the composition named there, not a new verb.
common/stays pure. The record and theAbilityTypedeclaration must not import Minecraft. All game knowledge lives in the translator.- Condition-gated and dose-aware for free. Take
Condition whenandint minDoseas the last two record components and passv.when/v.minDosein the builder — thenwhenandminDosejust work. - Perceivable. The effect must have an observable signature.
If it doesn’t inherently (a pure stat tweak), pair it with an
emitterin the same gene or reconsider. - Capped if it has a radius. Any effect that touches more than
one entity takes a
max_targets, no exceptions. - Names are
snake_case, matching the JSON style of the rest of the format. - Errors name the offender. Choice params get this automatically; a range check in the builder should say the key, the rule and the value it got.
Adding a condition flag
Two edits, both a one-liner:
- Add the name to
AbilityType.CONDITION_FLAGS. - Add a
casetoGeneAbilityHandler.flagHolds(name, horse, record)returning the boolean read off the live horse.
Combinators (all / any / not) and negation
already compose it. No parser change — readCondition validates
against the list.
Where the spec names the same source, use the spec’s name. Where it does not,
check it is not really an entity_state predicate in disguise —
those belong on a selector, not on the self-flag list.
Adding a trigger
Triggers are a small sealed hierarchy, so this one does touch the parser:
- Add a record to
GeneAbility.Trigger(e.g.record OnJump() implements Trigger {}). - Add its name to
AbilityType.TRIGGERSand a branch toGeneSpecParser.readTrigger(a bare-string case if it takes no argument, an object case if it does). - Handle it wherever triggers are checked in the translator
(
GeneAbilityHandler.maybeEmitand any new effect that reads a trigger).
A trigger is shared across every effect that takes one, so keep the set tiny and
prefer on_change(condition)-style generality over a verb per game
event. Before adding one, check
the full vocabulary — if the spec names it, use
that name and signature, and if it is a state edge, it is
on_change rather than a new trigger.
When the format is not enough
Everything up to here is a file. This tab is the other path: a Java
class against the Gene interface, for the genes the format
cannot say. That is a smaller set than it sounds — reading another gene's
dose (cream reads pearl), or rewriting both pigment channels together against a
reading of the coat (grey's remap onto the gradient's neutral column). Reach for it
when you have tried the vocabulary and found the
measurement missing, not when you have not found the numbers.
What a gene is allowed to do
A gene has four kinds of effect, and may have any combination:
- The natural coat — restrict eumelanin / phaeomelanin in phase 1. Reserved for genes that exist in real life.
- The artificial coat — add or remove signed R/G/B in phase 3.
- Minecraft behaviour — a data-driven gene may carry an
effectsblock: walk on water, resist fire/fall, trail particles, be milked for a fluid. A closed set of five verbs with booleanwhenconditions — full reference and the "add a new effect" contract in Gene effects. (attributeandmob_effectparse but are not executed yet.) - Status effects — stats, body size, health. Built — see the horse’s body.
The two coat effects are mutually exclusive and the choice is
declared (Gene.isNatural()), not inferred. A
gene that wants both registers as two genes. The separation is a promise to
the player that the real-world genetics are real — see
Philosophy §6.
Allele rules
Binding on every gene, this mod’s and anyone else’s:
- Tokens are alphanumeric —
a-z,A-Z,0-9, nothing else. That keeps the code string’s/and-separators unambiguous for free, and it lets real scientific allele names (SW1,W36,D1,TE2) be used verbatim. - Length 1 to 128, with strong guidance to keep it to 1–5. Tokens end up on pen signs, in the info panel and in the genotype code; a 40-character token is legal and will look terrible everywhere.
- Unique within the gene, and the gene key must be namespaced
<modid>.<gene>. - The wild type is the least magical version, and by
convention it is named
n. For a real-world gene that is the ordinary allele; for a magical gene it is “this horse does not have the magic”. Naming it consistently matters more than it looks:nis the most-printed token in the mod, andGeneCodeDisplay.shortFormleans on recognising it to keep a genotype readable. - Any number of alleles. Two is the common case, not the
assumption: MATP has three, and the model
scales to a thirty-allele
KITwithout a special case, because you declare an outcome per combination rather than a dominance label per gene. - A narrow palette wants three forms, not two — white, black, coloured, in that declaration order. It is a recommendation rather than a rule and it has a shape worth following exactly; Three forms, where the palette is narrow is the whole of it.
- The
alleles()order is bookkeeping, not biology. It fixes which allele lands in which slot of anAllelePair, so a pair equals its reverse and a code string round-trips — and it must agree with eachAllele.order(). Nothing about what a combination does consults it.
The interface
public interface Gene {
// --- required ---------------------------------------------------------
String key(); // "<modauthor>.<gene>"
List<Allele> alleles(); // any number; the order is a slot
// order, not a dominance ranking
Allele defaultAllele(); // what a missing code segment reads as
int priority(); // processing order; no default (see below)
// --- the combination table --------------------------------------------
List<Expression> expressions(); // every distinct outcome, wild types too
Expression expressionOf(AllelePair pair); // THE function
// ...and the same question in the context of the whole genotype, for a gene
// that depends on another. This is what the coat pipeline calls.
default Expression expressionIn(AllelePair pair, Genotype genotype) {
return expressionOf(pair);
}
// --- the wild population ----------------------------------------------
FounderTable founderTable(FounderContext context); // weight per combination
// --- phase declaration ------------------------------------------------
default boolean isNatural() { return true; }
// --- gameplay-economy metadata (§19); sensible defaults --------------
default String description() { return GeneDescriptions.of(key()); }
default GeneRarity rarity() { return GeneRarity.DEFAULT; } // gold-ingot tier
default boolean hasGeneCarrot() { return true; } // false: sex, disorders
default boolean geneCarrotHomozygous() { return true; } // <Gene><Gene>
default Optional<FounderTable> spliceTable() { return Optional.empty(); } // else uniform draw
// --- override only if a combination genuinely cannot exist -------------
// Almost every locus lets any two of its alleles pair up. Say otherwise and
// the combination gets no catalogue entry and is not counted in
// totalGenotypes(). Only the sex locus does, to rule out Y/Y.
default boolean canOccur(AllelePair pair) { return true; }
// --- derived; you do not implement these ------------------------------
default boolean isVisible(AllelePair p, Genotype g); // !expression.wildType()
default boolean isDeterministic(AllelePair p, Genotype g); // expression.deterministic()
default boolean affectsCoat(); // is any outcome not a wild type?
// false => left out of the texture key
default Allele fromToken(String token); // token -> allele
default Expression expression(String id);
}
If every one of your outcomes is a wild type, your gene is heritable and
invisible: it still gets a slot in the genotype code, still breeds, still
shows in a punnett square — but the coat pipeline skips it,
affectsCoat() is false, and it is left out of
Genotype.coatCode() so it cannot fork the texture cache. The
sex locus is the worked example.
dominance(), and there never will be
A gene has alleles; every unordered combination of two of them produces
something; the gene says what by declaring
Expressions and mapping pairs to them. “Dominant”
and “recessive” were shorthand for which combinations
happen to share an outcome — they only describe a two-allele
locus, and they cannot express codominance at all. Say the table instead;
it is shorter, it is exact, and it works for any number of alleles. See
the genetics model.
The short path — a base class
Most hand-written genes answer seven of those eight methods the same way: build two alleles in the right slot order, list them, name a default, list the expressions, count copies to map a pair to one of them, and turn a frequency into a founder table. Three base classes do all seven from a declaration, leaving you the one method that is the gene.
| Base | You write | For |
|---|---|---|
AbstractNaturalGene | restrict(ctx, coat) | A real-life coat gene: it takes red or black away in phase 1. |
AbstractMagicalGene | tint(ctx, coat, colour) | A gene that adds signed RGB in phase 3. |
AbstractAbilityGene | abilitiesWhenExpressed(epi) | A gene that paints nothing and changes what the horse does. Seventeen built-ins use it. |
public final class MushroomGene extends AbstractNaturalGene {
public MushroomGene() {
super(gene("horsegenetics.mushroom", 32, "Mushroom")
.variant("Mu", "Mushroom (Mu)")
.wildAllele("mu", "Wild-type (mu)") // optional; "n" by default
.recessive() // or .dominant()
.hardyWeinberg(1.0 / 34) // or .founders(CARRIERS_ONLY, 6.0)
.wild("Red pigment is left alone.")
.carrier("mushroom-carrier", "Mushroom carrier", "One copy shows nothing...")
.outcome("mushroom", "Mushroom", "Red pigment cut hard..."));
}
@Override
protected PigmentField restrict(CoatBuildContext ctx, PigmentView coat) {
PigmentField f = coat.mutableCopy(); // never write through `coat`
...
return f;
}
}
That is the real mushroom gene, and it is the
shortest in the mod on purpose — everything above the paint function is a
declaration, so what is left to read is the dilution itself. The declaration is
checked when the object is built, not when it first paints, so a missing
outcome(...) is a sentence naming the gene rather than a
NullPointerException four hours into a world.
These fit two alleles, one variant, one outcome. A gene with
three alleles, or one whose heterozygote is the expressing
combination (magic sectoral
heterochromia), does not fit and should not be bent into
one — implement Gene directly. Nothing is taken
away by the bases existing; they only mean the common case is short. Other
shapes that recur have their own informal bases already
(RecessiveDisorderGene, AbstractMagicStatGene,
HairColorGene, AbstractEyeGene), and a new one is
worth writing the third time a shape appears, not the first.
Registering from another mod
Genes is an open registry: a gene from any mod is
registered through the same method as a built-in, sorts into the same one
(priority, key) order, and is indistinguishable downstream. The way in
is a mod-bus event:
@Mod("yourmod")
public final class YourMod {
public YourMod(IEventBus modEventBus) {
modEventBus.addListener(this::addGenes);
}
private void addGenes(RegisterHorseGenesEvent event) {
event.register(new YourGene());
}
}
- It fires once, at the one safe moment — after every mod
has been constructed and before anything has parsed a genotype code. That is
why it is an event rather than a static call:
Genes.registeris public and works from a mod constructor, but when a constructor runs depends on mod load order, and the registry decides the genotype code’s layout. One firing point means every mod’s genes land in the same place every launch. - The key must be
<yourmodid>.<gene>, lower-case, one dot. Refused otherwise, loudly — the namespace is the only thing keeping two mods’ “dun” apart, and a collision is not a crash: it is that mod’s gene silently missing from every horse. - Registration order never decides gene order. Everything is
sorted on
(priority, key), ties broken alphabetically, so two players with the same mods get the same horses whatever order those mods loaded in. - Then the registry freezes. A gene registered after that throws, with a sentence saying why. It buys no speed — the orderings were already computed once and cached — it buys a named failure instead of horses whose loci have all shifted by one segment, which reads as a breeding bug and is not one.
- Adding a gene changes the genotype code, and this mod has no save compatibility: horses saved before your mod was added will not load with it, and will not load again once it is removed.
Walkthrough 1 — a natural gene
Silver dapple landed as a built-in on 2026-09-02
(horsegenetics.silver, common/genetics/genes/SilverGene.java) —
so this walkthrough now doubles as a description of a real gene. Your own
gene would use a mymod.* key; everything else is unchanged.
The smallest useful shape. Silver dapple dilutes eumelanin only,
so a chestnut carrying it looks unchanged — a single
restrictAll call is the entire coat effect.
public final class SilverGene implements Gene {
public static final String KEY = "mymod.silver";
public static final int WILD_ONE_IN = 60; // 1 in 60 per allele
/** Eumelanin kept. Phaeomelanin is untouched, so a chestnut is unchanged. */
private static final float KEEP_BLACK = 0.45f;
// gene order token label
public final Allele Z = new Allele(KEY, 0, "Z", "Silver (Z)");
public final Allele n = new Allele(KEY, 1, "n", "Wild-type (n)");
private final List<Allele> alleles = List.of(Z, n);
// --- the two outcomes, declared once ----------------------------------
private final Expression WILD = Expression.wildType("Black pigment is left alone.");
private final Expression SILVER = Expression.of("silver", "Silver dapple")
.describe("Black pigment walked toward chocolate, with red untouched - so "
+ "a chestnut carrying it looks unchanged.")
.restrict((ctx, coat) -> {
PigmentField f = coat.mutableCopy(); // never write through `coat`
CoatRegions.restrictAll(ctx.skin(), f,
(field, px, py, p) -> field.dilute(px, py, 1.0f, KEEP_BLACK, 0.12f));
return f;
});
private final List<Expression> expressions = List.of(WILD, SILVER);
// 1 in 60 per allele, as the three combination shares it implies
private final FounderTable founders = FounderTable.hardyWeinberg(Z, n, 1.0 / WILD_ONE_IN);
@Override public String key() { return KEY; }
@Override public String name() { return "Silver dapple"; }
@Override public List<Allele> alleles() { return alleles; }
@Override public Allele defaultAllele() { return n; }
@Override public List<Expression> expressions() { return expressions; }
@Override public int priority() { return 35; } // a dilution: after agouti (20)
@Override public FounderTable founderTable(FounderContext c) { return founders; }
/** The whole gene: which combination gives which outcome. */
@Override
public Expression expressionOf(AllelePair pair) {
return pair.has(Z) ? SILVER : WILD;
}
}
Notes on that
Allele(geneKey, order, token, label)— and nothing else. An allele carries no “visible” or “deterministic” hint, because both are properties of a combination and live on theExpression.- A wild-type expression has no painter at all, so
“does nothing” is structural rather than an early
return nullyou have to remember. The composer skips it. - Use
dilute, not a barerestrictBlack, whenever the effect is a dilution — the zero-red column is a trap. - Use
whiten, never a scaling of both channels, whenever the effect is white hair — a marking, its soft edge, a roan fleck, a bald patch. Scalingredandblacktogether unmasks the red a black horse was hiding and the fade browns; the gold diagonal is the same trap from the other side.whiten(1)is exactly(0, 0), so it is also the right verb for a hard-edged marking. - Override
expressionIn(pair, genotype)and return a wild type when a gene should decline to run on a base that has nothing for it to do — that is how agouti stays silent on a chestnut.
Walkthrough 2 — a magical gene
Phase 3 works on a signed, uncapped ColorField. Two shapes matter,
and the difference is whether the gene needs to know what it is painting
over.
Shape A — a blind, unconditional add
The gene commits hard enough that nothing can pull the horse back. This is order-independent and needs no read access:
@Override public boolean isNatural() { return false; }
private final Expression BLUE = Expression.of("blue", "Blue")
.describe("Unconditionally, unmistakably blue - on any base coat at all.")
.tint((ctx, coat, accumulated) -> {
ColorField delta = ColorField.deltaLike(accumulated);
HorseSkinGeometry.forEachTexel(ctx.skin(), (px, py, part, face, point) -> {
// A resolved channel tops out at 255, so -510 lands hard on 0 and
// +510 lands hard on 255, whatever else the horse carries.
delta.add(px, py, -510, -510, 510);
delta.addOpacity(px, py, 255); // so it shows on a dominant white too
});
return delta;
});
Shape B — read the accumulator, then walk toward a target
When the gene wants a specific colour and wants to keep the shading
underneath, a blind add cannot do it: to reach pink on a black mane a fixed delta
has to push so hard that a pale mane saturates to white. Read
ColorView.visible first and return the delta that lands where you
want:
/** The signed step from this texel's accumulated channel to its target one. */
private static int toward(ColorView colour, int px, int py, int channel, int target) {
int seen = colour.visible(px, py, channel); // what a viewer would see
int wanted = (int) Math.round(seen + (target - seen) * (STRENGTH_PERCENT / 100.0));
return wanted - switch (channel) { // minus what is already there
case 0 -> colour.red(px, py);
case 1 -> colour.green(px, py);
default -> colour.blue(px, py);
};
}
That is a legitimate choice — but it means your position in
Genes.magicalOrder() now matters, so pick your
priority() deliberately and say so in the gene’s Javadoc.
Mane colour (112) runs before magic zebra (120) precisely so that
stripes black out a coloured mane rather than the other way round. The
far end of the same argument is the whole modifier band at 600+
(the paint order): a gene that reads
somebody else's marking has to be painted after it.
Flat paint (set) is almost never what you want
ColorField.set replaces the accumulator instead of adding to
it. It is reserved for a masking expression that must read
identically on black, chestnut or white —
Magic zebra is the clearest example. Using it makes your gene
strictly order-dependent and erases every gene before it.
Walkthrough 3 — a gene that changes the horse, not the coat
Speed, max health, jump strength, body size and the disorders a horse expresses all
come from the same place: a gene that additionally implements
TraitContribution. The full machinery is on
the horse’s body; this is how you write one.
It is the shortest kind of gene in the mod, because there is no painter. An allele list, a combination table, a founder table, one method.
public final class StrideGene implements Gene, TraitContribution {
public static final String KEY = "yourmod.stride";
public final Allele S = new Allele(KEY, 0, "S", "Long stride (S)");
public final Allele n = new Allele(KEY, 1, "n", "Wild-type (n)");
private final List<Allele> alleles = List.of(S, n);
// EVERY outcome is a wild type: this gene never paints.
private final Expression LONG = Expression.wildType("stride-long", "Long stride",
"Two copies. The full bonus to movement speed.");
private final Expression HALF = Expression.wildType("stride-half", "Part stride",
"One copy, half the bonus - the alleles add rather than one masking the other.");
private final Expression PLAIN = Expression.wildType("stride-plain", "Wild type",
"No copy. Baseline speed.");
@Override public String key() { return KEY; }
@Override public int priority() { return 95; } // the non-coat sub-band
@Override public List<Allele> alleles() { return alleles; }
@Override public Allele defaultAllele() { return n; } // declared LAST, see below
@Override public List<Expression> expressions() { return List.of(LONG, HALF, PLAIN); }
@Override public FounderTable founderTable(FounderContext c) {
return FounderTable.hardyWeinberg(S, n, 0.25);
}
@Override public Expression expressionOf(AllelePair pair) {
return switch (pair.count(S)) {
case 2 -> LONG;
case 1 -> HALF;
default -> PLAIN;
};
}
@Override
public void contribute(AllelePair pair, Genotype genotype, TraitBuilder out) {
out.addSpeed(pair.count(S) * 0.012);
}
}
The four rules that are specific to this kind of gene
- Every expression must be a
wildType. There is no way to build a non-wild one without a painter, and you do not want one:wildTypemeans “changes nothing about the coat”, and that is what makesGene.affectsCoat()false, keeps your gene out of the texture key, and collapses your whole locus to a single entry in the genotype catalogue. Get this wrong and every horse in the world re-bakes a texture. - Declare the baseline allele last.
GenotypeCatalog.allPairsOfwalks the allele list backwards, so the last-declared allele is the one a catalogue entry ends up carrying — andGeneCodeDisplayhides a gene sitting entirely on itsdefaultAllele(). Declare the variant first and the baseline last, and an ordinary horse’s short genome string is unchanged. - The baseline allele should contribute zero. Then an
all-wild-type horse resolves to the flat baselines in
HorseTraits, and every number a player sees is a departure from a known starting point. If your gene is a trade-off, put the whole trade on the variant — see MSTN, where the sprint allele buys speed and costs hearts rather than the stayer being paid a bonus. - Use the non-coat priority sub-band, 80–99. It sits after every gene that paints and before the magical band. Position among the non-coat genes is arbitrary — contributions are additive and order-independent by construction — so the number only fixes a stable slot in the genotype code.
If it is a disorder
Implement HealthContribution instead (it extends
TraitContribution), declare a Condition constant, and
report it alongside the numbers:
public static final Condition WEAK_HOCKS = Condition.impairing(
"weak-hocks", "Weak hocks",
"The hind joints do not hold. The horse is sound to ride but jumps badly.");
@Override
public void contribute(AllelePair pair, Genotype genotype, TraitBuilder out) {
if (pair.homozygousFor(w)) {
out.condition(WEAK_HOCKS).addHealth(-3.0).addJump(-0.15);
}
}
The marker interface is what lets the server’s health.mode setting
switch your disorder out of a world without switching your gene out —
it stays registered, stays in the genotype code, and stays inherited. Viability is
not something you declare: it is derived from the severity of the conditions you
report, so you cannot kill a foal without saying what killed it.
If your disorder is the ordinary “two alleles, only the double-variant does
anything” shape, extend RecessiveDisorderGene instead and supply
the numbers — it writes the three expressions, the carrier wording and the
founder table for you. Six of the seven built-in health genes do.
A wild-caught horse is an adult that survived. If founders can be affected, the disorder is not a breeding hazard, it is background noise — and the carrier wording on your heterozygote expression, which is the whole player-facing value of the gene, stops meaning anything.
Walkthrough 4 — a gene that makes the horse do something
Not a colour and not a stat: emit light, mend what stands near it, spread moss,
hand a player a bucket of lava. That is the third capability a gene can implement,
and it reuses the effect vocabulary a data-driven
gene’s effects block parses into.
public final class BeaconGene implements Gene, AbilityContribution {
private final List<GeneAbility> glow = List.of(
new GeneAbility.Glow(10, List.of(), GeneAbility.Condition.ALWAYS, 1));
@Override
public List<GeneAbility> abilitiesFor(AllelePair pair, Genotype genotype) {
return pair.has(B) ? glow : List.of();
}
}
Nothing else is needed. HorseAbilities.activeFor collects built-in and
data-driven contributions together, and the NeoForge translator has one switch for
both — so a verb that exists works for your gene the day you use it.
- Return the list you mean.
minDoseexists because a gene file has no expression language and needs some way to say “only if homozygous”. You have the whole of Java, so answer the question inabilitiesForand leave it at 1. - Hold the records as constants. The method is called from a per-tick path (cached by genetic code, but still), and an ability list is immutable data — there is no reason to rebuild it per call.
- If the verb you want does not exist, add it — the
contract is four places and none of them is the parser. See
adding a new effect. Adding it to the shared
vocabulary rather than hooking the game directly is what keeps
common/free of Minecraft and makes the behaviour available to gene files too. - Every effect must be perceivable. An ability a player cannot see coming is one nobody learns to breed for. If yours has no natural signature, pair it with a coat mark the way healer does — and note that milk and verdant do not, which is logged as a gap rather than defended.
Per-horse variation: declare it, then read it by name
A gene that varies between horses carrying the same alleles declares
every number it needs in epiSchema(), and reads them back
from ctx.epigeneticsFor(key()) — the values stored on the
allele copy that expresses at your gene, the dominant copy on a
heterozygote and the higher-priority copy on a homozygote.
@Override
public EpiSchema epiSchema() {
return EpiSchema.of(
EpiValue.seed("seed"), // a noise field
EpiValue.uniform("spacing", 2.2, 4.2), // body units
EpiValue.uniform("reach", 0.35, 0.95)); // fraction of the drop
}
// ...and in the painter:
EpiValues epi = ctx.epigeneticsFor(KEY);
long seed = epi.seed("seed");
double spacing = epi.get("spacing");
double reach = epi.get("reach");
- Pick the right kind. A
SCALARis a magnitude and drift nudges it continuously; aseedor acategoryis replaced whole or not at all, because there is no “slightly different” noise field or body site. Getting this wrong is how a horse’s particles would wander from its mane to its tail by accident. - Declare the distribution you actually want.
uniformis the default; usepowerorgaussianwhere the population should be skewed, as ednrb's cover and the magical stat deltas are. Founders roll from it; nothing else ever does. - Use
EpiValue.colourfor a colour rather than packing one into a single number — three channels drift gradually, and a founder's still comes out bright. - Founders roll fresh values; a foal inherits the copy’s numbers, so your pattern runs in families for free.
- Mark the expression
.varies(). Determinism is per outcome, so a gene whose homozygote is random and whose heterozygote is not says exactly that, with no per-gene predicate to get subtly wrong. - Prefer normalising against
bounds(skin, part)/bodyBounds(skin)rather than hard-coded numbers, so the effect works on the foal mesh too.
When you need both copies
“The copy that expresses” is the right question almost everywhere: one locus, one look, one set of numbers. It is the wrong question for a gene whose two alleles paint at the same time — mane colour’s heterozygote is a solid colour from one copy with the other copy’s bands over it, and asking for the expressed copy would paint the stripes in the base colour, which is a horse with no stripes.
Rng base = ctx.epigeneticsForCopy(KEY, 0); // pair.first()
Rng band = ctx.epigeneticsForCopy(KEY, 1); // pair.second()
The slots follow your alleles() declaration order, since
AllelePair canonicalizes by it — so declare the allele whose
role you want in slot 0 first, and the mapping is fixed rather than
guessed. Reach for this only when both copies genuinely paint; for everything else
the single-copy form is what a reader expects.
Epigenetics on the body, not just the coat
A trait can vary per horse the same way a coat can. Implement
EpigeneticTraitContribution and you are handed the same
expressing-copy Rng:
@Override
public void contribute(AllelePair pair, Genotype genotype, GeneEpigenetics epigenetics,
TraitBuilder out) {
// expressed() for a gene with one result per locus...
double weight = 1.0 + epigenetics.expressed().nextGaussian() * 0.1;
// ...copy(slot) for a CODOMINANT one, where both alleles contribute and
// asking for the expressed copy would count one twice and the other never.
double sum = signedDelta(pair.first(), epigenetics.copy(0))
+ signedDelta(pair.second(), epigenetics.copy(1));
}
It is still deterministic and still heritable, for the same reason the coat is: the
seed is stored on the record and travels with the allele. Rng also
carries a nextGaussian() for a trait that wants a bell rather than a
flat spread — bounded at ±6σ, and exactly 0 at the midpoint, so
both of the notes below still hold. Note the two
consequences. A caller asking a bare genotype for its body
(HorseTraits.resolve(genotype)) gets your contribution’s
midpoint, via MidpointRng — so write your draw
so that its midpoint is a sensible answer. And a homozygote expresses whichever
copy has the higher epigenetic priority, so two copies are two chances at a good
number rather than a doubled effect.
GeneEpigenetics lives in common/genetics/ rather than
common/trait/, because the ability side needs exactly the
same thing and the two packages must not depend on each other.
The four magical body-stat genes (size, speed,
health, jump) are the worked example, sharing an
AbstractMagicStatGene base: a codominant percentage per copy that
multiplies the stat through TraitBuilder.multiplyScaleUnclamped
(or multiplySpeedUnclamped / …Health… /
…Jump…), applied after every addition and bounded only
by the MAGICAL_* guards — the trait-side counterpart of the
coat’s uncapped phase-3 accumulator.
...and epigenetics on an ability
Same idea again, one layer along. A gene that grants behaviour normally
implements AbilityContribution, which is a pure function of the
genotype — right for every gene where the alleles fix what the horse does,
and two Hlr/Hlr healers should heal identically. When what the effect
looks like should vary between horses carrying the same allele, implement
EpigeneticAbilityContribution instead (one or the
other, never both) and you are handed the same GeneEpigenetics:
@Override
public List<GeneAbility> abilitiesFor(AllelePair pair, Genotype genotype,
GeneEpigenetics epigenetics) {
// One emitter per copy that shows, each reading its OWN copy - so the two
// halves of a codominant pair are genuinely independent.
List<GeneAbility> out = new ArrayList<>();
for (int slot = 0; slot < shown(pair).size(); slot++) {
Rng rng = epigenetics.copy(slot);
out.add(new GeneAbility.Emitter(..., HairPattern.randomBrightColour(rng), ...));
}
return out;
}
Declare every value your gene might need, whether or not a given combination uses it, and read it by name. Adding or re-ordering values in the schema is free. Renaming one orphans it: it reads as a fresh deterministic roll on the next parse, and every horse carrying the gene changes.
This used to be far sharper. A gene took a fixed sequence of draws off a single seed, so the position of a draw was the meaning of each number and inserting one silently rewrote every horse in every save. The particle locus is the gene that paid for that lesson; it still declares every value on every copy, but now because that is simply the honest description of the locus.
The same two consequences apply: asked with no epigenome the draws come from
MidpointRng, so make sure the midpoint is a sensible answer; and
anything caching the resulting ability list has to key on the epigenome as
well as the genotype, or one horse gets another horse’s colours.
Channels — the things a horse has exactly one of
Some properties are not a gene's to own outright, because a horse has exactly one of them and several genes want a say. Those are channels: one locus owns the property and everything else reaches it through a capability interface. There are three, and the pattern is worth learning because it is how the model absorbs a new kind of effect without a new kind of gene.
| Channel | Owner | Hook | How claims resolve |
|---|---|---|---|
| The colour LUT | LUT locus | LutContribution |
Closed. Exactly one gene implements it, permanently — a new palette is a new allele there, never a new gene. |
| The cutie mark | Cutie mark | CutieMarkContribution |
Open, folded. The owner draws the base emblem; every implementor then modifies it in codeOrder(). Order is stable and priority-driven. |
| The iris colour | — (the composer) | EyeColorContribution |
Open, ranked. Highest EyeColor.rank() wins, ties to the earlier gene. The three ranks are the three biological routes: a whole-body dilution loses to a gene aimed at the iris, which loses to an iris with no pigment cells at all. See Eye colour. |
| Part of an iris | — (the composer) | EyePatchContribution |
Open, composed. Not a channel at all: patches are absolute writes over the finished iris, applied in codeOrder(), last writer to a quadrant wins. It exists because a horse has one eye colour but can have two colours in an eye. |
The difference between closed, folded and ranked is not arbitrary — it comes from what the property is:
- The LUT is closed because a horse has two copies of one chromosome, so it can shift its palette at most one way. There is nothing for a second gene to add.
- The cutie mark can afford an open fold because it is the last thing drawn — nothing composes over it, nothing reads it back, and it is not in the baked coat, so a modifier cannot corrupt an accumulator or move a texture key.
- The iris is ranked rather than folded because the claims are different kinds: a pigment gene says what colour the iris is, a white locus says there is no pigment in it. Those do not blend; one of them is simply right.
- Part of an iris is the one that is not a channel, and the pair is worth studying before you add a channel of your own. “What colour are this horse's eyes” has one answer and needs a rank. “Paint this over part of them” has as many answers as there are genes, and they stack like paint. Splitting the two was what let one blue eye, a blue wedge and a two-coloured magical iris all exist without any of them fighting the others.
If you find yourself wanting another, the test is the same: does a horse have exactly one of the thing, and would two genes both writing it produce nonsense rather than a sum? If so it is a channel. If the contributions genuinely add up — as phase-3 colour deltas do — it is not, and it should be an ordinary accumulator.
The contract
Coats are cached per texture key and shared by every horse with that key. A gene that is not a pure function of its inputs does not render wrong — it renders as another horse’s coat.
- All randomness through
ctx.epigeneticsFor(...). Nonew Random(), noMath.random(), noRandomSourcefrom the level. - No input the texture key does not capture. Not entity UUID, position, world seed, wall-clock time or tick count.
- No mutable static state. Your gene is a singleton shared by every horse on the server; a cached field is a cross-contamination bug.
- Return a contribution; never write through your arguments.
coatis the previous gene’s output and the next gene’s input. - Mark
.varies()honestly. Claiming determinism you do not have poisons the cache for every horse sharing your genotype: the first to bake wins, and the rest render its coat. - Declare the phase honestly. Magical unless the gene exists in real life, and never both.
- Do not depend on load order. Every ordering the engine uses is (or will be) computed by sorting.
- Only inspect lower-priority genes in a genome-aware
founderTable— the higher ones have not been rolled yet, andFounderContext.pairthrows rather than pretend. - Cover every combination exactly once.
expressionOfmust return one of your declaredexpressions()for every one of the n(n+1)/2 pairs. Aswitchonpair.count(allele)with adefaultis the usual shape.
Registering it
A data-driven gene registers itself: every .json in
.minecraft/phc/genes/ is loaded at startup, in filename order,
and placed after the built-ins by its declared priority. Nothing below
applies to it. A Java gene is two edits, and
neither of them is an ordering — your gene’s own
priority() decides where it lands:
// 1. a field, so callers can name it
public static final KitGene KIT = new KitGene();
// 2. add it to BUILTINS. The order of this list is IRRELEVANT - the registry
// sorts every gene, built-in and data-driven alike, on (priority, key).
private static final List<Gene> BUILTINS = List.of(
SEX, EXTENSION, AGOUTI, TEST, CHAMPAGNE, GREY, MATP,
MAGIC_ZEBRA, PINK_HAIR, DUN, SILVER, MUSHROOM, ROAN, TOBIANO,
EDNRB, KIT, MITF, PAX3);
There are no hand-written CODE_ORDER / NATURAL_ORDER
lists any more. Two people who drop the same gene files in a different order have
to get the same horses, so registration order is never respected.
Multiplicative restriction commutes — except where a gene sets
pigment absolutely. Bay sets
its points with setBlack(1.0) / setRed(0.0), and the
whole PigmentField.dilute design exists because the dilutions have
to run after that. So: absolute setters early, dilutions
later. Silver must sit after agouti or a bay’s mane will not
lighten, and nobody will know why.
Every ordering is derived: Genes sorts every registered
gene — built-in and data-driven alike — on
(priority(), key), and
naturalOrder() / magicalOrder() are that list filtered
by phase. Natural band 0–99, magical band 100+
(a convention — out of band only warns). Within the natural band,
give an absolute pigment-setter a low number and a dilution a higher one
— agouti (20) must run before PigmentField.dilute or a bay’s
mane will not dilute. The current natural order is extension (10) → agouti
(20) → silver (30) → mushroom (32) → dun (34) → MATP (40)
→ champagne (50) → grey (55) → roan (70) → tobiano (72)
→ EDNRB / frame (74) → KIT (76) → MITF (78) → PAX3 (79).
Pick a number in a gap; ties break alphabetically by key.
Then
- Write the gene up. Add a page under
wiki/modelled on an existing gene page, and add it to theSECTIONSarray inwiki/nav.js. - Give the page a preview window, if the gene paints. Two
lines, and no third:
It shows the gene on a black, a bay and a chestnut, spinnable, with a dice that rerolls the epigenetics. Everything it draws it asks the registry for at run time — the gene's name, one button per outcome that looks different, any condition the genotype resolves — so there is nothing in the page to keep in step with the gene, and nothing to update when it gains an allele. If your gene declares
<div class="gene-preview" data-gene="horsegenetics.yourgene"></div> <script defer src="gene-preview/gene-preview.js"></script>coatDependsOn, the window grows a row of buttons per modifier locus on its own. It runs the same wasm the horse designer does, so it needs the wiki served over http; seewiki/gene-preview/gene-preview.js. - Bake samples:
./gradlew :common:bakeCoatSampleswritesbuild/coat-samples/*.pngwithout launching the game. - Run the tests:
./gradlew :common:test. - Regenerate the golden file if the gene deliberately changes
an existing coat: delete
common/src/test/resources/coat-golden.txt, run the test, copycommon/build/coat-golden.txtback — and say so in the commit message. - Add an entry to To be verified saying what to look at in-game and where.
Testing a gene on its own
Because both hooks are pure, a gene is unit-testable against a synthetic coat
with no game and no composer — which is what
GeneCoatHookTest does:
// the code is gene-keyed and tolerant: name only what you care about,
// and every gene you leave out reads as its default allele.
Genotype g = Genotype.parse("horsegenetics.extension=E/E-horsegenetics.silver=Z/z");
Epigenome ep = Epigenome.fromSeed(1234L);
CoatBuildContext ctx = new CoatBuildContext(g, ep, Skin.ADULT, true);
PigmentField before = new PigmentField(HorseSkinGeometry.SHEET_SIZE);
PigmentField after = new SilverGene().restrict(pair, ctx, before);
// `before` must be untouched, and `after` must be a pure function of the inputs.
Things that will bite you
| Symptom | Cause |
|---|---|
| Your dilution leaves jet-black points | You scaled black without a blackTint. The gradient’s zero-red column reads black down to black ≈ 0.4. Use dilute. |
| Your effect should read grey and reads tan | You scaled both pigments, walking down the gradient’s diagonal, which runs through the golds. Walk toward the red = 0 column instead. |
| Your magical gene is invisible on a white horse | You added colour but not opacity. A transparent texel needs addOpacity or set. |
| Your pattern has a flat band on the chest, rump or leg fronts | A pure function of X puts every face perpendicular to X at one phase. Add a slant on |z| — or just use BodyStripes. If your stripes want to change direction by body region, that is a body map and not a slant: see ZebraStripes. |
| Your pattern has a visible seam at the shoulder | You sampled noise in texture space. Sample in body space with BodyNoise. |
| A marking ends in a perfect ring | A hard y <= cutoff cut. Use a smoothstep fade, as BayCoat does. |
| Two horses with different genotypes share a coat | A texture-id collision. KEY_BY_ID should throw — if it did not, your textureKey inputs are wrong. |
| Your gene works on adults and breaks on foals | The foal mesh has no MANE or MUZZLE, and its head / neck use rest-pose AABBs. Guard with hasPart. |
| Near-black texels in a cremello | Expected — that is the white template’s own shading surviving the multiply. Check PigmentField values, not composed pixels. |
Switching a gene off without deleting it
Sometimes a gene is wrong rather than broken — it loads, it paints, and what it paints is not worth shipping. Killswitching it means it cannot reach a horse or the wiki, while every file stays on disk so it can be picked up and retooled later.
For a gene that ships with the mod, that is one line: take its filename
out of common/src/main/resources/horsegenetics/genes/index.json. That file
is the only thing that registers a bundled gene, so removing the line is the whole of the
switch — the gene is not in Genes.codeOrder(), no horse carries it, it
has no carrot, and it is in neither gene editor.
Then re-run the three bake tasks, and the derived artefacts follow on their own:
./gradlew :common:bakeGeneBundle :common:bakeGeneIcons :common:bakeGeneWikiPages
./gradlew :web:bakeDesignerAssets
That drops it from the designer’s genes.json, from the generated spans
of pages.js and index.html, and from its landing card —
a gene page is registered by existing, so the page goes off the wiki’s
page list without being touched.
- Put a banner on the gene’s page. The file is still there and still reachable by a direct link, so without one it reads as a live gene. Say why it is off and what puts it back — wing cloak is the worked example.
- Keep something linking to the page, usually the
gaps entry explaining the decision. Otherwise
check-links.mjs --orphansis right to call it unreachable.
Both goldens will move, and the shape of the move is the check. The bake golden should lose exactly that gene’s lines and no others. The pipeline golden will show every line as changed, because a genotype code names every registered gene and yours has just left it — so compare the hash column, not the line: if the gene was wild-type in those cases, every hash should come back byte-identical. Anything else means switching it off changed a horse, which is worth understanding before you commit it.
Machine-facing brief
Written to be pasted into a capable model along with a reference image. Terse on
purpose. The two tables below are generated from
wiki/gene-creator/fixtures/expected.json, which
:common:bakeSpecFixtures writes straight out of SpecSchema -
that file is the authoritative machine-readable copy and is worth handing over
alongside this page if the model can read it.
The vocabulary
Masks — where
| Name | Parameters and defaults | Notes |
|---|---|---|
ALL | — | everywhere |
PARTS | parts:parts | named boxes. the ONE mask where the list is the mask, so an inverted PARTS means "everywhere except these" |
AXIS | parts:parts axis(Y|X|Z) space(part|body|units|local) from=0 to=1 softness=0.15 | soft band along one axis. space=local on a PITCHED part - see rule 7 |
CENTERLINE | parts:parts halfWidth=1 softness=0.35 offset=0 | stripe near z=0 |
STRIPES | parts:parts seed=0 spacing=3 duty=0.45 warp=1 | wrapping bands, chevroned |
DAPPLES | parts:parts seed=0 spacing=3.5 warp=0.45 edge0=0.35 edge1=0.78 | round cells with a web between |
PATCHES | parts:parts seed=0 scale=6 threshold=0.5 softness=0.12 | big irregular blobs |
NOISE | parts:parts seed=0 scale=8 low=0 high=1 | smooth shading; ALSO the threshold tool - coverage is clamp01(low+(high-low)*n), so low=-2.6 high=1 keeps only the top quarter |
FRACTAL | parts:parts seed=0 scale=6 octaves=3 lacunarity=2.13 gain=0.5 warp=0 shape(fbm|ridged|billow) threshold=0.5 softness=0.12 | PATCHES at several scales. octaves buys detail WITHOUT moving coverage (the sum is RMS-normalised), so 1 octave IS a PATCHES mask. shape=ridged turns the blob outlines into forking tapering LINES - the only lace here; 0.85/0.04 threshold/softness is a filament, 0.7/0.1 a ribbon. 2-3 octaves, not 6: past that the detail is under a texel and breaks into stipple - use warp for wander instead. scale is the COARSEST octave |
PATH | parts:parts plane(side|top|front) space(body|units) points:[u0,v0,u1,v1,...] pointsMin:[same length] curve=false closed=false fill=false width=1 softness=0.25 | a shape you DREW, 2 to 64 points, extruded through the horse. plane=side (x,y) appears on BOTH flanks; top (x,z) straddles the spine; front (z,y) rings the barrel. space=body normalises over the WHOLE-HORSE box, where the underline is v 0.33 and the SPINE is 0.62 - not 1.0 - and the flank is u 0.18-0.72. width/softness are body units in either space. curve=Catmull-Rom through every point; fill implies closed and makes width unread. pointsMin is the SAME shape at the gene's weakest - exactly as many points, each moving to its twin as the gene's dial knob runs from its min to its max, so the mark shrinks rather than fading; it needs a knob marked "dial":true. The only mask that carries a shape instead of a rule for one, so its shape is the same on every horse - pointsMin is the one thing about it that is not |
CHOICE | parts:parts seed=0 options=2 is=0 | the only BRANCH. draws one integer in [0,options) per horse and returns 1 where it equals is - the same answer on every texel of that horse. give each outcome a layer on the same seed with a different is |
PIGMENT | parts:parts channel(darkness|red|black|total) from=0.5 to=1 spread=0 spreadFrom(any|above|below|ahead|behind) | reads the PIGMENT LEVELS. channel=black tells a bay's points (0.85+) from its barrel (0.2-0.45); channel=darkness SATURATES on a bay (barrel 0.84, points 0.95). spread grows what the mask selected, after invert, in body units; spreadFrom=above grows the selection DOWNWARD (a drip), any is the isotropic disc |
LUMA | parts:parts channel(dark|light|white|saturation|red|green|blue) from=0.5 to=1 spread=0 spreadFrom(any|above|below|ahead|behind) | reads the RESOLVED COLOUR - after the gradient chart and after every magical gene before this one. white is NOT light: it is the achromatic floor, so bald white reads 1 and a palomino's gold near 0. prefer dark over light+invert, because invert also inverts what spread grows. MAGICAL genes only, never emissive - the parser refuses both |
EDGE | parts:parts width=0.6 softness=0.25 | the rim of each part's box, on the face the texel is on - a wireframe. the one mask that asks about the MODEL rather than about body space |
SPOTS | parts:parts seed=0 spacing=4 radius=0.9 vary=0.5 chance=1 stretch=1 axis(X|Y|Z) shape(round|heart) mirror=false arc=1 angle=0 offsetX=0 offsetY=0 offsetZ=0 softness=0.25 | countable marks. shape=heart. mirror=true samples |z|, the only way to get the same marks on both sides. arc<1 clips EVERY mark in the field to the same wedge centred on angle degrees - a tiling of those reads as SHINGLED (scales, feathers, tiles) rather than as a closed net. offsetX/Y/Z move where THIS mask reads the cell centre WITHOUT moving the cell, so one band of a concentric set can sit off-centre while the rest stay put |
RINGS | parts:parts seed=0 spacing=6 radius=2 thickness=0.6 vary=0.4 chance=1 arc=1 offsetX=0 offsetY=0 offsetZ=0 softness=0.2 | annuli; arc<1 opens them. offsetX/Y/Z as on SPOTS |
SPECKLE | parts:parts seed=0 spacing=0.7 size=0.45 density=0.5 clumping=0 clumpScale=7 softness=0.3 | fine stipple |
STROKES | parts:parts seed=0 spacing=3 length=12 axis(X|Y|Z) width=0.8 curl=0.35 softness=0.25 | tapering wandering lines. width MUST stay well under spacing |
SPIRAL | parts:parts seed=0 radius=4 turns=2 width=0.5 axis(Z|X|Y) offset=0 softness=0.2 | one figure per part, centred on its box - reads as a decal, avoid |
WAVES | parts:parts seed=0 axis(X|Y|Z) across(Y|X|Z) shape(sine|triangle|saw) space(part|body|units|local) from=0 to=1 wavelength=8 amplitude=0.5 spacing=0 phase=0 softness=0.15 | the only PERIODIC shape. axis is the direction the wave TRAVELS and across is the axis the BAND sits on (from/to measured along it) - the reverse of AXIS: a topline band with lobes hanging down is axis=X across=Y. spacing=0 is one edge; >0 repeats into ribbons. shape=triangle|saw gives STRAIGHT edges. wavelength is ALWAYS body units while amplitude is in whatever space says - which is how six genes displaced their band off the horse |
GOO | parts:parts seed=0 axis(X|Y|Z) across(Y|X|Z) space(part|body|units|local) from=0.75 to=1.6 spacing=4 drop=3 width=1.6 bulb=1.5 vary=0.6 chance=0.75 wobble=0.5 sag=0.6 softness=0.1 | drips: a band whose edge sags into separate runs, each with a bead at the tip, filleted into the band. axis/across as on WAVES; drips hang from the "from" edge away from "to". A DISTANCE FIELD, not a displaced line - vary gives every drip its own length and place, chance leaves cells empty, bulb is the tip radius as a multiple of the stem half-width. Use it for anything LIQUID; WAVES repeats one lobe forever and reads as triangles at barrel scale. Only from/to are in "space" - every other length is BODY units. Put "to" past the end of the part or the top of the back goes bare. sag arcs the edge up BETWEEN drips (0 = ruled line with beads on it). End faces (the rump, for a band along X) are carried round the corner from each side, or they come out a slab |
CRACKLE | parts:parts seed=0 scale=5 gap=0.5 warp=0.35 chance=1 softness=0.08 measure(wall|centroid) vertexWeight=0 | tiling polygons with an even channel - straight sides, 3-way corners. measure=centroid reads the SAME tessellation from each cell's middle out: it covers everything FARTHER than gap/2 BODY UNITS from the centre (the field runs 0 to about scale, walls near 0.55 of that) - so a central disc is it INVERTED, a ring [a,b] is gap=2a times gap=2b inverted, and gaps written as fractions of the cell select nothing. The lattice is 3D and most centres lie off the skin, so a small centre mark rarely shows; rings concentric with the OUTLINE are safer as bands of wall distance. It is the only per-cell radial coordinate a crackle outline is GUARANTEED concentric with, and the answer to "shade/band each irregular cell independently". vertexWeight blends in distance to the 3-way corners, so a threshold on the field pools at the junctions and thins between them - a vein network, which an even channel cannot be. A band of wall distance [a,b] is CRACKLE(gap=2a) MULTIPLY then CRACKLE(gap=2b) SUBTRACT; the crack itself is CRACKLE(gap=2a) inverted |
SVG | parts:parts d:svg transform:text viewBox:box plane(side|top|front) space(body|units) originU=0 originV=0 sizeU=1 sizeV=1 fit(meet|slice|none) align(xMidYMid|...) flipY=TRUE fill=TRUE fillRule(nonzero|evenodd) width=1 cap(butt|round|square) join(miter|round|bevel) miterLimit=4 dash=0 gap=0 dashOffset=0 softness=0.25 | an SVG path, drawn. The whole grammar: every command incl. S/T reflection and the elliptical arc, subpaths (so a letter O has a hole), fill rule, transform list, viewBox + preserveAspectRatio, and a stroke with caps, joins and dashes. Flattened once at load, so it costs what PATH costs. fill and flipY are the only two flags in the format that default TRUE - a <path> with no stroke is filled, and SVG's y runs DOWN. originU/V + sizeU/V are the viewport in the plane's own axes; fit=none stretches, which is what a marking meant to span the ribcage usually wants. Same on every horse, like PATH. Get one in with node intake/tools/svg-to-mask.mjs |
FAN | parts:parts plane(side|top|front) space(body|units) originU=0.5 originV=0.5 spacing=0.4 duty=0.5 twist=0 inner=0 outer=0 softness=0.15 | bars radiating from a point, WIDENING as they go - sunburst, gills under a cap, wing bars. The polar twin of WAVES: WAVES takes its phase from a straight line so its bars are parallel forever, whatever the wavelength. spacing is an ANGLE (the arc one cycle covers ONE body unit out), so a full turn holds 2*pi/spacing bars and 0.4 is about sixteen - a number that reads like a body-unit spacing puts three bars on the whole horse. twist rotates the zero with distance (sunburst vs pinwheel); inner/outer bound it, outer=0 never stops |
NORMAL | parts:parts axis(Y|X|Z) space(body|local) round=0 from=-1 to=1 | which way the SURFACE faces, dotted with a body axis - rim light, upward-plane wash, shell sheen. The 2nd mask after EDGE that asks about the model. round=0 is the FLAT face normal, which on boxes takes 3 values and gives hard zones; above 0 it blends toward the normal an ellipsoid of the same box would have, which is continuous - use 0.7-0.9 for a sheen. NOT iridescence: the coat is a baked texture and nothing here knows where a camera is, so it shifts with the surface, never with the viewer |
Ops — what
| Name | Parameters and defaults | Notes |
|---|---|---|
DILUTE natural | keepRed=1 keepBlack=1 blackTint=0 | wash pigment out |
RESTRICT natural | red=0 black=0 | remove pigment |
SET_PIGMENT natural | red=0 black=0 | drive pigment to a level |
WHITEN natural | amount=1 | white hair; the only honest way to white in phase 1 |
TINT magical | red=0 green=0 blue=0 opacity=100 | signed RGB percent |
TOWARD magical | color:color hue=-1 saturation=0.8 lightness=0.55 strength=100 opacity=100 | walk the VISIBLE colour toward one colour. negative strength = away |
FLAT magical | color:color hue=-1 saturation=0.8 lightness=0.55 opacity=100 | replace |
RAMP magical | colors:colors hue=0 hueSpan=60 saturation=0.8 lightness=0.55 axis(X|Y|Z|noise|cell|cellId) space(part|body|units|local) from=0 to=1 seed=0 scale=5 strength=100 opacity=100 | sweep along an axis. varies COLOUR, never coverage. space=local runs it along a pitched part's own length. THREE OF THE SIX AXES ARE NOT AXES: X/Y/Z are a straight line and so monotonic - the colour sweeps ONCE and never comes back. noise reads a smooth field, so it wanders and doubles back with no hard edge (oil slick, marbling); cell reads distance to the middle of the texel's OWN cell, so every cell fades independently; cellId reads one key per cell, constant across it, so neighbours take unrelated stops (the difference between iridescent and a gradient). All three read seed+scale and ignore space/from/to - point them at the mask's own seed and scale and the colour lines up with the shape |
PALETTE magical | colors:colors hue=0 hueSpread=40 saturation=0.8 lightness=0.55 seed=0 scale=5 strength=100 opacity=100 | one colour per cell of its OWN lattice - cells meet at a WALL. a hard edge no mask softness can remove |
INVERT magical | amount=100 opacity=100 | negate the visible colour |
Mask modifiers: combine (MULTIPLY default, MAX,
MIN, ADD, SUBTRACT) and invert
(boolean, applied before spread). Layer modifier:
emissive (how brightly it glows: true for full, or any
number / $knob in [0, 1], scaled by the mask's coverage;
magical phase only, and incompatible with a PIGMENT or
LUMA mask).
The fold starts at 1, and position decides what parts means.
The first mask defines the layer's region — outside its parts,
coverage is 0 and an invert does not recover it. Every later mask only
modifies that region, so outside its own parts it is skipped rather than
zeroing anything. PARTS is exempt: there the list is the mask.
They are all silent-failure shapes, so each error names the file, the layer and
the fix: a first mask combining by MAX or ADD (coverage
starts at 1, so it always returns 1 and the layer covers the horse); a
to that can fall below from, checked across the whole
range of any knob feeding either end; a LUMA mask in a natural gene;
a PIGMENT or LUMA mask in an emissive layer;
emissive on a natural gene; and a perDose triple in an
expression where every claimed combination carries the same number of copies of
the first-declared allele — which is a constant with two decorative
elements, and is how five genes once shipped invisible.
Template
{
"format": 3,
"key": "modid.mygene",
"name": "My Gene",
"phase": "magical",
"priority": 275,
"rarity": "RARE",
"blurb": "One sentence, what a player sees.",
"notes": [
"Optional. Prose for whoever opens this file next - why the numbers are the numbers,
what was tried and abandoned. Printed on the gene's wiki page as written. Most genes
have none."
],
"alleles": [
{ "token": "Myg", "label": "My Gene (Myg)" },
{ "token": "Myc", "label": "My Gene, coloured (Myc)" },
{ "token": "n", "label": "Wild-type (n)" }
],
"knobs": [
{ "name": "shapeSeed", "type": "seed" },
{ "name": "hue", "min": 0.0, "max": 360.0 },
{ "name": "size", "min": 1.2, "max": 2.4 },
{ "name": "extent", "min": 0.3, "max": 1.0, "dial": true }
],
"expressions": [
{
"id": "white",
"name": "My Gene",
"description": "What this outcome looks like, in prose.",
"notes": [ "Optional, and the same idea for one outcome: which layer does the work,
why it is drawn this way, what it collides with." ],
"when": [ "Myg/Myg", "Myg/Myc", "Myg/n" ],
"layers": [
{
"name": "what this layer draws",
"masks": [
{ "type": "AXIS", "parts": ["BODY"], "axis": "X", "space": "part",
"from": 0.5, "to": 1.0, "softness": 0.15 },
{ "type": "SPOTS", "parts": ["BODY"], "seed": "$shapeSeed",
"spacing": 4.0, "radius": "$size", "vary": 0.3, "chance": 0.6,
"softness": 0.2 }
],
"op": { "type": "TOWARD", "color": "#ffffff", "strength": 92 }
}
]
},
{
"id": "coloured",
"name": "Coloured My Gene",
"description": "The same shape in a colour the line carries. Fully recessive.",
"when": { "Myc": 2 },
"layers": [ "...same layers, op uses \"hue\": \"$hue\" instead of \"color\"..." ]
},
{ "id": "wild", "name": "Wild type", "description": "Nothing.", "wildType": true }
],
"founders": {
"Myg/Myg": 0.006, "Myg/Myc": 0.008, "Myg/n": 0.114,
"Myc/Myc": 0.010, "n/n": 99.862
}
}
The prompt
Copy everything in the box, paste it into Claude, attach a reference image and add one or two sentences describing the marking. It answers with the file, and with a tool gap section if — and only if — some element of the reference cannot honestly be drawn with the vocabulary above.
This block used to end “output the JSON only, no commentary”, and that
was right about the ninety per cent of the job that is transcription: a paragraph
of throat-clearing in front of a file you are going to paste into a directory is
pure cost. But the vocabulary is not finished, and the failure it was
buying was worse than the noise it was preventing — a model told to output
JSON and nothing else will always find something to emit, so the answer
to “draw a shape this format cannot express” came back as a confident
approximation with no sign attached that it was one.
CRACKLE, WAVES, FRACTAL, PATH
and CHOICE are all here because a person hit that wall and
said which measurement was missing instead of settling.
So the instruction is now conditional and it is narrow. Silence is still the default; the section is only for a shape the format genuinely cannot measure, it has a fixed shape so it is skimmable, and it has to end in a concrete mask design rather than in a complaint. The bar is deliberately the same one this page sets for a human author — a different measurement, not different tuning — because a proposal that clears it is worth the five-file contract and one that does not is worth an afternoon with the numbers instead.
You are writing one gene file for the Horse Genetics Minecraft mod. Output the
JSON. Add nothing after it EXCEPT the TOOL GAP section described at the end, and
only under the condition described there.
BODY SPACE. Right-handed, model units, 2 texels per unit on a 128x128 sheet.
X: 0 at the tail, + toward the nose. Y: 0 at the hooves, + up. Z: 0 at the
spine, + to the horse's right. Adult extents: whole horse x 0-41, y 0-33.7,
z -5..5. BODY x 7.5-29.5, y 11-21. NECK x 22.1-34.2, y 13.8-27.7. HEAD
x 28.1-36.7, y 24.2-32. MUZZLE x 34.2-41, y 21.7-28.5. MANE x 20.9-30.6,
y 18.2-33. TAIL x 0-10.5, y 5.9-20. Legs y 0-11: front x 25.4-29.4, hind
x 7.5-11.5. The barrel is 22 units long. A texel is 0.5 units.
PARTS: BODY NECK HEAD MUZZLE MANE TAIL LEFT_EAR RIGHT_EAR and the four legs.
Groups: ALL LEGS FRONT_LEGS HIND_LEGS EARS HAIR FACE POINTS BARREL.
IF YOU HAVE AN SVG. Use the SVG mask and paste the d string verbatim - every
command, both cases, as many subpaths as it has. Do not re-type it as PATH
points: that is where the holes close up and the arcs become chords. Copy the
viewBox and any transform across too; place it with originU/originV +
sizeU/sizeV in the plane's own axes, and reach for fit=none when the marking is
meant to span a whole region rather than sit proportionally inside it.
A LAYER IS "where x what": its masks fold into one coverage value 0-1 (the fold
starts at 1; the first mask multiplies into it, later ones combine by
MULTIPLY|MAX|MIN|ADD|SUBTRACT, each optionally "invert": true), and its op
applies scaled by that coverage.
RULES THAT ARE NOT OPTIONAL
1. REGION FIRST, SHAPE LAST. The list is a fold from 1. A region MAXed on AFTER
a shape mask yields max(shape,1)=1 on that region and the shape is lost. The
loader REFUSES a first mask combining by MAX or ADD.
2. A MASK'S "parts" MEANS DIFFERENT THINGS BY POSITION. The first mask defines
the layer's region: outside its parts, coverage is 0 and an invert does not
bring it back. A later mask only modifies that region, so outside its own
parts it is SKIPPED, not zeroed - which is how you write "this ground, minus
the throat". PARTS is exempt: there the list is the mask, so an inverted
PARTS means "everywhere except these".
3. STROKES width must be well under spacing or the field becomes a wash.
4. PALETTE has hard walls between its own lattice cells; never use it when the
marking must have no hard edge. RAMP has none.
5. RAMP varies colour along an axis, never coverage. To fade out, fade the mask.
6. smoothstep with to < from is a HARD STEP in the original direction, not a
reversed ramp. Keep from < to - including across the full range of any knob
you point at either end, which the loader checks and refuses.
7. PIGMENT reads the pigment LEVELS; LUMA reads the RESOLVED COLOUR, after the
gradient chart and after every magical gene before this one. They are
different questions: a chart can make a horse whose black corner is violet,
so "the parts that LOOK black" is LUMA "dark", not PIGMENT. On PIGMENT use
"black" to find a bay's points (body 0.2-0.45, points 0.85+); "darkness"
saturates on a bay (body ~0.84, points ~0.95). On LUMA, "white" is the
achromatic floor and is NOT "light" - a palomino's gold is bright and not
white. Prefer "dark" over "light" + invert, because invert also inverts what
"spread" grows. "spread" (body units) grows whatever the mask selected, after
invert; "spreadFrom" restricts the direction, and "above" grows the selection
DOWNWARD - which is how a marking drips. LUMA is MAGICAL-phase only and never
emissive; the loader refuses both.
8. A LAYER CANNOT SEE WHAT THE LAYER ABOVE IT PAINTED. Every layer of a magical
gene gets the coat as it was before this gene started, so "paint X, then mask
on X" does not work in the obvious order - write the second mask as the union
of both conditions instead.
The same fact means LAYERS DO NOT STACK, THEY SUM. Where two layers overlap,
each one's pull toward its colour is measured from the ORIGINAL coat and the
pulls are added, so a dark core painted inside a pale disc gives pale-plus-
dark, and three near-whites nested one inside the next clamp to one flat
white with nothing inside it. Every nested marking - eyes, bands, a flame's
tones, bars on a wing - must be written DISJOINT: each layer multiplies in
the INVERTED shape of every layer painted after it (a disc minus the ring
minus the core; a ring minus the core). For hard-edged masks that is exactly
the picture stacking would have drawn. Eight genes of one batch arrived
nested and every pale form came out a blank white shape.
9. The NECK, HEAD, MUZZLE, MANE, EARS and TAIL are PITCHED, so their bounding
box is not their box and a "part"-space band across one is a horizontal
slice - a COLLAR wrapping crest and throat alike. Use space "local", which
takes the pitch out: axis X is across the part's depth (low = crest and
poll, high = throat), axis Y is along its length (a band there is a collar
square to the neck). Fractions run outside 0..1 on a pitched part, so a
crest band starts at -1.0.
10. Size things in body units: structure inside a shape needs several units.
11. Each of these is the ONLY way to get what it gets. WAVES repeats evenly
ALONG A LINE; FAN repeats evenly AROUND A POINT, and its bars widen with
distance - a sunburst, gills, wing bars, anything with a shared origin.
WAVES shape=triangle|saw and CRACKLE give straight edges. FRACTAL
shape=ridged gives lines that fork, taper and pinch out (lace: threshold
0.85 softness 0.04 for a filament, 0.7/0.1 for a ribbon; 2-3 octaves, since
past that the detail is under a texel and stipples). PATH carries a shape
you choose rather than a rule for making one, and SVG carries a shape
somebody DREW - paste the d string, do not re-type it as points. CHOICE is
the only branch, and it is PER HORSE: it returns the same answer on every
texel, so it can never give one cell of a lattice a different colour from
the next (that is RAMP axis=cellId). EDGE and NORMAL are the only two masks
that ask about the model rather than about position - EDGE where a box
stops, NORMAL which way its surface faces. Everything else is noise-derived
and wanders.
12. FRACTAL's "octaves" is a DETAIL knob and nothing else - the sum is
RMS-normalised, so raising it does not move how much of the horse clears
"threshold". One octave is exactly a PATCHES mask. Use "warp" for an edge
that wanders rather than more octaves.
12b. A knob may be marked "dial": true - ONE per gene, per-horse, never a
seed - to say THIS is how much of itself the gene is showing. It changes
nothing about painting; masks still read it as "$name". It lets the editor
preview the gene at any strength, and it lets a PATH carry "pointsMin": the
SAME shape at the gene's weakest, exactly as many points, each moving to its
twin as the dial runs min to max. That is how a drawn marking gets SMALLER
on a weakly marked horse instead of fainter - the one thing a drawn shape
could not do. Mark the dial on any gene whose reference describes a range of
expression ("from a few flecks to a full blanket"), and point the thresholds
at it.
12c. A layer's "emissive" is HOW BRIGHTLY it glows, not whether: true is all
of it, and any number or "$knob" in 0..1 is less. The mask's coverage
scales it, so a soft edge fades the glow out and you never draw the falloff
twice. Magical genes only, never with a PIGMENT or LUMA mask, and two genes
over one texel take the brighter rather than summing.
13. PATH's space "body" normalises over the WHOLE-HORSE box, hoof to ear tips.
The underline is v 0.33 and the SPINE is v 0.62 - not 1.0. Points at 0.7
float above the back and appear on nothing. The flank is u 0.18-0.72,
v 0.33-0.62. width and softness are body units in either space.
14. Compose before inventing. A crescent = RINGS arc<1. A halo round bare coat
= big soft SPOTS x small sharp SPOTS inverted. Concentric zones = several
SPOTS on ONE seed+spacing+stretch+vary, which keeps them concentric AND in
proportion because vary scales them all by the same per-cell factor.
Symmetry down both flanks = SPOTS "mirror": true. A shingled tiling (scales,
feathers, roof tiles) = SPOTS arc<1 + angle, which clips every cell of the
field to the same wedge - drawing them closed gives a NET, which is the
wrong picture. One band of a concentric set sitting deliberately off-centre
= that band's offsetX/offsetY, which moves where it MEASURES from without
moving the cell. A band of a CRACKLE cell between two wall distances a and b
= CRACKLE(gap=2a) MULTIPLY then CRACKLE(gap=2b) SUBTRACT, all other numbers
identical; the crack itself is the narrowest reading inverted. Anything
per-cell that has to line up with a CRACKLE - a radial fade, a core, a
colour - is CRACKLE measure=centroid or RAMP axis=cell/cellId on the SAME
seed and scale, and is the only arrangement that is guaranteed to line up:
SPOTS and DAPPLES lay down their own centres and have never known where a
crackle wall fell.
15. SIZE IT AGAINST A TEXEL BEFORE YOU SHIP IT. A texel is 0.5 body units. Any
band, ring, gap or stroke narrower than that averages away to nothing, and
it does it silently - the file loads, the gene registers, and the horse has
no marking on it. Concentric rings 0.25 units apart is the commonest way to
write a gene that draws nothing.
16. TWO THINGS THIS FORMAT CANNOT DO, so do not propose them. It cannot vary
anything by the angle to the CAMERA: the coat is a texture baked once per
horse, and no mask or op will ever see a viewer. True iridescence is
therefore out of reach; NORMAL reads the direction the SURFACE faces, which
is the structural half of it, and that is the honest approximation. And it
cannot offset a knob by a constant - there is no "$hue + 180" - so a
complement, an opposite, or a second colour a fixed distance round the wheel
has to be a second knob or a fixed colour.
KNOBS are the per-horse values, stored on the allele copy and inherited.
Reference one as "$name" in any numeric field. type "seed" for noise seeds;
"per": "leg" gives each leg its own draw. Give every colour gene a hue knob
0-360 unless the colour is fixed by concept.
EXPRESSIONS: one entry per distinct outcome, "when" listing combinations (a
list of "a/b", or an object of exact copy counts). Exactly one entry may omit
"when" as the catch-all. NEVER put a perDose value in an expression whose
claimed combinations all carry the same number of copies of allele[0] - perDose
indexes on that count, so the triple is a constant, and the loader refuses it.
FOUNDERS: do NOT invent percentages. Declare a "rarity" tier - COMMON,
UNCOMMON, RARE, EPIC, LEGENDARY or MYTHIC - and let intake/tools/founders.py
derive and budget the table, because the rates compound across eighty-odd
genes. If you write a table anyway it must be percentages over every
combination summing to 100, with carrier-only pairs omitted entirely.
CONVENTION - THREE FORMS, NOT TWO. Most magical markings ship as a white form
on allele[0] and a recessive "coloured" form that draws the same shape with
"hue": "$hue" instead of "color". If the marking's palette is NARROW - it names
one or two fixed colours, or its whole idea is a shape rather than a colour -
give it a BLACK form too, as allele[1], between the two:
allele[0] white / pale shows on every pair that carries it
allele[1] black shows on every pair that carries it and no white
allele[2] coloured the recessive; usually the homozygote alone
allele[3] n wild type
Write the black form by copying the white one and re-toning it: every
ACHROMATIC target moves into the dark end of the scale, keeping the ORDER
and the SPACING of the tones it had, and every SATURATED target is left
alone, because that colour is the gene rather than its lightness. One tone
becomes one near-black around #1f1f1f; three tones become three steps
between about #0d0d0d and #707070.
This is a RECOMMENDATION and not a rule. Skip it where the colour IS the
gene - a marking whose whole idea is that it is gold, or coral, or ember -
because a grey version of that is a different marking wearing its name.
And give BOTH achromatic forms as many steps as the coloured form has. A
coloured form that separates its layers into a dark ring, a mid field and a
bright core, against a white form that paints all three off-white, is not
two versions of one marking - it is the good one and the flat one. Grade the
white form onto the coloured form's own per-layer ordering, in greys.
PROSE: "blurb" is one to three sentences for a PLAYER; an expression's
"description" is what THAT outcome looks like, also for a player. "notes" - on
the gene and on any expression, an array of paragraphs - is for whoever opens
the file next: why a number is that number, which layers must stay in order,
and anything the description asked for that you did something else about.
Write that down rather than dropping it; it is printed on the gene's wiki page.
PRIORITY, a rule of thumb: the MORE of a horse a gene covers, the LOWER its
priority. Later paints over earlier, so a whole-coat gene late erases
everything under it and a small mark early gets erased. Roughly: 100-199
whole-coat grounds, 200-299 large fields, 300-399 repeating figures over a
region, 400-499 fine overlays, 600+ anything reading the coat with LUMA or
PIGMENT. Leave gaps. Break it on purpose when the marking is meant to sit on
top of everything, and say so in "notes" - it is a rule of thumb, not a rule.
NOW: read the attached image and the description below, decide what KIND of
shape each element is, and write the file. Prefer composing existing masks.
TOOL GAP - the one thing you may say besides the JSON. If an element of the
reference cannot be represented with the masks and ops above, do not quietly
approximate it and say nothing. Ship the closest honest approximation, record
what it is in "notes", and then, after the JSON, add:
ELEMENT which part of the reference, one line.
SHIPPED the composition you actually wrote, and how it falls short - what a
viewer would see that is wrong.
WHY NOT why no arrangement of the existing masks reaches it. The bar is a
different MEASUREMENT, not different tuning. DAPPLES and SPOTS both
measure distance to a cell centre, so neither will ever draw a shape
defined by the wall BETWEEN two cells - that is why CRACKLE exists.
If the gap is that you have not found the right numbers, or that the
shape is specific rather than general (PATH already covers that), it
is not a tool gap and you should say nothing.
PROPOSAL a new mask or op in the same shape as the tables above: a NAME, then
every parameter with its kind and default, one line on what it
measures at a texel, and what coverage 0 and coverage 1 mean. Name
the existing mask it is closest to and the one thing it does that
that one cannot. If it is really a new PARAMETER on an existing
mask, propose it that way instead - it is a smaller change and it is
the more common answer.
Before you write one, check it against the list in rule 16 and against these,
which came back from the last batch and are now BUILT - proposing them again
costs somebody an afternoon reading a page they already wrote:
angle around a pivot, bars that widen -> FAN
which way the surface faces -> NORMAL (round>0)
a drawn shape with holes, arcs and a stroke -> SVG
a vein that pools at its junctions -> CRACKLE vertexWeight
a per-cell radial coordinate aligned to a
CRACKLE, or a core inside its own cell -> CRACKLE measure=centroid
one cell of a lattice differing from the next
in COLOUR -> RAMP axis=cellId
colour that wanders instead of sweeping -> RAMP axis=noise
one band of a concentric set off-centre -> SPOTS/RINGS offsetX/Y/Z
a tiled field clipped to a shingle -> SPOTS arc + angle
"black or grey, not other colours" -> LUMA channel=saturation,
inverted (it was already there)
At most two of these, for the two worst gaps. No commentary of any other kind:
not on the JSON, not a summary of what you drew, not alternatives you rejected
for taste. A TOOL GAP is a claim that the format is missing something, and
acting on it costs somebody a five-file change across the game and both editors.
DESCRIPTION: <<your one or two sentences here>>
The order to do it in
A gene is not finished when it parses. It is finished when the artefacts derived from it have been re-baked, because every one of them is checked in and fails silently when stale — a parity oracle that is a snapshot of the Java is green by definition once it is out of date, and a designer running yesterday's WebAssembly draws yesterday's horse. Work down this tab in order.
Verifying what comes back
./gradlew :common:test --tests "*GeneFilesTest*"— every gene file through the real parser, which is where a duplicate priority and each of the refusals above turns into a named error. One test class, not the whole suite. A traced SVG over the point ceiling fails here;node intake/tools/fit-svg.mjs <gene.json> --writeis the fix.- Add the file to
horsegenetics/genes/index.json. It is a hand-kept list and nothing generates it: a jar has no directory to walk, so a gene missing from the index loads in dev and vanishes in a build. python intake/tools/founders.pyif you let the tier set the founder table, which is the default advice —--checkreports without writing.- Bake and look:
./gradlew :common:bakeGeneIconswrites one horse per gene towiki/assets/gene-icons/. A gene that paints nothing gets no icon at all, which is the fastest possible failure signal. If it did paint but you cannot see it,node intake/tools/coverage.mjs <gene.json>says which layer reached which part, and by how much. - If it lands in the wrong place, paint colour bands and look — see the Gameplay tab. Do not reason about body space.
- Then the regeneration table: a new gene file
means
:common:bakeGeneBundle,:common:bakeGeneIcons,:common:bakeGeneWikiPages, and the coat golden file moves. - A natural gene’s wiki page must carry a
data-tab="science"panel — a page’sviewsare derived from the tab panels it actually has, so one without it is absent from the science view rather than empty in it.node wiki/tools/check-gene-tabs.mjsgoes red on the first page that forgets, and its allowlist is empty on purpose.
If the model came back with a tool gap, that is the point to read it — after you have seen the approximation on a horse and can judge whether the shortfall it describes is the one you are looking at. A proposal that survives that is a mask contract; one that does not is usually a number.
The spread-out contracts
Four things in this codebase are spread across four or five files each
(a new mask is five now, and six if it carries a parameter kind the format has not seen
before), and every one of them fails silently when you miss one: the game and the tools
simply start describing different genes, and nothing goes red. They moved here out of
CLAUDE.md, which is standing rules rather than reference — but
read the relevant one before you start, not after.
A new gene file
Not four files, but two — and the second one is the one everybody forgets.
common/src/main/resources/horsegenetics/genes/<slug>.json— the gene itself.…/genes/index.json— a hand-kept list of every file in that folder. Nothing generates it. A jar has no directory to walk, soGeneSpecLoader.fromClasspathreads the index instead: a gene missing from it loads perfectly in a dev run off the file system and is simply absent from a built mod. That is the worst shape of failure this codebase has — it works on the machine you tested it on.
Then the regeneration table in CLAUDE.md: a gene file means
:common:bakeGeneBundle, :common:bakeGeneIcons,
:common:bakeGeneWikiPages, :common:bakeMarkingFacts,
:web:bakeDesignerAssets, and the
coat golden file moves — adding a gene changes the registry order, which changes
every horse’s epigenome slots, which changes every hash in the file.
A new mask or op
SpecSchema.java— the parameter table, with the fallback each parameter takes when the file leaves it out.SpecPainter.java— what it actually does per texel.wiki/gene-creator/js/schema.jsandjs/spec-engine.js— the creator's mirror of both. The fallbacks must be identical: the creator drops any setting equal to its default, so a default that differs writes a file that plays differently from how it previewed. If the mask needs a new field out ofBodyNoiserather than one of the ones already there, that is a fifth file —wiki/gene-creator/js/noise.js, which is where the hash arithmetic is ported, and whereCRACKLE'scellEdgeandcellVertexboth had to go.- wiki/making-a-gene.html, the Masks & ops tab — the table a gene author reads — and the two terse tables on the Prompt tab, which are what a model is handed. Those are separate tables saying the same thing to different readers, and a mask that reaches only one of them is invisible to the other.
A mask that carries something other than numbers and words needs a
Kind. SVG needed three — the path data itself,
a free string for the transform list, and a four-number box for the
viewBox — and each one has to be read in
GeneSpecParser.readParams, mirrored in spec-model.js's
exporter (which decides what gets written back out), and given a control in
ui.js. A kind the exporter does not know about is silently dropped from
every file the creator writes.
A flag that defaults true is not the same change as a flag.
The creator omits any setting equal to its default, so a true-by-default flag has to be
written out when it is unticked — which means the fallback has to live in
the table rather than in the exporter, and it has to be compared by the parity check.
It was not compared at all until SVG's fill and
flipY arrived, because until then every flag in the format was false.
Then re-bake the fixtures and re-run parity, and make sure something in
example-genes/prismatic.json uses the new thing. That file
exists to exercise every mask and op once; without a case that reaches your addition,
check-parity.mjs is green about it by definition.
And check that a probe case actually reaches it — by breaking the port
on purpose. Not by reading the fixture and judging: reverse a sign in
spec-engine.js, re-run check-parity.mjs, and see whether it
goes red. If it stays green, the coverage you thought you had is imaginary. That is
not hypothetical — PATH's pointsMin was added to
prismatic's crescent, and the check was completely green with
the blend in the creator running backwards. The parity fixtures
sample four texels per part. A mask that covers most of the horse will be hit by one of
them; a drawing placed on the flank is a few dozen texels and will not, so a
prismatic layer using it can be green for the wrong reason. Where the new
thing is a self-contained piece of arithmetic — a path grammar, a fit, a distance
— bake it as an answer table in
SpecFixtureTool and compare it directly in parity.js, the way
the composer's numbers and the posed mesh already are. That is what
schema.svg in expected.json is: eleven path strings, each
exercising one thing that is easy to get subtly wrong, compared point for point.
A new effects verb
- A
recordonGeneAbility. - One
register(new AbilityType(...)). - A
casein the NeoForge translator —server/GeneAbilityHandler, orGeneYieldHandlerfor an interaction. - wiki/making-a-gene.html, the Effects tab — a section per verb, in the order they are declared.
A verb with a client-render component also needs a RenderLayer. None of
it touches SpecSchema or the parity check — effects do not paint.
A gene-carrot recipe
KnownGeneSpliceRecipe— what it requires.SpliceRecipeDisplay— the canonical slot layout.RarityItems— the tier-to-item table, deliberately outsidecommon/.wiki/gene-carrot/gene-carrot.js, which redraws both for the wiki.
The wiki cannot read the middle two — they are Minecraft-side — so a change there is silent on the pages until it is made in the JavaScript too.
A gene file
Adding or changing a file in
common/src/main/resources/horsegenetics/genes/ invalidates four baked
artefacts, all checked in: :common:bakeGeneBundle (the copy the browser
fetches, since TeaVM cannot walk a classpath index),
:common:bakeGeneIcons (one snapshot per gene, for its own page, its
landing-page card, and the hover cards in the horse designer, the breed designer and
the spawn egg - the jar picks them up at build time),
:common:bakeGeneWikiPages (the gene page itself, plus the generated spans of
wiki/pages.js and the landing page) and :common:bakeMarkingFacts
(the breed designer's measured coverage, parts and colour for every outcome, which its
markings filters read). It also moves
the coat golden file, because a new gene occupies a real position in the genotype
code.
What to build it with
The gene creator writes these files and previews them on a
horse using the same engine the game runs, so what you see is what breeds. It offers
every mask and op in the vocabulary, including the new ones — though a
PATH’s points are still a list of numbers there rather than
something you draw. It handles one visible outcome on a two-allele gene and says so
plainly when your gene is bigger than that — anything with more alleles or more
outcomes is hand-edited JSON. When you would rather describe the marking than build
it, the next tab is a prompt that hands the whole job to Claude.
common/genetics/spec/,
common/coat/pattern/SpecPainter.java,
common/genetics/spec/AbilityType.java,
common/genetics/spec/HorseAbilities.java,
neoforge-26.1.2/…/server/GeneAbilityHandler.java.