Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Welcome to Mortar 🦀

Hello and welcome to the world of Mortar!

What is Mortar?

Imagine you’re writing dialogue scripts for a game. You might encounter these frustrations:

  • Text cluttered with various technical markup, making it messy
  • Wanting to play sound effects when certain characters appear, but not knowing how to annotate
  • Writers and programmers constantly “stepping on each other’s toes”

Mortar was created to solve these problems.

It’s a language specifically designed for writing game dialogue, with the core principle of achieving strict separation between text content and event logic.

In essence, our philosophy is simple:

Let text be text, let code be code.

You can focus on writing stories, while the program focuses on handling game logic—the two don’t interfere with each other, yet work together perfectly.

What Can It Do?

Mortar is particularly suitable for these scenarios:

  • 🎮 Game Dialogue Systems: RPG dialogues, visual novels
  • 📖 Interactive Fiction: Text adventures, branching narratives
  • 📚 Educational Content: Interactive tutorials, guided learning scenarios
  • 🤖 Chat Scripts: Structured dialogue logic
  • 🖼️ Multimedia Presentation: Synchronization of text and media events

Why Choose Mortar?

Compared to other dialogue systems, Mortar has these features:

  • Clean and Clear: No messy markup in the text
  • Precise Control: Can specify triggering events at specific character positions (like playing sound effects)
  • Easy to Understand: Syntax designed to feel as natural as everyday writing
  • Easy Integration: Compiles to JSON format, usable by any game engine

Quick Glance

This is what Mortar code looks like:

node OpeningScene {
    text: "Welcome to the magical world!"
    with events: [
        0, play_sound("magic_sound.wav")
        7, sparkle_effect()
    ]
    
    text: "Ready to start your adventure?"
    
    choice: [
        "Yes, I'm ready!" -> AdventureStart,
        "Let me think about it..." -> Hesitate
    ]
}

Looks intuitive, doesn’t it?

Next Steps


Mortar is an open-source project, licensed under MIT/Apache-2.0 dual license ❤️

Quick Start Guide

Let’s write your first Mortar dialogue! Don’t worry, it’s simpler than you think.

Step 1: Create a File

Create a file named hello.mortar (use any text editor, or you can first read Editor Support to learn how to configure your editor).

Step 2: Write Simple Dialogue

Write this in the file:

// This is a comment, this line will be ignored by the compiler, no worries!
// I'll explain the mortar code in comments.

node StartScene {
    text: "Hello, welcome to this interactive story!"
}

That’s it! You’ve written your first dialogue node.

Explanation:

  • node declares a dialogue node (can also be shortened to nd)
  • StartScene is the name of this node (using PascalCase naming)
  • text: is followed by the dialogue content
  • Don’t forget the curly braces {}, they wrap the node’s content

💡 Naming Convention Tips:

  • “Node names” use PascalCase, like StartScene, ForestPath
  • Function names use snake_case, like play_sound, get_player_name
  • We recommend using only English, numbers, and underscores for identifiers

Step 3: Add Some Sound Effects

Now let’s make our dialogue more lively. Assume you’ve already discussed with the programming team—we want each sentence to print slowly like a typewriter:

node StartScene {
    text: "Hello, welcome to this interactive story!"
    with events: [
        // Play a sound effect when the 'H' character appears
        0, play_sound("greeting.wav")
        // Show an animation when the 'w' of "welcome" appears
        7, show_animation("wave")
    ]
}

// Tell Mortar what functions are available
// The programming team needs to bind these functions in the project
fn play_sound(file_name: String)
fn show_animation(anim_name: String)

Explanation:

  • with events: binds the event list to the text line right above it, wrapped in square brackets []
  • 0, play_sound("greeting.wav") means: at index 0 (which corresponds to the character ‘H’ if using a typewriter effect), play the sound.
  • The numbers are “indices,” representing character positions starting from 0. Indices can be decimals (floating-point numbers).
  • These indices are flexible. Some games might use a typewriter effect, while others might align with voice acting. How you count them depends entirely on your project’s needs.
  • The fn declarations at the bottom tell the compiler what parameters these functions require.
  • It’s recommended to use snake_case for function parameter names: file_name, anim_name

Step 4: Add Multiple Dialogue Segments

A node can have several text segments:

node StartScene {
    text: "Hello, welcome to this interactive story!"
    // ↕ This event list binds to the text above it
    with events: [
        0, play_sound("greeting.wav")
    ]
    
    // Second text segment
    text: "I think your name is Ferris, right?"
    
    // Third text segment
    text: "Let's get started then!"
}

These three text segments will display in sequence. The first text has events, while the latter two don’t.

Step 5: Let Players Make Choices

Now let’s involve the player:

node StartScene {
    text: "What would you like to do?"
    
    choice: [
        "Explore the forest" -> ForestScene,
        "Return to town" -> TownScene,
        "I'm done playing" -> return
    ]
}

node ForestScene {
    text: "You bravely ventured into the forest..."
}

node TownScene {
    text: "You returned to the warm town."
}

Explanation:

  • choice: indicates options are here
  • "Explore the forest" -> ForestScene means: display the “Explore the forest” option, if chosen jump to the node named ForestScene
  • The benefit of using PascalCase for nodes shows here—easy to recognize and maintain!
  • return indicates ending the current dialogue

Step 6: Compile the File

Open command line (terminal/CMD), and enter:

mortar hello.mortar

This will generate a hello.mortared file containing JSON format data that your game can read.

Seeing “command not found”? That means you haven’t installed the mortar compiler yet! Please read Installation to install it.

JSON compressed to one line? Add the --pretty parameter:

mortar hello.mortar --pretty

Want to customize output filename and extension? Use the -o parameter:

mortar hello.mortar -o my_dialogue.json

Complete Example

Let’s combine what we just learned and “add some details”:

node WelcomeScene {
    text: "Hello, welcome to the Magic Academy!"
    with events: [
        0, play_sound("magic_sound.wav")
        7, sparkle_effect()
    ]
    
    text: $"Your name is {get_player_name()}, right?"
    
    text: "Ready to start your adventure?"
    
    choice: [
        "Of course!" -> AdventureStart,
        "Let me think about it..." -> Hesitate,
        // Conditional option (only shows if has backpack)
        "Check backpack first" when has_backpack() -> CheckInventory
    ]
}

node AdventureStart {
    text: "Great! Let's go!"
}

node Hesitate {
    text: "No problem, take your time~"
}

node CheckInventory {
    text: "Your backpack has some basic items."
}

// Function declarations
fn play_sound(file: String)
fn sparkle_effect()
fn get_player_name() -> String
fn has_backpack() -> Bool

New features explained:

  • $"Your name is {get_player_name()}, right?" This is called string interpolation, content in {} will be replaced with the function’s return value
  • when indicates this option is conditional, only “displayed” when has_backpack() returns true
  • -> String and -> Bool indicate the function’s return type. Mortar performs static type checking to prevent type mixing!

What to Learn Next?

Congratulations! You’ve learned the basics of Mortar 🎉

Installation

To use Mortar, you need to install the compilation tools. Don’t worry, the process is simple!

If you already have Rust installed, it’s very convenient:

cargo install mortar_cli

Wait for the installation to complete, then check if it was successful:

mortar --version

Seeing the version number means the installation was successful!

Method 2: Build from Source (Not for Average Users)

Want to experience the latest development version? You can build from source (this also requires a Rust development environment):

# Download source code
git clone https://github.com/Bli-AIk/mortar.git
cd mortar

# Build
cargo build --release

# The compiled program is here
./target/release/mortar --version

Tip: The compiled executable is located at target/release/mortar, you can add it to your environment variables.

Method 3: Download from GitHub Release (Not for Average Users)

If you don’t want to use Rust or Cargo, you can also download pre-compiled binaries directly from Mortar’s GitHub Release page.

Linux / macOS

  1. Open the Release page and download the corresponding version, for example mortar-x.x.x-linux-x64.tar.gz or mortar-x.x.x-macos-x64.tar.gz.
  2. Extract to any directory:
tar -xzf mortar-x.x.x-linux-x64.tar.gz -C ~/mortar
  1. Add the executable path to environment variables, for example:
export PATH="$HOME/mortar:$PATH"
  1. Check if installation was successful:
mortar --version

Windows

  1. Download the corresponding version of mortar-x.x.x-windows-x64.zip.
  2. Extract to any directory, for example D:\mortar.
  3. Add the directory to system environment variable PATH:
    • Right-click “This PC” → “Properties” → “Advanced system settings” → “Environment Variables”
    • Find Path in “System variables” or “User variables” → Edit → Add D:\mortar
  4. Open a new command prompt and check installation:
mortar --version

⚠️ Note:

  • Need to manually set environment variables
  • May encounter issues when opening new terminals or modifying system configuration
  • Not very user-friendly for average users

Therefore, we recommend Method 1 (Cargo) for a smoother installation experience.

Verify Installation

Run this command to test:

mortar --help

You should see help information explaining various usage options.

For a better writing experience, you can install the language server:

cargo install mortar_lsp

Then configure it in your favorite editor.

Check Editor Support to learn how to configure your editor.

Encountering Problems?

“cargo command not found”

You need to install Rust first. Visit https://rust-lang.org/ and follow the installation guide.

“Build failed”

Make sure your Rust version is new enough:

rustup update

Other Issues

Next Steps

Installed? Then go try out Quick Start Guide!

Understanding Mortar Language

Now that you’ve learned the basics, let’s dive deeper into Mortar’s core philosophy.

Three Key Components of Mortar

Writing a Mortar script mainly involves handling these things:

1. Nodes

Think of nodes as “scenes” or “segments” in a dialogue. Each node can contain:

  • Multiple text segments
  • Associated events
  • Player choices
  • Next node

2. Text & Events

This is Mortar’s core feature:

  • Text: Pure dialogue content, without any rich text or technical markup
  • Events: Actions triggered at specific character positions (sound effects, animations, etc.)
  • They are written separately but linked through indices.

3. Choices

The key to player participation:

  • List multiple options
  • Each option can jump to a different node
  • Can set conditions (e.g., must have certain items to display)

Mortar’s Design Philosophy

Separation of Concerns

Traditional dialogue systems might look like this:

"Hello<sound=greeting.wav>, welcome<anim=wave> here!"

Looks messy, right? Writers need to remember various markup, and programmers find it hard to maintain.

Mortar’s approach:

text: "Hello, welcome here!"
with events: [
    0, play_sound("greeting.wav")
    7, show_animation("wave")
]

Text is text, events are events. Clear and simple!

Position as Time

Mortar uses “character position” to control when events occur:

text: "Hello world!"
with events: [
    0, sound_a()  // Triggers at "H"
    6, sound_b()  // Triggers at "w"
    11, sound_c()  // Triggers at "!"
]

This position can be:

  • Integer: Suitable for typewriter effects (displaying one character at a time)
  • Decimal: Suitable for voice synchronization (e.g., at 2.5 seconds into a line)

Declarative Syntax

You only need to describe “what to do”, not “how to do it”:

choice: [
    "Option A" -> NodeA,
    "Option B" when has_key() -> NodeB
]

The code implementation of has_key() is entirely done by the programming team.

Data Flow: From Mortar to Game

Let’s look at the complete process:

Write Mortar    You write dialogues in Mortar language
   │
   ▼
Compile to JSON    mortar command compiles it to JSON
   │
   ▼
Game Reads and Executes    Your game engine reads the JSON and executes accordingly

Detailed Component Explanations

Want to learn more about each part?

Tips

  • Start Simple: Write pure text dialogues first, then gradually add events and choices
  • Use Comments: Use // to leave notes for yourself
  • Name Wisely: “Node names” and function names should be self-explanatory
  • Keep it Clean: Mortar’s advantage is cleanliness, don’t make logic too complex

Ready to dive deeper? Start with Nodes!

Nodes: Building Blocks of Dialogue

Nodes are the most basic units in Mortar. Think of them as a “scene” or “segment” in a dialogue.

The Simplest Node

node OpeningScene {
    text: "Hello, world!"
}

That’s it! A node needs:

  • node keyword (can also be shortened to nd)
  • A name (here it’s OpeningScene)
  • Content inside curly braces {}

Node Naming Conventions

⚠️ Important: We recommend using PascalCase

✅ Recommended naming style:

node OpeningScene { }       // PascalCase: first letter of each word capitalized
node ForestEntrance { }     // Clear and readable
node BossDialogue { }       // Self-explanatory
node Chapter1Start { }      // Can include numbers

⚠️ Not recommended naming styles:

node ScèneOuverture { }    // Non-ASCII text not recommended
node opening_scene { }     // Don't use snake_case (that's for functions)
node openingscene { }      // All lowercase is hard to read
node opening-scene { }     // Kebab-case not recommended
node 1stScene { }          // Don't start with numbers

We recommend the following naming conventions:

  • Use English word combinations
  • Capitalize the first letter of each word
  • Names should be meaningful, describing the node’s purpose
  • Avoid special characters and non-ASCII characters
  • Keep the naming style consistent within the project

The reasons for this are simple:

  • Easier for team maintenance: Using a unified naming convention makes it easier for team members to understand what each node does.
  • Fewer cross-platform issues: Some special characters or non-ASCII text may display differently across operating systems and editors; using English words avoids these problems.
  • Aligns with common programming practices: Most programming languages and open-source projects use this naming convention, making learning and communication smoother.
  • Better for code navigation: Standard names make it easier for editors/IDEs to find related nodes, improving work efficiency.

What Can Go Inside a Node?

A node can contain:

1. Text Blocks

node Dialogue {
    text: "This is the first sentence."
    text: "This is the second sentence."
    text: "And a third one."
}

Multiple text segments will display in order.

2. Event Lists

node Dialogue {
    text: "Hello!"
    with events: [
        0, play_sound("hi.wav")
        5, show_smile()
    ]
}

The event list is associated with the text above it.

3. Choices

node Choice {
    text: "Where do you want to go?"
    
    choice: [
        "Forest" -> ForestScene,
        "Town" -> TownScene
    ]
}

