CanvasMC Source
Learn how to contribute to CanvasMC
Introduction
This guide will help you get started and understand developing and contributing with CanvasMC's source code
Getting Started
Prerequisites
Before you begin, ensure you have the following installed:
- Java 21 (or latest LTS version supported by CanvasMC)
- Git
- IntelliJ IDEA (recommended) or any modern Java IDE
- Gradle (optional, wrapper included)
Cloning the Repository
git clone https://github.com/CraftCanvasMC/Canvas.git
cd CanvasPatches
Canvas uses a modified version of paperweight, Weaver. Weaver fixes and adds features ontop of the existing paperweight system, and is required for when plugins use NMS with Canvas, or for forks of Canvas. This system adds "base" patches, which are essentailly feature patches applied before all Canvas patches. These patches are used for things like removing the old profilers, rebrand, etc. Things that probably should be it's own dedicated patch so it's isolated from Canvas features and fixes.
Canvas uses the per-file format of patching, alongside base-feature patching to make its changes to the server source. We do not use feature patches
Credit to кармучик, who built and maintains the Weaver system.
To build the Canvas development environment, simply run this command in the root of your cloned Canvas repository:
./gradlew applyAllPatchesAfter that has completed, you can now start developing with Canvas' internals.
Understanding the Environment
The Canvas development environment has a lot of directories with a lot of files that mean different things. The table bellow will help outline what these directories are and their purpose.
Files that are created by feature patches are created in the directory that the feature patch is applied to.
| Directory | Purpose |
|---|---|
/canvas-api/src/main/java/ | Canvas' API files, added by Canvas. This does not contain source code from other forks, and all new API files should be placed here |
/canvas-server/src/main/java/ | Canvas' Server files, added by Canvas. This does not contain source code from other forks or Minecraft, and all new server files should be placed here |
/paper-api/ | All Paper API sources |
/paper-server/ | All Paper Server sources |
/folia-api/ | All Folia API sources |
/folia-server/ | All Folia Server sources |
/canvas-server/minecraft-patches/ | All Canvas patches to the Minecraft source directory |
/canvas-server/paper-patches/ | All Canvas patches to the Paper server source directory |
/canvas-server/folia-patches/ | All Canvas patches to the Folia server source directory |
/canvas-api/folia-patches/ | All Canvas patches to the Folia API source directory |
/canvas-api/paper-patches/ | All Canvas patches to the Paper API source directory |
/canvas-server/src/minecraft/java/ | The Minecraft source code |
Making Changes
Canvas is very strict about code quality. This section will teach you how to make changes to the server source in a maintainable and clean way. Do note, some of these rules do not apply to the Canvas source files that were added by Canvas.
Formatting
All modifications to Minecraft files, Paper files, and Folia files should be marked. Canvas-added files do not need markings.
- You need to add a comment with a short and identifiable description of the patch.
- These comments should generally be about the reason the change was made, what was before, or what the change is.
- After the general commit description, you can add additional information either after a
;or in the next line.
- Multi-line changes start with
// Canvas start - <COMMIT DESCRIPTION>and end with// Canvas end - <COMMIT DESCRIPTION>- We do not enforce the end having a description, but we do prefer it
- One-line changes should have a
// Canvas - <COMMIT DESCRIPTION>at the end of the line
These comments are incredibly important to keep track of changes across files to remember what they are for
Example:
public void baseTick() {
// Canvas start - don't tick if we are spinning
if (this.isSpinning) {
this.becomeDizzy();
return;
}
// Canvas end - don't tick if we are spinning
...
}
public void singleLineChangeExample() {
this.level().dontBeStupid(); // Canvas - don't be stupid
this.updateFriends();
this.explode();
}We generally follow the usual Java style, or what is programmed into most IDEs and formatters by default. However, when in doubt or the code around you is in a clearly different style, use the same style as the surrounding code.
Imports
We like to steer away from using imports as much as possible. Imports can cause a maintainability burden when imports change during an upstream update, either from Minecraft, Paper, or Folia. When making changes, please use the fully qualified class name instead of adding imports at the import section of a file.
import net.minecraft.server.MinecraftServer;
// Don't add imports here, use the fully qualified class name like bellow.
public class SomeRandomVanillaClassExample {
public final net.minecraft.server.level.ServerLevel world; // Canvas - add world
}Access Transformers
Sometimes, Vanilla code already contains a field, method, or type you want to access but the visibility is too low (e.g. a private field in an entity class).
Canvas can use access transformers to change the visibility or remove the final modifier from fields, methods, and classes. Inside the build-data/canvas.at file,
you can add ATs that are applied when you ./gradlew applyAllPatches. You can read about the format of ATs here.
Rebuilding Patches
After making all of your changes, we need to turn them into per-file patches so they are ready to be PRed to the Canvas repository. Good thing Canvas has a fun script that does all of the work for you! Simply run this command in the root of your cloned Canvas repository:
./rebuildPatchesThis script automatically detects changes made to Canvas and runs the necessary commands to rebuild patches. If something went wrong(like a patch wasn't made but it should have been)
then look through the logs of the script, as it runs through gradle so all logs from the gradle tasks will be printed. If you made changes to the build.gradle.kts files in canvas-api
or canvas-server, then you need to run the script with --force. There should be no other reason you are running --force, as this just runs all rebuild tasks for all modified
and unmodified directories. The script is shown bellow to show what it does in more detail
#!/bin/bash
set -e
force_run=false
gradle_run=false
for arg in "$@"; do
case "$arg" in
--force)
force_run=true
echo "Force mode enabled. All Gradle tasks will run."
;;
--gradle)
gradle_run=true
echo "--gradle flag detected. Will run rebuildFoliaSingleFilePatches."
;;
esac
done
echo "Processing file patches..."
declare -A gradle_tasks
process_changes() {
local dir="$1"
local project="$2"
if [ ! -d "$dir" ]; then
echo "Error: The directory '$dir' does not exist or is not valid."
exit 1
fi
cd "$dir"
if $force_run || ! git diff --quiet || ! git diff --cached --quiet; then
echo "Changes detected in $dir (or force mode enabled). Running Gradle fixup and rebuild tasks."
gradle_tasks["fixup${project}FilePatches"]="true"
gradle_tasks["rebuild${project}FilePatches"]="true"
else
echo "No changes detected in $dir"
fi
cd - > /dev/null
}
run_gradle_task() {
local task="$1"
if [ "${gradle_tasks[$task]}" = "true" ]; then
echo "Running Gradle task: $task"
./gradlew "$task" -Dpaperweight.debug=true || echo "Gradle task '$task' failed, continuing..."
echo "Gradle task '$task' completed (or failed but continuing)."
else
echo "Skipping Gradle task '$task' as no changes were detected."
fi
}
process_changes "./paper-server/" "PaperServer"
process_changes "./paper-api/" "PaperApi"
process_changes "./folia-server/" "FoliaServer"
process_changes "./folia-api/" "FoliaApi"
process_changes "./canvas-server/src/minecraft/java" "Minecraft"
gradle_rebuild_task=false
if $force_run || ! git diff --quiet "./canvas-server/build.gradle.kts" || ! git diff --cached --quiet "./canvas-server/build.gradle.kts"; then
echo "Changes detected in ./canvas-server/build.gradle.kts"
gradle_rebuild_task=true
fi
if $force_run || ! git diff --quiet "./canvas-api/build.gradle.kts" || ! git diff --cached --quiet "./canvas-api/build.gradle.kts"; then
echo "Changes detected in ./canvas-api/build.gradle.kts"
gradle_rebuild_task=true
fi
if $gradle_rebuild_task || $gradle_run; then
gradle_tasks["rebuildFoliaSingleFilePatches"]="true"
fi
echo "Running fixup tasks..."
run_gradle_task "fixupPaperApiFilePatches"
run_gradle_task "fixupPaperServerFilePatches"
run_gradle_task "fixupFoliaApiFilePatches"
run_gradle_task "fixupFoliaServerFilePatches"
run_gradle_task "fixupMinecraftFilePatches"
echo "Running rebuild tasks..."
run_gradle_task "rebuildPaperApiFilePatches"
run_gradle_task "rebuildPaperServerFilePatches"
run_gradle_task "rebuildFoliaApiFilePatches"
run_gradle_task "rebuildFoliaServerFilePatches"
run_gradle_task "rebuildMinecraftFilePatches"
run_gradle_task "rebuildFoliaSingleFilePatches"Adding Configurations
Adding configuration options to Canvas is very simple with its API backend. It works using an annotation based yaml serializer, which takes the class provided and turns that into a yaml configuration, and you can add features to via annotations to fields. The system auto updates your configuration with new and removed options and even tells you when these options are removed or added at startup! Making one is as easy as adding a new field to the end of the section you want to add your config to. If this configuration has multiple configurations that are associated with eachother, create an inner class like this:
public ExampleMultiConfig exampleMultiConfig = new ExampleMultiConfig();
public static class ExampleMultiConfig {
public boolean enabled = false;
public int count = 43;
public float chance = 0.212;
}As a formatting rule, we like to add new configuration options to the end of the section it belongs to. Like if
the section already exists for it, like the chunks section, add it to the end of the inner class Config$Chunks.
If the section is the root section, add it at the end of the file above the method buildSerializer. We also like to
ensure all configuration options have an @Comment annotation describing the option. More detail about the annotations
bellow. If your configuration needs to run something after the configuration init has been run, you can add code at
the end of the post(context -> {...}) block in the buildSerializer method with what you need. However, if possible,
use runtime modifiers.
Runtime Modifiers
Runtime modifiers are a way to apply modifications to the memory version of the config(meaning this isn't saved to disk).
This can allow choosing defaults for a configuration option if the option is an int that is <= 0 as an example.
The runtime modifier takes 2 arguments, a pattern and a modifier. The "pattern" is a String pattern that is checked over
each field after deserializing, and if the pattern matches the fully qualified configuration option, it applies the
runtime modifier. The pattern could be something like the following:
example.*- this will cover all configs in the 'example' section of the configtest*- this will cover all configs that start withtestenableSuperCoolMechanic- this will cover theenableSuperCoolMechanicconfig in the root directory*.enable- this will cover allenablefields in all sections
All normal Java string patterns work with this system. The 2nd argument is a RuntimeModifier, which takes a
Class<T>, and a Function<T, T>. This will allow checking what class type the field is, and then applying
your modifications to the field. It can be something like this:
// This makes all booleans that match the pattern we defined in
// argument 1 true in memory, even if the configuration option
// saved to disk marks the configuration as false
new RuntimeModifier<>(boolean.class, (original) -> true)Here is a quick example of a runtime modifier:
// This is in the `buildSerializer` method, after we create our validators and handlers
// and before we define our post instructions
.runtimeModifier("*.enable", new RuntimeModifier<>(boolean.class, (original) -> net.minecraft.SharedConstants.IS_RUNNING_IN_IDE || original))Using Annotations
As an annotation based configuration API, we use annotations to add extra detail to our configuration options.
Canvas requires an @Comment annotation when adding new options, with the only exception being for new section fields
Using these annotations is very easy, it's as simple as adding an annotation to your field, and the serializer will do the rest
of the work. Lets try an @Comment annotation:
@Comment("This enables a super cool optimization that boosts performance 10000000%!")
public boolean enableSuperCoolOptimization = false;The annotation we just showed was a single-line comment annotation, but comment annotations can support multiline comments, like so:
@Comment(value = {
"This is super interesting stuff! I really don't know what I'm going to say since",
"it's 2am at the time of writing this section of the docs! This is such a cool config!!"
})
public boolean enableSuperCoolOptimization = false;Canvas also includes numerous other annotations. Keep in mind, all annotation handlers are processed from the first to the last annotation declared, meaning order does matter. The other annotations are documented bellow in their respective sections, handlers and validators.
Annotation Handlers
Annotation handlers allow you to customize the way specific fields appear in the YAML configuration file when it is saved to disk. This is particularly useful for adding metadata such as comments, warnings, or other markers alongside configuration entries.
Defining a Handler
To create your own annotation handler:
-
Create a custom annotation to mark the fields you want to handle.
-
Your annotation must be annotated with:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD)This ensures your annotation is accessible at runtime and can only be used on fields.
-
Create a class that implements the
AnnotationContextProvider<T>interface, whereTis your annotation type. -
Annotate your handler class with
@RegisteredHandler, passing a string name. This name must match the method name that returns your annotation class.
Example: @Experimental
This annotation adds a comment block above the field to denote that it’s an experimental feature.
Step 1: Define the annotation
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Experimental {}Step 2: Create the handler
@RegisteredHandler("experimental")
public class ExperimentalProcessor implements AnnotationContextProvider<Experimental> {
@Override
public void apply(final @NotNull StringWriter yamlWriter, final String indent, final String fullKey, final Field field, final @NotNull Experimental annotation) {
yamlWriter.append(indent).append("## ").append("=== EXPERIMENTAL FEATURE ===").append("\n");
}
public Class<Experimental> experimental() {
return Experimental.class;
}
}Serialization Flow
When the config is being saved to disk, all non-static fields are inspected. If a field has an associated annotation with a registered handler, the handler is invoked before the field is written out.
The handler’s apply() method is passed everything you need to write to the YAML output.
Parameters Provided
The apply() method provides:
StringWriter yamlWriter: The internal writer used to build the YAML string that will be written to disk.String indent: The current indent level. Always prepend this when adding new lines to keep formatting consistent.String fullKey: The fully-qualified config key, likefeatures.enableOptimization.Field field: The JavaFieldbeing serialized.T annotation: The annotation instance you defined, used to customize behavior.
Example Output
For the field:
@Experimental
public boolean example = false;The saved YAML will include:
## === EXPERIMENTAL FEATURE ===
example: falseAnnotation Validators
Annotation validators are a way to enforce validation rules on values loaded from a YAML configuration file. These are used in conjunction with custom annotations
such as @NonNegativeNumericValue, which validate that a field satisfies certain criteria before the configuration is accepted.
This system enables you to hook into the configuration deserialization process and throw a ValidationException if a field’s value does not meet the requirements,
which will prevent the server from starting until all requirements are met to avoid the server breaking due to an improper value in the configuration
Defining a Validator
To create your own validator:
-
Create an annotation that defines your validation rule.
-
Your annotation must be annotated with:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD)This ensures it’s available at runtime and only applicable to fields.
-
Create a class implementing the
AnnotationValidationProvider<T>interface, whereTis the annotation you're using. -
Annotate your validator class with
@RegisteredHandler, passing a String that matches the name of the method that returns your annotation class.
Example: @NonNegativeNumericValue
This validator ensures the field is a non-negative number. Here's how it's set up:
Step 1: Define the annotation
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface NonNegativeNumericValue {}Step 2: Create the validator
@RegisteredHandler("nonNegative")
public class NonNegativeProcessor implements AnnotationValidationProvider<NonNegativeNumericValue> {
@Override
public boolean validate(final String fullKey, final Field field, final NonNegativeNumericValue annotation, final Object value) throws ValidationException {
if (value instanceof Number number) {
if (number.floatValue() < 0) {
throw new ValidationException("Value must be a non-negative value");
}
return true;
}
throw new ValidationException("NonNegativeNumericValue validation applied to a non-numeric object.");
}
public Class<NonNegativeNumericValue> nonNegative() {
return NonNegativeNumericValue.class;
}
}Validation Flow
When a configuration file is being deserialized, the validator is invoked automatically if the field has the relevant annotation:
if (field.isAnnotationPresent(annotation)) {
try {
validator.validate(key, field, field.getAnnotation(annotation), value);
} catch (ValidationException exception) {
throw new RuntimeException("Field " + key + " did not pass validation of " + annotation.getSimpleName()
+ " for reason of '" + exception.getMessage() + "'");
}
}Parameters Provided
The validate() method gives you access to:
String fullKey: The fully qualified configuration key (e.g.,example.someCoolConfiguration)Field field: The JavaFieldbeing validatedT annotation: The annotation instance, allowing you to access any custom parameters you've definedObject value: The actual value loaded from YAML to validate
Example Use Case
# config.yml
max-tick-rate: -1@NonNegativeNumericValue
public int maxTickRate = 20;This would fail validation with:
RuntimeException: Field maxTickRate did not pass validation of NonNegativeNumericValue
for reason of 'Value must be a non-negative value'Making a PR
When making a PR with your changes, please ensure you have done the following:
Ensure you have followed correct formatting guidelines
Rebuilt all patches
Fixed any generalized formatting issues
Ensured any configuration options added have at least a @Comment annotation
Test THOROUGHLY. The PR needs to be production ready at the time of merging, meaning all testing needs to be done preferably before the PR is made
Last updated on