Shipping a Java Game Server Without the Java


Voidgun's dedicated server is a Java application. It runs libGDX in headless mode, simulates Box2D physics at 45 ticks per second, manages WebRTC peer connections, and handles up to 32 players per match. Shipping it as a JAR file means shipping the entire game's source code in a zip archive that anyone can decompile in seconds. Java bytecode is famously easy to reverse. ProGuard-style obfuscation renames things, but the structure, logic, and algorithms are all still right there.

GraalVM Native Image solves this by compiling the server ahead of time into a single native Linux binary. No JVM, no bytecode, no class files. The output is machine code. It also happens to start instantly and use a fraction of the memory, but the real motivation is simple: I don't want to hand out readable source code with every server binary.

WHY NOT JUST OBFUSCATE?

Java obfuscators like ProGuard rename classes and methods to meaningless letters. The problem is that everything else survives: control flow, string constants, method signatures, class hierarchies, field layouts. Tools like JD-GUI, CFR, and Procyon reconstruct readable Java from obfuscated bytecode in seconds. You can follow the logic, find the network protocol, extract the physics constants, and understand the game simulation without much effort.

Native Image compilation does something fundamentally different. It runs a static analysis of the entire application at build time, determines which code is actually reachable, compiles it all to native machine code, and strips the rest. The output is a standard ELF binary. Reverse engineering it means working with x86_64 assembly, reconstructing data structures from memory layouts, and dealing with inlined methods and optimized control flow. It's the same difficulty as reverse engineering any C or C++ program.

THE BUILD PIPELINE

The server build happens in two stages. First, Gradle assembles a fat JAR containing the server code, the shared core module, all dependencies (libGDX, Box2D, Java-WebSocket, webrtc-java), and the native .so libraries for Linux. Then native-image takes that JAR and compiles it into a standalone executable.

The Gradle task that drives it:

tasks.register('nativeImage', Exec) {
  dependsOn jar

  commandLine 'native-image',
    '-jar', jarFile.absolutePath,
    '-o', "${outputDir}/${outputName}",
    '-H:ReflectionConfigurationFiles=' + reflectConfig,
    '-H:ResourceConfigurationFiles=' + resourceConfig,
    '-H:JNIConfigurationFiles=' + jniConfig,
    '-march=compatibility',
    '--no-fallback',
    '-Dfile.encoding=UTF-8',
    '-H:AdvancedObfuscation=export-mapping'
}

A few flags worth noting:

--no-fallback Fail the build if anything requires JVM fallback. No silent regressions.
-march=compatibility Produce a binary that runs on any x86_64 CPU, not just the build machine's specific model.
AdvancedObfuscation Additional symbol stripping on top of native compilation. Exports a mapping file for debugging.

THE CLOSED-WORLD PROBLEM

Native Image operates on a closed-world assumption: everything the application will ever use must be known at build time. No dynamic class loading. No runtime reflection unless explicitly declared. No JNI calls unless registered. This is the opposite of how most Java applications work, and it's where most of the configuration effort lives.

Voidgun's server hits all three pain points: reflection, JNI, and bundled resources.

REFLECTION CONFIG

libGDX deserializes JSON config files using reflection. When you write json.fromJson(GameConfig.class, file), it introspects the class at runtime: fields, constructors, types. Native Image strips all of this metadata by default, so every reflectable class must be declared in reflect-config.json.

The game config classes are straightforward:

{
  "name": "studio.whitlock.spacedout.core.shared.GameConfig",
  "allDeclaredFields": true,
  "allDeclaredConstructors": true
}

But the config file is 450 lines long, because the server also reflects into libGDX internals (HeadlessApplication, Color, Array), Box2D physics classes (World, Body, Fixture, every shape type, every def type), and the entire webrtc-java library (PeerConnectionFactory, RTCPeerConnection, RTCDataChannel, plus 40+ enum and observer types).

Getting this list right is a trial-and-error process. The native binary launches, hits a reflection call to an unregistered class, and crashes. You add the class, rebuild, and hit the next one. The build takes minutes, so each cycle is slow. There's no shortcut.

JNI CONFIG

Both Box2D and webrtc-java use JNI to call into native C/C++ libraries. These libraries call back into Java through JNI method lookups, and Native Image needs to know about every single callback.

For Box2D, that means collision callbacks:

{
  "name": "com.badlogic.gdx.physics.box2d.World",
  "methods": [
    { "name": "beginContact", "parameterTypes": ["long"] },
    { "name": "endContact", "parameterTypes": ["long"] },
    { "name": "preSolve", "parameterTypes": ["long", "long"] },
    { "name": "postSolve", "parameterTypes": ["long", "long"] },
    { "name": "contactFilter", "parameterTypes": ["long", "long"] }
  ]
}

For webrtc-java, it's every observer callback and field that the native WebRTC library accesses: PeerConnectionObserver (12 callbacks including onIceCandidate, onDataChannel, onConnectionChange), RTCDataChannelObserver (onStateChange, onMessage), and CreateSessionDescriptionObserver (onSuccess, onFailure). The JNI config is 737 lines.

RESOURCE BUNDLING

Native Image doesn't include classpath resources by default. The server needs JSON config files (weapons, maps, game settings), text files, and the native .so shared libraries that Box2D and webrtc-java load at runtime.

The Gradle task generates the resource config dynamically at build time:

{
  "resources": {
    "includes": [
      { "pattern": ".*\\.json$" },
      { "pattern": ".*\\.txt$" },
      { "pattern": ".*\\.so$" },
      { "pattern": "com/badlogic/gdx/.*" }
    ]
  }
}

