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
- Want to try it right away? Check out Quick Start Guide
- Need to install tools? Go to Installation
- Want to learn more? Start with Core Concepts
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:
nodedeclares a dialogue node (can also be shortened tond)StartSceneis 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
fndeclarations 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" -> ForestScenemeans: display the “Explore the forest” option, if chosen jump to the node namedForestScene- The benefit of using PascalCase for nodes shows here—easy to recognize and maintain!
returnindicates 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 valuewhenindicates this option is conditional, only “displayed” whenhas_backpack()returns true-> Stringand-> Boolindicate the function’s return type. Mortar performs static type checking to prevent type mixing!
What to Learn Next?
- Want to understand deeper? Check out Core Concepts
- Want to see more examples? Go to Practical Examples
- Want to know all features? Browse Functions and Choices
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!
Method 1: Install with Cargo (Recommended)
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
- Open the Release page and download the corresponding version, for example
mortar-x.x.x-linux-x64.tar.gzormortar-x.x.x-macos-x64.tar.gz. - Extract to any directory:
tar -xzf mortar-x.x.x-linux-x64.tar.gz -C ~/mortar
- Add the executable path to environment variables, for example:
export PATH="$HOME/mortar:$PATH"
- Check if installation was successful:
mortar --version
Windows
- Download the corresponding version of
mortar-x.x.x-windows-x64.zip. - Extract to any directory, for example
D:\mortar. - Add the directory to system environment variable PATH:
- Right-click “This PC” → “Properties” → “Advanced system settings” → “Environment Variables”
- Find
Pathin “System variables” or “User variables” → Edit → AddD:\mortar
- 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.
Editor Support (Optional but Recommended)
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
- Check GitHub Issues
- Or ask in Discussions
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?
- Nodes: Building Blocks of Dialogue - All node usage
- Text and Events: The Art of Separation - How to elegantly associate text and events
- Choices: Let Players Decide - Creating branching dialogues
- Functions: Connecting to Game World - Declaring and using functions
- Variables and Constants - Track state and expose key-value strings
- Branch Interpolation - Build Fluent-style snippets with per-branch events
- Localization Strategy - Structure repositories for multilingual builds
- Control Flow in Nodes - Use
if/elseto gate dialogue - Event System and Timelines - Reuse named cues and cinematic playlists
- Enums and Structured Choices - Model discrete states for cleaner branching
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:
nodekeyword (can also be shortened tond)- 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.-> Boutside the node: After node A has finished executing (even via areturn), 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
returnorbreak, 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
- Learn how to use Text and Events in nodes
- Learn more about Choice System usage
- Check out Complete Examples
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
- Follow-up principle: events immediately follow the corresponding text
- Use moderately: not every sentence needs events
- Ordered arrangement: write events in ascending order by position (though not mandatory)
- 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
- Learn about Choice System
- Study Function Declarations
- See Complete Examples
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:
| Keyword | Effect | Subsequent Execution |
|---|---|---|
return | End current node | Won’t execute following content |
break | Exit current choice | Will 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(orBoolean) 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
- At least one unconditional option: Ensure players always have a choice
- Keep option text concise: Generally no more than 20 characters
- Clear logic: Don’t nest too deep (suggest max 2-3 levels)
- 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
- Learn Function Declarations
- See Complete Interactive Story Example
- Learn how to Integrate with Your Game
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:
| Type | Alias | Description | Example |
|---|---|---|---|
String | - | String | "Hello", "file.wav" |
Number | - | Number (integer or decimal) | 42, 3.14 |
Bool | Boolean | Boolean | true, 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
- Self-explanatory names: Function names should say what they do
- Moderate parameters: Generally no more than 7 parameters
- Clear types: All parameters and return values should have types
- 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
- See Complete Examples
- Learn how to Integrate with Your Game
- Check out JSON Output Format
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
variablesarray of the.mortaredfile.
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
- Author or update the source language (
locales/en). - Sync node/layout changes across other locales.
- Run
cargo run -p mortar_cli -- locales/en/story.mortar --prettyfor every locale directory. - Bundle the resulting
.mortaredartifacts 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
choiceornext. - 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:
run EventNameexecutes the event immediately, ignoring the stored index (duration still matters unless you explicitly override it).with EventNameorwith events: [ EventA EventB ]attaches events to the previoustextblock 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
withto tie events to a specific text block; userunfor global beats. - Override indices when the same cue needs different timing in each context.
- Combine timelines with
now runif 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:
- Load enums into a registry or generate native enums from them.
- Track Mortar variables (such as
current_state) alongside your gameplay state. - 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:
- Display three text segments
- Player makes a choice
- 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
whenconditions - 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:
-
Read JSON: Parse the compiled JSON file
-
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"); } -
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
- Want to learn more complex branching narratives? See Creating Interactive Stories
- Want to know specific integration methods? See Integrating with Your Game
- Want to understand syntax deeply? See Core Concepts
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:
-
Add More Branches
- Add “touch the spring with hand” option
- Include “make a wish to the spring” mysterious branch
-
Add State Tracking
- Record player’s choices
- Display player’s decision path in ending
-
Multiple Ending Variants
- Unlock hidden endings based on previous game progress
- Add “true ending” requiring specific conditions
-
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
- Want to learn about integration? See Integrating with Your Game
- Want to understand JSON structure? See JSON Output Format
- Back to examples overview: Complete Examples and Explanations
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:
JsonUtilityorNewtonsoft.Json - Godot: Built-in
JSONclass - 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:
- Load node
- Display text sequentially
- Trigger events
- 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:
- Parse
conditionfield - Call corresponding function
- Display choice only if returns
true
6. String Interpolation
Texts marked interpolated: true contain {function()}:
{
"content": "Hello, {get_player_name()}!",
"interpolated": true
}
Implementation:
- Find
{...}patterns - Extract function names
- Call functions to get values
- 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:
- Split Files: Break the dialogue into smaller files by chapter, scene, or character. Load them as needed.
- Compression: Compress the JSON files (e.g., using Gzip) and decompress them at runtime.
- Binary Format: For maximum performance, convert the JSON into a custom binary format for faster loading and parsing.
- 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:
- Separate Files: Compile different
.mortarfiles for each language (e.g.,story_en.mortar,story_zh.mortar) and load the appropriate JSON based on the player’s language setting. - External String Tables: Use keys in your
.mortarfile (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:
- ✅ Parse JSON structure
- ✅ Implement function interface
- ✅ Build dialogue engine
- ✅ Handle events and choices
- ✅ Manage state
The beauty of Mortar is its simplicity:
- Clean JSON structure
- Clear separation of concerns
- Easy to extend
Next Steps
- Understand JSON structure: JSON Output Format
- Common problems: FAQ
- Back to examples: Complete Examples
Command Line Interface
Mortar provides a simple and easy-to-use command-line tool for compiling .mortar files.
Installation
Install from crates.io (Recommended)
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.mortarfile to compile
Optional Parameters
| Parameter | Short | Description |
|---|---|---|
--output <FILE> | -o | Specify output file path |
--pretty | - | Generate formatted JSON (with indentation) |
--version | -v | Display version information |
--help | -h | Display 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 successful1- Compilation failed (syntax error, type error, etc.)2- File read failed3- 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
- Learn about editor support: Editor Support
- Check JSON output format: JSON Output Format
- Back to quick start: Quick Start Guide
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:
- First adapt JetBrains IDEs through LSP2IJ plugin (such as IntelliJ IDEA, PyCharm, etc.)
- 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
Recommended Practices
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
- Learn about compilation tools: Command Line Interface
- Check output format: JSON Output Format
- Back to quick start: Quick Start Guide
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": [ ... ]
}
variablesmirror theletdeclarations from your script (formalized in v0.4) and ship initial values if present.constantsincludepub constentries with apublicflag so engines can expose localized strings.enumsdescribe 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:
-
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 forif/elseguards 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 anindex, optionalindex_variable, and anactionsarray ({ "type": "play_sound", "args": ["intro.wav"] }).
-
type: "run_event"— Inserts a named event definition into the flow.name: references the entry in the top-leveleventsarray.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:truewhen the call should fire immediately instead of respecting the event’s intrinsicduration.
-
type: "run_timeline"— Executes a timeline defined undertimelines. Timelines let you orchestrate multiplerun/waitstatements (debuted in v0.4) and are ideal for cinematic sequences. -
type: "choice"— Presents selectable options exactly where they are authored.options: an array of objects withtext, optionalnext, optionalaction("return"/"break"), optional nestedchoicearrays, and optionalconditionblocks (function calls with arguments). This replaces the oldchoicesarray and no longer needschoice_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=functionBool=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:
- return - End current node (if there is a subsequent node, it will continue)
- No subsequent jump - Dialogue naturally ends
- 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- StringBool/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:
- Use Git to manage Mortar files
- Divide files by feature modules to reduce conflicts
- Establish naming conventions
- Write clear comments
How to integrate with game engines?
Basic process:
- Write Mortar files
- Compile to JSON
- Read JSON in game
- Implement corresponding functions
- 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?
- Carefully check the location indicated by error message
- Check for missing brackets, quotes
- Check keyword spelling
- 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?
- Ensure JSON format is correct (check with
--pretty) - Check game code parsing logic
- Check for encoding issues (use UTF-8)
Still Have Questions?
- 📖 Check Example Code
- 💬 Ask at GitHub Discussions
- 🐛 Report bugs at GitHub Issues
- 📚 Read Contributing Guide
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
- Search Existing Issues - Confirm that the issue has not already been reported.
- Update to the Latest Version - Confirm that the issue still exists in the latest version.
- 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: []
}
- Run
mortar test.mortar. - 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
- Use a preprocessor to merge files.
- 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
-
Install Rust (1.70+):
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -
Clone the repository:
git clone https://github.com/Bli-AIk/mortar.git cd mortar -
Build the project:
cargo build -
Run tests:
cargo test -
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
-
Create an Issue - Describe the changes you want to make.
-
Fork the repository.
-
Create a feature branch:
git checkout -b feature/my-feature # or git checkout -b fix/bug-description -
Develop:
- Write code.
- Add tests.
- Run tests to ensure they pass.
- Use clippy to check the code.
-
Commit your changes:
git add . git commit -m "feat: add new feature" -
Push the branch:
git push origin feature/my-feature -
Create a Pull Request.
Commit Message Guidelines
Use Conventional Commits:
<type>(<scope>): <description>
[optional body]
[optional footer]
Type:
feat- New featurefix- Bug fixdocs- Documentation changesstyle- Code formatting (does not affect code execution)refactor- Refactoringtest- Adding testschore- 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:
- CI Checks - Ensure all automated tests pass.
- Wait for Review - Maintainers will review your code.
- Respond to Feedback - Make changes based on suggestions.
- 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
- Create a new directory under
docs/:docs/your-language/ - Copy
book.tomland modify the language settings. - Translate all
.mdfiles in thesrc/directory. - Test the build:
mdbook build docs/your-language - 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
- 💬 GitHub Discussions - Ask questions and have discussions.
- 🐛 GitHub Issues - Report bugs.
- 📧 Email - See the project README.
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 🎉