4. Mixed Usage

node CompleteExample {
    // First text + events
    text: "Welcome to the Magic Academy!"
    with events: [
        0, play_bgm("magic.mp3")
        11, sparkle()
    ]
    
    // Second text
    text: "Are you ready?"
    
    // Let player make a choice
    choice: [
        "Ready!" -> StartAdventure,
        "Wait..." -> Wait
    ]
}

Node Jumping

Method 1: Arrow Jumps

Use -> after a node ends to specify the next node:

node A {
    text: "This is node A"
} -> B  // After A completes, jump to B

node B {
    text: "This is node B"
}

Method 2: Jump Through Choices

node MainMenu {
    text: "Choose an option:"
    
    choice: [
        "Option 1" -> Node1,
        "Option 2" -> Node2
    ]
}

Method 3: Return to End Node

node End {
    text: "Goodbye!"
    
    choice: [
        "Exit" -> return  // End current node
    ]
}

Please note: return inside a node only ends the current node’s execution. If the node has an arrow jump, that jump will still be executed after the node finishes.

node A {
    text: "This is node A"
    
    choice: [
        "End current node" -> return  // This only ends the execution of node A's content
    ]
} -> B  // The return does not prevent this jump to B

node B {
    text: "This is node B"
}

Explanation:

  • return: Finishes the execution of the current node. It does not automatically jump to another node.
  • -> B outside the node: After node A has finished executing (even via a return), it will still jump to node B.

Node Execution Flow

Let’s look at an example:

node Scene1 {
    text: "First sentence"    // Display this first
    text: "Second sentence"   // Then display this
    
    choice: [                // Then show choices
        "A" -> Scene2,
        "B" -> Scene3,
        "C" -> break
    ]
    
    text: "After choice"     // 4. Only reach here if chose break option
} -> Scene4                   // 5. If not interrupted, finally jump here

Key points:

  • Text blocks execute in order
  • When encountering choice, player needs to make a decision
  • If choice has return or break, it affects subsequent flow
  • Arrow at node end is the “default exit”

For the break keyword, see Choices: Let Players Decide.

Common Usage Patterns

Pure Text Node (No Choices)

node Start {
    text: "The story begins on a dark night..."
    text: "Suddenly, a loud bang!"
    text: "You decide to check it out."
} -> NextScene

Pure Choice Node (No Text)

node ChoiceExample {
    choice: [
        "Attack" -> Attack,
        "Flee" -> Escape
    ]
}

Segmented Dialogue

node Dialogue {
    text: "Hi, nice to meet you."
    
    // First choice point
    choice: [
        "Hello" -> break,
        "Goodbye" -> return
    ]
    
    text: "So..."  // Only see this if chose "Hello"
    text: "Let's chat."
}

Common Questions

Q: Can “node names” be duplicated?

No! Each node name must be unique.

Q: Does node order matter?

No. You can define node B first, then node A, as long as jump relationships are correct.

Q: Can a node be empty?

Technically yes, but it’s meaningless:

node EmptyNode {
}  // Compiler will warn you

Q: Can you jump from node A back to node A?

Yes! Loops are allowed:

node Cycle {
    text: "Want to go again?"
    
    choice: [
        "Again!" -> Cycle,  // Jump back to itself
        "No thanks" -> return
    ]
}

Next Steps

Text and Events: The Art of Separation

This is what makes Mortar unique: text and events are written separately but precisely associated.

Why Separate?

Imagine you’re writing game dialogue with events, the traditional way might be:

"Hello<sound=hi.wav>, welcome<anim=wave> to<color=red>here</color>!"

Problems arise:

  • 😰 Writers see a bunch of “markup”, hard to focus on the text itself
  • 😰 Programmers need to parse complex markup, error-prone
  • 😰 Adding or removing event parameters is quite cumbersome

Mortar’s approach:

text: "Hello, welcome to here!"
with events: [
    0, play_sound("hi.wav")
    7, show_animation("wave")
    18, set_color("red")
    22, set_color("normal")
]

Clean! Clear! Maintainable!

Text Block Basics

Simplest Text

node Example {
    text: "This is a text segment."
}

Multiple Text Segments

node Dialogue {
    text: "First sentence."
    text: "Second sentence."
    text: "Third sentence."
}

They will display in order.

Using Quotes

Both single and double quotes work:

text: "Double quotes"
text: 'Single quotes'

Event System

Basic Syntax

with events: [
    index, function_call
    index, function_call
]

with events attaches the event list to the most recent text statement. Indices start from 0, type is Number, and support integers or decimals. Your engine decides how to interpret these indices (typewriter steps, timeline positions, etc.).

Simple Example

Using character indices as an example:

text: "Hello world!"
with events: [
    0, sound_a()  // At "H"
    6, sound_b()  // At "w"
    11, sound_c()  // At "!"
]

Character indices:

  • “H” = position 0
  • “e” = position 1
  • “l” = position 2
  • “l” = position 3
  • “o” = position 4
  • “ “ = position 5
  • “w” = position 6

Method Chaining

You can call multiple functions at the same position:

with events: [
    0, play_sound("boom.wav").shake_screen().flash_white()
]

Or write them separately:

with events: [
    0, play_sound("boom.wav")
    0, shake_screen()
    0, flash_white()
]

Both ways have the same effect.

Decimal Indices

Indices can be decimals, which is especially useful for voice synchronization:

text: "Hello, world!"
with events: [
    0.0, start_voice("hello.wav")   // Start playing voice
    1.5, blink_eyes()               // Blink at 1.5 seconds
    3.2, show_smile()               // Smile at 3.2 seconds
    5.0, stop_voice()               // End at 5 seconds
]

When to use decimals?

Our recommendation:

  • Typewriter effect: use integers (one trigger per character)
  • Voice sync: use decimals (trigger by timeline)
  • Video sync: use decimals (precise to frames)

String Interpolation

Want to insert variables or function return values into text? Use $ and {}:

text: $"Hello, {get_player_name()}!"
text: $"You have {get_gold()} gold."
text: $"Today is {get_date()}."

Note:

  • Add $ before the string to declare it as an “interpolated string”
  • Put variables/functions inside {}
  • Functions must be declared in advance

Practical Examples

Typewriter Effect with Sound

node Typewriter {
    text: "Ding! Ding! Ding!"
    with events: [
        0, play_sound("ding.wav")  // First "Ding"
        6, play_sound("ding.wav")  // Second "Ding"
        12, play_sound("ding.wav")  // Third "Ding"
    ]
}

Narration with Background Music

node Narration {
    text: "In a distant kingdom..."
    with events: [
        0, fade_in_bgm("story_theme.mp3")
        0, dim_lights()
    ]
    
    text: "There lived a brave knight."
}

Voice Synchronized Animation

node Dialogue {
    text: "I'll tell you a secret..."
    with events: [
        0.0, play_voice("secret.wav")
        0.0, set_expression("serious")
        2.5, lean_closer()
        4.0, whisper_effect()
        6.0, set_expression("normal")
    ]
}

Event Function Declarations

All functions used must be declared first:

// Declare at end of file
fn play_sound(file: String)
fn shake_screen()
fn flash_white()
fn set_expression(expr: String)
fn get_player_name() -> String
fn get_gold() -> Number

See Functions: Connecting to Game World for details.

Best Practices

✅ Good Practices

// Clear structure
text: "Hello, world!"
with events: [
    0, greeting_sound()
    7, sparkle_effect()
]

❌ Bad Practices

text: "Hello"
text: "world"
with events: [
    0, say_hello()  // Associated text is wrong!
]

Recommendations

  1. Follow-up principle: events immediately follow the corresponding text
  2. Use moderately: not every sentence needs events
  3. Ordered arrangement: write events in ascending order by position (though not mandatory)
  4. Meaningful naming: function names should be self-explanatory

Common Questions

Q: What happens if position exceeds text length?

Compiler will warn, but won’t error. Runtime behavior depends on your game.

Q: Can there be no events?

Of course! Not every text segment needs events. But events must be attached to text.

text: "This is pure text."
// No events, completely fine

Q: Execution order of multiple events at same position?

Executes in written order:

with events: [
    0, first()   // Executes first
    0, second()  // Then this
    0, third()   // Finally this
]

Next Steps

Choices: Let Players Decide

Choices are the key to making dialogues interactive! Players can choose different paths, leading to different endings.

Simplest Choice

node ChoiceExample {
    text: "Where do you want to go?"
    
    choice: [
        "Forest" -> ForestScene,
        "Town" -> TownScene
    ]
}

node ForestScene {
    text: "You arrived at the forest."
}

node TownScene {
    text: "You arrived at the town."
}

The syntax is simple:

  • choice: keyword
  • Inside square brackets [] is the list of options
  • Each option: "text" -> target node

Various Ways to Write Choices

Basic Jump

choice: [
    "Option A" -> NodeA,
    "Option B" -> NodeB,
    "Option C" -> NodeC
]

Conditional Choices

Only displayed when condition is met:

choice: [
    "Normal option" -> Node1,
    "Show only with key" when has_key() -> Node2,
    "Show only level>=10" when is_level_enough() -> Node3
]

Two ways to write conditions:

// Function style: when separated by space

"Option text" when condition_function() -> target

// Chain style: wrapped in parentheses

("Option text").when(condition_function()) -> target

Special Behaviors

Return - End Node

choice: [
    "Continue" -> NextNode,
    "Exit" -> return  // Directly end node
]

Break - Interrupt Choice

choice: [
    "Option 1" -> Node1,
    "Option 2" -> Node2,
    "Never mind" -> break  // Exit choice, continue current node
]

text: "Okay, let's continue."  // Only reach here if chose break

Nested Choices

Choices can contain more choices!

choice: [
    "Eat something" -> [
        "Apple" -> EatApple,
        "Bread" -> EatBread,
        "Never mind" -> return
    ],
    "Do something" -> [
        "Rest" -> Rest,
        "Explore" -> Explore
    ],
    "Leave" -> return
]

Player first chooses “Eat something”, then sees the second layer of options.

Practical Examples

Simple Branch

node ChatWithNPC {
    text: "A merchant approaches you."
    text: "He says: 'Need to buy anything?'"
    
    choice: [
        "Browse goods" -> EnterShop,
        "Ask for news" -> AskSth,
        "Politely decline" -> SayNO
    ]
}

Complex Choice with Conditions

node Door {
    text: "You face a mysterious door."
    
    choice: [
        "Push directly" -> PushDoor,
        "Open with key" when has_key() -> OpenDoorWithKey,
        "Use magic" when can_use_magic() -> OpenDoorWithMagic,
        "Leave" -> return
    ]
}

Multi-level Nesting

node Restaurant {
    text: "Welcome! What would you like to eat?"
    
    choice: [
        "Chinese" -> [
            "Fried rice" -> End1,
            "Noodles" -> End2,
            "Go back" -> break
        ],
        "Western" -> [
            "Steak" -> End3,
            "Pasta" -> End4,
            "Go back" -> break
        ],
        "Nothing" -> return
    ],
    
    text: "Think about it then~"  // Reach here if chose break
}

Clever Use of Break

node ImportantChoice {
    text: "This is an important decision."
    text: "Are you sure?"
    
    choice: [
        "Yes!" -> ConfirmRoute,
        "Let me think..." -> break  // Exit choice
    ],
    
    text: "Okay, take your time."
    
    // Can have another choice
    choice: [
        "Now I'm sure" -> ConfirmRoute,
        "Forget it" -> return
    ]
}

Return vs Break

Easy to confuse, let’s clarify:

KeywordEffectSubsequent Execution
returnEnd current nodeWon’t execute following content
breakExit current choiceWill continue executing following content

Return Example

node Test {
    text: "Start"
    
    choice: [
        "Exit" -> return
    ]
    
    text: "This won't execute"  // Because returned above
}

Break Example

node Test {
    text: "Start"
    
    choice: [
        "Break" -> break
    ]
    
    text: "This will execute"  // Continue after break
}

Condition Functions

All functions used after when must:

  • Return Bool (or Boolean) type
  • Be declared in advance
// Declare these functions in the file
fn has_key() -> Bool
fn can_use_magic() -> Boolean
fn is_level_enough() -> Bool

See Functions: Connecting to Game World for details.

Best Practices

✅ Good Practices

// Clear option text
choice: [
    "Greet friendly" -> Friendly,
    "Stay alert" -> Alert,
    "Turn and leave" -> Leave
]
// Reasonable use of conditions
choice: [
    "Normal option" -> Node1,
    "Special option" when special_unlock() -> Node2
]

❌ Bad Practices

// Option text too long
choice: [
    "I think we should first go to the forest and look around, then decide what to do next..." -> Node1
]
// All options have conditions (might all not display!)
choice: [
    "Option 1" when cond1() -> A,
    "Option 2" when cond2() -> B,
    "Option 3" when cond3() -> C
]

Recommendations

  1. At least one unconditional option: Ensure players always have a choice
  2. Keep option text concise: Generally no more than 20 characters
  3. Clear logic: Don’t nest too deep (suggest max 2-3 levels)
  4. Provide fallback: Give players “return” or “cancel” options

Common Questions

Q: Can choices jump to themselves?

Yes! This creates loops:

node Cycle {
    text: "Continue?"
    
    choice: [
        "Yes" -> Cycle,  // Jump back to itself
        "No" -> return
    ]
}

Q: What if all options have conditions but none are met?

Choice condition checking essentially marks options.

Simply put:

  • When condition is met, option availability is marked as “selectable”.
  • When condition is not met, option availability is marked as “not selectable”.
  • Specific display effects (grayed out, hidden, tooltip text, etc.) are determined by program implementation, not directly controlled by mortar.

⚠️ If all option conditions are not met, it’s recommended to keep at least one unconditional option in the DSL to avoid “no options available” situations.

Q: How deep can nested choices go?

Technically no limit, but suggest no more than 3 levels, otherwise everyone gets confused.

