Mastering AI Tool-Calling with Java: Build Your Own Dungeon Master with Quarkus and LangChain4j
Markus

Markus @myfear

About: Markus is a Java Champion, former Java EE Expert Group member, founder of JavaLand, reputed speaker at Java conferences around the world, and a very well known figure in the Enterprise Java world.

Location:
Munich, Germany
Joined:
Mar 26, 2024

Mastering AI Tool-Calling with Java: Build Your Own Dungeon Master with Quarkus and LangChain4j

Publish Date: Jul 25
0 0

Welcome, traveler. You are about to embark on a journey through code and imagination, where your compiler becomes your spellbook and your AI model takes on the role of a cunning, unpredictable Dungeon Master. In this hands-on tutorial, we’ll build a full-stack, interactive text adventure powered by Quarkus and LangChain4j, running entirely with a local large language model via Ollama.

But this isn’t just narrative generation. Our AI Dungeon Master will obey the rules of the realm. We'll integrate classic RPG mechanics like health, inventory, and skill checks. These game rules are implemented in Java, exposed to the AI as callable "tools" via LangChain4j, and invoked dynamically during gameplay.

Let’s roll initiative.

Prerequisites

Before you don your cloak and unsheath your IDE, ensure you have the following installed:

  • Java Development Kit (JDK) 17 or higher

  • Apache Maven 3.8.6+

  • Podman or Docker (for running Ollama containers)

  • A local model like llama3.1 or mistral

Project Setup — Laying the Foundation

Open your terminal and run the following command to create a new Quarkus project:

mvn io.quarkus.platform:quarkus-maven-plugin:create \
  -DprojectGroupId=org.acme \
  -DprojectArtifactId=ai-dungeon-master \
  -DclassName="org.acme.DungeonMasterResource" \
  -Dpath="/dungeon" \
  -Dextensions="quarkus-rest-jackson,quarkus-langchain4j-ollama"
cd ai-dungeon-master
Enter fullscreen mode Exit fullscreen mode

Open src/main/resources/application.properties and configure Ollama:

quarkus.langchain4j.ollama.chat-model.model-id=llama3.1:latest
Enter fullscreen mode Exit fullscreen mode

And, as usual, feel free to grab the complete project from my Github repository.

Core Game Mechanics — Modeling the Hero

Our first stop is character creation. We define a simple Player class with attributes and an inventory.

Create src/main/java/org/acme/Player.java:

package org.acme;

import java.util.ArrayList;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;

public class Player {

    private int hp;
    private final int maxHp;
    private final int strength;
    private final int dexterity;
    private final int intelligence;
    private final List<String> inventory;

    public Player() {
        this.maxHp = 20;
        this.hp = 20;
        this.strength = 14;
        this.dexterity = 12;
        this.intelligence = 10;
        this.inventory = new ArrayList<>();
        this.inventory.add("a rusty sword");
        this.inventory.add("a healing potion");
    }

    // This constructor is for JSON deserialization
    @JsonCreator
    public Player(@JsonProperty("hp") int hp, @JsonProperty("maxHp") int maxHp,
            @JsonProperty("strength") int strength, @JsonProperty("dexterity") int dexterity,
            @JsonProperty("intelligence") int intelligence, @JsonProperty("inventory") List<String> inventory) {
        this.hp = hp;
        this.maxHp = maxHp;
        this.strength = strength;
        this.dexterity = dexterity;
        this.intelligence = intelligence;
        this.inventory = inventory;
    }

    public String getStatusSummary() {
        return String.format(
                "HP: %d/%d, Strength: %d, Dexterity: %d, Intelligence: %d, Inventory: [%s]",
                hp, maxHp, strength, dexterity, intelligence, String.join(", ", inventory));
    }

    // Standard Getters
    public int getHp() {
        return hp;
    }

    public int getMaxHp() {
        return maxHp;
    }

    public int getStrength() {
        return strength;
    }

    public int getDexterity() {
        return dexterity;
    }

    public int getIntelligence() {
        return intelligence;
    }

    public List<String> getInventory() {
        return inventory;
    }

    // Methods to modify player state
    public void takeDamage(int amount) {
        this.hp = Math.max(0, this.hp - amount);
    }

