gushjs
v0.1.0
Published
A lightweight, human-readable dialogue scripting system for games. Write conversations in plain JSON — no compiler required.
Maintainers
Readme
README.md 12.04 KB •462 lines • Formatting may be inconsistent from source
GushJS
Version 0.1.0 — Early release. API is stable but the library is young. Use with caution in production.
A minimal JSON dialogue framework for JavaScript games and applications.
Write branching conversations in plain JSON. Drop a single file into your project. No compiler, no dependencies, no build step, no editor required. Works anywhere JavaScript runs — browser, Node.js, Electron, or any JS game engine.
The format is the spec. If you can read JSON, you can write dialogue.
Why GushJS?
Most dialogue tools fall into one of two traps: they require a proprietary editor, or they compile to a runtime format that's unreadable by humans. Ink is a great example — elegant to write, but the compiled .ink.json is bytecode you can't hand-edit, can't review in a PR, and can't produce without an external compiler.
GushJS skips all of that. The file you write is the file the runtime reads. One JSON file. One parser. Total ownership.
Inspired by Ink by Inkle Studios.
Installation
npm install gushjsimport { startDialogue, chooseOption, isActive } from 'gushjs';Zero dependencies. Works in Node.js, Electron, and the browser.
File Format
GushJS files are plain JSON with the .gush extension.
Knots
A knot is a top-level key — the entry point for a conversation.
{
"shopkeeper": { ... },
"guard": { ... }
}Start a dialogue at a knot by name. Defaults to the first key if none is specified.
Node Fields
| Field | Type | Description |
|---|---|---|
| text | string or array | What the NPC says. Can be a string, a string array (random pick), or a weighted array. Supports inline {...} expressions. |
| choices | array | The player's available responses. |
| response | string or array | NPC reply after the player picks this choice. Same format as text. |
| end | boolean | true = end dialogue here (like Ink's -> DONE). |
| goto | string | Divert to another knot or stitch by name. |
| tunnel | string | Divert to a knot and automatically return here when it ends. |
| return | boolean | true = return from the current tunnel. |
| flags | array | State mutations to apply when this choice is picked. |
| actions | array | Named actions fired when dialogue ends. |
| condition | string | Expression that must be truthy for this choice to appear. |
| sticky | boolean | true = choice remains available after being picked. |
| tags | array | Metadata strings attached to this node (e.g. for audio or animation cues). |
| timeout | object | { seconds, choiceIndex } — passed through to the caller to implement timed auto-selection. |
| redirect | array | Conditional diverts checked at the top of a knot. |
| stitches | object | Sub-sections within a knot, addressable as "knot.stitch". |
| (any other key) | any | Treated as an auto-flag: written to state under the autoFlagPrefix namespace. |
Quick Example
{
"shopkeeper": {
"text": "Welcome{visits.shopkeeper > 1: back}, traveller. What can I do for you?",
"choices": [
{
"text": "I'm looking for supplies.",
"flags": ["player.met_shopkeeper"],
"response": "Good timing — I restocked just yesterday. Take what you need.",
"choices": [
{ "text": "These will be perfect, thank you.", "end": true }
]
},
{
"text": "Just browsing.",
"response": "Take your time. I'm not going anywhere.",
"choices": [
{ "text": "Thanks. I'll let you know.", "end": true }
]
}
]
}
}Ink Cheat Sheet
| Ink | GushJS |
|---|---|
| === knot_name === | Top-level key: "knot_name": { ... } |
| = stitch_name | "stitches": { "stitch_name": { ... } } |
| NPC says something | "text": "NPC says something" |
| * Player choice | { "text": "Player choice", ... } |
| + Sticky choice | { "text": "...", "sticky": true, ... } |
| NPC reply to a choice | "response": "NPC reply" |
| -> DONE | "end": true |
| -> another_knot | "goto": "another_knot" |
| -> knot -> (tunnel) | "tunnel": "knot_name" |
| ~ met_shopkeeper = true | "flags": ["player.met_shopkeeper"] |
| ~ gold = 5 | "flags": ["player.gold=5"] |
| ~ gold = gold + 1 | "flags": ["player.gold += 1"] |
| ~ temp x = 0 | "flags": ["$x=0"] |
| { met_shopkeeper: * choice } | "condition": "player.met_shopkeeper" |
| { gold > 10: * choice } | "condition": "player.gold > 10" |
| { not met } | "condition": "!player.met_shopkeeper" |
| { visited(knot) } | "condition": "visits.knot > 0" |
| {a\|b\|c} (cycling) | {a\|b\|c} in text string |
| {~a\|b\|c} (shuffle) | {~a\|b\|c} in text string |
| {a\|b\|} (once) | {!a\|b\|} in text string |
| {flag: text} (inline cond.) | {flag: yes\|no} in text string |
| # tag | "tags": ["tag"] |
Stitches
Stitches are sub-sections within a knot. Address them with dot notation.
{
"inn": {
"text": "The innkeeper looks up.",
"stitches": {
"bar": {
"text": "She's wiping down the bar.",
"choices": [
{ "text": "A drink, please.", "end": true }
]
}
},
"choices": [
{ "text": "Head to the bar.", "goto": "inn.bar" }
]
}
}Sticky Choices
By default, a choice disappears after the player picks it — like a normal Ink * choice. Add "sticky": true to keep it available, like Ink's +.
{
"text": "Anything else?",
"sticky": true,
"choices": [
{ "text": "What's the weather like?", "sticky": true, "response": "Cloudy.", "choices": [
{ "text": "Thanks.", "end": true }
]},
{ "text": "Goodbye.", "end": true }
]
}Tunnels
Tunnels divert to another knot and automatically return when it ends — like Ink's -> knot ->.
{
"text": "Before we talk, let me introduce myself.",
"choices": [
{
"text": "Sure.",
"tunnel": "introduction",
"choices": [
{ "text": "Now, about that job...", "goto": "main_job" }
]
}
]
}The introduction knot ends normally (with "end": true or "return": true) and execution resumes at the choice that launched the tunnel.
Visit Counts
The runtime tracks how many times each knot has been entered this session. Use visits.knot_name in conditions or inline text.
{
"condition": "visits.guard > 0"
}{
"text": "{visits.shopkeeper > 1: Welcome back!|Hello, stranger.}"
}Conditions
Conditions support dot-paths, negation, comparison operators, visit counts, and temp variables.
Simple truthy check
{ "condition": "player.met_shopkeeper" }Negation
{ "condition": "!player.met_shopkeeper" }Comparison operators
>, <, >=, <=, ==, != are all supported.
{ "condition": "player.gold >= 10" }
{ "condition": "visits.guard > 1" }
{ "condition": "$step == 2" }Flags
Flags write values into the state object you passed at startup, or into session-scoped temp variables.
"flags": [
"player.met_shopkeeper",
"player.reputation=5",
"world.shop_visited=true",
"player.gold += 10",
"player.gold -= 3",
"$local_step=1"
]| Syntax | Effect |
|---|---|
| "path.to.key" | Sets to true |
| "path=value" | Sets to that value (auto-coerced) |
| "path += n" | Adds n to current value |
| "path -= n" | Subtracts n from current value |
| "path *= n" | Multiplies by n |
| "path /= n" | Divides by n |
| "$var=value" | Sets a temp variable (session only, not written to your state) |
Weighted / Random NPC Lines
Allow text (or response) to be an array. The parser picks one each time the node is entered.
Equal probability
{
"text": [
"Morning. What do you need?",
"Back again? What is it this time?",
"Aye, what?"
]
}Weighted probability
Use objects with a weight field. Higher weight = more likely. Unspecified weight defaults to 1.
{
"text": [
{ "text": "Morning. What do you need?", "weight": 3 },
{ "text": "Not now, I'm busy.", "weight": 1 }
]
}Timeout Choices
Add a timeout object to any node. The parser passes it through in the passage — your engine is responsible for setting the timer and calling chooseOption(timeoutIndex) when it fires.
{
"text": "He raises his sword. Do you run?",
"timeout": { "seconds": 5, "choiceIndex": 0 },
"choices": [
{ "text": "Run!", "goto": "escape" },
{ "text": "Stand your ground.", "goto": "fight" }
]
}const passage = startDialogue(src, state);
if (passage.timeout) {
setTimeout(() => chooseOption(passage.timeout.choiceIndex), passage.timeout.seconds * 1000);
}Auto-Flags (Choice Metadata)
Any unrecognised field on a choice is automatically written into your state under the autoFlagPrefix namespace (default: "player"). This lets you tag choices with tone, skill, personality — anything — without hardcoding flag names.
{ "text": "I'll pay you double.", "tone": "bribe", "skill": "persuasion" }When that choice is picked, the parser automatically does:
state.player.tone = "bribe"
state.player.skill = "persuasion"You can then condition on it anywhere:
{ "condition": "player.tone == bribe" }The prefix can be changed per-dialogue via the autoFlagPrefix argument to startDialogue. Pass an empty string "" to write directly to the root of your state object.
Inline Text Expressions
Embed dynamic content directly in text and response strings using {...}.
Inline conditional
{ "text": "Welcome{player.met_shopkeeper: back}, traveller." }
{ "text": "{player.gold > 10: You look wealthy.|You look tired.}" }{condition: yes}— shows "yes" if truthy, nothing if falsy.{condition: yes|no}— shows "yes" if truthy, "no" if falsy.
Cycling sequence
Rotates through options each time the text is shown (loops).
{ "text": "She says: {Hello.|Good day.|Aye.}" }Shuffled sequence
Picks a random option each cycle.
{ "text": "She mutters: {~Not again.|What now?|Hm.}" }Once sequence
Advances through options once; the last value sticks forever.
{ "text": "{!First time I've seen you here.|Back again, are you?}" }Redirect
redirect is an array of conditional diverts checked at the top of a knot, before the player sees any text. The first matching entry wins. Omitting condition makes it unconditional.
{
"guard": {
"redirect": [
{ "condition": "flag.travelling", "goto": "guard_return" }
],
"text": "Heading out?",
"choices": [ ... ]
}
}Tags
Tags are metadata strings on any node. The runtime passes them through in the passage object — your game can use them to trigger animations, play sounds, etc.
{
"text": "She draws her sword.",
"tags": ["combat_music", "anim:draw_sword"],
"choices": [ ... ]
}const passage = startDialogue(src, state);
if (passage.tags.includes('combat_music')) startCombatMusic();Actions
Named strings fired to your engine when dialogue ends.
{ "text": "Goodbye.", "end": true, "actions": ["pop_modal", "record_departed"] }The onEnd callback receives { actions: ["pop_modal", "record_departed"] }.
API
// Start a dialogue. Returns the first passage.
// autoFlagPrefix defaults to "player" — namespace for auto-flags from unknown choice fields.
startDialogue(gushJsonStr, state, onEnd, knot, autoFlagPrefix)
// Select a player choice by index. Returns the next passage or null.
chooseOption(index)
// Get the current passage without advancing.
currentPassage()
// True if a dialogue is in progress.
isActive()Passage Object
{
text: "What the NPC says.",
choices: ["Choice one", "Choice two"],
tags: ["optional_tag"],
timeout: { seconds: 5, choiceIndex: 0 } // only present if the node defines one
}Running the Example
node run_example.jsThe example (example.gush) is a multi-knot inn conversation with branching, goto jumps, and flags.