Q: Can I use interpolation in option text?

Yes. Any string can use string interpolation.

Next Steps

Functions: Connecting to Game World

Functions are the bridge between Mortar and your game code. Through function declarations, you tell Mortar: “These features will be implemented by my game”.

Why Do We Need Function Declarations?

In Mortar scripts, you’ll call various functions:

text: "Boom!"
with events: [
    0, play_sound("boom.wav")
    2, shake_screen()
]

But where are these functions? They’re in your game code!

Function declarations are a “contract”:

  • You tell Mortar: my game has these functions, what parameters they need, what they return
  • Mortar checks types during compilation to ensure you use them correctly
  • After compiling to JSON, your game implements these functions

Function Naming Conventions

⚠️ Important: We recommend using snake_case

✅ Recommended naming style:

fn play_sound(file_name: String)         // snake_case: all lowercase, words separated by underscores
fn get_player_name() -> String           // Clear and readable
fn check_inventory_space() -> Bool       // Self-explanatory
fn calculate_damage(base: Number, modifier: Number) -> Number

⚠️ Not recommended naming styles:

fn playSound() { }              // Avoid camelCase (that's other languages' style)
fn PlaySound() { }              // Don't use PascalCase (that's for nodes)
fn play-sound() { }             // Kebab-case not recommended
fn sonido_ñ() { }               // Non-ASCII text not recommended
fn playsound() { }              // All lowercase hard to read

Parameter naming conventions:

// ✅ Good parameter naming
fn move_to(x: Number, y: Number)
fn load_scene(scene_name: String, fade_time: Number)

// ❌ Bad parameter naming
fn move_to(a: Number, b: Number)        // No semantics
fn load_scene(s: String, t: Number)     // Unclear abbreviations

Naming suggestions:

  • Use English words, all lowercase
  • Multiple words separated by underscore _
  • Start with verb, describe function purpose: get_, set_, check_, play_, show_
  • Parameter names should be descriptive
  • Keep naming style consistent within project

Basic Syntax

fn function_name(param: Type) -> ReturnType

No Parameters, No Return Value

fn shake_screen()
fn clear_text()
fn show_menu()

With Parameters, No Return Value

fn play_sound(file: String)
fn set_color(color: String)
fn move_character(x: Number, y: Number)

With Return Value

fn get_player_name() -> String
fn get_gold() -> Number
fn has_key() -> Bool

With Parameters and Return Value

fn calculate(a: Number, b: Number) -> Number
fn find_item(name: String) -> Bool

Supported Types

Mortar supports types from JSON:

TypeAliasDescriptionExample
String-String"Hello", "file.wav"
Number-Number (integer or decimal)42, 3.14
BoolBooleanBooleantrue, false

Note: Bool and Boolean are the same, use whichever you prefer.

Complete Example

// A complete Mortar file

node StartScene {
    text: $"Welcome, {get_player_name()}!"
    with events: [
        0, play_bgm("theme.mp3")
    ]
    
    text: $"You have {get_gold()} gold."
    
    choice: [
        "Go to shop" when can_shop() -> Shop,
        "Go adventure" -> Adventure
    ]
}

node Shop {
    text: "Welcome to the shop!"
}

node Adventure {
    text: "Adventure begins!"
    with events: [
        0, start_battle("Goblin")
    ]
}

// ===== Function Declarations =====

// Play background music
fn play_bgm(music: String)

// Get player name
fn get_player_name() -> String

// Get gold amount
fn get_gold() -> Number

// Check if can shop
fn can_shop() -> Bool

// Start battle
fn start_battle(enemy: String)

Using in Events

Call Functions Without Parameters

with events: [
    0, shake_screen()
    2, flash_white()
]

fn shake_screen()
fn flash_white()

Call Functions With Parameters

with events: [
    0, play_sound("boom.wav")
    2, set_color("#FF0000")
    4, move_to(100, 200)
]

fn play_sound(file: String)
fn set_color(hex: String)
fn move_to(x: Number, y: Number)

Method Chaining

with events: [
    0, play_sound("boom.wav").shake_screen().flash_white()
]

fn play_sound(file: String)
fn shake_screen()
fn flash_white()

Using in Text Interpolation

Only functions with return values can be used in ${}:

text: $"Hello, {get_name()}!"
text: $"Level: {get_level()}"
text: $"Status: {get_status()}"

fn get_name() -> String
fn get_level() -> Number
fn get_status() -> String

Note: Functions in interpolation must return String!

// ❌ Error: function has no return value
text: $"Result: {do_something()}"
fn do_something()  // No return value


// ❌ Error: return type is not String
text: $"Result: {get_hp()}"
fn get_hp() -> Number  // Wrong return type

// ✅ Correct
text: $"Result: {get_result()}"
fn get_result() -> String

Using in Conditions

Functions after when must return Bool / Boolean:

choice: [
    "Special option" when is_unlocked() -> SpecialNode
]

fn is_unlocked() -> Bool

Position of Function Declarations

By convention, put all function declarations at the end of the file:

// Node definitions
node A { ... }
node B { ... }
node C { ... }

// ===== Function Declarations =====
fn func1()
fn func2()
fn func3()

But position doesn’t really matter, you can put them anywhere.

Static Type Checking

Mortar checks types at compile time:

// ✅ Correct
with events: [
    0, play_sound("file.wav")
]
fn play_sound(file: String)

// ❌ Error: wrong parameter type
with events: [
    0, play_sound(123)  // Passed number, but needs string
]
fn play_sound(file: String)

This helps you catch errors early!

Implementing Functions (Game Side)

Mortar only handles declarations, actual implementation is in your game code.

The compiled JSON will contain function information:

{
  "functions": [
    {
      "name": "play_sound",
      "params": [
        {"name": "file", "type": "String"}
      ]
    },
    {
      "name": "get_player_name",
      "return": "String"
    }
  ]
}

Your game reads the JSON and implements these functions.

See Integrating with Your Game for details.

Best Practices

✅ Good Practices

// Clear naming
fn play_background_music(file: String)
fn get_player_health() -> Number
fn is_quest_completed(quest_id: Number) -> Bool
// Reasonable parameters
fn spawn_enemy(name: String, x: Number, y: Number)
fn set_weather(type: String, intensity: Number)

❌ Bad Practices

// Unclear naming
fn psm(f: String)  // What does this mean?
fn x() -> Number   // What is x?
// Too many parameters
fn do_complex_thing(a: Number, b: Number, c: String, d: Bool, e: Number, f: String)

Recommendations

  1. Self-explanatory names: Function names should say what they do
  2. Moderate parameters: Generally no more than 7 parameters
  3. Clear types: All parameters and return values should have types
  4. Organize by category: Put related functions together with comments

Common Questions

Q: Must I declare all functions I use?

Yes! Using undeclared functions will cause errors.

Q: Can I write function instead of fn?

Yes! Both are identical:

fn play_sound(file: String)
function play_sound(file: String)  // Same thing

Q: Can I declare but not use?

Yes. Declared but unused functions will get compiler warnings, but not errors.

Q: Can functions be overloaded?

No. Each function name can only be declared once.

// ❌ Error: duplicate declaration
fn test(a: String)
fn test(a: Number, b: Number)

Q: Can parameters have default values?

Not currently supported. All parameters are required.

Next Steps

Variables, Constants, and Initial State

Mortar v0.4 introduced workspace-wide state so dialogue can react to progress. All declarations must live at the top level of your script (outside node blocks or functions), which keeps Mortar’s runtime deterministic and easy to serialize.

Declaring Variables

Use let followed by a name, type, and optional initializer. Mortar supports three primitive types: String, Number, and Bool.

let player_name: String
let player_score: Number = 0
let is_live: Bool = true

Key rules:

  • No null, default value (empty string, 0, false) will be provided if not assigned.
  • Reassignment happens inside nodes: player_score = player_score + 10.
  • Keep declarations outside nodes and functions so the compiler can include them in the top-level variables array of the .mortared file.

Every variable becomes a key in the exported JSON, making it trivial for your game to hydrate a hash map or dictionary.

Public Constants for Key-Value Text

Non-dialogue UI strings or configuration can be defined via pub const. These entries are immutable and marked as public in the JSON output so localization pipelines or scripting layers can expose them.

pub const welcome_message: String = "Welcome to the expedition!"
pub const continue_label: String = "Continue"
pub const exit_label: String = "Exit"

Constants are ideal when you need consistent button labels, notifications, or metadata for every language variant.

Runtime Usage

Inside a node you can freely mix variable assignments with text:

node AwardPoints {
    player_score = player_score + 5
    text: $"Score updated: {player_score}"
}

The serializer records those assignments under pre_statements or inline text blocks, ensuring the execution order mirrors the script. Use this mechanism to keep gameplay state close to the dialogue flow without turning Mortar into a general-purpose programming language. For richer domain modeling, pair variables with enums and branch interpolation.

Branch Interpolation

Mortar borrows from Fluent’s “non-symmetric localization” so writers can embed rich, language-specific variations without branching entire nodes. The v0.4 implementation (see examples/branch_interpolation.mortar) centers on branch variables that you declare alongside other let bindings.

Declaring Branch Variables

Branch variables can be driven by booleans or enums.

let is_forest: Bool = true
let is_city: Bool
let current_location: Location

enum Location {
    forest
    city
    town
}

let place: branch [
    is_forest, "forest"
    is_city, "city"
]

let location: branch<current_location> [
    forest, "deep forest"
    city, "bustling city"
    town, "quiet town"
]

You can also define ad-hoc branch blocks inside a node for single-use placeholders, but hoisting them to the top level keeps translations centralized and mirrors the structure from the official example.

Using Branches in Nodes

Insert branch variables with the usual interpolation syntax:

node LocationDesc {
    text: $"Welcome to the {place}! You are currently in the {location}."
}

Each branch variable becomes an entry in the node’s branches array. Engines resolve the correct case at runtime using the boolean or enum values you manage through variables and control flow.

Branch Events

Branch values have their own event lists and indices. Inline events (with events) operate on literal characters in the surrounding text, while branch-specific events arrays count from zero inside the placeholder. This matches the behavior in examples/branch_interpolation.mortar.

text: $"You look toward the {object} and gasp!"
with events: [
    4, set_color("#33CCFF")
    18, set_color("#FF6B6B")
]

let object: branch<current_location> [
    forest, "ancient tree", events: [
        0, set_color("#228B22")
    ]
    city, "towering skyline", events: [
        0, set_color("#A9A9A9")
        9, set_color("#696969")
    ]
]

Branches and Game Logic

Use branch selectors to keep localized snippets in sync with game state:

  • Booleans toggle between quick phrases like "sir" vs "ma'am".
  • Enum-driven branches swap entire segments (“forest outskirts” vs “city plaza”).
  • Branch events let each variant own its color cues, sound effects, or motion.

Because branch data is exported alongside nodes, your engine can cache every case, present preview tooling, or run automated localization checks without duplicating nodes or choices.

Localization Strategy

Mortar v0.4 recommends maintaining multiple language files rather than mixing translations inside a single Mortar script. This section describes a practical workflow for teams shipping multilingual dialogue.

Separate Mortar Sources per Locale

Organize your repository like this:

mortar/
├─ locales/
│  ├─ en/
│  │  └─ story.mortar
│  ├─ zh-Hans/
│  │  └─ story.mortar
│  └─ ja/
│     └─ story.mortar

Each language folder mirrors the same node names so your engine can swap .mortared builds based on runtime locale. Mortar does not attempt to translate strings automatically; it simply keeps structure consistent.

Share Logic via Constants and Functions

Use pub const for UI labels and fn declarations for reusable hooks so every locale references the same identifiers:

pub const continue_label: String = "Continue"
fn play_sound(file: String)

Translators can copy these declarations verbatim and only edit the human-readable text nodes. Because pub const entries appear in the top-level JSON, tooling can detect missing translations easily.

Building Docs for Each Locale

This repository already mirrors documentation under book/en and book/zh-Hans. Follow the same convention for gameplay scripts: add one mdBook per locale or create localized guides under docs/ so your internal contributors know where to make changes.

Shipping Workflow

  1. Author or update the source language (locales/en).
  2. Sync node/layout changes across other locales.
  3. Run cargo run -p mortar_cli -- locales/en/story.mortar --pretty for every locale directory.
  4. Bundle the resulting .mortared artifacts with your game build.

Because Mortar’s syntax enforces separation between text and logic, you never have to duplicate event wiring or conditionals—only the strings change. This makes localization predictable while still allowing advanced constructs like branch interpolation for gendered or region-specific flavor text.

Control Flow in Nodes

Mortar now supports if/else blocks inside nodes so you can gate text or events based on variables, function calls, or enum comparisons. This feature is intentionally lightweight—there are no loops yet—so dialogue remains readable.

Syntax Overview

let player_score: Number = 123

node ExampleNode {
    if player_score > 100 {
        text: "Perfect score!"
    } else {
        text: "Keep pushing."
    }
}

Each branch may contain any sequence of valid node statements: text, assignments, choices, or even nested if chains. The serializer flattens these blocks into content entries with a condition field, making the .mortared file declarative for your game engine.

Supported Expressions

You can compare numbers, check booleans, and call parameterless functions:

if has_map() && current_region == forest {
    text: "You spread the map across the stump."
}

Under the hood, expressions become AST nodes (binary operators, unary operators, identifiers, or literals). Use helper functions (fn has_map() -> Bool) whenever the condition depends on game-side data that Mortar itself cannot calculate.

Best Practices

  • Keep branches short. If you need radically different conversations, jump to distinct nodes using choice or next.
  • Update state before the condition so both sides can reference the latest values.
  • Combine with variables and enums to track structural progress (chapter, route, etc.).

Future releases plan to add while loops and more expressive statements, but today’s if/else building block already covers most dynamic text scenarios without compromising Mortar’s clarity.

Event System and Timelines

Events have always been core to Mortar, but v0.4 expands them into first-class definitions that you can reuse or orchestrate through timelines. This section mirrors examples/performance_system.mortar, which demonstrates every feature end to end.

