How a plugin gives its own mobs natural spawns, or changes the spawns of existing ones. How the spawner works inside is in spawning.md.
- Add
depend: MobPluginto yourplugin.yml, so the vanilla rules and categories are registered before youronEnable()runs. - Rules are keyed by the entity's network type id (
YourMob::getNetworkTypeId()). The spawner counts mobs by that id, so the two must match. - Register in
onEnable(). Rules registered or removed later apply from the next tick. - Register the entity itself with PocketMine's
EntityFactoryas usual: spawn rules only decide where and when it appears. - Nothing spawns, yours included, when the server owner turns
mob-natural-spawningoff.
Everything below lives under IvanCraft623\MobPlugin\spawning: the registries, rules and
enums at its root, conditions in condition\, population in population\, the JSON
loader in parse\ and VanillaMobCategories in parse\schema\.
SpawnRuleRegistry::getInstance()->register(new SpawnRules(
MyBoss::getNetworkTypeId(),
VanillaMobCategories::MONSTER,
[
new SpawnRuleGroup([
new BandCondition(SpawnBand::CAVE),
new BiomeTagCondition("mountain"),
new BlockLightCondition(0, 0),
new SpawnsOnBlock([VanillaBlocks::STONE(), VanillaBlocks::DEEPSLATE()]),
], weight: 100),
],
static fn(World $world, Vector3 $pos, SpawnRuleGroup $group) : Entity => new MyBoss(Location::fromObject($pos, $world)),
new EntitySizeInfo(2.0, 1.0)
));| Argument | Meaning |
|---|---|
| identifier | The entity's network type id. |
| category | Which population cap the mob counts against. See Categories. |
| groups | One or more SpawnRuleGroups. Every group whose conditions match competes in a weighted pick. |
| factory | Builds the entity at the position without spawning it; the spawner spawns it. An exception thrown here is not caught. |
| size | The mob's collision box as EntitySizeInfo(height, width). The spawner checks that it fits before calling the factory, so no entity is built just to test for room. |
register() throws an \InvalidArgumentException when the category is unknown, or when
the identifier already has rules and override: true was not passed.
A SpawnRuleGroup takes its conditions first; everything else is a named argument.
| Option | Default | Meaning |
|---|---|---|
weight |
1 |
Share of the weighted pick among matching groups. |
herds |
[] |
SpawnHerds: how many mobs spawn together, and the events they are sent. One is picked at random for each spawn. One mob alone when empty. |
rarity |
0 |
Once picked, the group spawns one time in this many. 0 for always. |
permutations |
[] |
SpawnPermutations of identifier, weight and optional creation event: each herd member spawns as one of these mobs, picked by weight, using that mob's registered factory. List the group's own identifier to keep it in the pick. |
requiredLiquid |
SpawnLiquid::NONE |
The liquid the feet must be in. NONE means out of any liquid; use WATER or LAVA for aquatic mobs. |
minPlayerDistance, maxPlayerDistance |
24.0, 128.0 |
Distance to the nearest player, in blocks. 0.0 and INF for no bound. |
surfaceDensityLimit, caveDensityLimit |
null |
Most mobs of this type allowed around the position. null for no limit. |
A SpawnHerd can name events: event goes to the members from eventSkipCount on, and
initialEvent to the first initialEventCount. A SpawnPermutation event goes to that
mob as it is created. Once the whole herd is built, the events are sent and the herd spawns.
SpawnEventRegistry::getInstance()->register(
"myplugin:spawn_enraged",
MyBoss::class,
static fn(MyBoss $boss, SpawnEventContext $ctx) => $boss->setEnraged(true)
);minecraft:entity_born is handled out of the box: the member spawns as a baby.
A handler runs for mobs of its class and their subclasses, the nearest class winning.
SpawnEventContext gives the herd built so far, the mob's index in it and the group.
Register in onEnable() after your entities. An event nothing handles is ignored.
register() throws when the class already handles the event. The vanilla event names are
the constants of VanillaSpawnEvents.
Events that set a variant (the horse's make_*, the tropical fish's become_*) are
registered from a map: extend SpawnEventMap and give it one event for each case, then
register them all at once.
/** @extends SpawnEventMap<HorseColor> */
final class HorseColorSpawnEventMap extends SpawnEventMap{
use SingletonTrait;
private function __construct(){
foreach(HorseColor::cases() as $case){
$this->register(match($case){
HorseColor::WHITE => VanillaSpawnEvents::MAKE_WHITE,
// ...
}, $case);
}
}
}
SpawnEventRegistry::getInstance()->registerVariants(
Horse::class,
HorseColorSpawnEventMap::getInstance()->getAll(),
static fn(Horse $horse, HorseColor $color) => $horse->setColor($color)
);The match has to name every case, so PHPStan reports a case left out and a constant that
no longer exists.
A group spawns only where all its conditions pass.
| Condition | Passes when |
|---|---|
BandCondition(SpawnBand) |
The position is on the surface (SURFACE: on a column's ground) or underground (CAVE: anywhere below it). |
BiomeTagCondition(string $tag) |
The biome has the tag. See Biome tags. |
DifficultyCondition(int $min, int $max) |
The world's difficulty is in the range (World::DIFFICULTY_*). |
BrightnessCondition(int $min, int $max) |
The light level, sky included, is in the range (0 to 15). |
BlockLightCondition(int $min, int $max) |
The light from blocks alone is in the range. |
HeightCondition(?int $min, ?int $max) |
The block the mob stands on is in the Y range (and its feet too, in a liquid). null for no bound. |
WorldAgeCondition(?int $min, ?int $max) |
The world's age in ticks is in the range. |
SpawnsOnBlock(Block[] $blocks) |
The block under the feet is one of the blocks. Blocks match by type and variant, not by placement state. |
SlimeChunkCondition() |
The chunk is a Bedrock slime chunk. |
LightChanceCondition(int $bound) |
A roll that is likelier the lighter the position is: never at light 0, always at $bound or above. |
MoonPhaseChanceCondition() |
A roll that is likelier the fuller the moon is. |
AllOf(array), AnyOf(array), Not(SpawnCondition) |
Combine other conditions. Not is how any of the above is negated: new Not(new SpawnsOnBlock([...])) forbids those blocks. |
Two things are easy to miss:
- Surface or underground is a condition. A group without a
BandConditionspawns in both. - Nothing is added for you. Vanilla monsters only spawn in the dark because
MobPlugin adds
new BlockLightCondition(0, 0)to its own. A custom monster needs it in its groups, as in the example above.
Implement SpawnCondition:
final class NightCondition implements SpawnCondition{
public function test(SpawnConditionContext $ctx) : bool{
$time = $ctx->getWorld()->getTimeOfDay();
return $time >= World::TIME_NIGHT && $time < World::TIME_SUNRISE;
}
}SpawnConditionContext gives the position (getX(), getY(), getZ()), the light
(getLight(), getBlockLight()), the block under the feet (getBelowItemStateId()),
the distance to the nearest player, the world age (getTime()), the population around
the position (getPopulation()), hasRoomFor($size), the random source (getRandom())
and the world itself (getWorld()).
Every condition must be immutable (make every property readonly) and free of side
effects: test() changes nothing, doesn't depend on which conditions ran before it, and
takes any randomness from $ctx->getRandom().
A plain SpawnCondition is always correct. CacheableCondition is an optimisation for
one specific case, and using it anywhere else gives wrong spawns.
The spawner sorts positions into kinds by four values: the biome, the band
(surface or cave), the world's difficulty and the liquid at the feet. A
CacheableCondition is asked once per kind, and its answer is reused for every later
position of that kind, without calling it again.
So the question to ask is: could two positions with the same biome, band, difficulty and liquid ever get different answers?
- No: implement
CacheableCondition. It only receives those four values, throughCacheableConditionContext. - Yes: implement
SpawnCondition.
| The condition reads | Interface |
|---|---|
| The biome, the band, the difficulty or the liquid at the feet | CacheableCondition |
| The position, the light, the block below, the time, the population, the world | SpawnCondition |
| A random roll | SpawnCondition |
| Something of your own that changes rarely, like a config option | CacheableCondition, see below |
final class DesertCondition implements CacheableCondition{
public function test(CacheableConditionContext $ctx) : bool{
return $ctx->getBiomeId() === BiomeIds::DESERT;
}
}A cacheable condition may also depend on its own constructor arguments, since those never change. If it depends on anything else that can change while the server runs (a config option, a list your plugin edits), the reused answers go stale. Tell the spawner to ask again whenever that happens:
SpawnRuleRegistry::getInstance()->invalidateCache();A custom combinator should implement ReducibleCondition or extend CompositeCondition,
so the cacheable conditions inside it are still decided once; otherwise it runs whole on
every attempt.
BiomeTagCondition tests the shared BiomeTagMap, which starts from the bundled Bedrock
data (forest, mountain, ocean, monster…). Tag a custom biome, or add your own tag
to a vanilla one:
BiomeTagMap::getInstance()->addTag($biomeId, "myplugin:haunted");removeTag() takes one away. Both apply from the next tick.
A category caps how many of its mobs may be around a position, over the 9×9 chunks around it, separately on the surface and underground. Once the cap is reached nothing of that category spawns there. A cap of 0 means never in that band.
VanillaMobCategories |
Surface cap | Cave cap |
|---|---|---|
MONSTER |
8 | 16 |
ANIMAL |
4 | 4 |
AMBIENT |
0 | 2 |
WATER_ANIMAL |
36 | 36 |
CAT |
4 | 0 |
Register your own, or replace a vanilla one to change its caps:
MobCategoryRegistry::getInstance()->register(new MobCategory(
"myplugin:spirit",
surfaceCap: 2,
caveCap: 6
));Rules hold the category's id, so replacing a category applies to the rules already registered.
$registry = SpawnRuleRegistry::getInstance();
// Stop creepers from spawning naturally.
$registry->unregister(EntityIds::CREEPER);
// Add a condition to every zombie group.
$zombie = $registry->get(EntityIds::ZOMBIE);
if($zombie !== null){
$groups = array_map(
static fn(SpawnRuleGroup $group) : SpawnRuleGroup => $group->withConditions([new NightCondition()]),
$zombie->getGroups()
);
$registry->register(
new SpawnRules($zombie->getIdentifier(), $zombie->getCategoryId(), $groups, $zombie->getFactory(), $zombie->getSize()),
override: true
);
}EntityIds here is IvanCraft623\MobPlugin\data\bedrock\EntityIds.
$counts = MobPopulation::getInstance()->around($world, $chunkX, $chunkZ);
$counts->getCategoryCount(VanillaMobCategories::MONSTER, SpawnBand::CAVE);
$counts->getIdentifierCount(EntityIds::ZOMBIE, SpawnBand::SURFACE);around() scans 81 chunks on every call: keep its result. Inside a condition use
$ctx->getPopulation(), which the spawner has already counted.
Only entities whose identifier has registered rules are counted. A mob you spawn your own way is given a band from where it stands when first counted; to choose it yourself:
MobPopulation::getInstance()->getBands()->set($entity, SpawnBand::SURFACE);SpawnRulesParser reads Bedrock spawn rules: one JSON object keyed by entity identifier,
each value a minecraft:spawn_rules file as Mojang ships it
(resources/bedrock/spawning/spawn_rules.json is an example).
$parser = SpawnRulesParser::createVanilla();
foreach($parser->parseFile($path) as $identifier => [$categoryId, $groups]){
// $factory and $size: yours, for the mob named by $identifier
SpawnRuleRegistry::getInstance()->register(new SpawnRules($identifier, $categoryId, $groups, $factory, $size));
}The parser gives the category and the groups; the factory and the size are yours to
supply. It is strict: anything it can't compile throws SpawnRulesParseException with
the JSON path. A group needs minecraft:spawns_on_surface, minecraft:spawns_underground
or both; one with neither is dropped.
Your own JSON components are registered on the parser before parsing:
$parser->registerComponent(
"myplugin:my_filter",
static function(ComponentParseContext $ctx, SpawnRuleGroupBuilder $builder) : void{
$builder->addCondition(new MyCondition($ctx->map(MyFilterData::class)->field));
}
);ComponentParseContext reads the component's payload: map() and mapList() into a
class of typed public properties, resolveBlocks() for block names, and getValue(),
objectOrList() and getPath() for anything else. SpawnRuleGroupBuilder sets what the
component means for the group: addCondition(), setWeight(), setRarity(),
setHerds(), setPermutations(), setLiquid(), setPlayerDistance(),
setDensityLimit(), allowHabitatBand() and markNeverSpawns().