No description
Find a file
2026-07-28 21:04:58 +02:00
.github/workflows Adds an example github action that confirms the mod is building and working. 2026-06-08 14:02:34 -04:00
gradle/wrapper Update gradle properly (legacy gradle came from mc project that change the gradlew file quite a bit) 2026-06-03 17:10:29 -04:00
src/main added weakspot telegraph, multiblock weakspot, weakspot splitting, weakspott patterns and test prefabs 2026-07-28 21:04:58 +02:00
.gitignore Starcrystal added|ChunkPersistence 2026-07-25 16:46:48 +02:00
ARCHITECTURE.md added weakspot telegraph, multiblock weakspot, weakspot splitting, weakspott patterns and test prefabs 2026-07-28 21:04:58 +02:00
build.gradle.kts Fix: Changing mod_name not updating build jar. 2026-06-19 13:21:06 -04:00
gradle.properties Starcrystal added|ChunkPersistence 2026-07-25 16:46:48 +02:00
gradlew Update gradle properly (legacy gradle came from mc project that change the gradlew file quite a bit) 2026-06-03 17:10:29 -04:00
gradlew.bat Update gradle properly (legacy gradle came from mc project that change the gradlew file quite a bit) 2026-06-03 17:10:29 -04:00
README.md added weakspot telegraph, multiblock weakspot, weakspot splitting, weakspott patterns and test prefabs 2026-07-28 21:04:58 +02:00
settings.gradle.kts Starcrystal added|ChunkPersistence 2026-07-25 16:46:48 +02:00

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.md is 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.0 range

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. type is a DefaultArg, which the command framework binds by name — only RequiredArg is positional. A bare /bossnode BossOak does 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, BreakBlockInteraction calls performBlockBreak directly and never fires DamageBlockEvent, 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, Spread and Adjacent are 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_Colossus is 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 35 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 WeakPointLinkBlock field 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 01. 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.GatherType and Quality — the requirement
  • A tool's matching tool spec Power and Quality — 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, Quality is 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: omit Quality (any pickaxe works) or require Quality: 4 (Cobalt/Thorium on OreAdamantite only; everything else does literally nothing and never even fires DamageBlockEvent).

Treat quality as a coarse endgame lockout, not a difficulty dial. Graded tiers across many custom ores need custom tools carrying their own Quality values. The everyday difficulty knobs are Level and Hardness.

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.14.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 power 0.5 on OreCopper, ItemLevel 20 giving effectiveness 2.0 — that is 0.125 per hit, so a few hits per second puts break-even far above the shipped 0.01/s. With a crude pickaxe it is 0.0078 per hit, and the same 0.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 01 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 Hytale worldgen provider (v1), whose ore config lives in Server/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. /bossnode fails with a message if the target chunk is not loaded, and node lookup only sees loaded chunks.
  • Debug chat noise. BlockBreakMessageSystem announces 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_Shard and the Starcrystal and BossOak blocks reuse vanilla textures, models and icons.
  • Placeholder gear mapping. ItemLevelGearPower is a stand-in until Hytale's gear progression is final — see Gear mapping is a placeholder.
  • boss_oak.prefab.json is unused and stale. Its markers reference a block id that no longer exists; BossOak uses 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