Declaring Named Events

Use the event keyword at the top level:

event SetColor {
    index: 0
    action: set_color("#228B22")
}

event MoveRight {
    action: set_animation("right")
    duration: 2.0
}

event MoveLeft {
    action: set_animation("left")
    duration: 1.5
}

event PlaySound {
    index: 20
    action: play_sound("dramatic-hit.wav")
}

Each event can define a default index, an action, and an optional duration. In the serialized JSON it appears under the global events array so engines can trigger it anywhere.

Running Events Inline

Inside a node you have two primary tools:

  1. run EventName executes the event immediately, ignoring the stored index (duration still matters unless you explicitly override it).
  2. with EventName or with events: [ EventA EventB ] attaches events to the previous text block so their indices line up with characters.
node WithEventsExample {
    text: "Welcome to the adventure!"
    with SetColor  // single event shortcut

    text: "The forest comes alive with sound and color."
    with events: [
        MoveRight
        PlaySound
    ]
}

When Mortar serializes this node, inline associations become events arrays nested under the corresponding text content items.

Custom Indices

You can override an event’s index at runtime while still using the same definition. This is how examples/performance_system.mortar lines up PlaySound with specific characters:

let custom_time: Number = 5

node CustomIndexExample {
    text: "Be quiet...until you hear...Blast!"
    run PlaySound with custom_time      // immediate run, index override stored for metadata

    custom_time = 28

    // Attach the run to the text so the sound waits until character 28
    with run PlaySound with custom_time
}

Any run ... with <NumberOrVariable> statement becomes a ContentItem::RunEvent with index_override populated in the .mortared file. Prepending with means “attach this run to the previous text block”.

Timelines

Timelines (timeline OpeningCutscene { ... }) provide a scriptable playlist of run, wait, and now run statements:

timeline OpeningCutscene {
    run MoveRight
    wait 1.0
    run MoveLeft
    wait 0.5
    now run PlaySound   // ignore PlaySound's duration and continue immediately
    wait 10
    run SetColor
}

Inside a node you can call run OpeningCutscene to execute the entire sequence:

node TimelineExample {
    text: "Watch the opening cutscene..."
    run OpeningCutscene
}

Timelines are perfect for cutscenes, choreographed animations, or any moment where several systems must stay in sync.

Practical Tips

  • Define reusable events for anything more complex than a one-off inline action.
  • Use with to tie events to a specific text block; use run for global beats.
  • Override indices when the same cue needs different timing in each context.
  • Combine timelines with now run if you need to skip an event’s duration.

The v0.4 JSON exposes both events and timelines arrays so tooling and engines can inspect or reuse staging logic independent of text content.

Enums and Structured Choices

Enums complement Mortar’s variable system by letting you represent a closed set of states—chapter progression, affinity tiers, weather, and more. They pair naturally with branch interpolation and if statements.

Declaring Enums

Define enums at the top level:

enum GameState {
    start
    playing
    game_over
}

Every variant becomes a string literal in the serialized JSON, making it easy for your engine to switch or pattern-match.

Using Enum Variables

Create variables whose type is the enum:

let current_state: GameState

Assignments work like any other variable:

node StateMachine {
    if boss_defeated() {
        current_state = game_over
    }
}

Inside text, combine them with branch placeholders to produce localized snippets:

let status: branch<current_state> [
    start, "just getting started"
    playing, "in the thick of it"
    game_over, "wrapping up the journey"
]

node Status {
    text: $"Current state: {status}."
}

Engine Integration

The .mortared file exposes enums under the top-level enums array so you can validate assignments or show debugging tools. Typical usage:

  1. Load enums into a registry or generate native enums from them.
  2. Track Mortar variables (such as current_state) alongside your gameplay state.
  3. Feed those values back into Mortar via assignments or function calls.

By treating enums as first-class citizens you keep designer intent obvious while empowering the engine to enforce valid transitions.

Complete Examples and Explanations

After learning so much, let’s look at real examples!

This chapter contains three progressively advanced examples:

📝 Writing Simple Dialogue

The simplest introductory example, suitable for:

  • Just starting to learn Mortar
  • Want to see results quickly
  • Creating a simple NPC dialogue

You’ll learn:

  • Basic nodes and text
  • Simple event binding
  • Basic choice jumping

📖 Creating Interactive Stories

A complete interactive short story, suitable for:

  • Want to create branching narratives
  • Need multiple choice layers
  • Making text adventure games

You’ll learn:

  • Complex choice structures
  • Conditional logic
  • Multiple ending design
  • String interpolation

🎮 Integrating with Your Game

Complete process of actual integration with game engines, suitable for:

  • Planning to use Mortar in projects
  • Need to understand JSON output
  • Want to implement your own parser

You’ll learn:

  • Compilation process
  • JSON structure parsing
  • Function call implementation
  • Runtime execution logic

We recommend reading in order, each example is a bit more complex than the previous one.

Ready? Start with Writing Simple Dialogue!

Writing Simple Dialogue

Let’s start with the simplest scenario: a brief conversation between an NPC and the player.

Scene Setup

Imagine you’re making an RPG game with a villager NPC who greets the player and asks if they need help.

Version 1: Pure Text Dialogue

The simplest version, just write out the dialogue:

// Villager's greeting
node VillagerGreeting {
    text: "Hello there, adventurer!"
    text: "Welcome to our little village."
    text: "Do you need any help?"
    
    choice: [
        "I need help" -> OfferHelp,
        "No thanks" -> PoliteFarewell
    ]
}

node OfferHelp {
    text: "Great! Let me see what I can do for you..."
    text: "Here's a map, hope it helps!"
}

node PoliteFarewell {
    text: "Alright, have a pleasant journey!"
}

Expected result:

  1. Display three text segments
  2. Player makes a choice
  3. Jump to different nodes based on choice

Version 2: Adding Sound Effects

Now let’s make the dialogue more lively by adding sound effects:

node VillagerGreeting {
    text: "Hello there, adventurer!"
    with events: [
        // Play greeting sound when "H" appears
        0, play_sound("greeting.wav")
    ]
    
    text: "Welcome to our little village."
    with events: [
        // Play warm music at "little village"
        15, play_music("village_theme.ogg")
    ]
    
    text: "Do you need any help?"
    
    choice: [
        "I need help" -> OfferHelp,
        "No thanks" -> PoliteFarewell
    ]
}

node OfferHelp {
    text: "Great! Let me see what I can do for you..."
    
    text: "Here's a map, hope it helps!"
    with events: [
        // Item get sound effect
        0, play_sound("item_get.wav"),
        // Show item icon at the same time
        0, show_item_icon("map")
    ]
}

node PoliteFarewell {
    text: "Alright, have a pleasant journey!"
    with events: [
        0, play_sound("farewell.wav")
    ]
}

// Function declarations
fn play_sound(file_name: String)
fn play_music(filename: String)
fn show_item_icon(item_name: String)

New additions:

  • Each dialogue segment has appropriate sound effects
  • Special effects when getting items
  • All used functions are declared

Version 3: Dynamic Content

Make dialogue more personalized by greeting with player’s name:

node VillagerGreeting {
    // Use string interpolation to dynamically insert player name
    text: $"Hello there, {get_player_name()}!"
    with events: [
        0, play_sound("greeting.wav")
    ]
    
    text: "Welcome to our little village."
    with events: [
        15, play_music("village_theme.ogg")
    ]
    
    text: "Do you need any help?"
    
    choice: [
        "I need help" -> OfferHelp,
        "No thanks" -> PoliteFarewell
    ]
}

node OfferHelp {
    text: "Great! Let me see what I can do for you..."
    
    text: "Here's a map, hope it helps!"
    with events: [
        0, play_sound("item_get.wav"),
        0, show_item_icon("map")
    ]
    
    text: $"Good luck, {get_player_name()}!"
}

node PoliteFarewell {
    text: "Alright, have a pleasant journey!"
    with events: [
        0, play_sound("farewell.wav")
    ]
}

// Function declarations
fn play_sound(file_name: String)
fn play_music(filename: String)
fn show_item_icon(item_name: String)
fn get_player_name() -> String  // Returns player name

New additions:

  • String interpolation using $"..." syntax
  • {get_player_name()} gets replaced with actual player name
  • More personalized feel

Version 4: Conditional Options

Some players might already have a map, let’s add conditional logic:

node VillagerGreeting {
    text: $"Hello there, {get_player_name()}!"
    with events: [
        0, play_sound("greeting.wav")
    ]
    
    text: "Welcome to our little village."
    with events: [
        15, play_music("village_theme.ogg")
    ]
    
    text: "Do you need any help?"
    
    choice: [
        // Only show this option when player needs map
        "I need help" when need_map() -> OfferHelp,
        
        // Players with map see this
        "I already have a map" when has_map() -> AlreadyHasMap,
        
        // This option always shows
        "No thanks" -> PoliteFarewell
    ]
}

node OfferHelp {
    text: "Great! Let me see what I can do for you..."
    text: "Here's a map, hope it helps!"
    with events: [
        0, play_sound("item_get.wav"),
        0, show_item_icon("map")
    ]
    text: $"Good luck, {get_player_name()}!"
}

node AlreadyHasMap {
    text: "Oh, looks like you're well prepared!"
    text: "Then have a safe journey!"
}

node PoliteFarewell {
    text: "Alright, have a pleasant journey!"
    with events: [
        0, play_sound("farewell.wav")
    ]
}

// Function declarations
fn play_sound(file_name: String)
fn play_music(filename: String)
fn show_item_icon(item_name: String)
fn get_player_name() -> String
fn need_map() -> Bool      // Check if needs map
fn has_map() -> Bool       // Check if already has map

New additions:

  • Options with when conditions
  • Different options based on player state
  • More realistic game logic

Compiling and Using

Save as village_npc.mortar, then compile:

# Compile to JSON
mortar village_npc.mortar

# For formatted JSON
mortar village_npc.mortar --pretty

# Specify output filename
mortar village_npc.mortar -o npc_dialogue.json

Implementing in Game

Your game needs to:

  1. Read JSON: Parse the compiled JSON file

  2. Implement functions: Implement all declared functions

    // For example in Unity C#
    void play_sound(string filename) {
        AudioSource.PlayOneShot(Resources.Load<AudioClip>(filename));
    }
    
    string get_player_name() {
        return PlayerData.name;
    }
    
    bool has_map() {
        return PlayerInventory.HasItem("map");
    }
    
  3. Execute dialogue: Display text, trigger events, handle choices according to JSON

Summary

This example demonstrates:

  • ✅ Basic nodes and text
  • ✅ Event binding
  • ✅ Choice jumping
  • ✅ String interpolation
  • ✅ Conditional logic
  • ✅ Function declarations

From this simple example, you can create more complex dialogue systems!

Next Steps

Creating Interactive Stories

Now let’s create a complete interactive short story with multiple branches and endings.

Story Outline

“The Mysterious Forest” - An adventurer’s tale:

  • Player encounters a mysterious magical spring in the forest
  • Different choices lead to different endings
  • Features conditional logic and multi-layered nested choices

Complete Code

// ========== Opening Scene ==========
node OpeningScene {
    text: "Night falls as you walk alone through the dark forest."
    with events: [
        0, play_ambient("forest_night.ogg"),
        5, fade_in_music()
    ]
    
    text: "Suddenly, a strange blue light flickers ahead."
    with events: [
        10, flash_effect("#0088FF")
    ]
    
    text: "You approach and see a pool of glowing spring water..."
    
    choice: [
        "Observe cautiously" -> ObserveSpring,
        "Drink directly" -> DirectDrink,
        "Leave this place" -> ChooseLeave
    ]
}

// ========== Observation Branch ==========
node ObserveSpring {
    text: "You crouch down and carefully examine the spring."
    text: "Ancient text appears on the water's surface..."
    with events: [
        0, show_text_effect("ancient_runes")
    ]
    
    text: "The text reads: 'Those who drink this sacred spring shall gain true knowledge and power.'"
    
    choice: [
        "Then I'll drink it" -> CautiousDrink,
        "Feels scary, better leave" -> ChooseLeave,
        
        // Special option for players with equipment
        "Collect water with magic bottle" when has_magic_bottle() -> CollectWater
    ]
}

node CautiousDrink {
    text: "You carefully cup some spring water and take a small sip."
    with events: [
        35, play_sound("drink_water.wav")
    ]
    
    text: "A cool energy surges through your body!"
    with events: [
        0, screen_flash("#00FFFF"),
        0, play_sound("power_up.wav")
    ]
    
    text: $"You feel your power growing... Power increased by {get_power_bonus()} points!"
    
} -> GoodEndingPower

node CollectWater {
    text: "You take out your precious magic bottle and carefully collect the spring water."
    with events: [
        0, play_sound("bottle_fill.wav"),
        44, show_item_obtained("holy_water")
    ]
    
    text: "This is a priceless treasure that could save your life at a critical moment!"
    
} -> GoodEndingWisdom

// ========== Direct Drink Branch ==========
node DirectDrink {
    text: "Without hesitation, you take a big gulp!"
    with events: [
        23, play_sound("gulp.wav")
    ]
    
    text: "Gulp gulp—"
    
    // Check if player has enough resistance
    choice: [
        // Has resistance: safe
        "(Continue)" when has_magic_resistance() -> DirectDrinkSuccess,
        
        // No resistance: trouble
        "(Continue)" -> DirectDrinkFail
    ]
}

node DirectDrinkSuccess {
    text: "Thanks to your strong magic resistance, the spring's power is perfectly absorbed!"
    with events: [
        0, play_sound("success.wav")
    ]
    
    text: "You feel more powerful than ever!"
    
} -> GoodEndingPower

node DirectDrinkFail {
    text: "Oh no! The magic is too strong, your body can't handle it!"
    with events: [
        0, screen_shake(),
        0, play_sound("magic_overload.wav")
    ]
    
    text: "Your vision goes black as you collapse to the ground..."
    
} -> BadEndingUnconscious

