Not 17, not 25. Both 1.21.1 and 1.21.11 require it. The system default here is Java 8, which is why the path is written out explicitly in gradle.properties.
How a shot travels
Nothing gameplay-related happens on the client. It reads input and draws the menu; every arrow is spawned server-side.
While any toggle is on, vanilla bow charging is cancelled outright — otherwise a normal shot would stack on top of the custom volley. One real arrow is consumed per fire event, not per projectile, so volleys stay affordable. Settings are re-sent on joining any world, and in multiplayer FireHandler applies the guest limits from trap 15 before a single arrow spawns.
File map
Two copies of the same code, one per Minecraft version: ZollasArsenal-1.21.1 for 1.21.1 (18 Java files, about 1,560 lines) and ZollasArsenal-1.21.11 (15 files, about 1,330 lines). The columns give each file's length in each copy; a dash means that version does not have it. A feature added to one copy has to be ported to the other by hand.
| 1.21.1 | 1.21.11 | ||
|---|---|---|---|
| ZollasArsenal.java | Entry point. Registers the four packet types and their receivers, the vanilla bow override, and respawn and disconnect handling. | 193 | 190 |
| ZollasSettings.java | The nine settings, their bounds, and clamping. Velocity is stored in m/s. | 69 | 69 |
| mixin/PersistentProjectileEntityMixin.java | Patches vanilla's arrow to detonate and to save its explosive flag. On 1.21.1 it also corrects the impact point — see trap 14. | 92 | 85 |
| entity/ArrowImpact.java | The blast, and on 1.21.1 the impact correction, as ordinary code outside the mixin. | 82 | 71 |
| entity/ExplosiveArrow.java | Interface the mixin puts on every arrow, carrying the explosive flag. | 15 | 15 |
| entity/ExplosiveArrowEntity.java | Legacy. Still registered so arrows in old 1.21.1 saves load; nothing fires it. | 168 | — |
| entity/ModEntities.java | Registers that legacy entity type. | 30 | — |
| network/FireRequestPayload.java | Client to server, one per shot. Carries only which hand. | 27 | 27 |
| network/UpdateSettingsPayload.java | Client to server, on every menu change, on close, and on joining a world. | 56 | 56 |
| network/GiveWeaponPayload.java | Client to server, behind the Bow and Crossbow buttons. Host and operators only. | 24 | 24 |
| network/ClearArrowsPayload.java | Client to server, empty. Clears arrows in every dimension. Host and operators only. | 27 | 27 |
| server/FireHandler.java | Spawns the volley: cone-scatter maths, ammo, the m/s conversion, and the guest limits. | 167 | 167 |
| server/ArsenalPermissions.java | Who counts as host or operator, and the limits everyone else fires under. | 43 | 45 |
| server/PlayerSettingsManager.java | Settings per player, the fire-rate clamp, the blast knockback modifier, and the one-time limits notice. | 121 | 121 |
| 1.21.1 | 1.21.11 | ||
|---|---|---|---|
| gui/ZollasScreen.java | The menu: four toggles, four sliders, the Bow and Crossbow buttons, and the Clear Arrows and No Blast Knockback corner buttons. | 251 | 253 |
| ZollasArsenalClient.java | Both keybinds, the tick loop, fire pacing, and settings sync on joining a world. | 168 | 171 |
| ExplosiveArrowRenderer.java | Draws the legacy arrow entity. | 21 | — |
| ArsenalClientState.java | Holds the one settings instance the screen and tick loop share. | 8 | 8 |
| 1.21.1 | 1.21.11 | ||
|---|---|---|---|
| fabric.mod.json | Mod id, display name, author, entrypoints, and the mixin config. The file most worth checking first when something odd happens. | 1 | 1 |
| assets/zollas_arsenal/lang/en_us.json | Everything the player reads: menu labels, the menu title, and the Controls category name. | 21 | 19 |
| zollas_arsenal.mixins.json | Declares the arrow mixin to Fabric. | 12 | 12 |
| data/minecraft/tags/entity_type/arrows.json | Puts the legacy arrow in vanilla's arrows tag. Folder is entity_type, singular, since 1.21. | 6 | — |
| assets/template-mod/icon.png | Leftover from the Fabric template. Nothing references it. | — | — |
| build.gradle | Loom, split source sets, Java 21 release target. The 1.21.11 copy uses the fabric-loom-remap plugin. | 68 | 66 |
| gradle.properties | Minecraft, mapping and library versions, the mod version, and the explicit JDK 21 path. | 20 | 23 |
| gradle/wrapper/gradle-wrapper.properties | Gradle 8.8 for 1.21.1, which Loom 1.7.4 needs; 9.5.1 for 1.21.11. Do not swap them. | 9 | 9 |
Traps already hit
Each of these cost a debugging session once. The numbers are permanent, so "number six" always means the same trap, and new ones are only ever added to the end.
The mod id and entrypoints in fabric.mod.json
Must be zollas_arsenal, with net.zollas.arsenal.ZollasArsenal as main and net.zollas.arsenal.client.ZollasArsenalClient as client.
If a build ever loads a mod called "template-mod", a leftover template file has overwritten this one again.
ClientPlayNetworking comes from Fabric, not Minecraft
The import is net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking. There is no such class under net.minecraft.client.network.
Got wrong twice.
Math.round on a double returns a long
Assigning it to the arrowsAmount int field needs an explicit (int) cast.
The abstract method is getDefaultItemStack
In 1.21.1 Yarn mappings PersistentProjectileEntity declares getDefaultItemStack(), not asItemStack().
A custom entity without a renderer crashes on sight
The EntityRendererRegistry.register call in onInitializeClient is load-bearing. Drop it and the game throws the instant an explosive arrow needs drawing.
Symptom to recognise: everything renders fine until the moment one is fired.
dimensions takes two floats
In this API version FabricEntityTypeBuilder.dimensions(...) takes width and height directly, not an EntityDimensions object.
Every spawned entity needs its own ItemStack copy
Passing one shared stack into many entities and then decrementing it empties the stack every one of them holds. Always hand each entity a copyWithCount(1).
This surfaced only much later, as "cannot encode empty ItemStack" on autosave — the longest hunt in the project.
Receiver callbacks hand you a PlayerEntity
Not a ServerPlayerEntity. Cast it explicitly before use.
Explosions shove arrows that are still in flight
The blast applies velocity to every nearby entity, arrows included. explode() snapshots nearby projectile velocities before the explosion and restores them after, so a chain detonation does not scatter the rest of the volley.
setVelocity does not take metres per second
It takes blocks per tick. One block is one metre and there are twenty ticks in a second, so the menu value is divided by TICKS_PER_SECOND in FireHandler before it reaches the arrow. The old hard-coded 3.0f was 60 m/s all along, which is now the slider's default.
The top of the slider is 500 blocks per tick, which carries the arrow past the loaded chunks before the next tick runs — it is culled, nothing lands, and the bow appears dead. FireHandler therefore caps what physics receives at MAX_BLOCKS_PER_TICK, currently 100. The menu still displays whatever the player chose.
An arrow's coordinates during a hit callback are stale
They describe where it began the tick, not where it struck. explode() takes its position from the HitResult, and snapTo() moves the entity onto the impact point before super runs — vanilla emits the landing sound at the entity's own coordinates, so the explosion and the sound each needed handling.
Found through the velocity slider: at the top of the range the detonation and then the thud both arrived next to the shooter, a whole flight behind the target. At vanilla speed the same bug was there and invisible.
A vanilla ArrowEntity cannot be corrected from outside, which first pushed the mod onto a bespoke entity — and that broke @e[type=arrow] and tipped arrows, since the selector matches one exact id and nothing can be aliased onto it. The mixin replaced that: the mod fires genuine minecraft:arrow entities again, and the patch supplies both the impact correction and the detonation.
Land the arrow a tenth of a block short of the impact point, not exactly on it. Vanilla reads a landed arrow's facing from the vector between its position and the hit point, then nudges it back along that vector so the shaft protrudes. Snap it exactly onto the hit point and that vector is zero — the arrow loses its orientation and lies flat in the surface.
Static maps outlive the world; world time does not
PlayerSettingsManager keys both its maps by player UUID in static fields, so they survive into the next world. getWorld().getTime() does not — it restarts near zero in a freshly created world. A fire tick recorded at 500000 in an established world therefore sits in the future of a new one, and the rate limiter refuses every shot until the new clock catches up.
The symptom was a brand new world where no toggle would fire, that healed after closing and reopening the game — because that is what clears a static map. canFireNow now treats any backwards jump as a fresh start, and per-player state is dropped on disconnect.
The client must push its settings on joining a world
The server's copy now resets per world, so ClientPlayConnectionEvents.JOIN has to re-send it. Any setting added later must ride along in that payload or it will silently run at its default.
Do not send directly from the JOIN callback: the channel is not reliably ready yet, canSend returns false, and the sync is dropped with no error. The join sets a flag and the client tick retries until it goes through.
Cancel blast push with the attribute, not a mixin
Vanilla's explosion scales its push on a living entity by one minus explosion_knockback_resistance, and explosion damage types sit in the no_knockback tag — so the blast push is the only thing that moves a player. No Blast Knockback adds a temporary +1.0 modifier to that attribute. Both facts were checked against the 1.21.1 jar.
Temporary modifiers never reach the save file, so they cannot outlive the mod — but they also vanish when a dying player's entity is replaced. ServerPlayerEvents.AFTER_RESPAWN reapplies it from the settings, which survive because they are keyed by UUID.
The impact correction is 1.21.1 only — on 1.21.11 it makes arrows bounce
Mojang's projectile rework changed landing. In 1.21.11, applyCollision moves the arrow onto the hit point before onBlockHit runs, so the stale position behind trap 10 no longer exists. onBlockHit then zeroes the velocity and never moves the arrow forward again.
Pulling the arrow back 0.1 block on 1.21.11 therefore left it hovering past the 0.06 margin shouldFall() checks: it dropped, landed, was pulled back, and bounced forever. The 1.21.11 build has no correction at all. The 1.21.11 behaviour was read from that version's bytecode. The 1.21.1 build keeps its correction because, there, the tick moves the arrow forward after the hit and cancels the pull-back out — reasoned from that version's landing code, not re-checked in bytecode, and no bounce has been reported on it.
In multiplayer, guests get limits and the host keeps everything
ArsenalPermissions.isTrusted is true for the world's host — the player whose game is the server, as when inviting friends through Essential — or anyone with operator level 2. Everyone else cannot use Clear Arrows or the weapon buttons, never fires explosive arrows, and is held to 20 arrows a volley and 5 shots a second. They are told once in chat. No Blast Knockback is unrestricted because it only affects the player using it.
Limits are applied when a shot fires, never written into the stored settings, so a later /op takes effect on the very next shot. The permission API differs per version: hasPermissionLevel(2) and isHost(getGameProfile()) on 1.21.1, getPermissions().hasPermission(new Permission.Level(PermissionLevel.GAMEMASTERS)) and isHost(getPlayerConfigEntry()) on 1.21.11. Every client-to-server message is now behind canSend, so the mod stays quiet on a server that lacks it. Everyone must run the same mod version — the settings packet layout changes between versions.
Build environment
This combination is known to work. The pinned versions are not preferences — each one is load-bearing.
- Java
- JDK 21
- Gradle wrapper · 1.21.1 copy
- gradle-8.8-bin.zip
- JDK path in gradle.properties
- org.gradle.java.home
- Temp directory
- set TEMP to C:\uatmp
- 1.21.11 port
- ZollasArsenal-1.21.11 · Loom 1.17 · Gradle 9.5.1
- No refmap is correct
- mixin names baked in at remap
- 2.0.0 is a clean break
- new mod id, namespace and package
- Source sets
- src/main · src/client
Fabric Loom 1.7.4 cannot run on Gradle 9. A fresh template download defaults to 9.5.1, so the wrapper properties file has to be corrected by hand after regenerating it.
Written as a Windows short path, because a non-ASCII character in the username breaks properties-file parsing otherwise.
The daemon fails to start otherwise, with "invalid argument: connect" deep in Java's pipe setup. Java opens an internal socket file under the temp path, and the accented character in the Windows username breaks it. Same root cause as the gradle.properties short path, different symptom.
A separate copy, so the 1.21.1 build keeps working. Yarn 1.21.11+build.6 and Fabric API 0.141.6. Renames to know: getWorld became getEntityWorld, getPos became getEntityPos, NBT saving moved to ReadView/WriteView, keybind categories became registered objects, and text colours need a full ARGB value. The retired custom arrow entity was left out of this copy.
Loom 1.17 writes Minecraft's runtime method names directly into the compiled mixin, so the 1.21.11 jar has no refmap file. Checked: all four patch points resolve to real 1.21.11 methods.
Version 2.0.0 renamed the mod id, the resource namespace, the Java package, the main classes and the project folders. Fabric no longer sees a 2.0.0 jar as the same mod as anything older, so an old jar left in a mods folder loads alongside it instead of being refused — always delete the old one. Keybinds are stored under the new names, so they started over at their defaults, and every player needs 2.0.0 or later.
Split by Loom. Client-only classes must not be referenced from common code, or the mod breaks on a dedicated server.