Best Practices

Opinionated guidance for writing clean, maintainable plotknot stories. These are conventions, not rules — adapt them to your project.

Story structure

Name sections for what happens, not where

// Good: describes the narrative beat
## refuse_the_call
## accept_the_quest
## betray_the_party

// Avoid: generic location names
## room_3
## scene_12
## part_2

Stage IDs are derived from headings (lowercased, spaces to underscores). Names that describe the narrative beat make goto targets readable and debugging easier.

Use hub-and-spoke for open worlds, linear for directed stories

Hub-and-spoke: a central stage offers choices that branch out and return.

## village_hub

choice:
- Visit the tavern -> tavern
- Explore the ruins -> ruins
- Talk to the elder -> elder

Linear: sections chain forward without returning.

## intro
-> act_one
## act_one
-> act_two

Mix both: use a hub for exploration, linear chains for cutscenes and combat sequences.

Prefer choice: over -> for player agency

Use -> (goto) for automatic transitions the player doesn’t choose. Use choice: when the player should decide. If you find yourself writing a choice: with only one option, it’s probably a ->.

Variables

Use declare for initial state, set for changes

# Setup
declare gold = 100
declare has_key = false
declare player_name = "Ada"

# Later, during play
set gold = gold - 30
set has_key = true

declare seeds the runtime’s variable store at load time. set changes values during play. Declared variables suppress “used before being set” warnings.

Use character properties for NPC state

Dot notation keeps character state organized and separate from global variables:

set barista.friendship = 0
set keeper.mood = "suspicious"

if barista.friendship > 2:
  <friendly> Hey! Good to see you again.

In the runtime, character properties live in the chars table, separate from vars. This prevents name collisions and makes save/load cleaner.

Name booleans as questions

// Good: reads as a condition
declare has_silver_sword = false
declare knows_the_truth = false
declare met_vex = false

if has_silver_sword:
  You raise the gleaming blade.

// Avoid: ambiguous names
declare sword = 0
declare truth = false

Variation blocks

Keep items short

Variation items work best as single lines or short paragraphs. Long items make the -- separators hard to scan:

// Good: short, scannable
cycle
  The guard eyes you suspiciously.
--
  The guard nods. You're a familiar face now.
--
  The guard waves you through without a word.

// Avoid: long items blur together
cycle
  The guard eyes you suspiciously. He shifts his weight,
  hand resting on the pommel of his sword. The afternoon
  sun catches the badge on his chest...
--
  The guard nods...

Choose the right mode

Mode Use when
sequence Progressive unlocks (tutorial steps, escalating tension)
cycle Ambient flavor that should always have something to say
once One-time reveals (first-visit exposition, tutorial prompts)
pick Random flavor where repetition is acceptable
shuffle Random flavor where you want to avoid repeats until all are seen

Don’t put critical state changes in variation items

Variation items can be skipped (in once mode, after all items play). If a set statement is inside a variation item, it might never run:

// Risky: set might never execute
once
  set tutorial_seen = true
  Welcome! Here's how to play...

// Safer: set outside the variation
set tutorial_seen = true
once
  Welcome! Here's how to play...

Runtime integration

Register functions before starting the story

local story = plotknot.load(data)

-- Register first
story:register_function("npc_mood", function(name)
  return game_state.npcs[name].mood
end)

story:on_call("play_sound", function(name)
  audio.play(name)
end)

-- Then start
story:start()

If a function is called before it’s registered, the runtime produces a warning and returns nil.

Use on_stage for side effects, not logic

on_stage is for triggering music, animations, and UI changes — not for game logic that affects the story:

// Good: side effects
story:on_stage("boss_room", function()
  music.play("boss_theme")
  camera.shake()
end)

// Avoid: game logic that affects story state
story:on_stage("boss_room", function()
  story:variables().boss_health = 100  -- use declare/set in the script instead
end)

Handle unknown calls gracefully

The runtime warns on unknown function calls but doesn’t crash. Check story:warnings() during development to catch missing registrations:

local warnings = story:warnings()
if #warnings > 0 then
  for _, w in ipairs(warnings) do
    print("WARNING: " .. w)
  end
end

Performance

Story size

Compiled .lua files are plain tables. A 100-section story compiles to roughly 50-100 KB. This is negligible for any modern device. Don’t worry about story size unless you’re targeting extremely constrained hardware.

Variation state

The runtime tracks variation state per block. Each block stores a small counter (integer). Even hundreds of variation blocks add negligible memory overhead.

Goto chains

The runtime follows goto chains automatically. A chain of 10 gotos resolves in one advance() call. No performance concern.

Localization

Use consistent key naming

// Good: hierarchical, predictable
Hello, traveler! greeting.tavern.001
The door creaks open. scene.cellar.door_open

// Avoid: inconsistent, hard to search
Hello, traveler! hello_1
The door creaks open. door_sound_text

Interpolation in localized text

Interpolated text uses a parts array in the compiled output. The runtime assembles the final string at display time. This means translations can reorder variables:

You have $gold coins. inventory.gold_count

The translator receives the template and can produce: "Vous avez $gold pièces." — the interpolation still works.

Next steps