// ========== Leave Branch ==========
node ChooseLeave {
    text: "You decide to stay cautious and leave this mysterious place."
    
    text: "After a few steps, you look back..."
    
    text: "The spring's glow gradually dims, as if saying: 'The opportunity is lost.'"
    with events: [
        63, fade_out_effect()
    ]
    
} -> NormalEndingCautious

// ========== Ending Nodes ==========
node GoodEndingPower {
    text: "=== Ending: Power Awakening ==="
    with events: [
        0, play_music("victory_theme.ogg")
    ]
    
    text: "You have received the spring's blessing and become a powerful warrior!"
    text: $"Final power: {get_final_power()}"
    text: "From now on, you are unstoppable in your adventures."
    
    text: "[Game Over]"
}

node GoodEndingWisdom {
    text: "=== Ending: Path of the Wise ==="
    with events: [
        0, play_music("wisdom_theme.ogg")
    ]
    
    text: "You have shown true wisdom, knowing how to use treasures."
    text: "The sacred spring water has become your most precious possession."
    text: "In later adventures, this bottle saved you countless times."
    
    text: "[Game Over]"
}

node BadEndingUnconscious {
    text: "=== Ending: Price of Greed ==="
    with events: [
        0, play_music("bad_ending.ogg"),
        0, screen_fade_black()
    ]
    
    text: "When you wake up, it's already the next morning."
    text: "The spring has disappeared, and so has your power."
    text: "You regret not being more cautious..."
    
    text: "[Game Over]"
}

node NormalEndingCautious {
    text: "=== Ending: Ordinary Path ==="
    with events: [
        0, play_music("normal_ending.ogg")
    ]
    
    text: "You chose safety over adventure."
    text: "While you didn't gain power, you didn't encounter danger either."
    text: "A plain and simple life is also a way of living."
    
    text: "[Game Over]"
}

// ========== Function Declarations ==========
// Audio and Visual Effects
fn play_ambient(filename: String)
fn play_sound(file_name: String)
fn play_music(filename: String)
fn fade_in_music()
fn fade_out_effect()
fn screen_fade_black()

// Special Effects
fn flash_effect(color: String)
fn screen_flash(color: String)
fn screen_shake()
fn show_text_effect(effect_name: String)
fn show_item_obtained(item_name: String)

// Conditional Checks
fn has_magic_bottle() -> Bool
fn has_magic_resistance() -> Bool

// Value Getters
fn get_power_bonus() -> Number
fn get_final_power() -> Number

Story Structure Diagram

                    Opening
                       │
           ┌───────────┼───────────┐
           │           │           │
      Observe      Direct      Choose
       Spring      Drink       Leave
           │           │           │
      ┌────┼────┐      │      Normal_Cautious
      │    │    │      │         Ending
  Cautious Collect Leave Check
   Drink   Water      Resistance
      │      │       /     \
      │      │    Success  Fail
      │      │       │       │
      │      │    Good    Bad_Unconscious
      └──────┴────Power     Ending
             │    Ending
          Good_Wisdom
           Ending

Key Technique Analysis

1. Multi-layer Choices

Using conditional logic to show different options to different players:

choice: [
    "Normal option" -> NormalNode,
    "Special option" when has_special_item() -> SpecialNode
]

2. Hidden Branches

The DirectDrink node handles this cleverly:

choice: [
    // Both options show same text
    "(Continue)" when has_magic_resistance() -> Success,
    "(Continue)" -> Fail
]

Players can’t see the difference, but outcomes vary—this is a hidden branch!

3. Clever Use of String Interpolation

Dynamically display values:

text: $"Power increased by {get_power_bonus()} points!"
text: $"Final power: {get_final_power()}"

4. Event Synchronization

Trigger multiple events at the same position:

with events: [
    0, screen_flash("#00FFFF"),
    0, play_sound("power_up.wav")  // Trigger simultaneously
]

5. Chapter-style Organization

Use comments to separate different sections:

// ========== Opening Scene ==========

// ========== Observation Branch ==========

// ========== Ending Nodes ==========

Makes code more readable!

Compile and Test

# Compile
mortar forest_story.mortar -o story.json --pretty

# Check generated JSON structure
cat story.json

Game Implementation Points

In your game, you need to implement:

1. Conditional Check Functions

bool has_magic_bottle() {
    return Inventory.HasItem("magic_bottle");
}

bool has_magic_resistance() {
    return Player.Stats.MagicResistance >= 50;
}

2. Value Calculation Functions

float get_power_bonus() {
    return Player.Level * 10 + 50;
}

float get_final_power() {
    return Player.Stats.Power;
}

3. Sound Effects and Visual Effects

void play_sound(string filename) {
    AudioManager.Play(filename);
}

void screen_flash(string color) {
    ScreenEffects.Flash(ColorUtility.TryParseHtmlString(color, out Color c) ? c : Color.white);
}

Extension Suggestions

You can build on this foundation:

  1. Add More Branches

    • Add “touch the spring with hand” option
    • Include “make a wish to the spring” mysterious branch
  2. Add State Tracking

    • Record player’s choices
    • Display player’s decision path in ending
  3. Multiple Ending Variants

    • Unlock hidden endings based on previous game progress
    • Add “true ending” requiring specific conditions
  4. Integrate with Game Systems

    • Endings affect subsequent storylines
    • Give different rewards

Summary

This example demonstrates:

  • ✅ Multi-branch narrative design
  • ✅ Flexible use of conditional logic
  • ✅ Hidden options and branches
  • ✅ String interpolation
  • ✅ Multiple ending implementation
  • ✅ Sound and visual effect coordination

This is Mortar’s true power—making complex branching narratives clear and manageable!

Next Steps

Integrating with Your Game (WIP)

⚠️ This chapter will be refactored. Content is for limited reference only.

Mortar will provide official Bevy and Unity bindings in the future.

This chapter will guide you step-by-step on how to truly use Mortar—from compilation to running in your game.

Complete Process Overview

1. Write Mortar script (.mortar)
         ↓
2. Use compiler to generate JSON
         ↓
3. Game loads JSON file
         ↓
4. Parse JSON data structure
         ↓
5. Implement function call interface
         ↓
6. Write dialogue execution engine
         ↓
7. Run dialogue and respond to events

Example: A Simple Dialogue

Let’s start with the simplest example.

Step 1: Write Mortar File

Create simple.mortar:

node StartScene {
    text: "Hello!"
    with events: [
        0, play_sound("hi.wav")
    ]
    
    text: $"Are you {get_name()}?"
    
    choice: [
        "Yes" -> Confirm,
        "No" -> Deny
    ]
}

node Confirm {
    text: "Nice to meet you!"
}

node Deny {
    text: "Oh, sorry, wrong person."
}

fn play_sound(file: String)
fn get_name() -> String

Step 2: Compile to JSON

mortar simple.mortar -o simple.json --pretty

The generated simple.json looks roughly like this:

{
  "nodes": {
    "StartScene": {
      "texts": [
        {
          "content": "Hello!",
          "events": [
            {
              "index": 0,
              "function": "play_sound",
              "args": ["hi.wav"]
            }
          ]
        },
        {
          "content": "Are you {get_name()}?",
          "interpolated": true,
          "events": []
        }
      ],
      "choices": [
        {
          "text": "Yes",
          "target": "Confirm"
        },
        {
          "text": "No",
          "target": "Deny"
        }
      ]
    },
    "Confirm": {
      "texts": [
        {
          "content": "Nice to meet you!",
          "events": []
        }
      ]
    },
    "Deny": {
      "texts": [
        {
          "content": "Oh, sorry, wrong person.",
          "events": []
        }
      ]
    }
  },
  "functions": [
    {
      "name": "play_sound",
      "params": [{"name": "file", "type": "String"}],
      "return_type": null
    },
    {
      "name": "get_name",
      "params": [],
      "return_type": "String"
    }
  ]
}

Unity C# Integration Example

Step 1: Create Data Structures

using System;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public class MortarDialogue {
    public Dictionary<string, NodeData> nodes;
    public List<FunctionDeclaration> functions;
}

[Serializable]
public class NodeData {
    public List<TextBlock> texts;
    public List<Choice> choices;
    public string next_node;
}

[Serializable]
public class TextBlock {
    public string content;
    public bool interpolated;
    public List<Event> events;
}

[Serializable]
public class Event {
    public float index;
    public string function;
    public List<object> args;
}

[Serializable]
public class Choice {
    public string text;
    public string target;
    public string condition;
}

[Serializable]
public class FunctionDeclaration {
    public string name;
    public List<Param> @params;
    public string return_type;
}

[Serializable]
public class Param {
    public string name;
    public string type;
}

Step 2: Load JSON

using System.IO;
using UnityEngine;

public class DialogueManager : MonoBehaviour {
    private MortarDialogue dialogue;
    
    public void LoadDialogue(string jsonPath) {
        string json = File.ReadAllText(jsonPath);
        dialogue = JsonUtility.FromJson<MortarDialogue>(json);
        Debug.Log($"Loaded {dialogue.nodes.Count} dialogue nodes");
    }
}

Step 3: Implement Function Interface

using System.Collections.Generic;
using UnityEngine;

public class DialogueFunctions : MonoBehaviour {
    private Dictionary<string, System.Delegate> functionMap;
    
    void Awake() {
        InitializeFunctions();
    }
    
    void InitializeFunctions() {
        functionMap = new Dictionary<string, System.Delegate>();
        
        // Register all functions
        functionMap["play_sound"] = new System.Action<string>(PlaySound);
        functionMap["get_name"] = new System.Func<string>(GetName);
        functionMap["has_item"] = new System.Func<bool>(HasItem);
    }
    
    // ===== Actual function implementations =====
    
    void PlaySound(string filename) {
        AudioClip clip = Resources.Load<AudioClip>($"Sounds/{filename}");
        if (clip != null) {
            AudioSource.PlayClipAtPoint(clip, Camera.main.transform.position);
        }
    }
    
    string GetName() {
        return PlayerData.Instance.playerName;
    }
    
    bool HasItem() {
        return Inventory.Instance.HasItem("magic_map");
    }
    
    // ===== Generic method to call functions =====
    
    public object CallFunction(string funcName, List<object> args) {
        if (!functionMap.ContainsKey(funcName)) {
            Debug.LogError($"Function not defined: {funcName}");
            return null;
        }
        
        var func = functionMap[funcName];
        
        // Call based on parameter count
        if (args == null || args.Count == 0) {
            if (func is System.Func<string>) {
                return ((System.Func<string>)func)();
            } else if (func is System.Func<bool>) {
                return ((System.Func<bool>)func)();
            } else if (func is System.Action) {
                ((System.Action)func)();
                return null;
            }
        } else if (args.Count == 1) {
            if (func is System.Action<string>) {
                ((System.Action<string>)func)((string)args[0]);
                return null;
            }
        }
        
        Debug.LogError($"Function call failed: {funcName}");
        return null;
    }
}

Step 4: Implement Dialogue Engine

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;

public class DialogueEngine : MonoBehaviour {
    public TextMeshProUGUI dialogueText;
    public GameObject choiceButtonPrefab;
    public Transform choiceContainer;
    
    private MortarDialogue dialogue;
    private DialogueFunctions functions;
    private string currentNode;
    private int currentTextIndex;
    
    void Start() {
        functions = GetComponent<DialogueFunctions>();
    }
    
    public void StartDialogue(MortarDialogue dialogueData, string startNode) {
        dialogue = dialogueData;
        currentNode = startNode;
        currentTextIndex = 0;
        ShowNextText();
    }
    
    void ShowNextText() {
        if (!dialogue.nodes.ContainsKey(currentNode)) {
            Debug.LogError($"Node not found: {currentNode}");
            return;
        }
        
        NodeData node = dialogue.nodes[currentNode];
        
        if (currentTextIndex < node.texts.Count) {
            TextBlock textBlock = node.texts[currentTextIndex];
            StartCoroutine(DisplayText(textBlock));
        } else {
            // All text displayed, show choices
            if (node.choices != null && node.choices.Count > 0) {
                ShowChoices(node.choices);
            } else if (!string.IsNullOrEmpty(node.next_node)) {
                // Jump to next node
                currentNode = node.next_node;
                currentTextIndex = 0;
                ShowNextText();
            } else {
                // Dialogue ends
                EndDialogue();
            }
        }
    }
    
    IEnumerator DisplayText(TextBlock textBlock) {
        string content = textBlock.content;
        
        // Handle string interpolation
        if (textBlock.interpolated) {
            content = ProcessInterpolation(content);
        }
        
        // Prepare events for typewriter effect
        Dictionary<int, List<Event>> eventMap = new Dictionary<int, List<Event>>();
        if (textBlock.events != null) {
            foreach (var evt in textBlock.events) {
                int index = Mathf.FloorToInt(evt.index);
                if (!eventMap.ContainsKey(index)) {
                    eventMap[index] = new List<Event>();
                }
                eventMap[index].Add(evt);
            }
        }
        
        // Typewriter effect
        dialogueText.text = "";
        for (int i = 0; i < content.Length; i++) {
            dialogueText.text += content[i];
            
            // Trigger events at specific character index
            if (eventMap.ContainsKey(i)) {
                foreach (var evt in eventMap[i]) {
                    functions.CallFunction(evt.function, evt.args);
                }
            }
            
            yield return new WaitForSeconds(0.05f); // Adjust speed here
        }
        
        yield return new WaitForSeconds(0.5f); // Wait after text is shown
        
        currentTextIndex++;
        ShowNextText();
    }
    
    string ProcessInterpolation(string text) {
        // A more robust implementation would use regex or a proper parser
        int start = text.IndexOf('{');
        while (start >= 0) {
            int end = text.IndexOf('}', start);
            if (end < 0) break;
            
            string funcCall = text.Substring(start + 1, end - start - 1);
            string funcName = funcCall.Replace("()", ""); // Simplified parsing
            
            object result = functions.CallFunction(funcName, null);
            text = text.Replace($"{{{funcCall}}}", result?.ToString() ?? "");
            
            start = text.IndexOf('{', start + 1);
        }
        return text;
    }
    
