- Java 100%
|
Some checks failed
Verifying Everything Compiles / build (push) Has been cancelled
|
||
|---|---|---|
| .github/workflows | ||
| gradle/wrapper | ||
| src/main | ||
| .gitignore | ||
| ARCHITECTURE.md | ||
| build.gradle.kts | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| README.md | ||
| settings.gradle.kts | ||
Boss Resources
A Hytale server plugin that adds boss resource nodes — large ore and tree clusters that behave like a single gathering encounter rather than a pile of individual blocks.
Inspired by the boss ores and boss trees in Fantasy Life. A node is a cluster of blocks sharing one health pool. Its body is impervious: damage only lands through weak points on the surface, and each weak point wears out after a few hits and reappears somewhere else on the node. You cannot stand still and grind it down — you have to keep finding the next spot.
What a boss is lives entirely in JSON. Blocks, geometry, tuning, loot, effects and progression are all definition fields, so a new ore or tree is a file rather than a code change.
Reading or extending the code?
ARCHITECTURE.mdis the detailed walkthrough — this document is for playing with it and authoring content.
Status
Early but working. The mechanic runs end to end, nodes persist across restarts, and content is fully data-driven. The main gap is that nodes are placed by command rather than generated into the world. See Known limitations.
Requirements
- Java 25 (JetBrains Runtime recommended, for hot reload)
- A Hytale server in the
>=0.5.3 <0.6.0range
Quick start
./gradlew setupHytaleDev # one-time: fetch server + assets
./gradlew runServer # run a dev server with the plugin loaded
In game, in Adventure mode (see the warning below):
/bossnode # Starcrystal_Fire, the default
/bossnode --type=BossOak # a boss tree
/bossnode --type=Starcrystal_Fire_Elite # every mechanic turned on
/bossnode --type=Starcrystal_Fire_Colossus # a big node, where relocation and splitting read
--type=is required syntax, not a nicety.typeis aDefaultArg, which the command framework binds by name — onlyRequiredArgis positional. A bare/bossnode BossOakdoes not fail; it silently places the default node, and whatever you were trying to test is simply absent.
The node appears on the ground in front of you, speckled with weak points. Mine a weak point and the
remaining health is reported as a percentage; mine the body and nothing happens at all. The command
needs the hytale:Builder permission group.
Any pickaxe works on the crystals, a hatchet on the tree; bare hands do neither. See Tools and tiers.
Adventure mode only. In Creative,
BreakBlockInteractioncallsperformBlockBreakdirectly and never firesDamageBlockEvent, so the mechanic is bypassed entirely. Breaking any node block that way tears the whole node down rather than leaving an orphaned cluster behind.
What ships
| Definition | Shape | Notes |
|---|---|---|
Starcrystal_Fire |
prefab | hand-built crystal formation, custom loot table, mining |
Starcrystal_Fire_Elite |
prefab | the same formation with every mechanic on — but see the note below on what a 3×3×3 node can and cannot express |
Starcrystal_Fire_Colossus |
prefab | a 7×7×7 formation, 127 blocks, 44 markers — big enough for relocation and splitting to read |
Starcrystal_Fire_Colossus_* |
prefab | six variants, one per relocation pattern — _Random _Adjacent _Opposite _Orbit _Vertical _Spread |
Starcrystal_Fire_Pillar |
cylinder | same crystal as a column; inherits everything else |
BossOak |
cylinder | boss tree, woodcutting, physics-inert trunk and leaves |
Plus the blocks they use, a Prefab_Weakpoint_Placeholder marker for authoring, and two progression
stats (Mining_Power, Woodcutting_Power).
Node size decides which mechanics can express anything. Relocation patterns exist to make the player traverse the boss, and splitting exists to make them choose between targets — neither means much when every block is within arm's reach. On a 3×3×3 formation
Orbit,Opposite,SpreadandAdjacentare mechanically indistinguishable, and fragments land close enough to be a convenience rather than a decision. What does work at that scale is decoys, hit budget, hardness and knockback — none of which need space. Build big if you want the patterns to read.Height is the other half of it. Weak points above about three blocks are not a challenge, they are an errand — you cannot reach them without building a step, and doing that once is novel and doing it six times is tedious.
Starcrystal_Fire_Colossusis seven blocks tall but every one of its markers sits in the bottom three layers; the crown is silhouette, not gameplay.
How it works
The node is ordinary blocks plus plugin logic — not one large multi-block model. That choice is what makes weak points possible: they are a different block type, so they are visible at a glance and can be moved at runtime by swapping a block.
The node owns its health; the engine does not. Every DamageBlockEvent on a node block is
cancelled, so the engine never damages or breaks these blocks itself. BossNode keeps a single float
from 1.0 down to 0.0 and shatters the cluster when it runs out.
That matters for a specific reason. The engine resolves its block-health storage from the chunk of the block that was hit, so an earlier design that redirected damage onto a central core block opened a second, invisible health pool whenever a weak point sat in a different chunk from the core. Owning the number removes the whole class of problem — and costs nothing that was ever visible, since the core is buried inside the node and its crack overlay was never on screen.
Damage is scaled, not replaced: event.getDamage() is the engine's own figure derived from the
held tool, so dividing it keeps every tool's relative strength intact.
| Class | Role |
|---|---|
asset/BossResourceType |
a boss definition, loaded from Server/BossResources |
asset/NodeEffects, NodeEffect |
particles and sounds |
asset/NodeRegen |
recovery while the node is left alone |
asset/NodeTelegraph, WeakPointSplit, DecoyConfig |
the weak point mechanics, one config each |
shape/NodeShape + Sphere/Cylinder/Prefab |
geometry, and which blocks may be weak points |
shape/WeakPointArea |
optional restriction on top of a shape's surface |
relocation/RelocationPattern + six patterns |
where the next weak point appears |
bossnode/WeakPoint |
one weak point: its blocks, hits, and whether it is real |
bossnode/BossNode |
one placed node: health, live weak points, geometry |
bossnode/BossNodeChunk |
chunk component that persists the nodes cored in a chunk |
bossnode/BossNodeRegistry |
block position → node lookup |
bossnode/BossNodeCommand |
/bossnode --type=<id> |
bossnode/BossNodeDamageSystem |
the whole mining interaction |
bossnode/BossNodeBreakSystem |
safety net for nodes broken behind the plugin's back |
stats/GatheringStats |
gathering power = gear + the node's power stat |
Nodes persist as a ChunkStore component, so they are written to disk with their chunk and come back
on load. Only the type id, position, health and live weak points are stored — block membership is
recomputed from the shape.
Weak points
BossNode holds the shape's eligible surface blocks and a list of live weak points. A weak point is
an object, not a position: it may cover several blocks, it may be a decoy, and it may be telegraphed
— marked and visible but not yet hittable. When one is used up it reverts to the node's ordinary
material and something takes its place.
The choice is deliberately blind to where the player is standing. Steering replacements towards the miner would work against the reason weak points expire at all, and it keeps the result independent of who lands the final hit — which matters as soon as two people work the same node.
Recently spent positions are barred, so a weak point never comes back on the block it just wore out on:
| Field | Default | Meaning |
|---|---|---|
WeakPointCount |
5 | how many are active at once |
HitsPerWeakPoint |
20 | hits before one wears out and moves |
HitsPerWeakPointMax |
— | set above HitsPerWeakPoint to roll a range instead of a fixed count |
WeakPointSize |
1 | blocks each weak point covers |
WeakPointMemory |
3 | recently spent positions barred from reuse; 0 disables |
A fixed hit budget is countable. Three hits per weak point means an experienced player stops watching the node and starts counting swings; a range of 3–5 costs one field and puts their eyes back on it.
WeakPointSize above 1 grows a cluster outward from the chosen block over adjacent candidates, face
neighbours only, so it reads as one wound rather than two weak points that happen to be near each
other. It is always a request: a prefab whose markers sit one block apart yields small weak points
however large a number you author.
The contrast that matters is weak point vs body, not anchor vs cluster. A weak point should be obvious against the node's ordinary material, and every block of one cluster should look identical to every other — otherwise a three-block weak point reads as three things rather than one.
BlockType.Tint is the cheapest way to get there. Starcrystal_Fire_Weakpoint is the body block's
texture with one field added:
"Textures": [{ "Sides": "BlockTextures/Rock_Crystal_Red.png", … }],
"Tint": ["#c06a2e"]
Same crystal, gone darker. That is the whole difference, and it holds from any angle and at any distance — which a gem lump on the top face does not.
Tint is a Color[] that sets all six faces at once; TintUp/TintDown/TintNorth/… exist if you
want per-face control. Adjusting the shade is one hex value, so dialling a new node in is: copy the
body block, add a tint, done.
Cluster membership is shown by behaviour rather than by paint: the Hit effect fires at every
block of the weak point that was struck, so hitting one corner of a three-block cluster makes all
three react at once. That says "these are one target" far more clearly than a colour difference, and
it costs no extra block types — it works on any node, including ones that never author a variant.
An earlier attempt tinted only the non-anchor blocks of a cluster instead. It read badly — the centre and the edges of one weak point looked like different things, which is the opposite of the intended message. The mechanism survives as the optional
WeakPointLinkBlockfield below, but neither shipped node uses it.
WeakPointLinkBlock (optional) paints every block of a cluster except its anchor. Reach for it
only if a node genuinely needs cluster boundaries to be visible — a very large WeakPointSize, or two
clusters that routinely end up adjacent. Keep the difference tiny if you do; the failure mode above is
easy to fall into. It applies to decoys as well, deliberately, since link blocks on real weak points
only would be a free answer to which cluster is worth hitting.
The memory is a preference, not a constraint: a shape with too few eligible blocks takes as much
separation as it can afford rather than running out of weak points and becoming unkillable. You need
more eligible blocks than WeakPointCount for it to bite at all, and at least
WeakPointCount + WeakPointMemory for it to always be honoured.
WeakPointArea narrows where they may appear, on top of whatever the shape offers — a sphere's whole
shell is reachable, but you rarely want weak points on the crown where nobody can stand:
"WeakPointArea": { "MinY": -2, "MaxY": 1, "MaxRadius": 3 }
Bounds are offsets from the node's centre; omitted bounds are unlimited.
Relocation patterns
Where the next weak point appears is the knob that makes two nodes of identical size and hardness
play differently. Polymorphic on a Type discriminator, like shapes:
"Relocation": { "Type": "Orbit", "StepDegrees": 90, "Clockwise": true }
| Type | Fields | Plays like |
|---|---|---|
Random |
— | unpredictable; the default and the baseline the others are read against |
Adjacent |
MaxDistance (2) |
creeps a block or two; you follow a seam and never reposition |
Opposite |
KeepHeight (true) |
mirrors through the core; every break costs you the walk around |
Orbit |
StepDegrees (90), Clockwise (true) |
circles at a fixed pace — the only predictable one |
Vertical |
Direction (Up), Wrap (true) |
climbs or descends a layer at a time; a tree felled upward |
Spread |
— | maximises distance from every live weak point; a co-op pattern |
Patterns only express preference. The candidate list they are handed is already the shape's
surface narrowed by WeakPointArea, minus everything occupied and recently spent — so a pattern
cannot produce an illegal position, and adding one is a class plus a registration in setup().
Opposite with KeepHeight: false also flings the weak point from your feet to over your head,
which reads as two punishments for one break. Orbit deliberately prefers to stay at the height it
is at, or a "quarter turn" on a tall node would satisfy itself six blocks up and the circle you were
being taught to walk would not be a circle.
Telegraphs
The next weak point can be marked before the current one is gone:
"Telegraph": { "At": 2, "Block": "Starcrystal_Fire_Telegraph" }
At is how many hits are left when the warning appears, so 2 makes the last two hits the tell.
Without it, a break is a small punishment — the weak point vanishes and you go looking. With it, those
last hits become a decision: keep swinging, or start moving now and arrive as it opens.
Block is optional, and both shipped nodes leave it unset. A block swap makes the warning
unmissable, but it does so by replacing part of the node with something that is visibly not the node —
which on a hand-built prefab means overwriting whatever you placed there, and reads as a foreign
object stuck on rather than the crystal itself about to give. Particles coming out of the node's own
material say the same thing without lying about what the block is.
So the warning is the Telegraph entry in Effects: it plays at the marked position when the warning
starts and again on every hit while it stands. Since the player is hitting several times a second, one
short burst re-triggered at that rate reads as a sustained pulse with no ticking system involved — and
it costs nothing while nobody is mining.
Because the particle is then the entire warning, give it some weight: the shipped nodes use
Block_Break_Crystal tinted pure white at scale 2, which reads as the block fracturing from the
inside. Keep Block in reserve for a node whose material is so busy that particles get lost in it.
Splitting
A weak point can shatter into smaller ones instead of relocating:
"WeakPointSize": 4,
"Split": { "Into": 2, "Size": 2, "Depth": 1, "HitsScale": 0.5 }
The node opens as one big obvious target and gets busier as it dies. Fragments prefer the blocks their parent stood on, so they read as debris rather than as unrelated weak points that appeared nearby.
Fragments are placed through the definition's relocation pattern, measured from where the parent
was — exactly like any other weak point. So a Spread node scatters its fragments apart and an
Opposite node throws them across the boss. They are not clustered around the parent: that made the
split the one event on the node that ignored the rule the player had just been taught, and it read as
the weak point never having moved at all.
Depth is the safety rail: the count multiplies each generation, so Into: 3 at Depth: 3 is up to
27 live weak points from one. It defaults to 1 — split once, then behave normally. The other rail is
geometry: a split yields only as many fragments as there are free candidate blocks, so a cramped
prefab quietly produces fewer rather than failing. Note that splitting raises the live weak point
count for the rest of the node's life; it is never trimmed back to WeakPointCount.
If a split is standing on a telegraph, the telegraphed position becomes one of the fragments and the rest fill the remaining slots — the warning is never discarded.
Decoys
False weak points that take hits without transmitting them:
"Decoys": { "Count": 2, "DamageMultiplier": 0 }
The tell is deliberately not the block. Leave Block unset and a decoy uses the node's own
WeakPointBlock and is pixel-identical at rest. What gives it away is that a decoy never
telegraphs — a real weak point running out marks its successor and starts glowing, and a decoy has
nothing to mark. A player who has learned to read the telegraph can pick decoys out; one who has not,
cannot. That is a better puzzle than a block with a different tint.
DamageMultiplier defaults to 0 and is clamped to 0–1. Small non-zero values make decoys merely
inefficient rather than worthless. It cannot go negative: a decoy that healed the node would be a
wall for anyone who cannot yet tell the difference.
| Field | Default | Meaning |
|---|---|---|
Count |
0 | live decoys at once |
Block |
WeakPointBlock |
block id, for authors who want an easier read |
Hits |
HitsPerWeakPoint |
hits a decoy absorbs before moving |
DamageMultiplier |
0 | fraction of normal damage it transmits |
Erupting weak points
A breaking weak point can set off an explosion, using the engine's own ExplosionConfig embedded
whole — so you get damage, falloff, knockback, particles and sound in exactly the shape they are
written everywhere else in the game, and anything Hytale adds to explosions later arrives here free:
"Erupt": {
"DamageEntities": true,
"DamageBlocks": false,
"EntityDamage": 9.0,
"EntityDamageRadius": 4,
"EntityDamageFalloff": 1.5,
"Knockback": { "Type": "Point", "Force": 5, "VelocityType": "Set", "VelocityConfig": { … } }
}
Set "DamageBlocks": false. It defaults to true in the engine's config, which on a boss node
means the eruption chews holes in the node itself and leaves its block membership disagreeing with the
world. true is only right if you are deliberately building something that collapses as it is mined.
Nobody is exempt from the blast — the player who broke the weak point is standing right next to it and is precisely who it is for. Deaths are attributed to the node, not to the player's own pickaxe.
Shapes
Geometry is polymorphic on a Type discriminator:
"Shape": { "Type": "Sphere", "Radius": 3 }
"Shape": { "Type": "Cylinder", "Radius": 2, "Height": 9, "WeakPointSurface": "Side" }
"Shape": { "Type": "Prefab", "Prefab": "starcrystal_fire", "Marker": "Prefab_Weakpoint_Placeholder" }
Each shape decides what counts as its own "surface" — the band weak points are drawn from.
| Shape | Surface | Anchoring |
|---|---|---|
Sphere |
the one-block-thick outer shell | centred on the node position |
Cylinder |
Side (curved wall), Caps (flat ends) or All |
centred vertically |
Prefab |
wherever you placed marker blocks | stands on the node position |
A Cylinder with Side reads as a trunk you circle; with Caps, as a stump you strike from above.
Tools and tiers
Tool gating is not implemented in Java — Hytale already does it, and the plugin just avoids getting in the way. Two asset fields drive everything:
- The block's
Gathering.Breaking.GatherTypeandQuality— the requirement - A tool's matching tool spec
PowerandQuality— what the tool offers
BlockHarvestUtils.getSpecPowerDamageBlock finds the tool spec matching the block's gather type and
returns null when spec.getQuality() < requiredQuality. A null spec means zero power, which makes
performBlockDamage bail before DamageBlockEvent is ever fired — the player gets the vanilla
"unbreakable block" particle and sound instead.
Because weak points are the only damageable blocks, the weak point block's gathering config is the
tier gate for the whole node. Starcrystal_Fire_Weakpoint asks only for a pickaxe:
"Gathering": { "Breaking": { "GatherType": "OreCopper" } }
The corollary catches people out: the body block's gathering config is irrelevant, because the body
is never damageable. Giving an OreBlock a demanding GatherType gates nothing.
The vanilla quality ladder is sparse. Across every vanilla pickaxe tier,
Qualityis declared on exactly one spec —OreAdamantite: 4, on Cobalt and Thorium picks. Every other ore spec omits it. So against vanilla tools there are only two meaningful settings: omitQuality(any pickaxe works) or requireQuality: 4(Cobalt/Thorium onOreAdamantiteonly; everything else does literally nothing and never even firesDamageBlockEvent).Treat quality as a coarse endgame lockout, not a difficulty dial. Graded tiers across many custom ores need custom tools carrying their own
Qualityvalues. The everyday difficulty knobs areLevelandHardness.
Vanilla tool power on the two gather types this pack uses:
| Pickaxe | OreCopper |
Hatchet | Woods |
|
|---|---|---|---|---|
| Wood | 0.100 | Wood | 0.20 | |
| Crude | 0.125 | Copper | 0.20 | |
| Copper | 0.250 | Iron | 0.30 | |
| Scrap | 0.334 | Thorium | 0.50 | |
| Iron | 0.500 | Cobalt | 0.50 | |
| Cobalt / Thorium / Adamantite | 0.500 | Adamantite | 0.50 | |
| Mithril / Onyxium | 1.000 | Mithril | 0.50 |
Retier a node by editing its weak point block — no code change needed.
Gathering power
Damage is a product of three independent factors, deliberately kept separate:
float power = GatheringStats.totalPower(store, ref, event.getItemInHand(), node.getPowerStatId());
float damage = event.getDamage() * node.effectiveness(power) / node.getHardness();
| Factor | Source | Controls |
|---|---|---|
event.getDamage() |
engine, from the tool spec | tool tier, and the hard quality lockout |
effectiveness |
power / node level, clamped 0.1–4.0 |
how well your gear suits this node |
hardness |
the node | how tough this particular node is |
The 0.1 floor means an under-geared player chips slowly rather than bouncing off — the quality
lockout stays the thing that says a flat "no". The 4.0 ceiling stops late-game gear deleting an
early node. Both are constants on BossNode.
Power itself is the sum of two sources:
- Gear — via the installed
GearPowerSource, recomputed each hit - A power stat — read-only from this plugin's side, and named by the node itself
Which stat a node reads is its own PowerStat field, so an ore can draw on mining while a tree draws
on woodcutting:
{ "PowerStat": "Woodcutting_Power" } // BossOak
{ "PowerStat": "Mining_Power" } // Starcrystal_Fire, and the default when omitted
This is a per-definition choice, not something derived from the block's GatherType — a definition
already knows what kind of encounter it is. The pack ships both as ordinary EntityStatType assets
under Server/Entity/Stats/. Adding Fishing_Power, or one stat per ore family, is a new asset plus
one line in a definition — no Java.
Regeneration
A node recovers while it is left alone, so it is something you commit to rather than chip down over a week of casual visits:
"Regen": { "PerSecond": 0.01, "Delay": 5 }
PerSecond is a fraction of full health per second — 0.01 is a full heal in 100 seconds of being
ignored. Delay is the grace period before it starts, long enough that repositioning or chasing a
weak point round the far side never costs you ground. Omit the block entirely to switch regen off.
Recovery is credited at the next hit rather than on a timer, so it keeps working while the chunk is unloaded and across restarts — walk away for an hour and the node is found healed exactly as if the server had stayed up. Since a node has no health visual, the chat readout says so explicitly when it has recovered by more than a percent.
It already scales with difficulty. Per-hit damage is divided by Hardness, so a tougher node
takes longer to kill and loses more to regen over that time at the same authored rate. There is an
optional ScaleWithHardness that multiplies the rate by Hardness on top of that, off by default —
it makes the regen-to-damage ratio grow with the square of hardness and can easily produce a node
nobody can kill.
Watch the break-even. Regen above a player's damage per second makes a node unkillable however long they stand there. Damage per hit is
toolPower × effectiveness / Hardness, and you can read it straight off the percentage readout by comparing two consecutive hits.For
Starcrystal_Fire(hardness 8, level 10) with an iron pickaxe — tool power0.5onOreCopper,ItemLevel20 giving effectiveness 2.0 — that is0.125per hit, so a few hits per second puts break-even far above the shipped0.01/s. With a crude pickaxe it is0.0078per hit, and the same0.01/s becomes roughly half of that player's throughput. The gap between tiers is where regen does its work, so check your weakest intended tool, not your strongest.
Gear mapping is a placeholder
Hytale's gear and item progression are still in development, so any equipment-to-power number is a guess with a shelf life. All of it lives behind one interface:
public interface GearPowerSource {
float powerOf(@Nullable ItemStack heldItem);
}
ItemLevelGearPower is the current stand-in. It reads the stock ItemLevel field — the only
progression-shaped number every item already carries, including modded ones, running from 5 on a
crude pickaxe to 50 on mithril — and softens two cases because item data is unfinished: an item with
no ItemLevel set scores UNRATED_POWER (5) instead of nothing, and bare hands score
BARE_HANDS_POWER (1) so the effectiveness floor governs unarmed mining rather than a zero wiping
out the damage product.
Nothing else in the plugin inspects items. When gear is final, write a new GearPowerSource and
change the single registration line in BossResourcesMain.setup().
Playing well with other mods
The plugin never writes these power stats, only reads them. That makes each one a free extension
point: a levelling mod, food buff or armour set can raise it with setStatValue, addStatValue or a
keyed putModifier, and the contribution simply adds on top of gear. No coupling is needed in either
direction, and nothing here will clobber another mod's value.
Because they are EntityStatType assets living in the player's persisted EntityStats component,
anything another mod writes also survives restarts without either side writing persistence code. The
lookup degrades to zero when the stat type is absent, so a definition naming a stat nobody defined
means gear-only rather than a broken node.
Boss definitions are loaded from Server/BossResources in any asset pack, so another mod can
ship bosses without depending on this plugin's Java at all.
Levelling is intentionally not implemented here — this mod does one thing. Level is the matching
hook on the resource side.
Reading node health
There is a deliberate read surface for health bars, statistics trackers and anything else that wants to observe a node. Find one by block position and read it:
BossNode node = BossNodeRegistry.get(world, blockPos);
if (node != null) {
float health = node.getHealth(); // 1.0 .. 0.0, regen-aware
int percent = node.getHealthPercent(); // 100 .. 0
String label = node.getDisplayName(); // DisplayName, or the type id
}
Health reads apply any regeneration owed before reporting, are pure, and are safe from any thread at
any rate. It is normalised rather than an absolute hit point total — there is no getMaxHealth() on
purpose, because how many hits a node takes depends entirely on the tool, so a fraction is the only
figure that means the same thing to everyone. Collections like getMembers() and getWeakPoints()
come back read-only.
Rather than polling, you can subscribe:
BossNodeEvents.register(new BossNodeListener() {
@Override
public void onDamaged(BossNode node, float before, float after, Ref<EntityStore> source) {
myBossBar.show(source, node.getDisplayName(), after);
}
@Override
public void onShattered(BossNode node, Ref<EntityStore> source) {
myBossBar.hide(source);
}
});
| Callback | Fires |
|---|---|
onDamaged |
every landed hit; before - after is the damage actually dealt |
onShattered |
health hit zero |
onWeakPointMoved |
a weak point wore out and was replaced |
All methods default to no-ops, so implement only what you need. Callbacks run inline on the thread that processed the hit — don't block, and queue any structural work. A listener that throws is caught and logged, so a broken health bar can't make a node unminable.
Set DisplayName on a definition to give UI something better than the asset id to show.
There is intentionally no way to write health from outside: damage has to run the whole pipeline — effectiveness, weak point wear, rotation, effects, loot — and a setter would let a caller skip all of it and leave a node whose blocks and health disagree.
Making your own boss
Create Server/BossResources/<Name>.json. Everything is optional except the two block ids.
{
"OreBlock": "Starcrystal_Fire",
"WeakPointBlock": "Starcrystal_Fire_Weakpoint",
"Level": 10,
"Hardness": 8,
"PowerStat": "Mining_Power",
"Regen": { "PerSecond": 0.01, "Delay": 5 },
"WeakPointCount": 3,
"HitsPerWeakPoint": 3,
"WeakPointMemory": 3,
"DropList": "Drops_Boss_Starcrystal_Fire",
"DropRolls": 3,
"WeakPointArea": { "MinY": -1, "MaxY": 1 },
"Shape": { "Type": "Prefab", "Prefab": "starcrystal_fire", "Marker": "Prefab_Weakpoint_Placeholder" },
"Effects": { "...": "see below" }
}
| Field | Default | Meaning |
|---|---|---|
OreBlock |
— | body of the node, and the resting material at weak point positions |
DisplayName |
the type id | translation key for health bars and other UI |
WeakPointBlock |
— | the damageable block; its Gathering config gates the whole node |
WeakPointLinkBlock |
WeakPointBlock |
body blocks of a multi-block weak point, so a cluster reads as one target |
Level |
10 | gathering power needed to work it at full speed |
Hardness |
6 | how many times tougher than an ordinary block |
PowerStat |
Mining_Power |
which EntityStatType counts as this node's power |
WeakPointCount |
5 | live weak points at once |
HitsPerWeakPoint |
20 | hits before one wears out and moves |
HitsPerWeakPointMax |
— | upper end of the hit budget; rolls a range when set |
WeakPointSize |
1 | blocks each weak point covers |
WeakPointSpacing |
2 | minimum distance between separate weak points; 0 lets them touch |
WeakPointMemory |
3 | recently spent positions barred from reuse |
WeakPointArea |
unrestricted | narrows where weak points may appear |
Relocation |
Random |
where the next weak point appears |
Telegraph |
off | marks the next weak point before the current one is spent |
Split |
off | fragments a breaking weak point into smaller ones |
Decoys |
none | false weak points that absorb hits — off on every shipped node |
Erupt |
none | explosion when a weak point breaks |
EruptChance |
1 | probability from 0–1 that a break actually erupts |
Shape |
sphere r=3 | geometry |
Regen |
off | recovery while left alone |
DropList |
none | ItemDropList under Server/Drops, rolled on destruction |
DropRolls |
1 | how many times to roll it |
Effects |
none | particles and sounds |
Fields are inherited — name a Parent and override only what differs:
{ "Parent": "Starcrystal_Fire", "Shape": { "Type": "Cylinder", "Radius": 2, "Height": 9 }, "HitsPerWeakPoint": 5 }
Definitions are referenced by id rather than copied, so editing one retunes nodes already standing in the world. With the dev asset pack file-watched, that is live.
Building a prefab
Build the node in-world, mark the spots that should be minable with Prefab_Weakpoint_Placeholder —
it is deliberately textured as the magenta missing-texture checker so a stray marker is impossible to
miss — then save it:
/pos1 /pos2
/prefab save my_boss_tree --pack Opheys:boss_resources
Every block keeps whatever you placed; the prefab is the palette. Only markers are special: they
become the weak point candidates, showing WeakPointBlock while live and OreBlock while not. The
prefab is anchored horizontally centred and standing on the node position.
Blocks, and the physics trap
You can build with vanilla blocks freely — with one exception that will otherwise ruin a tree.
Of roughly 2900 vanilla blocks, 633 declare a Support map, and trunks and leaves are among them:
vanilla Wood_Oak_Trunk needs a full face below it, vanilla leaves decay without a trunk nearby.
Build a boss tree from those and a player collapses the whole thing by chopping one ordinary block at
the base, bypassing the mechanic entirely.
That is why Wood_BossOak_Trunk and Plant_Leaves_BossOak exist: they set "Support": {}, which
makes BlockPhysicsUtil skip the check. IgnoreSupportWhenPlaced is not enough on its own — it
only covers placement, not later re-evaluation when a neighbour changes.
| Block kind | Use directly? |
|---|---|
No Support key — stone, all 31 vanilla ores, brick, planks, most deco |
yes |
Has a Support map — trunks, leaves, branches, stalactites, crystal clusters |
needs an inert copy |
Making the copy is one field:
{ "Parent": "Wood_Oak_Trunk", "BlockType": { "Support": {} } }
Ore formations need none of this — no vanilla Ore_* block declares support at all, so an ore
boss can be built entirely from stock blocks.
Effects
A boss node cancels its DamageBlockEvent, which also cancels the engine's own hit particles, break
particles and sounds. Whatever a definition declares under Effects is therefore the only feedback
a player gets, and every slot is optional:
"Effects": {
"Hit": { "ParticleSystemId": "Block_Hit_Crystal", "SoundEventId": "SFX_Crystal_Hit" },
"WeakPointSpent": { "ParticleSystemId": "Block_Break_Crystal", "SoundEventId": "SFX_Crystal_Break", "Scale": 1.6 },
"WeakPointSpawn": { "ParticleSystemId": "Firework_GS", "SoundEventId": "SFX_Gem_Break", "Scale": 1.2, "Color": "#ff7a33" },
"Shatter": { "ParticleSystemId": "Explosion_Medium", "SoundEventId": "SFX_Crystal_Break", "Scale": 2.0 }
}
| Slot | Fires |
|---|---|
Hit |
every damage tick that lands on a weak point — at every block of it, not just the one struck |
WeakPointSpent |
at a weak point as it wears out and reverts |
WeakPointSpawn |
at the replacement, so the player can find where to hit next |
Shatter |
at the core when the node's health runs out |
ParticleSystemId and SoundEventId are vanilla asset ids — the filename stem of any
.particlesystem under Server/Particles/ or any .json under Server/Audio/SoundEvents/. Both
are validated at load, so a typo is a decode error rather than a silent no-op.
Scale sizes the particle system up. Color tints it, per slot — so the same system can be orange in
one slot and cyan in another, and you rarely need to hunt for a system that happens to match your ore.
Particle Color behaves much better than block Tint, and for a specific reason: vanilla particle
art is greyscale. Every texture the shipped effects touch measures under 3% saturation —
Shapes/Crystal.png, Basic/Spark.png, Basic/Glow_Direction.png, Basic/Star3.png are all
effectively white. A tint over white lands exactly on the hue you asked for, so any colour works,
including one nowhere near the node's own palette. Contrast that with BlockType.Tint, which is only
usable over the _GS textures vanilla authored for it and does nothing much over a saturated one.
The shipped nodes use that to separate two things that would otherwise look identical, since both are crystal-break systems:
| Slot | Colour | Reads as |
|---|---|---|
Hit |
#ff8c3a warm orange |
chips off the weak point — "you are working this block" |
Telegraph |
#66e0ff pale cyan |
the one thing on the node that is not in its palette |
The cyan is a readability choice over a thematic one: a fire crystal warning in ice blue is odd
fiction, but hue is what the eye catches in peripheral vision, and a warm warning on a red-orange node
kept getting missed. #ffe873 gold is the thematic alternative if you would rather have it blend —
one field, and the trade is that it is a much quieter signal.
Duration is not optional for every system, and getting this wrong leaves particles in the world
forever. A particle system is unbounded if any of its spawners does not terminate itself, and there
are two ways that happens:
Spawner's TotalParticles |
Behaviour |
|---|---|
{ "Min": 25, "Max": 35 } |
finite — emits its budget and stops. Safe without Duration. |
-1 |
explicitly infinite. Needs Duration. |
| absent entirely | also infinite — the field is a nullable Range with no default. Needs Duration. |
The absent case is the one that bites, because the file looks innocent. Every ambient glow, charge and
sparkle system in vanilla is unbounded this way — Fire_Charge_Charging_Constant, Dust_Sparkles,
Aura_Sphere — precisely because they are meant to be started and stopped by whatever owns them.
A boss node has nothing to stop them with: fire one at a weak point, destroy the node, and the emitter
is still running at a position where nothing exists any more.
To check before adopting one, read the spawner it names:
unzip -p ~/.gradle/caches/hytale-assets/release-0.5.7-Assets.zip \
'*/Fire_Charge_Charging_Constant1.particlespawner' | grep -c TotalParticles # 0 = unbounded
Prefer a finite system for anything a node fires repeatedly. The Telegraph slot re-triggers on
every hit, so a short finite burst reads as a sustained pulse anyway and can never outlive the node.
Some systems carry a hardcoded PositionOffset and will land next to the block rather than on it —
Magic_Hit is offset a full block diagonally. Check the .particlesystem file before adopting one.
Loot
Point DropList at any ItemDropList asset under Server/Drops — the same tables the rest of the
game uses, so boss loot can reference items from any mod. DropRolls is how a boss yields a handful
of results rather than one:
{
"Container": { "Type": "Multiple", "Containers": [
{ "Type": "Single", "Item": { "ItemId": "Starcrystal_Fire", "QuantityMin": 2, "QuantityMax": 4 } },
{ "Weight": 40, "Type": "Single", "Item": { "ItemId": "Boss_Shard" } }
]}
}
With a DropList set, the core block's own ordinary drop is suppressed so the table is the single
source of reward. Omit DropList and the core just drops what it normally would.
Assets
The plugin doubles as an asset pack: src/main/resources mirrors the vanilla Assets.zip layout and
is symlinked into run/mods/ during dev runs, so asset edits are live — no restart, no rebuild.
Java changes still need a rebuild.
src/main/resources/
Server/BossResources/ boss definitions
Server/Prefabs/ hand-built shapes
Server/Drops/ loot tables
Server/Entity/Stats/ Mining_Power, Woodcutting_Power
Server/Item/Items/ block + item definitions
Server/Languages/en-US/server.lang display names
A placeable block and its item are one file: an item JSON with an inline BlockType block. The
filename is the asset id.
Known limitations
- No world generation. Nodes are placed by command only. The server runs the
Hytaleworldgen provider (v1), whose ore config lives inServer/World/Default/Ores/; nothing there is wired up yet. This is the biggest missing piece — everything else, persistence included, is ready for it. - No health visual. The core block is buried inside the node, so a crack overlay would never be visible. Health is only communicated through the chat percentage — which is also the only way a player learns their node has been regenerating.
- Chunks must be loaded.
/bossnodefails with a message if the target chunk is not loaded, and node lookup only sees loaded chunks. - Debug chat noise.
BlockBreakMessageSystemannounces every block break by every player, unrelated to boss nodes. Remove it or gate it behind config before using this on a real server. - Placeholder art.
Boss_Shardand the Starcrystal and BossOak blocks reuse vanilla textures, models and icons. - Placeholder gear mapping.
ItemLevelGearPoweris a stand-in until Hytale's gear progression is final — see Gear mapping is a placeholder. boss_oak.prefab.jsonis unused and stale. Its markers reference a block id that no longer exists;BossOakuses a cylinder instead.
Useful commands
./gradlew runServer -Ddebug=true -Dhotswap=true # debugging + hot swap
./gradlew hytaleJvmDoctor # check JVM/hot swap setup
./gradlew updatePluginManifest # regenerate manifest.json from gradle.properties
./gradlew build --refresh-dependencies # if dependencies fail to resolve
Credits
Built on the Hytale Plugin Template and AzureDoom's Hytale Gradle Plugin.
License
MIT