Mastering AI Tool-Calling with Java: Build Your Own Dungeon Master with Quarkus and LangChain4j
Turn a local LLM into a dice-rolling, decision-making RPG game master. Powered by Java, Quarkus, and the magic of LangChain4j.
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
ormistral
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
Open src/main/resources/application.properties
and configure Ollama:
quarkus.langchain4j.ollama.chat-model.model-id=llama3.1:latest
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);
}
}
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;
}
}
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);
}
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;
}
}
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);
}
}
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>
Launch and Play!
Start your game server:
./mvnw quarkus:dev
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.