    void ShowChoices(List<Choice> choices) {
        // Clear old choices
        foreach (Transform child in choiceContainer) {
            Destroy(child.gameObject);
        }
        
        foreach (var choice in choices) {
            // Check condition
            if (!string.IsNullOrEmpty(choice.condition)) {
                bool conditionMet = (bool)functions.CallFunction(choice.condition, null);
                if (!conditionMet) continue;
            }
            
            GameObject button = Instantiate(choiceButtonPrefab, choiceContainer);
            button.GetComponentInChildren<TextMeshProUGUI>().text = choice.text;
            
            string target = choice.target;
            button.GetComponent<UnityEngine.UI.Button>().onClick.AddListener(() => {
                OnChoiceSelected(target);
            });
        }
    }
    
    void OnChoiceSelected(string target) {
        if (target == "return") {
            EndDialogue();
            return;
        }
        
        // Clear choices before showing next text
        foreach (Transform child in choiceContainer) {
            Destroy(child.gameObject);
        }
        
        currentNode = target;
        currentTextIndex = 0;
        ShowNextText();
    }
    
    void EndDialogue() {
        dialogueText.text = "";
        // Clear choices
        foreach (Transform child in choiceContainer) {
            Destroy(child.gameObject);
        }
    }
}

Step 5: Usage

public class GameController : MonoBehaviour {
    public DialogueEngine dialogueEngine;
    public DialogueManager dialogueManager;
    
    void Start() {
        // Load dialogue
        dialogueManager.LoadDialogue("Assets/Dialogues/simple.json");
        
        // Start dialogue
        dialogueEngine.StartDialogue(dialogueManager.dialogue, "StartScene");
    }
}

Godot GDScript Integration Example

Simplified Implementation

extends Node

# Dialogue data
var dialogue_data = {}
var current_node = ""
var current_text_index = 0

# UI references
@onready var dialogue_label = $DialogueLabel
@onready var choice_container = $ChoiceContainer

func load_dialogue(json_path: String):
    var file = FileAccess.open(json_path, FileAccess.READ)
    var json_string = file.get_as_text()
    file.close()
    
    var json = JSON.new()
    var error = json.parse(json_string)
    if error == OK:
        dialogue_data = json.data
        print("Loaded %d nodes" % dialogue_data.nodes.size())
    else:
        print("JSON parse error: ", json.get_error_message(), " at line ", json.get_error_line())

func start_dialogue(start_node: String):
    current_node = start_node
    current_text_index = 0
    show_next_text()

func show_next_text():
    if not current_node in dialogue_data.nodes:
        return
    
    var node = dialogue_data.nodes[current_node]
    
    if current_text_index < node.texts.size():
        var text_block = node.texts[current_text_index]
        display_text(text_block)
    else:
        if node.has("choices") and node.choices.size() > 0:
            show_choices(node.choices)
        elif node.has("next_node"):
            current_node = node.next_node
            current_text_index = 0
            show_next_text()
        else:
            end_dialogue()

func display_text(text_block: Dictionary):
    var content = text_block.content
    
    if text_block.get("interpolated", false):
        content = process_interpolation(content)
    
    # Typewriter effect
    dialogue_label.text = ""
    for i in range(content.length()):
        dialogue_label.text += content[i]
        
        # Trigger events
        if text_block.has("events"):
            for event in text_block.events:
                if int(event.index) == i:
                    call_function(event.function, event.args)
        
        await get_tree().create_timer(0.05).timeout
    
    current_text_index += 1
    await get_tree().create_timer(0.5).timeout
    show_next_text()

func process_interpolation(text: String) -> String:
    var regex = RegEx.new()
    regex.compile("\\{([^}]+)\\}")
    
    for match in regex.search_all(text):
        var func_call = match.get_string(1).replace("()", "")
        var value = call_function(func_call, [])
        text = text.replace(match.get_string(), str(value))
    
    return text

func show_choices(choices: Array):
    # Clear old choices
    for child in choice_container.get_children():
        child.queue_free()
    
    for choice in choices:
        # Check condition
        if choice.has("condition") and not choice.condition.is_empty():
            var condition_met = call_function(choice.condition, [])
            if not condition_met:
                continue
        
        var button = Button.new()
        button.text = choice.text
        button.pressed.connect(on_choice_selected.bind(choice.target))
        choice_container.add_child(button)

func on_choice_selected(target: String):
    if target == "return":
        end_dialogue()
        return
    
    current_node = target
    current_text_index = 0
    # Clear choices before showing next text
    for child in choice_container.get_children():
        child.queue_free()
    show_next_text()

func end_dialogue():
    dialogue_label.text = ""
    for child in choice_container.get_children():
        child.queue_free()

# ===== Function calling =====

func call_function(func_name: String, args: Array):
    if has_method(func_name):
        return call(func_name, args)
    else:
        print("Unknown function: ", func_name)
        return null

# Function implementations
func play_sound(args: Array):
    var filename = args[0]
    # Play sound logic here
    print("Playing sound: ", filename)

func get_name(args: Array) -> String:
    return "Player" # Replace with actual player data

func has_item(args: Array) -> bool:
    return true # Replace with actual inventory check

General Implementation Points

1. JSON Parsing

Different engines have different JSON parsing methods:

  • Unity: JsonUtility or Newtonsoft.Json
  • Godot: Built-in JSON class
  • Unreal: FJsonObjectConverter
  • Custom: Use libraries like rapidjson, nlohmann/json

2. Function Mapping

Use dictionary/map to map function names to actual implementations:

// C# example
Dictionary<string, Delegate> functions = new Dictionary<string, Delegate>();
functions["play_sound"] = new Action<string>(PlaySound);
# GDScript example
var functions = {
    "play_sound": play_sound,
    "get_name": get_name
}

3. Dialogue Flow Control

Core logic:

  1. Load node
  2. Display text sequentially
  3. Trigger events
  4. Show choices or jump to next node

4. Event Triggering

Events have index indicating trigger timing:

  • Integer index: Suitable for typewriter effect (character by character)
  • Decimal index: Suitable for voice sync (by timeline)

Implementation approaches:

  • Immediate: Trigger all events at once
  • Progressive: Trigger based on display progress
  • Timed: Calculate trigger time based on index

5. Conditional Logic

Choices can have conditions, only displayed when condition is met:

{
  "text": "Use magic",
  "target": "MagicPath",
  "condition": "can_use_magic"
}

Implementation:

  1. Parse condition field
  2. Call corresponding function
  3. Display choice only if returns true

6. String Interpolation

Texts marked interpolated: true contain {function()}:

{
  "content": "Hello, {get_player_name()}!",
  "interpolated": true
}

Implementation:

  1. Find {...} patterns
  2. Extract function names
  3. Call functions to get values
  4. Replace placeholders

Performance Optimization Suggestions

1. Pre-parse and Preload

Parse JSON once at game start and cache it in memory. Preload related resources like audio and images before the dialogue begins.

2. Object Pooling

For UI elements like choice buttons, use an object pool to avoid frequent creation and destruction, which can cause performance overhead.

3. Lazy Loading

For very large dialogue files, consider loading nodes on-demand as the player progresses, rather than loading the entire file at once.

4. Asynchronous Operations

For complex string interpolations or conditional checks that might involve heavy computation, use asynchronous methods to avoid blocking the main game thread and causing frame drops.

5. Batching

Batch process events that trigger at the same time or in quick succession to reduce the number of individual calls.

Common Questions

Q: What if the JSON file is too large?

A:

  1. Split Files: Break the dialogue into smaller files by chapter, scene, or character. Load them as needed.
  2. Compression: Compress the JSON files (e.g., using Gzip) and decompress them at runtime.
  3. Binary Format: For maximum performance, convert the JSON into a custom binary format for faster loading and parsing.
  4. Streaming Load: Load the JSON in chunks or stream it to parse it progressively without loading the whole file into memory.

Q: How to implement a save/load feature?

A: You need to save the current state of the dialogue. The essential data to save includes:

{
  "current_node": "ForestScene",
  "current_text_index": 2,
  "game_variables": {
    "has_magic_bottle": true,
    "player_power": 150
  }
}

When loading, restore the dialogue engine to this state and continue execution.

Q: How to support fast-forwarding or skipping dialogue?

A:

  • Text: Immediately display the full text instead of using the typewriter effect.
  • Events: You can choose to either trigger all events in a text block instantly or skip them entirely.
  • Choices: The engine can jump directly to the next choice point, skipping all intermediate text.

Q: Can a dialogue be interrupted (e.g., by combat)?

A: Yes. The key is to save the dialogue state before switching to another game system (like combat). After the interruption is over, you can restore the state to resume the dialogue exactly where it left off.

Q: How to handle localization?

A: Mortar itself is language-agnostic. You can manage localization in a few ways:

  1. Separate Files: Compile different .mortar files for each language (e.g., story_en.mortar, story_zh.mortar) and load the appropriate JSON based on the player’s language setting.
  2. External String Tables: Use keys in your .mortar file (e.g., text: "dialogue_key_001") and look up the translated text from a language-specific database or file at runtime. String interpolation can still be used with this method.

Summary

Integrating Mortar into games involves these key steps:

  1. ✅ Parse JSON structure
  2. ✅ Implement function interface
  3. ✅ Build dialogue engine
  4. ✅ Handle events and choices
  5. ✅ Manage state

The beauty of Mortar is its simplicity:

  • Clean JSON structure
  • Clear separation of concerns
  • Easy to extend

Next Steps

Command Line Interface

Mortar provides a simple and easy-to-use command-line tool for compiling .mortar files.

Installation

cargo install mortar_cli

Build from Source

git clone https://github.com/Bli-AIk/mortar.git
cd mortar
cargo build --release

# Compiled executable is at:
# target/release/mortar (Linux/macOS)
# target/release/mortar.exe (Windows)

Verify Installation

mortar --version

Should display the version number, for example: mortar 0.3.0

Basic Usage

Simplest Compilation

mortar your_file.mortar

This generates a .mortared file with the same name (default is compressed JSON).

For example:

mortar hello.mortar
# Generates hello.mortared

Formatted Output

If you want human-readable formatted JSON (with indentation and line breaks):

mortar hello.mortar --pretty

Comparison:

# Compressed format (default)
{"nodes":{"Start":{"texts":[{"content":"Hello"}]}}}

# Formatted output (--pretty)
{
  "nodes": {
    "Start": {
      "texts": [
        {
          "content": "Hello"
        }
      ]
    }
  }
}

Specify Output File

Use -o or --output parameter:

mortar input.mortar -o output.json

# Or full form:
mortar input.mortar --output output.json

Combining Options

# Formatted output to specified file
mortar hello.mortar -o dialogue.json --pretty

# Or this way:
mortar hello.mortar --output dialogue.json --pretty

Complete Parameter List

mortar [OPTIONS] <INPUT_FILE>

Required Parameters

  • <INPUT_FILE> - Path to the .mortar file to compile

Optional Parameters

ParameterShortDescription
--output <FILE>-oSpecify output file path
--pretty-Generate formatted JSON (with indentation)
--version-vDisplay version information
--help-hDisplay help information

Usage Scenarios

Development Stage

During development, use --pretty for easy viewing and debugging:

mortar story.mortar --pretty

You can directly open the generated JSON to view structure.

Production Environment

When releasing games, use compressed format to reduce file size:

mortar story.mortar -o assets/dialogues/story.json

Error Handling

Syntax Errors

If the Mortar file has syntax errors, the compiler will clearly indicate them:

Error: Unexpected token
  ┌─ hello.mortar:5:10
  │
5 │     text: Hello"
  │          ^ missing quote
  │

Error messages include:

  • Error type
  • Filename and location (line number, column number)
  • Related code snippet
  • Error hint

Undefined Nodes

Error: Undefined node 'Unknown'
  ┌─ hello.mortar:10:20
  │
10 │     choice: ["Go" -> Unknown]
   │                      ^^^^^^^ this node doesn't exist
   │

Type Errors

Error: Type mismatch
  ┌─ hello.mortar:8:15
  │
8 │     0, play_sound(123)
  │                   ^^^ expected String, got Number
  │

File Not Found

$ mortar notfound.mortar
Error: File not found: notfound.mortar

Exit Codes

Mortar CLI follows standard exit code conventions:

  • 0 - Compilation successful
  • 1 - Compilation failed (syntax error, type error, etc.)
  • 2 - File read failed
  • 3 - File write failed

This is especially useful in CI/CD scripts:

#!/bin/bash
if mortar dialogue.mortar; then
    echo "✅ Compilation successful"
else
    echo "❌ Compilation failed"
    exit 1
fi

Common Questions

Q: Why is compressed format the default?

A: Compressed format files are smaller and load faster, suitable for production. Use --pretty during development to view.

Q: Can I compile an entire directory?

A: Not currently supported, but you can batch compile with shell scripts.

Q: Can output be something other than JSON?

A: Currently only JSON output is supported. JSON is a universal format that almost all languages and engines can parse.

Q: How to check syntax without generating a file?

A: There’s no dedicated check mode currently, but you can output to a temporary file:

mortar test.mortar -o /tmp/test.json

Summary

Key points of Mortar CLI:

  • ✅ Simple and easy to use, one command does it all
  • ✅ Clear error messages
  • ✅ Supports formatted output for easy debugging
  • ✅ Easy to integrate into development workflow
  • ✅ Fast and reliable

Next Steps

Editor Support

Mortar currently mainly provides editor support through LSP, helping you write dialogues more efficiently. Dedicated editor plugins are not yet available.

Editor Adaptation Plan

Our plan is:

  1. First adapt JetBrains IDEs through LSP2IJ plugin (such as IntelliJ IDEA, PyCharm, etc.)
  2. Then adapt VS Code

