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
- Language Guide — every feature with examples
- Extensibility — runtime hooks and custom functions
- Examples — complete annotated stories