Dialog-tree library for MinecraftTalk to anything.
YapYap API turns any entity into an RPG-style NPC with a programmable, branching conversation - authored in a datapack or in Java, rendered in a custom in-game screen.

What you get#
Branching trees
Nodes, click-through text pages, and responses gated by conditions. Same shape in JSON and Java.
Datapack native
data/<ns>/yapyap/dialogs/*.json, hot-reloaded with /reload, decoded through a Mojang Codec.
Extensible
condition and action types are a string-dispatched registry, so you can add your own from a mod.
Custom UI
Non-pausing screen with a portrait, a styled typewriter, and response rows. Works with zero textures.
Server-authoritative
The client only sends "I picked N". Every transition re-checks conditions, so modded clients cannot skip gates.
Persistent progress
Per-player flags and visited-node history saved with the player, copied on death.
60-second taste#
A datapack dialog at data/mypack/yapyap/dialogs/guard.json:
{
"npc_name": "Town Guard",
"start": "start",
"nodes": {
"start": {
"text": ["Move along, traveller.", "Unless you have business with the captain?"],
"responses": [
{ "text": "I need to see the captain.", "target": "pass",
"conditions": [ { "type": "yapyap:advancement", "advancement": "mypack:reached_town" } ] },
{ "text": "Just passing through.", "target": "end" }
]
},
"pass": {
"text": "...fine. Through the gate.",
"on_enter": [ { "type": "yapyap:set_flag", "flag": "guard_pass", "set": 1 } ],
"responses": [ { "text": "Thanks.", "target": "end" } ]
}
}
}
Bind it to a mob and start talking:
yapyap assign @e[type=villager,limit=1,sort=nearest] mypack:guard "Town Guard"
# now right-click the villager, or:
yapyap talk mypack:guard @s
OverviewGetting Started
Install as a dependency#
YapYap is a library mod. Drop the jar in mods/ alongside your mod, or consume it from Gradle.
gradle wrapper # first checkout only (or use your IDE's Gradle)
./gradlew build # -> build/libs/yapyap-0.1.0.jar
./gradlew runClient # dev client with the example dialogs loaded
./gradlew publishToMavenLocal
// build.gradle
dependencies {
implementation "dev.yapyap:yapyap:0.1.0" // from mavenLocal / your maven
}
# your neoforge.mods.toml
[[dependencies.yourmod]]
modId = "yapyap"
type = "required"
versionRange = "[0.1.0,)"
ordering = "AFTER"
side = "BOTH"
Pick an authoring path#
| You want to… | Use |
|---|---|
| Ship dialogs a pack-maker can edit without recompiling | Datapack JSON |
| Generate dialogs from your mod's data / register defaults | DialogBuilder + YapYapAPI |
| Gate conversations, react to choices, wire quests | Events |
| Add your own condition / action keywords | Custom Types |
In a dev client run /yapyap demo - you get a villager spawn egg. Place it, right-click the
villager, and you're in a live conversation with a Trades option and a fetch-quest.
OverviewCore Concepts
The model#
- Dialog - a whole conversation tree. Identified by the
ResourceLocationit is registered under (datapack path orYapYapAPI.registerDialog). - Node - one “screen”: a speaker, one or more click-through pages of text, and a list of responses.
- Response - a selectable answer. May carry
conditions(whether it shows),actions(run when chosen), and atargetnode.
Server-authoritative flow#
All navigation happens on the server. The client renders a fully-resolved snapshot and can only send “I chose index N”. On every transition the server:
- runs the node's
on_enteractions; - evaluates every response's
conditionsand builds the visible list; - resolves placeholders, names and portraits, then pushes the view.
When you pick a response the server re-checks its conditions (anti-cheat), runs its actions, then
navigates. An open_dialog / goto / close action overrides the response's target.
Datapack vs code#
Datapack dialogs are reloaded wholesale on every /reload. Code dialogs
(YapYapAPI.registerDialog) survive reloads. If both define the same id, the datapack wins.
Datapack AuthoringDialog Files
Dialog trees live at:
data/<namespace>/yapyap/dialogs/<path>.json
The dialog id is <namespace>:<path> - e.g. data/mypack/yapyap/dialogs/town/elder.json
→ mypack:town/elder. Files load on world load and every /reload; one bad
file is logged and skipped, the rest still load, and active conversations are closed.
Dialog#
{
"npc_name": "Elder Maren",
"portrait": "mypack:textures/gui/portrait/maren.png",
"portrait_entity": "minecraft:villager",
"start": "start",
"closable": true,
"auto_close_range": 8.0,
"nodes": {
"start": { /* Node */ }
}
}
| Field | Type | Default | Notes |
|---|---|---|---|
npc_name | text component | - | Default speaker name. |
portrait | id | - | 128×128 texture (see Textures). |
portrait_entity | id | - | Entity type rendered as a live model when there's no texture and no bound live entity. |
start | string | "start" | Id of the first node. |
nodes | object | required | At least one entry; must contain start. |
closable | boolean | true | Can the player press Esc to leave? |
auto_close_range | number | 8.0 | Blocks from the NPC before auto-close. ≤0 uses the server config value. |
Anything the vanilla text parser accepts: a bare string, a {"text": …} object, or an
array of components. In a text field on a node, a top-level array = one page per entry.
Datapack AuthoringNodes & Responses
Node#
{
"speaker": "Elder Maren",
"text": [
"You came back.",
"I wasn't sure you would."
],
"portrait": "mypack:textures/gui/portrait/maren_angry.png",
"type_speed": 60.0,
"on_enter": [ /* Action, ... */ ],
"responses": [ /* Response, ... */ ],
"auto_advance": "next_node"
}
| Field | Type | Notes |
|---|---|---|
speaker | text | Overrides the dialog / NPC name for this node. |
text | text or array | One entry = one click-through page. |
portrait | id | Overrides the portrait for this node. |
type_speed | number | Typewriter chars/second for this node (0 = instant). |
on_enter | Action[] | Run the moment the node becomes active. |
responses | Response[] | If empty, the node is terminal unless auto_advance is set. |
auto_advance | string | With no responses, the “Continue” button jumps here. |
Response#
{
"text": "Ask about the ruins",
"conditions": [ /* Condition, ... */ ],
"hide_when_locked": true,
"once": false,
"actions": [ /* Action, ... */ ],
"target": "ruins"
}
| Field | Type | Default | Notes |
|---|---|---|---|
text | text | required | The button label. |
conditions | Condition[] | [] | All must pass for the option to be usable. |
hide_when_locked | boolean | true | false = show it greyed-out instead of hiding it. |
once | boolean | false | true = gone forever after being chosen once. |
actions | Action[] | [] | Run in order when chosen. |
target | string | - | Next node id. Omit / "end" / "close" ends the conversation. |
Point several responses at different nodes and gate them with conditions (flags, items,
advancements…). There is no separate “router” node type - the conditions are the branching.
Datapack AuthoringConditions
Every condition is { "type": "yapyap:<id>", … }. They gate whether a response is shown or usable.
| Type | Fields | Passes when |
|---|---|---|
all_of | conditions: [ … ] | every sub-condition passes |
any_of | conditions: [ … ] | at least one passes |
not | condition: { … } | the sub-condition fails |
flag | flag, one of equals / (at_least and/or at_most) | the player flag matches |
visited | node, dialog? (defaults to current) | the player has entered that node before |
advancement | advancement (id) | the player has completed it |
score | objective, at_least?, at_most? | the player's scoreboard score is in range (missing = 0) |
permission_level | level (int) | source.hasPermission(level) |
has_item | item (id), count? (default 1) | the player's inventory holds ≥ count |
chance | chance (0.0-1.0) | a random roll succeeds (re-rolled each time the node is shown) |
sneaking | sneaking (bool) | the player's crouch state matches |
dimension | dimension (id) | the player is in that dimension |
"conditions": [
{ "type": "yapyap:flag", "flag": "smith_quest", "equals": 1 },
{ "type": "yapyap:has_item", "item": "minecraft:iron_ingot", "count": 5 },
{ "type": "yapyap:not", "condition": { "type": "yapyap:visited", "node": "lore" } }
]
Datapack AuthoringActions
Every action is { "type": "yapyap:<id>", … }. Actions run on on_enter and on chosen responses.
| Type | Fields | Effect |
|---|---|---|
run_command | command, as? (player/npc/server, default server), silent? (default true) | runs a command; placeholders substituted first. Gated by allowCommandActions. |
run_function | function (id) | runs an .mcfunction as the player (perm 2, output suppressed). Gated by allowCommandActions. |
set_flag | flag, set or add | writes / increments a player flag (0 = unset). |
give_item | item (id), count? (1) | gives the item, dropping overflow. |
take_item | item (id), count? (1) | removes up to count from the inventory. |
grant_advancement | advancement (id), criterion? | awards a criterion (or all of them). |
play_sound | sound (id), volume? (1.0), pitch? (1.0) | plays a sound at the player. |
message | message (text) | system chat message to the player. |
actionbar | message (text) | action-bar message to the player. |
open_dialog | dialog (id), node? | ends this conversation and starts another (keeps the NPC). |
open_trades | - | ends the conversation and opens the NPC's merchant screen (villager/merchant; trades auto-generate). |
close | - | ends the conversation. |
goto | node | jumps to another node in the current tree (overrides target). |
"on_enter": [
{ "type": "yapyap:take_item", "item": "minecraft:iron_ingot", "count": 5 },
{ "type": "yapyap:give_item", "item": "minecraft:iron_chestplate", "count": 1 },
{ "type": "yapyap:set_flag", "flag": "smith_quest", "set": 2 },
{ "type": "yapyap:play_sound", "sound": "minecraft:block.anvil.use", "volume": 0.7, "pitch": 1.1 },
{ "type": "yapyap:run_function", "function": "mypack:quests/smith_done" }
]
run_command and run_function can be disabled server-wide with the
allowCommandActions config key - do that when loading untrusted datapacks.
Datapack AuthoringPlaceholders
Substituted inside literal text runs at display time (styling and siblings are preserved).
| Token | Resolves to |
|---|---|
%player% | the talking player's account name |
%player_display% | the player's display name (team colours, nicknames) |
%npc% | the resolved speaker name |
%npc_profession% | capitalised villager profession, or traveler / villager |
%dialog% | the current dialog id |
%node% | the current node id |
%<key>% | anything added with DialogContext.setVar("key", "value") from a custom action |
"text": ["Oh - hello there, %player%.", "I'm the local %npc_profession%. What can I do for you?"]
Java AuthoringYapYapAPI
The single public entry point (dev.yapyap.api.YapYapAPI). All of it is safe to call from
common code - the UI is driven automatically over the network.
| Area | Methods |
|---|---|
| Dialogs | registerDialog(id, Dialog) · registerDialog(DialogBuilder) · getDialog(id) · dialogIds() |
| Custom types | registerCondition(id, MapCodec) · registerAction(id, MapCodec) |
| Conversations | openDialog(player, id) · openDialog(player, id, npc) · openDialogAtNode(player, id, node) · closeDialog(player) · isTalking(player) |
| NPC bindings | bindNpc(entity, id) · bindNpc(entity, NpcDialog) · unbindNpc(entity) · npcBinding(entity) |
| Player state | getFlag · setFlag · addFlag · hasVisited · resetProgress |
// open one yourself (server side)
YapYapAPI.openDialog(serverPlayer, MyMod.id("smith"));
YapYapAPI.openDialog(serverPlayer, MyMod.id("smith"), someVillager); // NPC context: %npc%, live portrait, freeze, range
// bind so right-click works
YapYapAPI.bindNpc(entity, MyMod.id("smith"));
// flags
YapYapAPI.setFlag(serverPlayer, "smith_quest", 2);
int stage = YapYapAPI.getFlag(serverPlayer, "smith_quest");
Java AuthoringDialogBuilder
Fluent trees in code. Register the result once (e.g. during FMLCommonSetupEvent).
import static dev.yapyap.api.action.DialogActions.*;
import static dev.yapyap.api.condition.DialogConditions.*;
ResourceLocation ingot = ResourceLocation.parse("minecraft:iron_ingot");
YapYapAPI.registerDialog(DialogBuilder.create(MyMod.id("smith"))
.npcName("Bram the Smith")
.portraitEntity(ResourceLocation.parse("minecraft:villager"))
.node("start", n -> n
.text("The forge runs hot today, %player%.")
.response(r -> r.text("Any work for me?")
.condition(flag("smith_quest", 0))
.target("offer"))
.response(r -> r.text("Here are your ingots.")
.condition(flag("smith_quest", 1), hasItem(ingot, 5))
.action(take(ingot, 5), grant(MyMod.id("smithing")))
.target("thanks"))
.response(r -> r.text("Passing through.").closeDialog()))
.node("offer", n -> n
.onEnter(setFlag("smith_quest", 1))
.text("Five iron ingots. Don't dawdle.")
.response(r -> r.text("You'll have them.").closeDialog()))
.node("thanks", n -> n
.text("Forged fresh. Wear it well.")));
Builder surface
| On | Methods |
|---|---|
DialogBuilder | create(id) · npcName · portrait · portraitEntity · start · notClosable() · autoCloseRange · node(id, spec) |
node spec | speaker · text (repeat for pages) · portrait · typeSpeed · onEnter(actions…) · autoAdvance · response(spec) |
response spec | text · condition(…) · showWhenLocked() · once() · action(…) · target(node) · closeDialog() |
Terse factories live in DialogConditions (flag, flagAtLeast,
visited, advancement, hasItem, chance, allOf,
anyOf, not, …) and DialogActions (command,
function, setFlag, give, take, grant,
sound, message, openDialog, openTrades, goTo,
close, …).
Java AuthoringEvents
Fired on NeoForge.EVENT_BUS around every conversation
(dev.yapyap.api.event.YapYapEvents). The base DialogEvent exposes
getPlayer() and getDialogId().
| Event | Cancelable | Extra | Fires |
|---|---|---|---|
Open | yes | getNpc() | before a conversation starts |
NodeEnter | no | getNodeId() | each time a node becomes active (after its on_enter) |
Choice | yes | getNodeId(), getResponseIndex() | when a valid response is picked, before its actions |
Close | no | getNodeId(), getReason() | when a conversation ends |
@SubscribeEvent
static void onOpen(YapYapEvents.Open event) {
if (questMod.isMidCutscene(event.getPlayer())) {
event.setCanceled(true); // block the conversation
}
}
@SubscribeEvent
static void onChoice(YapYapEvents.Choice event) {
analytics.record(event.getDialogId(), event.getNodeId(), event.getResponseIndex());
}
getReason() is a CloseReason: FINISHED, PLAYER_CLOSED,
OUT_OF_RANGE, NPC_GONE, REPLACED, RELOADED,
DISCONNECT, SHUTDOWN, API, INTERRUPTED, ERROR.
Java AuthoringCustom Condition & Action Types
condition and action types are a string-dispatched codec registry. Register
yours before dialogs are parsed (e.g. FMLCommonSetupEvent); datapacks can then use them by id.
public record WeatherCondition(String state) implements DialogCondition {
public static final MapCodec<WeatherCondition> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Codec.STRING.fieldOf("state").forGetter(WeatherCondition::state)
).apply(i, WeatherCondition::new));
@Override public boolean test(DialogContext ctx) {
boolean raining = ctx.player().serverLevel().isRaining();
return raining == "rain".equals(state);
}
@Override public MapCodec<? extends DialogCondition> codec() { return CODEC; }
}
// during setup:
YapYapAPI.registerCondition(MyMod.id("weather"), WeatherCondition.CODEC);
{ "type": "mymod:weather", "state": "rain" }
Actions are identical: implement DialogAction (void run(DialogContext) +
codec()) and call YapYapAPI.registerAction(id, codec). Steer the conversation
with ctx.control().close() / goTo(node) / open(dialog, node).
RuntimeNPCs & Behaviour
Binding a dialog to an entity#
A binding is a persistent data attachment. Set it from a command or the API; right-click then opens it.
yapyap assign @e[type=villager,limit=1,sort=nearest] mypack:elder "Elder Maren"
yapyap unassign @e[type=villager,limit=1,sort=nearest]
YapYapAPI.bindNpc(entity, MyMod.id("elder"));
YapYapAPI.npcBinding(entity).ifPresent(b -> ...);
While talking#
- The NPC is pinned in place and turned to face the player every tick. No persistent
NoAiflag is set, so a server crash mid-conversation leaves nothing to clean up - the mob simply resumes on the next tick once the session ends. - Any incoming damage to the player or the NPC ends the conversation immediately
(
CloseReason.INTERRUPTED). - Walking further than
auto_close_rangefrom the NPC, or the NPC dying / unloading, also ends it.
Right-click-to-talk#
Controlled by [npc] config: interactToTalk, requireEmptyHand,
sneakBypassesDialog. See Configuration.
Auto dialogs#
Two opt-in behaviours give un-bound entities a bundled conversation:
| Toggle | Command | Config key | Effect |
|---|---|---|---|
| Villagers | /yapyap villager_on <bool> | autoVillagerDialog | every un-bound adult villager greets you (yapyap:auto_villager) - uses %player% / %npc_profession%, has a Trades option |
| Funny mode | /yapyap funny <bool> | funnyMode | iron golems (yapyap:auto_iron_golem) and wardens (yapyap:auto_warden) get something to say |
Both default off. The commands flip keys in config/yapyap-common.toml
under [auto_dialogs], so you can also just edit the file.
RuntimeThe Conversation Screen
A non-pausing screen drawn over the world. It renders fully procedurally with zero textures; art is optional and swapped in per-asset (see Textures).
Behaviour
- Typewriter - text reveals character-by-character with styles preserved. Click or
press Space/Enter to skip to the full page, again to turn the page. Speed is the
client
typewriterCps(or a node'stype_speed;0= instant). - Responses appear only once the last page is fully shown. A locked
(
hide_when_locked: false) row is greyed with a padlock and ignores clicks. - Portrait fallback chain: authored texture → live model of the bound NPC
(rotates toward the cursor, inventory-preview style) → a stand-in model of the dialog's
portrait_entity→ a drawn silhouette. - Esc leaves if the dialog is
closable.
You rarely need a portrait PNG. A bound NPC already renders live; for command-started dialogs add
"portrait_entity": "minecraft:villager".
RuntimeProgress & Flags
Per-player state is a data attachment - saved with the player, copied on death.
- flags - named integers set by
set_flag/add_flag.0means unset. Read them in conditions (flag) or from the API. - visited -
"<dialogId>/<nodeId>"for every node the player has entered (drives thevisitedcondition). - consumed - which
onceresponses the player has already taken, keyed as<dialogId>/<nodeId>#<index>.
yapyap flag @s get smith_quest
yapyap flag @a set intro_seen 1
yapyap reset @s # clears visited + once history, keeps flags
once responses are keyed by index. Re-ordering responses in a shipped tree can shift which
one counts as “already taken”.
ReferenceCommands
/yapyap … - all subcommands require permission level 2.
| Command | Does |
|---|---|
talk <dialog> [<players>] [<npc>] | starts a conversation (optionally with an NPC entity for context) |
assign <npc> <dialog> [<name>] | binds a dialog to an entity |
unassign <npc> | removes the binding |
stop <players> | force-closes conversations |
reset <players> | clears visited / once history (keeps flags) |
flag <players> get|set|add|clear <flag> [<value>] | inspects / edits player flags |
list | lists every loaded dialog id (datapack + code) |
demo [<players>] | gives a villager spawn egg; the placed villager gets a random profession + the yapyap:demo_villager dialog (Trades + a wheat quest) |
villager_on <true|false> | toggles autoVillagerDialog (persisted to the config file) |
funny <true|false> | toggles funnyMode (persisted to the config file) |
ReferenceConfiguration
config/yapyap-common.toml#
| Section / key | Default | Meaning |
|---|---|---|
[npc] interactToTalk | true | right-click a bound entity to start its dialog |
[npc] requireEmptyHand | false | only start when the interacting hand is empty |
[npc] sneakBypassesDialog | true | sneaking while interacting skips the dialog |
[conversation] autoCloseRange | 8.0 | fallback blocks-from-NPC before auto-close |
[security] allowCommandActions | true | allow run_command / run_function |
[auto_dialogs] autoVillagerDialog | false | generic greeting for un-bound adult villagers |
[auto_dialogs] funnyMode | false | dialogs for iron golems and wardens |
config/yapyap-client.toml#
| Key | Default | Meaning |
|---|---|---|
typewriterCps | 45 | typewriter chars/second (0 = instant) |
dimBackground | false | darken the world behind the panel |
playTypingSound | true | soft click while text reveals |
The toggle commands (villager_on, funny) write the common file immediately;
NeoForge's file watcher also picks up manual edits.
ReferenceTextures
All optional - the screen has drawn fallbacks for everything. Files live under
assets/yapyap/textures/gui/ and are re-checked on every resource reload.
| File | Size | Notes |
|---|---|---|
dialog_panel.png | 64 × 64 | nine-slice, 8px border; drawn wide & short at the bottom |
portrait_frame.png | 32 × 32 | nine-slice, 6px border; keep the centre ~20px transparent |
icons.png | 32 × 16 | two 16px cells: [0] continue chevron, [1] lock |
portrait/<name>.png | 128 × 128 | referenced by "portrait" on a dialog / node / binding |
response_button.png | - | reserved for a future textured button skin |
ReferenceExamples
Bundled in the jar and loaded in runClient:
| Id | Shows |
|---|---|
yapyap:village_smith | datapack file - pages, condition branching, once, on_enter actions, run_function, flag-gated quest |
yapyap:demo_villager | datapack file - portrait_entity, open_trades, %npc_profession%, a wheat fetch-quest (from /yapyap demo) |
yapyap:auto_villager | code dialog - the greeting used by villager_on |
yapyap:auto_iron_golem · yapyap:auto_warden | code dialogs - funny mode |
The smith quest, end to end
{
"npc_name": { "text": "Bram the Smith", "color": "gold" },
"start": "greet",
"auto_close_range": 6.0,
"nodes": {
"greet": {
"text": ["Well met, %player%.", "Need something shaped, or here to gawk?"],
"responses": [
{ "text": "Any work for me?", "target": "offer",
"conditions": [ { "type": "yapyap:flag", "flag": "smith_quest", "equals": 0 } ] },
{ "text": "Here are your ingots.", "target": "reward",
"conditions": [
{ "type": "yapyap:flag", "flag": "smith_quest", "equals": 1 },
{ "type": "yapyap:has_item", "item": "minecraft:iron_ingot", "count": 5 }
] },
{ "text": "Just passing through.", "target": "end" }
]
},
"offer": {
"text": "Bring me five iron ingots.",
"on_enter": [ { "type": "yapyap:set_flag", "flag": "smith_quest", "set": 1 } ],
"responses": [ { "text": "You'll have them.", "target": "end" } ]
},
"reward": {
"text": "Ha! Good steel sense. Hold still...",
"on_enter": [
{ "type": "yapyap:take_item", "item": "minecraft:iron_ingot", "count": 5 },
{ "type": "yapyap:give_item", "item": "minecraft:iron_chestplate", "count": 1 },
{ "type": "yapyap:set_flag", "flag": "smith_quest", "set": 2 }
],
"responses": [ { "text": "[Nod]", "once": true, "target": "end" } ]
}
}
}
ReferenceChangelog
Unreleased#
- NPCs are frozen in place and turned to face the player during a conversation (no persistent
NoAi); any incoming damage to the player or NPC ends it (INTERRUPTED). /yapyap villager_on <bool>and/yapyap funny <bool>- toggle persistent[auto_dialogs]config keys. Bundled code dialogsyapyap:auto_villager/auto_iron_golem/auto_warden.- Close hint no longer overlaps the bottom response row.
portrait_entityon a dialog / NPC binding /DialogBuilder- renders a live 3D entity model in the portrait.yapyap:open_tradesaction;%npc_profession%placeholder./yapyap demo- villager spawn egg with a random profession + the demo dialog.
0.1.0 - initial#
Dialog/DialogNode/DialogResponsemodel with Codec serialization.- Datapack loading, hot-reloaded on
/reload; fluentDialogBuilder. - String-dispatched condition / action registry - 12 conditions, 12 actions.
- Server-authoritative state machine: per-transition re-checks, action-driven steering,
onceresponses,auto_advancechains. - Custom conversation screen with a style-preserving typewriter.
- Per-player persistence, NPC bindings, per-tick range auto-close.
- Lifecycle events,
/yapyapcommand, common + client config.