Future editor features are expected to include:

  • Automatic index updates
  • Visual structure diagrams

Language Server Protocol (LSP)

Mortar provides an LSP server, which is the core tool for implementing advanced editor features.

Install LSP

cargo install mortar_lsp

Features

1. Real-time Error Checking

Discover errors while editing, without waiting for compilation:

node TestNode {
    text: "Hello
    // ↑ Shows red underline: missing quote
}

2. Go to Definition

Support Ctrl/Command + click on node or function names to jump to definition:

node Start {
    choice: [
        "Next" -> NextNode  // ← Click to jump
    ]
}

node NextNode {  // ← Jump here
    text: "Arrived!"
}

3. Find References

Right-click on node or function → “Find All References” to list all usage locations.

4. Auto-completion

Provides auto-completion for keywords, defined nodes, function names, and type names.

5. Hover Information

Mouse hover over elements shows type or function signature information.

6. Code Diagnostics

LSP analyzes code and provides warnings or suggestions, such as unused nodes or functions.

Using LSP in Different Editors

  • JetBrains IDE: Adapt through LSP2IJ plugin
  • VS Code: Will support through official plugin in the future
  • Neovim, Emacs, Sublime Text: Can manually configure with respective LSP plugins

Even without dedicated plugins, you can still get through LSP in IDE:

  • Syntax highlighting (based on existing language features or manual configuration)
  • Real-time error checking
  • Go to definition and find references
  • Auto-completion

Future expansion plans will further improve automatic indexing and structure visualization features.

Summary

  • Mortar currently mainly relies on LSP to support editor features
  • JetBrains IDE will receive official adaptation first
  • VS Code will receive support subsequently
  • Feature coverage: error checking, completion, jumping, reference finding
  • Planned additions: automatic index updates, structure visualization

Good tools make writing dialogues easier!

Next Steps

JSON Output Format

Mortar v0.4 reorganizes the .mortared artifact so that every playable action appears in a single ordered stream. This chapter summarizes the v0.4 structure and shows how to consume it from tooling.

Top-Level Layout

Each file contains the following top-level arrays and objects:

{
  "metadata": { "version": "0.4.0", "generated_at": "2025-01-31T12:00:00Z" },
  "variables": [ ... ],
  "constants": [ ... ],
  "enums": [ ... ],
  "nodes": [ ... ],
  "functions": [ ... ],
  "events": [ ... ],
  "timelines": [ ... ]
}
  • variables mirror the let declarations from your script (formalized in v0.4) and ship initial values if present.
  • constants include pub const entries with a public flag so engines can expose localized strings.
  • enums describe the symbolic sets required by branch interpolation.

Metadata

The metadata block records the compiler version and timestamp. Both values are strings, and generated_at follows ISO 8601 in UTC. Use it for compatibility checks or caches.

Nodes and Linear Content

nodes is now an array. Each node object has:

{
  "name": "Start",
  "content": [ ... ],
  "branches": [ ... ],         // optional asymmetric text tables
  "variables": [ ... ],        // optional node-scoped mutable values
  "next": "NextNode"           // optional default jump
}

The content array is the authoritative execution order. Text, inline events, choices, and run statements appear exactly where they were written, removing the need to merge texts/runs/choice_position as older guides required.

Content Item Types

Every element inside content has a type field:

  1. type: "text" — A dialogue line or interpolated string.

    • value: rendered text (placeholders already flattened so clients can display immediately).
    • interpolated_parts: optional array describing each string fragment, expression, or branch case so you can rebuild smart previews.
    • condition: optional AST for if/else guards introduced in v0.4.
    • pre_statements: assignments that must run before showing the line.
    • events: inline triggers tied to the literal characters. Each event contains an index, optional index_variable, and an actions array ({ "type": "play_sound", "args": ["intro.wav"] }).
  2. type: "run_event" — Inserts a named event definition into the flow.

    • name: references the entry in the top-level events array.
    • args: serialized arguments passed to the underlying action.
    • index_override: { "type": "value" | "variable", "value": "..." } to re-time the event relative to the surrounding text block.
    • ignore_duration: true when the call should fire immediately instead of respecting the event’s intrinsic duration.
  3. type: "run_timeline" — Executes a timeline defined under timelines. Timelines let you orchestrate multiple run/wait statements (debuted in v0.4) and are ideal for cinematic sequences.

  4. type: "choice" — Presents selectable options exactly where they are authored.

    • options: an array of objects with text, optional next, optional action ("return"/"break"), optional nested choice arrays, and optional condition blocks (function calls with arguments). This replaces the old choices array and no longer needs choice_position.

Branch Definitions

If a node uses $"..." with branch placeholders, the compiler emits a branches array so clients can cache the localized pieces. Each case carries its own optional events, enabling the per-branch timing rules defined in v0.4.

Named Events and Timelines

The top-level events array contains reusable cues:

{
  "name": "ColorYellow",
  "index": 1.0,
  "duration": 0.35,
  "action": {
    "type": "set_color",
    "args": ["#FFFF00"]
  }
}

run_event references use these definitions, so an action can appear inline (with events) or be invoked elsewhere without duplicating parameters.

timelines describe scripted sequences:

{
  "name": "IntroScene",
  "statements": [
    { "type": "run", "event_name": "ShowAlice" },
    { "type": "wait", "duration": 2.0 },
    { "type": "run", "event_name": "PlayMusic", "ignore_duration": true }
  ]
}

Each run statement inherits the arguments specified in the corresponding event, while wait pauses the playback cursor.

Example Node

{
  "name": "Start",
  "content": [
    {
      "type": "text",
      "value": "Welcome to the adventure!",
      "events": [
        {
          "index": 0,
          "actions": [{ "type": "play_music", "args": ["intro.mp3"] }]
        }
      ]
    },
    {
      "type": "choice",
      "options": [
        { "text": "Begin", "next": "GameStart" },
        { "text": "Let me think", "action": "break" }
      ]
    }
  ],
  "next": "MainMenu"
}

Parsing Tips

Strongly type your reader so that new fields are easier to adopt. The following TypeScript and Python sketches mirror the serializer output:

type ContentItem =
  | {
      type: "text";
      value: string;
      interpolated_parts?: StringPart[];
      condition?: Condition;
      pre_statements?: Statement[];
      events?: EventTrigger[];
    }
  | {
      type: "run_event";
      name: string;
      args?: string[];
      index_override?: { type: "value" | "variable"; value: string };
      ignore_duration?: boolean;
    }
  | { type: "run_timeline"; name: string }
  | { type: "choice"; options: ChoiceOption[] };

interface MortaredFile {
  metadata: Metadata;
  variables: VariableDecl[];
  constants: ConstantDecl[];
  enums: EnumDecl[];
  nodes: Node[];
  functions: FunctionDecl[];
  events: EventDef[];
  timelines: TimelineDef[];
}
@dataclass
class EventTrigger:
    index: float
    actions: List[Action]
    index_variable: Optional[str] = None

@dataclass
class ContentText:
    type: Literal["text"]
    value: str
    interpolated_parts: Optional[List[StringPart]] = None
    condition: Optional[Condition] = None
    pre_statements: Optional[List[Statement]] = None
    events: Optional[List[EventTrigger]] = None

@dataclass
class ContentRunEvent:
    type: Literal["run_event"]
    name: str
    args: List[str] = field(default_factory=list)
    index_override: Optional[IndexOverride] = None
    ignore_duration: bool = False

@dataclass
class ContentChoice:
    type: Literal["choice"]
    options: List[ChoiceOption]

Continue modelling timelines, named events, and choice options in a similar fashion so your runtime can follow the same execution semantics as the Mortar compiler.

Frequently Asked Questions

While using Mortar, you might have some questions. Here are the most common ones with answers.

Basic Concepts

How is Mortar different from other dialogue systems?

The biggest difference is “separation of content and logic”:

  • Traditional systems: "Hello<sound=hi.wav>, welcome<color=red>here</color>!"
  • Mortar: Text is pure text, events are written separately and linked by position

Benefits:

  • Writers can focus on storytelling without technical markup
  • Programmers can flexibly control events without breaking text
  • Text content is easy to translate and modify

Why use character positions to trigger events?

Character positions give you precise control over event timing:

text: "Boom! A bolt of lightning streaks across the sky."
with events: [
    0, shake_screen()      // At "B" for Boom, screen shakes
    5, flash_effect()      // At "!" for flash effect
    6, play_thunder()      // At "A" for A bolt, thunder sound
]

This is especially useful for:

  • Typewriter effects (character-by-character display)
  • Voice synchronization
  • Sound effect coordination

Can I write dialogues without events?

Absolutely! Events are optional:

node SimpleDialogue {
    text: "Hello!"
    text: "Welcome!"
    
    choice: [
        "Thanks" -> Thanks,
        "Bye" -> return
    ]
}

This is perfectly valid and suitable for simple scenarios.

Syntax

Are semicolons and commas required?

Mostly optional! Mortar syntax is flexible:

// All three work
text: "Hello"
text: "Hello",
text: "Hello";

with events: [
    0, sound_a()
    1, sound_b()
]

with events: [
    0, sound_a(),
    1, sound_b(),
]

with events: [
    0, sound_a();
    1, sound_b();
]

But we recommend staying consistent with one style.

Must strings use double quotes?

Both single and double quotes work:

text: "Double quoted string"
text: 'Single quoted string'

What’s the difference between node and nd?

They’re identical! nd is just shorthand for node:

node OpeningScene { }
nd Opening { }      // Exactly the same

Similar shortcuts:

  • fn = function
  • Bool = Boolean

How do I write comments?

Use // for single-line comments, /* */ for multi-line:

// Single line comment

/*
Multi-line
comment
*/

node Example {
    text: "Dialogue"  // Can also be at line end
}

Nodes and Jumps

What are the requirements for node names?

Technically you can use:

  • English letters, numbers, underscores
  • But cannot start with a number

We strongly recommend using PascalCase:

// ✅ Recommended (PascalCase)
node OpeningScene { }
node ForestEntrance { }
node BossDialogue { }
node Chapter1Start { }

// ⚠️ Not recommended but works
node opening_scene { }  // snake_case is for functions
node forest_1 { }       // OK, but Forest1 is better

// ❌ Bad naming
node ScèneOuverture { }   // Avoid non-ASCII text
node 1node { }            // Cannot start with number
node node-1 { }           // Cannot use hyphens

Why recommend PascalCase?

  • Consistent with mainstream programming language type naming
  • Clear and readable, easy to recognize
  • Avoids cross-platform encoding issues
  • More standardized for team collaboration

Can I jump to a non-existent node?

No! The compiler checks all jumps:

node A {
    choice: [
        "Go to B" -> B,      // ✅ B exists, OK
        "Go to C" -> C       // ❌ C doesn't exist, error
    ]
}

node B { }

How do I end dialogue?

Three ways:

  1. return - End current node (if there is a subsequent node, it will continue)
  2. No subsequent jump - Dialogue naturally ends
  3. Jump to special node - Create a dedicated “ending” node
// Method 1: Using return
node A {
    choice: [
        "End" -> return
    ]
}

// Method 2: Natural end
node B {
    text: "Goodbye!"
    // No jump, dialogue ends
}

// Method 3: Ending node
node C {
    choice: [
        "End" -> EndingNode
    ]
}

node EndingNode {
    text: "Thanks for playing!"
}

Choice System

Can choices be nested?

Yes! And to arbitrary depth:

choice: [
    "What to eat?" -> [
        "Chinese" -> [
            "Rice" -> End1,
            "Noodles" -> End2
        ],
        "Western" -> [
            "Steak" -> End3,
            "Pasta" -> End4
        ]
    ]
]

How do I write when conditions?

Two syntaxes:

choice: [
    // Chain style
    ("Option A").when(has_key) -> A,
    
    // Function style
    "Option B" when has_key -> B
]

Condition functions must return Bool:

fn has_key() -> Bool

What if no choice conditions are met?

This is a game logic issue to handle. Mortar only compiles, doesn’t manage runtime.

Suggestions:

  • Keep at least one unconditional “default option”
  • Check for available options in game code

Event System

Can event indices be decimals?

Yes! Decimals are especially suitable for voice sync:

text: "This line has voice acting."
with events: [
    0.0, start_voice()
    1.5, highlight_word()   // At 1.5 seconds
    3.2, another_effect()   // At 3.2 seconds
]

Can multiple events be at the same position?

Yes! And they execute in order:

with events: [
    0, effect_a()
    0, effect_b()    // Also at position 0
    0, effect_c()    // Also at position 0
]

Game runtime will call these three functions in sequence.

Must event functions be declared?

Yes! All used functions must be declared:

node A {
    text: "Triggering something..."
    with events: [
        0, my_function()   // Using function
    ]
}

// Must declare
fn my_function()

Not declaring will cause compilation error.

Functions

Are function declarations just placeholders?

Yes! Actual implementation is in your game code:

// In Mortar file, just declare
fn play_sound(file: String)

// Real implementation in your game code (C#/C++/Rust etc.)
// For example in Unity:
// public void play_sound(string file) {
//     AudioSource.PlayClipAtPoint(file);
// }

Mortar is only responsible for:

  • Checking function names are correct
  • Checking parameter types match
  • Generating JSON so game knows what to call

What parameter types are supported?

Currently these basic types:

  • String - String
  • Bool / Boolean - Boolean (true/false)
  • Number - Number (integer or decimal)
fn example_func(
    name: String,
    age: Number,
    is_active: Bool
) -> String

Can functions have no parameters?

Yes!

fn simple_function()
fn another() -> String

Can functions have multiple parameters?

Yes! Separate with commas:

fn complex_function(
    param1: String,
    param2: Number,
    param3: Bool
) -> Bool

What are function naming conventions?

Strongly recommend using snake_case:

// ✅ Recommended (snake_case)
fn play_sound(file_name: String)
fn get_player_name() -> String
fn check_inventory() -> Bool
fn calculate_damage(base: Number, modifier: Number) -> Number

