The 5-Hour Problem
I've been developing content for Minecraft servers for well over a decade now. Want to know the most terrifying metric from all those years? It's not the lines of code I've written or the bugs I've squashed—it's the cumulative hours I've spent watching plugins compile, servers restart, and Docker images build.
Spoiler alert: it's way more than it should be.
But here's the thing—it wasn't always this painful. Back in my early days, I actually started with Skript, a plugin that let you write scripts in a beautifully simple language and watch them run immediately. No compilation. No waiting. Just pure, instant gratification.
Of course, I quickly outgrew Skript and graduated to Java (as one does), but I never stopped yearning for that magical moment when you could just throw some code together and boom—it works. That feeling of immediate feedback became my white whale.
The Landscape of Broken Dreams
Good news: I wasn't the only developer having these fever dreams about instant Minecraft development. Bad news: every existing solution felt like a deal with the devil.
The scripting environments that existed all seemed to pick their poison:
Option A: The Quick & Dirty Route
- Write unmaintainable spaghetti code that works... for now
- Hack around engine limitations until your code looks like abstract art
- Cross your fingers and hope future-you remembers what past-you was thinking
Option B: The Java Exile
- Kiss goodbye to a decade of Spigot ecosystem goodness
- Abandon battle-tested libraries and frameworks
- Build everything from scratch like it's 2009
Option C: The Java Purist
- Keep all the Java ceremony and bureaucracy
- Lose the scripting magic that made it fun in the first place
- Wonder why you didn't just stick with plain old Java
Option D: The Tool-less Wasteland
- No IDE support, no error checking, no refactoring
- Debug by printf and prayer
- Feel like you're coding in Notepad (because you basically are)
ImagineFun, the server I joined around 2020, used a custom JavaScript-based scripting environment that had been powering content for years. It worked, sure, but it broke pretty much every rule I cared about. Personally, I never felt at home with it—it was like living in a house where all the doors were slightly too short.
The Arrogant Solution
Fast forward a few years, and for absolutely no rational reason, I decided I could do better. Classic developer hubris, right? But sometimes hubris is exactly what you need to tackle an impossible problem.
I set myself some deceptively simple rules:
- Don't reinvent the wheel - The JavaScript ecosystem already solved most problems. Use it.
- Embrace your IDE - If it doesn't have autocomplete, error checking, and refactoring, it's broken.
- Java when you need it - No reflection hacks or memorizing APIs. First-class Java integration.
- Modern JavaScript - Classes, destructuring, spread operators—the full ES6+ experience.
Sounds easy, right? Narrator: It was not easy.
The Runtime Nightmare
The first (and biggest) dragon to slay was running JavaScript in the JVM. The usual suspects each had their own special brand of disappointment:
GraalVM looked promising on paper—modern JavaScript features, better performance, all the buzzwords. But it had one tiny problem: it doesn't support multi-threading. And guess what the Minecraft ecosystem runs on? That's right, threads. Lots of them.
Nashorn was stable and battle-tested, but stuck in the JavaScript stone age (ES5). Plus, it's been deprecated and removed from the JDK since Java 15. Using deprecated technology for a new project felt like building a house on quicksand.
I was about ready to throw in the towel when my internship started, blessing me with 5-hour daily commutes—the perfect environment for overthinking literally everything.
In a moment of beautiful madness, I decided: "If no one else is maintaining Nashorn, I might as well do it myself."
How hard could it be?
Enter Texel: Nashorn's Cooler Younger Sibling
Several weeks of caffeinated coding later, I had Texel—my very own Nashorn fork—running with all the ES6 features that make JavaScript actually enjoyable. CommonJS imports, arrow functions, destructuring, template literals—the whole modern JavaScript experience, all while compiling to Java bytecode.
It was like giving a classic car a Tesla engine. Same reliable foundation, but now it could actually compete in the modern world.
Under the Hood: Where the Magic Actually Happens
Now, let's talk about the fun stuff—the technical nightmare that somehow works.
Nashorn's original architecture is actually pretty clever. It parses JavaScript into an Abstract Syntax Tree (AST), then uses OW2 ASM—a bytecode manipulation framework that's basically the Swiss Army knife of JVM trickery—to generate actual Java bytecode. But here's the thing: it's not purely compilation. There's still a runtime layer that handles the dynamic parts of JavaScript, so it's more like a hybrid approach than pure ahead-of-time compilation.
The problem? Nashorn's compiler was stuck in 2009, generating bytecode patterns that modern JVMs could optimize about as well as a three-legged horse could win the Kentucky Derby.
So I overhauled the bytecode generation engine. Instead of Nashorn's original approach of "let's emit every possible safety check and hope for the best," Texel uses aggressive type inference at compile time. When we can prove a variable is always a number, we skip the runtime type checks. When we know a function call target, we inline it directly. The JVM's JIT compiler loves this stuff—predictable bytecode patterns that it can optimize into machine code that actually screams.
But here's where it gets spicy: JavaScript is a dynamically typed language, and sometimes you just can't know what's going to happen until runtime. For those cases, Nashorn still has all its original runtime logic intact—special handling for loops where the iterator type is unknown, property access on objects that might not exist, and all the other delightful edge cases that make JavaScript... JavaScript.
One of the more fascinating pieces is nasgen—Nashorn's system for converting Java-style implementations into proper JavaScript types. This is how NativeArray becomes the actual JS Array type with all its prototype methods, how NativeObject gets its JavaScript object behavior, and how the entire JavaScript standard library gets its personality. It's like a cultural exchange program between Java and JavaScript, except instead of learning languages, you're learning how to pretend to be a completely different type system.
But here's where it gets messy (and expensive): the interoperability between JavaScript and Java happens through the dynalink library. When you call a Java method from JavaScript, dynalink tries to find the Java method that fits the narrowest types it has at hand and runs it. This type resolution happens at runtime and has not insignificant overhead—every method call becomes a mini type-checking adventure that the JVM has to figure out on the fly.
Think of it like this: instead of having a pre-arranged meeting where everyone knows what language they're speaking, every interaction becomes an impromptu negotiation where dynalink plays translator, trying to figure out "okay, you passed a number, but is it an int, a double, or a BigInteger?" It works, but it's about as efficient as you'd expect from real-time type diplomacy.
The bytecode generation happens through OW2 ASM's visitor pattern—imagine walking through your JavaScript AST and having a very opinionated tour guide who translates every "const x = 5" into the exact sequence of JVM instructions needed to make it happen. Except this tour guide also knows when to take shortcuts, when to inline things, and when to just give up and defer to runtime logic.
Of course, none of this would matter if the generated bytecode was garbage. That's where months of profiling, benchmarking, and obsessing over JVM internals paid off. The final result generates bytecode that's, in some cases, faster than hand-written equivalent Java—not because I'm a wizard, but because when you control the entire compilation pipeline, you can make assumptions that normal Java code can't.
The Type Hinting Revolution
Next challenge: making Java and JavaScript play nicely together. Nashorn's Java.type
function works, but it's about as developer-friendly as assembly language. You get a dumbed-down version of the class, and good luck remembering what methods are available.
But here's the secret sauce—since we control the engine, we know exactly which Java types are being used. So why not tell the IDE about them?
Enter .d.ts
files, TypeScript's way of providing type information for JavaScript. They're already everywhere in the JavaScript ecosystem, and they're what makes your IDE smart enough to know what's available.
Image: HTML Dom type definitions in IntelliJ
So naturally, I built a second compiler (because why not?) that generates TypeScript definitions from Java classes at runtime. It looks at class structures and annotations, then automatically creates type definitions for anything used in JavaScript land.
This approach saves us from compiling the entire SDK (trust me, I tried—it was a resource-consuming nightmare that made IDEs cry).
The result? Full Java-like type completion and error checking in JavaScript. It's like having a bilingual interpreter who actually knows both languages fluently.
Image: Types from imported classes, generated Just In Time
Video: Global types, generated Just In Time
The Feedback Loop
We had the code, the runtime, and the types. But how do you debug transpiled JavaScript running as Java bytecode? The generated bytecode looks like it was obfuscated by someone who really didn't want you to understand it.
Time for the final piece of the puzzle: a real-time web-based viewer that shows you exactly what's happening in your code. Timings, performance metrics, and even a classic icicle chart that makes profiling feel like playing a game.
Image: The web-based timings viewer
The perfect soup of React, Cloudflare Workers, and a sprinkle of WebSockets. It lets you see your code in action, track down performance issues, and even visualize how your code is structured—all without leaving your browser.
Was it overkill? Absolutely. Was it a joy to work with? You bet.
Pretty safe to say I've spent more hours building this than developers will spend using it. But hey, at least I get bragging rights for using the latest React version (while using none of its new features, because who has time for that?).
Battle-Testing in Production
While building PixelScript, I was simultaneously on another mission: completely rewriting and reviving an old Minecraft server. Perfect timing for a real-world stress test.
Every piece of content you see in-game was written in JavaScript using PixelScript. The result? We recreated most of the original content in less than a month. The speed difference was like switching from a bicycle to a motorcycle with a fucking lockheed martin gambit engine haphazardly strapped to the back.
Word spread in my development circles, and the improved Nashorn fork started generating serious interest. ImagineFun decided to slowly migrate their entire runtime to use Texel, and despite still being mid-migration, the response has been positive. Developers are finally allowed to use language features they'd been seeing everywhere else for nearly a decade.
Other colleagues jumped on board, started using it on their own servers, or even started embedding the new compilers in their own products to create extension systems. Either way, they became an invaluable source of feedback and inspiration for new features.
Scaling Beyond the Funny Block Game
Look, I didn't spend months wrestling with JavaScript engines and bytecode generation just to make virtual blocks slightly easier to arrange. Well. I did, but that was just the beginning.
The truth is, PixelScript accidentally became something much bigger than a Minecraft scripting engine. At its core, it's a full suite of custom compilers, dependency managers, type generators, and enough runtime magic to make enterprise Java developers weep with joy (or pain, depending on your opinion of runtime behaviour). The engine itself is fully modular—the core stands on its own as a surprisingly digestible Gradle module that doesn't require a single block or creeper to function.
I've been experimenting with Spring Boot and JavaFX wrappers, and the idea of building full-scale web applications that get to live in JavaScript land while still having access to the entire Java ecosystem is... well, let's just say it makes me feel things that would'nt be appropriate to share in a blog post.
Who knows? Maybe the next revolution in enterprise development will come from someone who got really, really tired of waiting for Minecraft plugins to compile. Probably not, though. But a nerd can dream, right?
A top down view of the PixelScript architecture
The engine exposes a few components to the wrapper application
- The EventBus is a simple event dispatcher where you can hook into internal events like script loading, unloading, script runtime errors, etc.
- You can add custom global-scope extensions through the ExtensionRegistry.
- Even though the engine is opinionated about its own design, it still allows you to register custom transformation steps as a TranspilerFactory, which can be used to add custom syntax or modify the AST before it gets compiled.
- The ScriptLinker is responsible for managing dependencies between scripts, and linking them together at runtime. It handles CommonJS-style imports, so you can write modular code without worrying about the underlying mechanics.
- The FileWatcher is a stupid dumb watcher service that will dispatch a reload request to the ScriptLinker when a file changes, the linker will then kick off chains of reloads and re-executions to ensure that the latest code is always running without breaking dependency chains.
- There's an optional SyntaxHighlighter that can be used to pretty-print code from errors
- And finally, there's a TaskExecutor which runs a simple task queue that can be used to run code in a separate thread, or to schedule tasks to run at a later time. This is exposed in the api to plug into platform specific scheduling frameworks.
The Magic Moment: When Code Becomes Reality
After months of wrestling with compilers, type generators, and enough JavaScript engines to power a small startup, the moment of truth finally arrived. No more ceremonial server restarts. No more ritualistic recompiles. No more standing around like a developer at a broken vending machine, waiting for something to happen.
PixelScript had officially crossed the line from "ambitious side project" to "actual working software." Content could be written, loaded, updated, and executed in real-time—all while the engine quietly handled the boring stuff like compilation and type generation in the background.
The result? An entire game's worth of content materializing before your eyes, faster than you can say "hot reload." It's the kind of instant gratification that makes me wonder why we ever accepted anything less.
Video: PixelScript in action
FAQ: The Usual Suspects
Q: Why not use GraalVM?
A: GraalVM is brilliant, but it's like a sports car that can't handle traffic. The multi-threading limitations make it unsuitable for Minecraft's heavily threaded environment. Plus, it has some performance quirks when running on the JVM that make production use for our specific environment... interesting.
Q: Why not write your own engine from scratch?
A: I mostly ended up doing this anyway, but having a foundation built by minds far smarter than mine made the journey possible. Why reinvent the wheel when you can just make it spin faster? There are a lot of valid critiques about Nashorn, but the AST foundation is solid, the compiletime code generation using OW2 ASM is incredibly well thought out, and the runtime is stable. It just needed a little love to bring it into the modern era (and a lot of time reworking the compiler, bytecode generation and syntax parsing to support modern JavaScript features, but let's not talk about that).
Q: Opensource when?
A: Some parts of the Nashorn fork might find their way into an OpenJDK pull request eventually. The challenge is that most of the code relies on custom tooling (gradle plugins to generate stub classes, nasgen overhaul, testing suites, etc.) that makes it impossible to blindly submit upstream. PixelScript itself—the runtime, plugin, and type compiler—will remain closed source.
Q: How can I use this?
A: Maybe! Reach out and let's see if it's a good fit for your project. The worst that can happen is we have an interesting conversation about JavaScript engines and Minecraft development.
Q: OK, but, what's the catch?
A: The achilles' heel of PixelScript is long lived runtimes that often hot-reload. Classes that are loaded by the JVM at runtime can't be unloaded. Though script instances will be picked up by the garbage collector, the classes themselves will remain in memory until the JVM is restarted. This means that if you have a lot of scripts that are loaded and unloaded frequently (and I mean, tens of thousands of times), you might run into memory issues.
Q: All nice this talk and stuff, but how is it actually integrated?
A: Good question, me from 15 seconds ago!
Step 1: initialize the engine
// create a new PixelScriptEngine instance, pointing to the scripts directory. Files in this directory will be exacuted and watched for changes automatically PixelScriptEngine engine = new PixelScriptEngine(new File("scripts").getPath(), LOGGER);
Step 2: add custom bindings
// in this case, we'll add a literal binding to a custom Java function engine.getBindings().put("makeGreetingMessage", (BiFunction<String, Integer, String>) (name, age) -> { return "Hello " + name + ", you are " + age + " years old!"; }); // we're done! start the show engine.start();
Step 3: create and load a script
// script.js const message = makeGreetingMessage("ToetMats", 25); // callout to a Java function, bound as native JS api console.log(message); // "Hello ToetMats, you are 25 years old!"
Step 4: run the script
// Create a new script runtime, which will automatically load the script and execute it Script script = this.engine.createScriptRuntime(new File("scripts/script.js"))
Step 5: And then, when the script gets changed and is not in a directory that's already being watched:
// the script will be reloaded, and the new code will be executed script.reload();
Sometimes the best solutions come from refusing to accept that "good enough" is actually good enough. PixelScript started as a pipe dream and became a system that proves JavaScript and Minecraft can be best friends—with the right amount of engineering wizardry.
Freebie: example source code
This is some simple code that renders an image in-game, used to test the engine's capabilities and is the screenshot seen in the banner.
image.js
import {imageToTextComponents} from "./renderlib"; import {TextDisplayEntityHelper} from "../../../common/utils/TextDisplayUtil"; const ImageIO = Script.loadClass('javax.imageio.ImageIO'); const File = Script.loadClass('java.io.File'); const imageFile = new File(__plugin.getDataFolder(), "scripts/js.png"); const image = ImageIO.read(imageFile); const location = Bukkit.getPlayer('ToetMats').getLocation(); const imageComponent = imageToTextComponents(image); let up = location.clone(); up = up.add(0, 1.25, -.5); up.setYaw(location.getYaw() + 180); up.setPitch(0) const displayEntity = new TextDisplayEntityHelper(up, imageComponent) displayEntity.setLineWidth(100000); displayEntity.setBackgroundColor(0xff000000); displayEntity.setTextOpacity(1) displayEntity.setScale(.15) displayEntity.setSeeThrough(false) displayEntity.apply() Script.addUnloadCallback(() => { if (displayEntity) { displayEntity.despawn(); } });
renderlib.js
let BufferedImage = Script.loadClass("java.awt.image.BufferedImage"); let Color = Script.loadClass("java.awt.Color"); let RenderingHints = Script.loadClass("java.awt.RenderingHints"); const Component = Script.loadClass('net.kyori.adventure.text.Component'); const TextColor = Script.loadClass('net.kyori.adventure.text.format.TextColor'); const Integer = Script.loadClass('java.lang.Integer'); let ImageToDisplayConfig = { // Display characters PIXEL_CHAR: "⬛", EMPTY_CHAR: " ", // Default settings DEFAULT_MAX_WIDTH: 64, DEFAULT_MAX_HEIGHT: 32, DEFAULT_ALPHA_THRESHOLD: 128, DEFAULT_SCALE: 0.5, DEFAULT_LINE_WIDTH: 1000, // Positioning DEFAULT_SPACING_X: 0, DEFAULT_SPACING_Y: -0.1, DEFAULT_SPACING_Z: 0 }; /** * Resize an image while maintaining aspect ratio * @param {BufferedImage} originalImage - The original image * @param {number} maxWidth - Maximum width * @param {number} maxHeight - Maximum height * @return {BufferedImage} Resized image */ function resizeImage(originalImage, maxWidth, maxHeight) { let originalWidth = originalImage.getWidth(); let originalHeight = originalImage.getHeight(); // Calculate new dimensions while maintaining aspect ratio let aspectRatio = originalWidth / originalHeight; let newWidth = originalWidth; let newHeight = originalHeight; if (originalWidth > maxWidth || originalHeight > maxHeight) { if (aspectRatio > 1) { // Landscape newWidth = maxWidth; newHeight = Math.round(maxWidth / aspectRatio); } else { // Portrait newHeight = maxHeight; newWidth = Math.round(maxHeight * aspectRatio); } } // If no resize needed, return original if (newWidth === originalWidth && newHeight === originalHeight) { return originalImage; } // Create resized image let resizedImage = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_ARGB); let g2d = resizedImage.createGraphics(); g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2d.drawImage(originalImage, 0, 0, newWidth, newHeight, null); g2d.dispose(); return resizedImage; } /** * Convert RGB color to hex string * @param {Color} color - Java Color object * @return {string} Hex color string */ function colorToHex(color) { let r = Integer.toHexString(color.getRed()); let g = Integer.toHexString(color.getGreen()); let b = Integer.toHexString(color.getBlue()); // Pad with zeros if needed if (r.length === 1) r = "0" + r; if (g.length === 1) g = "0" + g; if (b.length === 1) b = "0" + b; return "#" + r + g + b; } /** * Check if two colors are equal (RGB only) * @param {Color} c1 - First color * @param {Color} c2 - Second color * @return {boolean} True if colors are equal */ function colorsEqual(c1, c2) { return c1.getRed() === c2.getRed() && c1.getGreen() === c2.getGreen() && c1.getBlue() === c2.getBlue(); } /** * Process image and convert to text components * @param {BufferedImage} image - The image to process * @param {number} alphaThreshold - Alpha threshold for transparency * @return {Array} Array of text components */ function imageToTextComponents(image) { let width = image.getWidth(); let height = image.getHeight(); let components = []; let currentText = ""; let currentColor = null; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { let rgb = image.getRGB(x, y); let pixelColor = new Color(rgb, true); const isLastOnRow = (x === width - 1); let char = ImageToDisplayConfig.EMPTY_CHAR; if (pixelColor.getAlpha() < 20) { pixelColor = new Color(0, 0, 0, 0); } else { char = ImageToDisplayConfig.PIXEL_CHAR; } if (currentColor === null) { // First colored pixel currentColor = pixelColor; currentText += char; } else if (colorsEqual(currentColor, pixelColor) && !isLastOnRow) { // Same color as current currentText += char; } else { if (currentText.length > 0) { components.push({ text: currentText, color: currentColor, isLastOnRow: isLastOnRow }); currentText = ""; } currentColor = pixelColor; currentText += char; } } } if (currentText.length > 0) { components.push({ text: currentText, color: currentColor }); } let currentComponent = null; for (const l in components) { const line = components[l]; if (currentComponent == null) { currentComponent = Component.text(line.text) .color(TextColor.fromHexString(colorToHex(line.color))); } else { currentComponent = currentComponent.append(Component.text(line.text) .color(TextColor.fromHexString(colorToHex(line.color)))) } if (line.isLastOnRow) { currentComponent = currentComponent.appendNewline() } } return currentComponent; } // Export functions module.exports = { imageToTextComponents };