urd 0.1.0

Dialogue scripting language for games — compiler, VM, and static analysis
Documentation

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 = Vm::new(graph, registry)?;

loop {
    match vm.next(None) {
        VmStep::Event(Event::Dialogue { speakers, lines, .. }) => {
            // render however you want
        }
        VmStep::Event(Event::Choice { options, .. }) => {
            let picked = show_menu(&options);
            vm.next(Some(picked));
        }
        VmStep::Ended => break,
        VmStep::Error(e) => eprintln!("runtime error: {e}"),
    }
}

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