Urd
Urd is a dialogue scripting language designed to be easily embeddable into any game engine.
It compiles .urd scripts into a directed graph that a lightweight VM walks one step at a time,
yielding events your engine decides how to render. Urd doesn't care about your UI, your renderer,
or your opinions — it just emits dialogue and choice events and gets out of the way.
Quick taste
const narrator = :{ name: "Narrator", name_color: "white" }
const zara = :{ name: "Zara", name_color: "cyan" }
global gold = 0
global has_torch = false
@entry
label start {
narrator: {
"The wind howls across the barren moor."
"Before you yawns the entrance to an ancient cave."
}
zara: "Halt, traveler. That cave has swallowed many who entered unprepared."
menu {
"Enter the cave" {
jump cave_entrance
}
"Take a torch from Zara" {
has_torch = true
zara: "Here — take this torch. May it keep the shadows at bay."
jump cave_entrance
}
"Walk away" {
narrator: "The Forgotten Cave remains just that — forgotten."
end!()
}
}
}
Notable features
Dialogue is a first-class construct
Speakers are values, not magic strings. Define a struct, give your characters names and colors (or whatever metadata your engine needs), and write dialogue that reads like a screenplay:
struct Character {
name: str
name_color: str
}
const merchant: Character = :{ name: "Aldric", name_color: "#42c8f5" }
merchant: "Welcome, stranger! These are dark times."
Multi-line dialogue blocks keep things tidy:
narrator: {
"In the age before the Sundering, three great powers held the world"
"in uneasy balance: the Guild, the Empire, and the Rebels."
}
Branching with menus
Player choices are expressed with menu blocks. Each option has a label and a body:
menu {
"Ask about the Guild" {
jump guild_lore
}
"Buy supplies and head out" {
gold = gold - 10
jump dungeon_entry
}
"Sleep on it" {
narrator: "You wake refreshed. The crossroads awaits."
jump choose_path
}
}
Labels and jumps
Labels are named sections of dialogue. jump transfers control. Think goto, but for conversations:
@entry
label start {
narrator: "Welcome."
jump chapter_one
}
label chapter_one {
narrator: "It begins."
end!()
}
Need subroutine-style calls? jump ... and return pushes a call frame:
let result = jump some_helper and return
Conditionals and match
Standard if/elif/else:
if gold >= 60 {
narrator: "The bards will sing of this."
} elif gold >= 10 {
narrator: "Not empty-handed, and still breathing."
} else {
narrator: "Survival is its own reward."
}
Pattern matching on enums, literals, ranges, and dice:
enum Faction { Guild, Empire, Rebel }
match player_faction {
Faction.Guild { hero: "I used to be one of them." }
Faction.Empire { hero: "The Empire never trusted the Guild." }
Faction.Rebel { hero: "Nobody listened." }
}
String interpolation
narrator: "You carry {gold} gold and {inventory_count} item(s)."
narrator: "Your {starting_dagger.name} is still sharp enough. Barely."
Dice as first-class values
Roll dice, match on results:
let roll = 2d6
match roll {
2..=4 { narrator: "Critical failure." }
5..=9 { narrator: "Partial success." }
10..=12 { narrator: "Critical hit!" }
}
Multi-file imports
Split your story across files and import what you need:
import "village.urd" as village
import (Faction, narrator, hero) from "characters.urd"
import Item from "items.urd"
Circular imports work fine — the compiler handles it.
Decorators
Attach custom metadata to dialogue events. Define them in-script or register them from your engine at runtime:
decorator slow<event: dialogue>(speed: float) {
event.text_speed = speed
}
@slow(0.3)
narrator: "Time seemed to stop..."
The built-in @entry marks where execution begins. @fluent plugs variables into
Project Fluent for localization.
Localization
First-class Fluent integration. Tag globals with @fluent, generate .ftl scaffolds,
and let translators handle pluralization and grammatical case without touching your scripts:
@fluent
global gold = 50
narrator: "You have {gold} gold."
Functions
Pure functions for when you need logic but not dialogue:
fn clamp(value: int, low: int, high: int) -> int {
if value < low { return low }
if value > high { return high }
return value
}
Frontend-agnostic VM
The VM is pull-based. Your engine drives the loop:
let mut vm = new?;
loop
Urd doesn't draw anything. It doesn't know what a screen is. It yields structured events and your engine does whatever it wants with them.
Tooling
- Tree-sitter grammar — syntax highlighting and indentation for Neovim, Helix, and Zed
- Language server (
urd-lsp) — diagnostics, hover, go-to-definition, rename, completion, and spellcheck - CLI runner (
quest) — run scripts in the terminal, export graphs, generate localization scaffolds - 20+ static analysis passes — from type checking and exhaustiveness to typo detection and infinite loop warnings
License
MIT