npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

gushjs

v0.1.0

Published

A lightweight, human-readable dialogue scripting system for games. Write conversations in plain JSON — no compiler required.

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 gushjs
import { 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.js

The example (example.gush) is a multi-knot inn conversation with branching, goto jumps, and flags.