    public void heal(int amount) {
        this.hp = Math.min(this.maxHp, this.hp + amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

This class is serializable and supports dynamic state updates as gameplay progresses.

Game Logic — Building the Dice Roller

Now for the magic: a "Tool" that the AI can use to determine the outcome of an action. This class will contain our dice-rolling logic.

Create src/main/java/org/acme/GameMechanics.java:

package org.acme;

import java.util.Random;

import dev.langchain4j.agent.tool.Tool;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

@ApplicationScoped
public class GameMechanics {

    private final Random random = new Random();

    @Inject
    PlayerProvider playerProvider;

    @Tool("Performs a skill check for a given attribute (strength, dexterity, or intelligence). Returns true for success, false for failure.")
    public boolean performSkillCheck(String attribute) {
        Player player = playerProvider.getCurrentPlayer();
        int attributeValue;
        switch (attribute.toLowerCase()) {
            case "strength":
                attributeValue = player.getStrength();
                break;
            case "dexterity":
                attributeValue = player.getDexterity();
                break;
            case "intelligence":
                attributeValue = player.getIntelligence();
                break;
            default:
                attributeValue = 10; // Neutral check for unknown attributes
        }

        // Classic D&D-style check: d20 + attribute modifier vs. a Difficulty Class (DC)
        int modifier = (attributeValue - 10) / 2;
        int diceRoll = random.nextInt(20) + 1; // A d20 roll
        int difficultyClass = 12; // A medium difficulty

        boolean success = (diceRoll + modifier) >= difficultyClass;

        System.out.printf("--- Skill Check (%s): Roll (%d) + Modifier (%d) vs DC (%d) -> %s ---%n",
                attribute, diceRoll, modifier, difficultyClass, success ? "SUCCESS" : "FAILURE");

        return success;
    }
}
Enter fullscreen mode Exit fullscreen mode

This method becomes a callable AI tool. The LLM will invoke it when prompted to evaluate uncertain outcomes.

AI Dungeon Master — Injecting Intelligence

With our rules in place, we need to create an AI service that knows how to use them.

Create src/main/java/org/acme/GameMaster.java:

package org.acme;

import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;

@RegisterAiService(tools = GameMechanics.class)
public interface GameMaster {

    @SystemMessage("""
            You are a creative and engaging dungeon master for a text-based adventure game.
            Your goal is to create a fun and challenging experience for the player.
            Describe the world, the challenges, and the outcomes of the player's actions in a vivid and descriptive manner.

            When the player describes an action that could succeed or fail (like attacking a goblin, sneaking past a guard,
            persuading a merchant, or forcing open a door), you MUST use the 'performSkillCheck' tool to determine the outcome.
            Base your choice of attribute (strength, dexterity, intelligence) on the nature of the action.

            After using the tool, you MUST narrate the result to the player. For example, if the skill check is a success,
            describe how the player heroically succeeds. If it's a failure, describe the unfortunate (and sometimes humorous) consequences.

            Always end your response by presenting the player with clear choices to guide their next action.
            """)
    String chat(@UserMessage String message);
}
Enter fullscreen mode Exit fullscreen mode

This binds the AI to our game logic. Every call to chat(...) will trigger a new response based on the prompt and rules.

REST Interface — Managing State and Interaction

Now let’s create the REST controller that connects everything.

First, define a response DTO: src/main/java/org/acme/GameResponse.java:

package org.acme;

public class GameResponse {
    private final String narrative;
    private final Player player;

    public GameResponse(String narrative, Player player) {
        this.narrative = narrative;
        this.player = player;
    }

    public String getNarrative() {
        return narrative;
    }

    public Player getPlayer() {
        return player;
    }
}
Enter fullscreen mode Exit fullscreen mode

Then implement the web resource and replace the DungeonMasterResource content with the following src/main/java/org/acme/DungeonMasterResource.java:

package org.acme;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/dungeon")
@ApplicationScoped 
public class DungeonMasterResource {

    @Inject
    GameMaster gameMaster;

    @Inject
    PlayerProvider playerProvider;

    private Player player = new Player();
    private final StringBuilder memory = new StringBuilder();

    @POST
    @Path("/start")
    @Produces(MediaType.APPLICATION_JSON)
    public GameResponse startGame() {
        this.player = new Player(); // Reset player for a new game
        memory.setLength(0); // Clear memory
        playerProvider.setCurrentPlayer(this.player); // Set current player for tools

        String startingPrompt = "The player has started a new game. Provide an engaging starting scenario in a fantasy tavern and present the first choice.";
        String narrative = gameMaster.chat(startingPrompt);
        memory.append("DM: ").append(narrative).append("\n");
        return new GameResponse(narrative, this.player);
    }

    @POST
    @Path("/action")
    @Produces(MediaType.APPLICATION_JSON)
    public GameResponse performAction(String action) {
        playerProvider.setCurrentPlayer(this.player); // Set current player for tools

        String playerStatus = "Current Player Status: " + player.getStatusSummary() + "\n";
        String fullPrompt = playerStatus + "Previous events:\n" + memory.toString() + "\nPlayer action: " + action;

        String narrative = gameMaster.chat(fullPrompt);

        // Append to memory
        memory.append("Player: ").append(action).append("\n");
        memory.append("DM: ").append(narrative).append("\n");

        return new GameResponse(narrative, this.player);
    }
}
Enter fullscreen mode Exit fullscreen mode

This setup enables rich, memory-enhanced gameplay while keeping player state in memory for simplicity.

The HTML Frontend

Create src/main/resources/META-INF/resources/index.html. It’s clean, styled, and functional. The JavaScript dynamically updates player state and handles actions.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>AI Dungeon Master</title>
    <style>
        /* ommited for brevity */
    </style>
</head>
<body>

<div class="container">
    <div class="game-panel">
        <h1>AI Dungeon Master</h1>
        <div id="narrative">Starting your adventure...</div>
        <div class="input-area">
            <input type="text" id="actionInput" placeholder="What do you do?" disabled>
            <button id="submitButton" onclick="performAction()" disabled>Submit</button>
        </div>
    </div>

    <div class="player-panel">
        <h2>Player Status</h2>
        <div id="player-stats">
            <p><strong>HP:</strong> <span id="player-hp">--</span></p>
            <p><strong>Strength:</strong> <span id="player-str">--</span></p>
            <p><strong>Dexterity:</strong> <span id="player-dex">--</span></p>
            <p><strong>Intelligence:</strong> <span id="player-int">--</span></p>
        </div>
        <h2>Inventory</h2>
        <ul id="player-inventory">
            <li>--</li>
        </ul>
    </div>
</div>

<script>
    const narrativeDiv = document.getElementById('narrative');
    const actionInput = document.getElementById('actionInput');
    const submitButton = document.getElementById('submitButton');

    async function updateUI(response) {
        const data = await response.json();

        // Update narrative
        narrativeDiv.innerText = data.narrative;

        // Update player stats
        const player = data.player;
        document.getElementById('player-hp').innerText = `${player.hp}/${player.maxHp}`;
        document.getElementById('player-str').innerText = player.strength;
        document.getElementById('player-dex').innerText = player.dexterity;
        document.getElementById('player-int').innerText = player.intelligence;

        // Update inventory
        const inventoryList = document.getElementById('player-inventory');
        inventoryList.innerHTML = ''; // Clear old items
        if (player.inventory && player.inventory.length > 0) {
            player.inventory.forEach(item => {
                const li = document.createElement('li');
                li.textContent = item;
                inventoryList.appendChild(li);
            });
        } else {
            inventoryList.innerHTML = '<li>Empty</li>';
        }

        actionInput.disabled = false;
        submitButton.disabled = false;
        actionInput.focus();
    }

    async function startGame() {
        const response = await fetch('/dungeon/start', { method: 'POST' });
        await updateUI(response);
    }

    async function performAction() {
        const action = actionInput.value;
        if (!action) return;

        narrativeDiv.innerText += "\n\n> " + action + "\n\n...thinking...";
        actionInput.value = '';
        actionInput.disabled = true;
        submitButton.disabled = true;

        const response = await fetch('/dungeon/action', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: action
        });
        await updateUI(response);
    }

    actionInput.addEventListener('keyup', function(event) {
        if (event.key === 'Enter') {
            event.preventDefault();
            performAction();
        }
    });

    // Start the game on page load
    startGame();
</script>

</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Launch and Play!

Start your game server:

./mvnw quarkus:dev
Enter fullscreen mode Exit fullscreen mode

Then open http://localhost:8080

in your browser. Your adventure begins immediately.

Try something like:

“I examine the bartender for clues.”

“I sneak into the back room.”

“I throw a punch at the orc.”

Watch the console logs to see the dice roll outcomes—just like a real tabletop game. Observe the logs to see the dice roll!

The Quest is Yours

You’ve just created a full-stack, AI-powered, stateful RPG engine using Java and Quarkus. Your AI Dungeon Master calls real Java methods behind the scenes to decide the fate of your player. The story is no longer static—it lives, breathes, and rolls 1d20.

What will you build next?

Check out quarkus.io to explore more extensions and keep leveling up your Java game.

Comments 0 total

    Add comment