Minecraft Damage Mod
Guide
Clone the template from GitHub and checkout the 1.21
branch, which is the default one.
Create a new flake (because we are using Nix).
git clone https://github.com/FabricMC/fabric-example-mod
cd fabric-example-mod
git checkout 1.21
nix flake init
Next we will need to add a nix-shell to the flake. The flake will look like this:
{
description = "A basic flake for my Minecraft mod";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
}: (
flake-utils.lib.eachDefaultSystem
(system: let
pkgs = import nixpkgs {
inherit system;
config = {
allowUnfree = true;
};
};
in {
packages = {
default = {}; # TODO: Add package here
};
apps = {
# TODO: Add app here
# default = flake-utils.lib.mkApp {
# drv = ...;
# };
};
devShells.default = pkgs.mkShell rec {
nativeBuildInputs = [];
buildInputs = with pkgs; [
flite
glfw
gradle
jdk21
libGL
openal
alsa-lib
pkgsCross.avr.buildPackages.gcc
pkgsCross.avr.buildPackages.avrdude
];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;
shellHook = ''
export JAVA_HOME=${pkgs.jdk21}
'';
};
})
);
}
Now we can enter the nix-shell:
nix develop --show-trace --command fish
While this is installing let's setup the C part of the mod. I will use a makefile to build the C code.
MCU = atmega328p
F_CPU = 16000000UL
CC = avr-gcc
CFLAGS = -mmcu=$(MCU) -DF_CPU=$(F_CPU) -Wall -Wextra -Os -std=gnu11 -g
PORT = /dev/ttyACM0 # or /dev/ttyUSB0 depending on your setup
PROGRAMMER = arduino
BAUD = 115200
main.elf: main.o
$(CC) $(CFLAGS) -o $@ $^
main.o: main.c
$(CC) $(CFLAGS) -c $< -o $@
main.hex: main.elf
avr-objcopy -j .text -j .data -O ihex $< $@
upload: main.hex
sudo avrdude -v -p $(MCU) -c $(PROGRAMMER) -P $(PORT) -b $(BAUD) -U flash:w:main.hex
clean:
rm -f *.o *.elf *.hex
.PHONY: upload clean
To have the clang lsp working we need to add a compile_flags.txt
file in the project root with the following content:
-std=gnu11
-xc
--target=avr
-mmcu=atmega328p
-I/nix/store/{path-to-avr-gcc}/avr/sys-include/
To get the path to the avr-gcc
sys-include directory, you can run:
avr-gcc -print-file-name=include
First let's build the Java mod to cache it for faster development:
./gradlew runClient
We will also need to add jSerialComm to the dependencies of the mod. Add the following to the build.gradle
file:
dependencies {
...
implementation "com.fazecast:jSerialComm:${project.jSerialComm_version}"
}
and then in the gradle.properties
file, add:
jSerialComm_version=2.11.2
Now, in my case I had to add my user to the dialout
group to be able to open the serial port without sudo
:
sudo usermod -aG dialout $USER
but on Nix I had to just change the configuration.nix file by adding the group.
Now let's start wokring on the mod itself. I will do everything in the client part of the mod. That is mainly because we will have the arduino connected to the computer that is playing the game.
I will add a few attributes in the ExampleModClient
class that we will use to do the communication with the Arduino:
import com.fazecast.jSerialComm.SerialPort;
import java.io.PrintWriter;
private static SerialPort port;
private static PrintWriter writer;
Next let's add some methods to handle opening and closing the serial port:
private static boolean openPort(String name) {
closePort(); // Close any previously opened port
port = SerialPort.getCommPort(name);
if (port.openPort()) {
System.out.println("Port " + name + " opened successfully.");
writer = new PrintWriter(port.getOutputStream(), true);
return true;
} else {
System.err.println("Failed to open port " + name);
port = null;
return false;
}
}
private static void closePort() {
if (writer != null) {
writer.close();
writer = null;
}
if (port != null && port.isOpen()) {
port.closePort();
port = null;
}
}
Lastly we will need a method to send the damage value to the Arduino:
private static void sendDamage(int damage) {
if (writer != null) {
writer.write(damage & 0xFF); // Send as a single byte
writer.flush();
} else {
System.err.println("Port is not open. Cannot send damage.");
}
}
Now we can modify the onInitializeClient
method to open the serial port and add callbacks for player damage:
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.client.MinecraftClient;
private float lastHealth = -1.0f;
@Override
public void onInitializeClient() {
ClientTickEvents.END_CLIENT_TICK.register(client -> {
if (writer == null) return;
PlayerEntity player = MinecraftClient.getInstance().player;
if (player == null) return;
float currentHealth = player.getHealth();
if (lastHealth != -1.0f && currentHealth < lastHealth) {
float damage = lastHealth - currentHealth;
sendDamage((int) damage);
System.out.println("Damage sent: " + damage);
}
lastHealth = currentHealth;
});
// Open the serial port
String portName = "/dev/ttyACM0"; // Change this to your Arduino port
if (!openPort(portName)) {
System.err.println("Failed to open serial port. Mod will not function correctly.");
}
}
Now we can try to build the entire thing. Run the following command in the project root:
./gradlew runClient
Now it is time for the Arduino code. Create a new file called main.c
in the project root and add the following code:
This will include the necessary headers and define the CPU frequency for the Arduino board. Make sure to adjust the F_CPU
value if you are using a different board or clock speed.
#define F_CPU 16000000UL
#include <avr/io.h>
#include <avr/interrupt.h>
We will define some magic stuff for the serial communication.
#define BAUD 9600
#define MY_UBRR F_CPU/16/BAUD-1
void uart_init(void) {
unsigned int ubrr = MY_UBRR;
UBRR0H = (ubrr >> 8);
UBRR0L = ubrr;
UCSR0B = (1 << RXEN0);
UCSR0C = (1 << UCSZ01) | (1 << UCSZ00);
}
uint8_t uart_read(void) {
while (!(UCSR0A & (1 << RXC0)));
return UDR0;
}
We will also use interrupts to handle everything in an "asynchronous" way.
volatile uint16_t blink_counter = 0;
volatile const uint16_t blink_delay = 500;
volatile uint16_t damage_counter = 0;
volatile uint16_t damage_delay = 100;
volatile uint8_t damage_active = 0;
volatile int damage_steps = 0;
ISR(TIMER1_COMPA_vect) {
blink_counter++;
if (blink_counter >= blink_delay) {
PORTB ^= (1 << PB5);
blink_counter = 0;
}
if (damage_active) {
damage_counter++;
if (damage_counter >= damage_delay) {
PORTB ^= (1 << PB4);
damage_counter = 0;
damage_steps--;
if (damage_steps <= 0) {
damage_active = 0;
PORTB &= ~(1 << PB4);
}
}
}
}
void timer1_init(void) {
TCCR1B |= (1 << WGM12);
OCR1A = 16000;
TIMSK1 |= (1 << OCIE1A);
TCCR1B |= (1 << CS10);
sei();
}
Then we need the entry point.
int main(void) {
DDRB |= (1 << PB5) | (1 << PB4);
uart_init();
timer1_init();
while (1) {
if (UCSR0A & (1 << RXC0)) {
uint8_t data = uart_read();
if (data == 0) data = 1;
damage_steps += data;
damage_active = 1;
}
}
}