The com/badlogic/gdx/* pattern is important. libGDX bundles platform-specific native libraries inside its JARs and extracts them at runtime. Without this pattern, Box2D crashes on startup because it can't find its own native code.

ADVANCED OBFUSCATION & ORACLE GRAALVM

Native compilation already makes reverse engineering hard. But the binary still contains symbol names, string tables, and debug metadata that a determined attacker can use to orient themselves. GraalVM offers a feature called Advanced Obfuscation that goes further: it strips and scrambles internal symbol names, renames methods and class references in the compiled output, and reduces the amount of structural information left in the binary. It's the difference between "hard to read" and "wall of anonymous functions."

The flag is simple:

-H:AdvancedObfuscation=export-mapping

The export-mapping option generates a mapping file that maps the obfuscated symbols back to their original names. This is essential for debugging: if the server crashes in production, you can decode the stack trace using the mapping file without shipping the original symbols in the binary itself.

The catch: Advanced Obfuscation is an Oracle GraalVM feature. It's not available in the Community Edition. This meant switching from GraalVM CE to Oracle's distribution, which also required upgrading to Java 25, as Oracle GraalVM bundles the latest JDK. The server code targets Java 11, but that's a source-level constraint. Native Image compiles everything ahead of time, so the runtime JDK version doesn't matter to the output binary. You compile with JDK 25, the binary runs on bare Linux with no JDK at all.

In the GitHub Actions workflow, this is one line:

- uses: graalvm/setup-graalvm@v1
  with:
    java-version: '25'
    distribution: 'graalvm'

Setting distribution to graalvm (not graalvm-community) pulls Oracle's build. Oracle GraalVM is free for production use under the GraalVM Free Terms and Conditions license, so there's no cost impact. You just get better output.

THE GITHUB ACTIONS WORKFLOW

The native binary is built entirely in CI. Push a version tag and GitHub Actions handles the rest:

# .github/workflows/native-image.yml
name: Build Native Server

on:
  push:
    tags: ['v*']
  workflow_dispatch:

jobs:
  build-linux:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: graalvm/setup-graalvm@v1
        with:
          java-version: '25'
          distribution: 'graalvm'
      - run: bash gradlew :server:jar
      - run: bash gradlew :server:nativeImage
      - uses: softprops/action-gh-release@v2
        if: startsWith(github.ref, 'refs/tags/')
        with:
          files: server/build/native/voidgun-server

The pipeline step by step:

1. Trigger Push a tag matching v* (like v2.0.0), or dispatch manually from the Actions tab.
2. GraalVM The graalvm/setup-graalvm@v1 action installs GraalVM JDK 25 with native-image on the PATH.
3. Fat JAR gradlew :server:jar builds the fat JAR with all dependencies and native libraries bundled inside.
4. Compile gradlew :server:nativeImage feeds the JAR plus the three config files into native-image. Takes several minutes.
5. Release If triggered by a tag push, the binary is uploaded to the GitHub release as voidgun-server-linux-amd64. Otherwise it's available as a downloadable build artifact.

The workflow also supports workflow_dispatch, so I can trigger a build manually from the GitHub UI at any time without tagging a release. Useful for testing native image changes before cutting a version.

WHAT ABOUT DOCKER?

The Dockerfile still exists for the lobby server, which runs as a standard JVM process:

# Build stage
FROM gradle:8-jdk11 AS build
COPY . .
RUN gradle :server:jar

# Run stage
FROM eclipse-temurin:11-jre
COPY --from=build server.jar .
COPY --from=build assets/ .
EXPOSE 9340 3478/udp 3478/tcp
ENTRYPOINT ["java", "-jar", "server.jar", "--lobby"]

The lobby doesn't need source protection. It's a matchmaking relay that lists servers and forwards signaling messages. It also never leaves my own infrastructure: the lobby binary runs on my server and is never distributed to anyone. The game servers are different. Those run the full simulation with physics, AI, weapon logic, and the entire game protocol, and the binary is published on GitHub for anyone to download and host their own server. Those get the native binary treatment.

THE SIDE BENEFITS

Source protection was the motivation, but the operational improvements are real:

Startup time JVM: ~3 seconds Native: instant
Memory at idle JVM: ~150 MB Native: ~30 MB
Deployment JVM: JAR + JRE install Native: single file, scp and run
Dependencies JVM: Java 11+ runtime Native: glibc (any modern Linux)

The instant startup is particularly nice for game servers. When a player creates a match, the server process can spin up and be accepting connections in milliseconds instead of waiting for JVM warmup. The reduced memory footprint also means cheaper VPS hosting. A $5 droplet can comfortably run multiple game server instances.

THE TRACING AGENT

Writing hundreds of lines of reflection and JNI config by hand would be brutal. GraalVM ships a tool that does most of the work for you: the native-image-agent. You run your application on a normal JVM with the agent attached, exercise the code paths you care about, and it records every reflective call, JNI access, resource lookup, and dynamic proxy creation into config files.

The command:

java -agentlib:native-image-agent=config-output-dir=config/ \
    -jar voidgun-server-2.0.0.jar --port 9339

Start the server, let a client connect, play for a bit, exercise the WebRTC handshake, fire some weapons, trigger Box2D collisions, load a few maps. Then shut down cleanly. The agent dumps reflect-config.json, jni-config.json, resource-config.json, and proxy-config.json into the output directory, populated with everything the JVM actually touched at runtime.

The output isn't perfect. It only captures code paths you actually hit during the tracing run. If you forgot to test a specific weapon type, or never triggered ICE restart, those reflection entries will be missing and the native binary will crash when it hits them. The agent also tends to be overly broad in some areas, registering internal JDK classes that don't actually need to be there. I used the agent's output as a starting point, then trimmed and supplemented it by hand over several build-test cycles.

You can also run it in merge mode to accumulate results across multiple runs:

java -agentlib:native-image-agent=config-merge-dir=config/ \
    -jar voidgun-server-2.0.0.jar --port 9339

Each run adds new entries without overwriting existing ones. Run once as a game server, once in lobby mode, once with bots, once with a browser client, once with a desktop client. The merged config covers all the paths. This is what got the bulk of the 450-line reflect config and 737-line JNI config built in a reasonable timeframe.

WHAT I LEARNED

The config files are the real work. Writing the Gradle task and the GitHub workflow took an afternoon. Even with the tracing agent doing the heavy lifting, getting the configs fully correct took days of build-test cycles. The agent catches 90% of it, but the remaining 10% are edge cases that only surface when a specific game event fires for the first time. The error messages from Native Image are often just a class name and a stack trace. You learn to read them fast.

JNI libraries need special attention. Box2D and webrtc-java both load native .so files at runtime. These need to be bundled in the resource config, and the code that extracts and loads them has to be reachable by the static analysis. If the loader uses reflection internally (libGDX's SharedLibraryLoader does), that needs config entries too.

Don't use the GraalVM Gradle plugin if you don't have to. The project has both approaches: a plugin-based config in nativeimage.gradle and a standalone Exec task. The standalone task is simpler, more transparent, and easier to debug. You can see exactly what flags are passed to native-image. The plugin adds dependency management overhead that isn't worth it for a single output binary.

The result: git tag v2.0.0 && git push --tags. A few minutes later, a single native Linux binary appears on the GitHub release page. Copy it to a server, run it, and 32 players are shooting each other in zero gravity. No JVM. No bytecode. No decompilers.

Built with libGDX. Compiled with GraalVM. Shipped as bare metal.

Leave a comment

Log in with itch.io to leave a comment.