// ⚠️ Not recommended
fn playSound() { }          // camelCase is other languages' style
fn PlaySound() { }          // PascalCase is for nodes
fn sonido_ñ() { }           // Avoid non-ASCII text

Parameter names should also use snake_case:

fn load_scene(scene_name: String, fade_time: Number)  // ✅
fn load_scene(SceneName: String, fadeTime: Number)    // ❌

String Interpolation

What is string interpolation?

Embedding variables or function calls in strings:

text: $"Hello, {get_name()}! You have {get_score()} points."

Note the $ before the string!

Must interpolation be functions?

Currently Mortar interpolation mainly uses function calls. Content in interpolation is replaced with function return values.

What happens without $?

Without $ it’s a plain string, {} is treated as regular characters:

text: "Hello, {name}!"    // Displays "Hello, {name}!"
text: $"Hello, {name}!"   // name is replaced with actual value

Compilation and Output

What format is the compiled file?

JSON format, default is compressed (no spaces or line breaks):

mortar hello.mortar           # Generate compressed JSON
mortar hello.mortar --pretty  # Generate formatted JSON

How do I specify output filename?

Use -o parameter:

mortar input.mortar -o output.json

Without specification, default is input.mortared

What’s the JSON structure?

Basic structure:

{
  "nodes": {
    "NodeName": {
      "texts": [...],
      "events": [...],
      "choices": [...]
    }
  },
  "functions": [...]
}

See JSON Output Format for details.

How do I read compilation errors?

Mortar error messages are friendly, indicating:

  • Error location (line, column)
  • Error reason
  • Related code snippet
Error: Undefined node 'Unknown'
  ┌─ hello.mortar:5:20
  │
5 │     choice: ["Go" -> Unknown]
  │                      ^^^^^^^ this node doesn't exist
  │

Project Practice

How to collaborate with multiple people?

Suggestions:

  1. Use Git to manage Mortar files
  2. Divide files by feature modules to reduce conflicts
  3. Establish naming conventions
  4. Write clear comments

How to integrate with game engines?

Basic process:

  1. Write Mortar files
  2. Compile to JSON
  3. Read JSON in game
  4. Implement corresponding functions
  5. Execute according to JSON instructions

See Integrating with Your Game for details.

What types of games is this suitable for?

Especially suitable for:

  • RPG dialogue systems
  • Visual novels
  • Text adventure games
  • Interactive stories

Basically any game needing “structured dialogue”!

Can it be used in non-game projects?

Of course! Any scenario needing structured text and events:

  • Educational software
  • Chatbots
  • Interactive presentations
  • Multimedia displays

Advanced Topics

Does it support variables?

Currently no built-in variable system, but you can:

  • Maintain variables in game code
  • Read/write variables through function calls
// Mortar file
fn get_player_hp() -> Number
fn set_player_hp(hp: Number)

// Implement these functions in game code

Does it support expressions?

Currently no complex expressions, but can implement through functions:

// Not supported:
choice: [
    "Option" when hp > 50 && has_key -> Next
]

// Can do this:
choice: [
    "Option" when can_proceed() -> Next  
]

fn can_proceed() -> Bool  // Implement logic in game

This feature is coming soon.

How to do localization (multiple languages)?

This feature is coming soon.

Does it support modularity?

Currently each .mortar file is independent, cannot reference each other.

Suggestions:

  • Write related dialogues in the same file
  • Or load multiple JSON files in game and integrate

This feature is coming soon.

Troubleshooting

Getting “syntax error” during compilation?

  1. Carefully check the location indicated by error message
  2. Check for missing brackets, quotes
  3. Check keyword spelling
  4. Ensure node names and function names are valid

“Undefined node” error?

Check:

  • Does the jump target node exist
  • Are node name cases consistent (case-sensitive!)
  • Any typos

“Type mismatch” error?

Check:

  • Function declaration parameter types
  • Do passed parameters match when calling
  • Is return type correct

Game can’t read generated JSON?

  1. Ensure JSON format is correct (check with --pretty)
  2. Check game code parsing logic
  3. Check for encoding issues (use UTF-8)

Still Have Questions?

We’re happy to help! 🎉

Contributing Guide

Thank you for your interest in Mortar! We welcome all forms of contributions.

Ways to Contribute

You can contribute to Mortar in the following ways:

  • 🐛 Report Bugs - If you find a problem, let us know.
  • Propose New Features - Share your creative ideas.
  • 📝 Improve Documentation - Make the documentation clearer.
  • 💻 Submit Code - Fix bugs or implement new features.
  • 🌍 Translate - Help translate the documentation.
  • 💬 Answer Questions - Help others in the Discussions.
  • Share the Project - Let more people know about Mortar.

Code of Conduct

When participating in the Mortar community, please:

  • ✅ Be friendly and respectful.
  • ✅ Welcome newcomers.
  • ✅ Accept constructive criticism.
  • ✅ Focus on what is best for the community.

We are committed to providing a friendly, safe, and welcoming environment for all.

Reporting Bugs

Before Reporting

  1. Search Existing Issues - Confirm that the issue has not already been reported.
  2. Update to the Latest Version - Confirm that the issue still exists in the latest version.
  3. Prepare a Minimal Reproduction Example - Simplify the problem as much as possible.

How to Report

Go to GitHub Issues to create a new issue.

A good bug report should include:

## Description
A brief description of the problem.

## Steps to Reproduce
1. Create a file with this content...
2. Run this command...
3. See the error...

## Expected Behavior
What should happen.

## Actual Behavior
What actually happened.

## Minimal Reproduction Example
```mortar
// The minimal code that can reproduce the problem
node TestNode {
    text: "..."
}

Environment Information

  • Mortar Version: 0.3.0
  • Operating System: Windows 11 / macOS 14 / Ubuntu 22.04
  • Rust Version (if building from source): 1.75.0

**Example**:

```markdown
## Description
Compiler crashes when compiling a node with an empty choice list.

## Steps to Reproduce
1. Create a file `test.mortar`.
2. Write the following content:
   ```mortar
   node TestNode {
       text: "Hello"
       choice: []
   }
  1. Run mortar test.mortar.
  2. The program crashes.

Expected Behavior

Should give a friendly error message: “Choice list cannot be empty”.

Actual Behavior

The program crashes directly, showing:

thread 'main' panicked at 'index out of bounds'

Environment Information

  • Mortar Version: 0.3.0
  • Operating System: Windows 11

## Proposing New Features

### Before Proposing

1. **Search Existing Issues** - Confirm that the feature has not already been proposed.
2. **Consider the Necessity** - Is this feature useful for most users?
3. **Consider Alternatives** - Are there other ways to implement it?

### How to Propose

Start a discussion on [GitHub Discussions](https://github.com/Bli-AIk/mortar/discussions).

**A good feature proposal should include**:

```markdown
## Problem/Need
Describe the problem you encountered or the need you want to solve.

## Proposed Solution
Describe in detail the feature you want to add.

## Example
Show how the feature would be used.

## Alternatives
Have you considered other implementation methods?

## Impact
Will this feature affect existing users?

Example:

## Problem/Need
When writing large dialogues, I often need to share function declarations between multiple files.
Currently, I need to repeat the declarations in each file, which is very troublesome.

## Proposed Solution
Add an `import` syntax to import function declarations from other files:

```mortar
import functions from "common_functions.mortar"

node MyNode {
    text: "Triggering shared logic."
    with events: [
        0, play_sound("test.wav")  // This function comes from common_functions.mortar
    ]
}

Alternatives

  1. Use a preprocessor to merge files.
  2. Solve it at the game engine level.

Impact

It will not affect existing code because the import keyword is not currently supported.


## Improving Documentation

The documentation is in the `docs/` directory and is written in Markdown.

### Types of Documentation

- **Tutorials** - Step-by-step guides for beginners.
- **How-to Guides** - Steps to solve a specific problem.
- **Reference** - Detailed technical descriptions.
- **Explanations** - Concepts and design ideas.

### Steps to Improve Documentation

1. Fork the repository.
2. Create a branch: `git checkout -b improve-docs`
3. Edit the documentation files.
4. Preview locally: `mdbook serve docs/zh-Hans` or `mdbook serve docs/en`
5. Commit your changes: `git commit -m "docs: improve installation instructions"`
6. Push the branch: `git push origin improve-docs`
7. Create a Pull Request.

### Documentation Style Guide

- **Clear and concise** - Explain complex concepts in simple language.
- **Friendly tone** - Like chatting with a friend.
- **Practical examples** - Provide runnable code.
- **Step-by-step** - From simple to complex.
- **Visual aids** - Use diagrams and emojis appropriately.
- **Code formatting** - Use syntax highlighting.

**Good documentation**:
```markdown
## Creating Your First Dialogue

Let's write a simple NPC dialogue:

```mortar
node Villager {
    text: "Hello, traveler!"
}

It’s that simple! After saving the file, compile it:

mortar hello.mortar

**Bad documentation**:
```markdown
## Node Creation

To create a node, use the node keyword, followed by an identifier and a block.
Inside the block, use the text field to define the text content.
To compile, use the mortar command with the filename as an argument.

Submitting Code

Development Environment Setup

  1. Install Rust (1.70+):

    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    
  2. Clone the repository:

    git clone https://github.com/Bli-AIk/mortar.git
    cd mortar
    
  3. Build the project:

    cargo build
    
  4. Run tests:

    cargo test
    
  5. Code checks:

    cargo clippy
    cargo fmt --check
    

Project Structure

mortar/
├── crates/
│   ├── mortar_compiler/  # Compiler core
│   ├── mortar_cli/       # Command-line tool
│   ├── mortar_lsp/       # Language server
│   └── mortar_language/  # Main library
├── docs/                 # Documentation
└── tests/                # Integration tests

Development Workflow

  1. Create an Issue - Describe the changes you want to make.

  2. Fork the repository.

  3. Create a feature branch:

    git checkout -b feature/my-feature
    # or
    git checkout -b fix/bug-description
    
  4. Develop:

    • Write code.
    • Add tests.
    • Run tests to ensure they pass.
    • Use clippy to check the code.
  5. Commit your changes:

    git add .
    git commit -m "feat: add new feature"
    
  6. Push the branch:

    git push origin feature/my-feature
    
  7. Create a Pull Request.

Commit Message Guidelines

Use Conventional Commits:

<type>(<scope>): <description>

[optional body]

[optional footer]

Type:

  • feat - New feature
  • fix - Bug fix
  • docs - Documentation changes
  • style - Code formatting (does not affect code execution)
  • refactor - Refactoring
  • test - Adding tests
  • chore - Changes to the build process or auxiliary tools

Example:

feat(compiler): add support for nested choices

Adds the ability to parse nested choices, now you can write:
choice: [
    "Option" -> [
        "Sub-option" -> Node
    ]
]

Closes #42
fix(cli): fix path issues on Windows

The path separator was incorrect when compiling on Windows, causing compilation to fail.
Now uses std::path::PathBuf to handle paths correctly.

Fixes #38

Code Style

Follow the standard Rust style:

# Format code
cargo fmt

# Check code
cargo clippy -- -D warnings

Code comments:

// Good comment: explains why, not what
// Use a hash map instead of a vector because we need O(1) lookup speed
let mut nodes = HashMap::new();

// Bad comment: repeats the code content
// Create a new HashMap
let mut nodes = HashMap::new();

Naming conventions:

// Use snake_case
fn parse_node() { }
let node_name = "test";

// Use PascalCase for types
struct NodeData { }
enum TokenType { }

// Use SCREAMING_SNAKE_CASE for constants
const MAX_DEPTH: usize = 10;

Testing

Add tests for new features:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_simple_node() {
        let input = r#"
            node TestNode {
                text: "Hello"
            }
        "#;
        
        let result = parse(input);
        assert!(result.is_ok());
        
        let ast = result.unwrap();
        assert_eq!(ast.nodes.len(), 1);
        assert_eq!(ast.nodes[0].name, "Test");
    }
}

Pull Request

A good PR should:

  • ✅ Solve a single problem.
  • ✅ Include tests.
  • ✅ Update relevant documentation.
  • ✅ Pass all CI checks.
  • ✅ Have a clear description.

PR description template:

## Changes
A brief description of what this PR does.

## Motivation
Why is this change needed? What problem does it solve?

## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Documentation update
- [ ] Code refactoring
- [ ] Other: ___

## Testing
How to test this change?

## Related Issue
Closes #issue_number

## Screenshots (if applicable)

Code Review

After submitting a PR:

  1. CI Checks - Ensure all automated tests pass.
  2. Wait for Review - Maintainers will review your code.
  3. Respond to Feedback - Make changes based on suggestions.
  4. Merge - After approval, it will be merged into the main branch.

Translating Documentation

Want to help translate the documentation into other languages? Great!

Currently Supported Languages

  • 🇨🇳 Simplified Chinese (zh-Hans)
  • 🇬🇧 English (en)

Adding a New Language

  1. Create a new directory under docs/: docs/your-language/
  2. Copy book.toml and modify the language settings.
  3. Translate all .md files in the src/ directory.
  4. Test the build: mdbook build docs/your-language
  5. Submit a PR.

Translation Guidelines

  • Keep the structure consistent - Do not change the documentation structure.
  • Localize examples - Adjust examples based on cultural context.
  • Keep terminology consistent - Maintain consistency in technical terms.
  • Do not translate code - Keep code examples in English.
  • Update links - Ensure internal links point to the corresponding language pages.

Community

Getting Help

Stay Connected

  • ⭐ Star the project to follow updates.
  • 👀 Watch the repository to receive notifications.
  • 🔔 Subscribe to Release notifications.

License

Contributed code will be licensed under the same licenses as the project:

  • MIT License
  • Apache License 2.0

By submitting a PR, you agree to distribute your contributions under these licenses.

Acknowledgements

Thank you to all contributors! Your help makes Mortar better ❤️

The list of contributors can be found in the project README.


Thank you again for your contribution! If you have any questions, feel free to ask in the Discussions 🎉