Skip to content

Instantly share code, notes, and snippets.

@R3V1Z3
Last active November 4, 2025 15:23
Show Gist options
  • Select an option

  • Save R3V1Z3/953d53977dffc6a0cc3ba3bf60962d44 to your computer and use it in GitHub Desktop.

Select an option

Save R3V1Z3/953d53977dffc6a0cc3ba3bf60962d44 to your computer and use it in GitHub Desktop.
-- Canvas Navigation Plugin for Storied
-- Provides spatial layout, smooth panning, and interactive link navigation
local Canvas = {}
-- Configuration
local SECTION_WIDTH = 60
local SECTION_HEIGHT = 20
local SECTION_PADDING = 10
local MAX_SECTIONS_PER_ROW = 3
local PAN_SPEED = 5.0
local SMOOTH_SPEED = 8.0
-- State
local camera = {x = 0, y = 0, targetX = 0, targetY = 0}
local sections = {}
local currentSectionIdx = 0
local links = {} -- Table of all clickable links in current section
local focusedLinkIdx = 0 -- Currently focused link (for tab navigation)
local visitedSections = {} -- Track which sections have been visited (by ID)
local hiddenSections = {} -- Track which sections are hidden (by ID)
local removedSections = {} -- Track which sections have been removed (by ID)
-- Parse markdown links from text: [text](target)
local function parseLinks(text)
local foundLinks = {}
for linkText, target in text:gmatch("%[([^%]]+)%]%(([^%)]+)%)") do
table.insert(foundLinks, {text = linkText, target = target})
end
return foundLinks
end
-- Find section by ID or title
local function findSectionByReference(ref)
-- Remove leading # if present
if ref:sub(1,1) == "#" then
ref = ref:sub(2)
end
-- Try exact title match FIRST (most common case)
for i, section in ipairs(sections) do
if section.title == ref then
return section
end
end
-- Try exact ID match
for i, section in ipairs(sections) do
if section.id == ref then
return section
end
end
-- Try title match (case-insensitive)
local lowerRef = ref:lower()
for i, section in ipairs(sections) do
if section.title:lower() == lowerRef then
return section
end
end
-- Try partial title match
for i, section in ipairs(sections) do
if section.title:lower():find(lowerRef, 1, true) then
return section
end
end
return nil
end
-- Check if a section has been visited (by title)
local function isVisited(sectionTitle)
return visitedSections[sectionTitle] == true
end
-- Check if a section is hidden (by title)
local function isHidden(sectionTitle)
return hiddenSections[sectionTitle] == true
end
-- Check if a section has been removed (by title)
local function isRemoved(sectionTitle)
return removedSections[sectionTitle] == true
end
-- Mark a section as visited (by title)
local function markVisited(sectionTitle)
visitedSections[sectionTitle] = true
hiddenSections[sectionTitle] = nil -- Unhide when visited
end
-- Hide a section (by title)
local function hideSection(sectionTitle)
hiddenSections[sectionTitle] = true
end
-- Remove a section (by title)
local function removeSection(sectionTitle)
removedSections[sectionTitle] = true
end
-- Restore a removed section (by title)
local function restoreSection(sectionTitle)
removedSections[sectionTitle] = nil
end
-- Filter out list items that contain only links to removed sections
local function filterRemovedSectionLinks(content)
local lines = {}
for line in content:gmatch("[^\n]+") do
local trimmedLine = line:match("^%s*(.-)%s*$") -- trim whitespace
-- Check for list item pattern: bullet + optional whitespace + content
local bullet, restOfLine = trimmedLine:match("^([*+-])%s+(.+)$")
if bullet and restOfLine then
-- Check if the rest is purely a link (no other text)
local linkText, target = restOfLine:match("^%[([^%]]+)%]%(([^%)]+)%)$")
if linkText and target then
-- This is a list item with ONLY a link
local targetSection = findSectionByReference(target)
if targetSection and isRemoved(targetSection.title) then
-- Skip this entire list item
goto continue
end
end
-- Otherwise, it's a list item with mixed content or just text - keep it
end
-- Keep this line (either not a list item, or a list item we want to keep)
table.insert(lines, line)
::continue::
end
return table.concat(lines, "\n")
end
local function calculateSectionPositions()
local currentX, currentY = 0, 0
local maxHeightInRow, sectionsInRow = 0, 0
for i, section in ipairs(sections) do
-- Check if section has metadata with x,y position
if section.metadata and section.metadata.x and section.metadata.y then
section.x, section.y = section.metadata.x, section.metadata.y
else
section.x, section.y = currentX, currentY
sectionsInRow = sectionsInRow + 1
maxHeightInRow = math.max(maxHeightInRow, SECTION_HEIGHT)
if sectionsInRow >= MAX_SECTIONS_PER_ROW then
currentX, currentY = 0, currentY + maxHeightInRow + SECTION_PADDING
maxHeightInRow, sectionsInRow = 0, 0
else
currentX = currentX + SECTION_WIDTH + SECTION_PADDING
end
end
section.width, section.height = SECTION_WIDTH, SECTION_HEIGHT
end
end
local function wrapText(text, maxWidth)
local lines, words = {}, {}
for word in text:gmatch("%S+") do table.insert(words, word) end
local currentLine = ""
for _, word in ipairs(words) do
if #currentLine + #word + 1 <= maxWidth then
currentLine = (#currentLine > 0) and (currentLine .. " " .. word) or word
else
if #currentLine > 0 then table.insert(lines, currentLine) end
currentLine = word
end
end
if #currentLine > 0 then table.insert(lines, currentLine) end
return lines
end
-- Parse and render inline markdown (bold, italic)
local function renderInlineMarkdown(text, x, y, maxWidth, baseColor, baseBold)
local currentX = x
local pos = 1
local isBold = baseBold or false
local isItalic = false
while pos <= #text and currentX < x + maxWidth do
if pos + 1 <= #text and text:sub(pos, pos + 1) == "**" then
isBold = not isBold
pos = pos + 2
elseif text:sub(pos, pos) == "*" and (pos == 1 or text:sub(pos - 1, pos - 1) ~= "*") and
(pos == #text or text:sub(pos + 1, pos + 1) ~= "*") then
isItalic = not isItalic
pos = pos + 1
elseif text:sub(pos, pos) == "_" then
local prevChar = (pos > 1) and text:sub(pos - 1, pos - 1) or " "
local nextChar = (pos < #text) and text:sub(pos + 1, pos + 1) or " "
if prevChar:match("[%s%p]") and nextChar:match("[%s%p]") or
prevChar:match("%s") or nextChar:match("%s") or
pos == 1 or pos == #text then
isItalic = not isItalic
pos = pos + 1
else
local char = text:sub(pos, pos)
buffer:write(currentX, y, char, baseColor, 0, isBold, false, isItalic)
currentX = currentX + 1
pos = pos + 1
end
else
local char = text:sub(pos, pos)
buffer:write(currentX, y, char, baseColor, 0, isBold, false, isItalic)
currentX = currentX + 1
pos = pos + 1
end
end
return currentX - x
end
-- Render text with inline link highlighting and markdown formatting
-- Links to removed sections are rendered as plain text
local function renderTextWithLinks(text, x, y, maxWidth, linkStyle)
local pos = 0
local currentX = x
local globalLinkIdx = #links + 1
while pos < #text do
local linkStart, linkEnd, linkText, target = text:find("%[([^%]]+)%]%(([^%)]+)%)", pos + 1)
if linkStart then
local beforeLink = text:sub(pos + 1, linkStart - 1)
if #beforeLink > 0 then
local charsRendered = renderInlineMarkdown(beforeLink, currentX, y, maxWidth - (currentX - x), 37, false)
currentX = currentX + charsRendered
end
-- Check if target section is removed
local targetSection = findSectionByReference(target)
local shouldRenderLink = not (targetSection and isRemoved(targetSection.title))
if shouldRenderLink then
-- Render as active link
local isFocused = (globalLinkIdx == focusedLinkIdx)
local linkColor = isFocused and 33 or 34
local linkBold = isFocused
local isUnderlined = true
if linkStyle then
table.insert(links, {
text = linkText,
target = target,
screenX = currentX,
screenY = y,
width = #linkText,
index = globalLinkIdx
})
end
for i = 1, #linkText do
if currentX < x + maxWidth then
buffer:write(currentX, y, linkText:sub(i, i), linkColor, 0, linkBold, isUnderlined)
currentX = currentX + 1
end
end
globalLinkIdx = globalLinkIdx + 1
else
-- Render as plain text (dimmed/grayed out)
local charsRendered = renderInlineMarkdown(linkText, currentX, y, maxWidth - (currentX - x), 30, false)
currentX = currentX + charsRendered
end
pos = linkEnd
else
local remaining = text:sub(pos + 1)
if #remaining > 0 then
renderInlineMarkdown(remaining, currentX, y, maxWidth - (currentX - x), 37, false)
end
break
end
end
end
local function renderSection(section, screenX, screenY, viewport)
local isCurrent = section.index == currentSectionIdx
-- Skip removed sections entirely
if isRemoved(section.title) then
return
end
if isCurrent then
links = {}
if focusedLinkIdx == 0 then
focusedLinkIdx = 1
end
end
local contentY = screenY
local contentX = screenX
local maxContentWidth = section.width
local lines = {}
local function formatHeading(text)
local cleaned = text:gsub("^#+%s*", "")
cleaned = cleaned:gsub("_", " ")
cleaned = cleaned:gsub("(%a)([%w_']*)", function(first, rest)
return first:upper() .. rest:lower()
end)
return cleaned
end
-- If section is hidden and not current, show placeholder
local hidden = isHidden(section.title)
if hidden and not isCurrent then
local placeholder = "???"
local centerX = contentX + math.floor((maxContentWidth - #placeholder) / 2)
local centerY = contentY + math.floor(section.height / 2)
buffer:write(centerX, centerY, placeholder, 30, 0, true)
return
end
-- Preprocess content to filter removed links in list items
local processedContent = filterRemovedSectionLinks(section.content)
for line in processedContent:gmatch("[^\n]+") do
if line:match("^#+%s") then
table.insert(lines, {type = "heading", text = line})
elseif line:match("^```") then
table.insert(lines, {type = "code", text = line})
else
if line:match("%[.-%]%(.-%)") then
table.insert(lines, {type = "text", text = line, hasLinks = true})
else
local wrapped = wrapText(line, maxContentWidth)
for _, wLine in ipairs(wrapped) do
table.insert(lines, {type = "text", text = wLine, hasLinks = false})
end
end
end
end
for _, line in ipairs(lines) do
if contentY >= screenY + section.height then break end
if line.type == "heading" then
local formatted = formatHeading(line.text)
local displayText = (#formatted > maxContentWidth) and formatted:sub(1, maxContentWidth) or formatted
buffer:write(contentX, contentY, displayText, 33, true)
elseif line.type == "code" then
buffer:write(contentX, contentY, line.text, 36, false)
else
if line.hasLinks then
renderTextWithLinks(line.text, contentX, contentY, maxContentWidth, isCurrent)
elseif line.text:match("%*%*") or line.text:match("%*[^%*]") or line.text:match("_[^_]+_") then
renderInlineMarkdown(line.text, contentX, contentY, maxContentWidth, 37, false)
else
buffer:write(contentX, contentY, line.text, 37, false)
end
end
contentY = contentY + 1
end
if isCurrent and #links == 0 then
focusedLinkIdx = 0
end
end
local function updateCamera(deltaTime)
local t = math.min(1.0, deltaTime * SMOOTH_SPEED)
camera.x = camera.x + (camera.targetX - camera.x) * t
camera.y = camera.y + (camera.targetY - camera.y) * t
if math.abs(camera.targetX - camera.x) < 0.5 then camera.x = camera.targetX end
if math.abs(camera.targetY - camera.y) < 0.5 then camera.y = camera.targetY end
end
local function centerOnSection(sectionIdx)
if sectionIdx < 0 or sectionIdx >= #sections then return end
local section = sections[sectionIdx + 1]
local viewport = getViewport()
camera.targetX = section.x + section.width / 2 - viewport.width / 2
camera.targetY = section.y + section.height / 2 - viewport.height / 2
end
local function navigateToLink(link)
local targetSection = findSectionByReference(link.target)
if targetSection then
gotoSection(targetSection.index)
currentSectionIdx = targetSection.index
centerOnSection(currentSectionIdx)
focusedLinkIdx = 0
-- Mark new section as visited
markVisited(targetSection.title)
-- Check if target section should be removed after visit
if targetSection.metadata and targetSection.metadata.removeAfterVisit then
targetSection._pendingRemoval = true
end
end
end
function globalRender()
buffer:clear()
if viewportChanged() then
centerOnSection(currentSectionIdx)
end
-- Mark current section as visited
local currentSection = sections[currentSectionIdx + 1]
if currentSection then
markVisited(currentSection.title)
end
local viewport = getViewport()
local cameraX, cameraY = math.floor(camera.x), math.floor(camera.y)
for _, section in ipairs(sections) do
if not isRemoved(section.title) then
local screenX, screenY = section.x - cameraX, section.y - cameraY
if screenX + section.width >= 0 and screenX < viewport.width and
screenY + section.height >= 0 and screenY < viewport.height then
renderSection(section, screenX, screenY, viewport)
end
end
end
local statusY = viewport.height - 1
if statusY >= 0 and currentSection then
local linkInfo = (#links > 0) and string.format(" | Arrows/Tab: cycle links (%d) | Enter: follow", #links) or ""
local status = string.format(" %s%s | 1-9: jump to section | Q: quit ",
currentSection.title, linkInfo)
if #status > viewport.width then status = status:sub(1, viewport.width) end
buffer:write(0, statusY, status, 30, false)
end
end
function globalUpdate(dt)
updateCamera(dt)
-- Handle pending section removals
for _, section in ipairs(sections) do
if section._pendingRemoval and section.index ~= currentSectionIdx then
removeSection(section.title)
section._pendingRemoval = nil
end
end
end
function globalHandleKey(key)
if key.name == "enter" then
if focusedLinkIdx > 0 and focusedLinkIdx <= #links then
navigateToLink(links[focusedLinkIdx])
end
elseif key.name == "tab" then
if #links > 0 then
focusedLinkIdx = (focusedLinkIdx % #links) + 1
end
elseif key.char >= "1" and key.char <= "9" then
local idx = tonumber(key.char) - 1
if idx < #sections then
gotoSection(idx)
currentSectionIdx = idx
centerOnSection(currentSectionIdx)
focusedLinkIdx = 0
end
elseif key.name == "q" or key.name == "Q" then
running = false
end
end
function globalHandleArrow(direction)
if direction == "up" then
if #links > 0 then
focusedLinkIdx = focusedLinkIdx - 1
if focusedLinkIdx < 1 then
focusedLinkIdx = #links
end
end
elseif direction == "down" then
if #links > 0 then
focusedLinkIdx = (focusedLinkIdx % #links) + 1
end
end
end
function globalHandleShiftTab()
if #links > 0 then
focusedLinkIdx = focusedLinkIdx - 1
if focusedLinkIdx < 1 then
focusedLinkIdx = #links
end
end
end
function globalHandleMouse(event)
if event.type == "down" then
for _, link in ipairs(links) do
if event.x >= link.screenX and event.x < link.screenX + link.width and
event.y == link.screenY then
navigateToLink(link)
return
end
end
elseif event.type == "move" then
local oldFocus = focusedLinkIdx
focusedLinkIdx = 0
for _, link in ipairs(links) do
if event.x >= link.screenX and event.x < link.screenX + link.width and
event.y == link.screenY then
focusedLinkIdx = link.index
break
end
end
end
end
function Canvas.init()
local allSections = getAllSections()
sections = {}
for i = 1, #allSections do
table.insert(sections, allSections[i])
end
calculateSectionPositions()
local current = getCurrentSection()
currentSectionIdx = current.index
-- Initialize section visibility based on metadata
for i, section in ipairs(sections) do
if section.metadata then
if section.metadata.hidden then
hideSection(section.title)
end
end
end
-- Mark starting section as visited
if sections[currentSectionIdx + 1] then
markVisited(sections[currentSectionIdx + 1].title)
end
centerOnSection(currentSectionIdx)
camera.x, camera.y = camera.targetX, camera.targetY
setMultiSectionMode(true)
enableMouse()
end
-- Public API
function Canvas.hideSection(ref)
local section = findSectionByReference(ref)
if section then
hideSection(section.title)
end
end
function Canvas.removeSection(ref)
local section = findSectionByReference(ref)
if section then
removeSection(section.title)
end
end
function Canvas.restoreSection(ref)
local section = findSectionByReference(ref)
if section then
restoreSection(section.title)
end
end
function Canvas.isVisited(ref)
local section = findSectionByReference(ref)
if section then
return isVisited(section.title)
end
return false
end
function Canvas.markVisited(ref)
local section = findSectionByReference(ref)
if section then
markVisited(section.title)
end
end
return Canvas
title author minWidth minHeight
The Depths of Khel-Daran
Maddest Labs
80
24
canvas = require("canvas")
canvas.init()

-- Track player state
storyState = {
  hasTorch = false,
  hasKey = false,
  hasAmulet = false,
  visitedLibrary = false,
  knowsRiddle = false,
  torchQuality = "dim"
}

entrance {"hidden": true}

You stand before the ancient ruins of Khel-Daran, a fortress swallowed by time and shadow. The stone archway before you exhales cold, stale air. Moss clings to the weathered pillars, and somewhere deep within, you hear the faint echo of water dripping.

Your torch flickers in the darkness. The adventure begins here.

What do you do?

entrance_examine {"hidden": true, "removeAfterVisit": true}

You take a moment to inspect the entrance more carefully. Ancient runes are carved into the archway, worn smooth by centuries of wind and rain. You can barely make out what appears to be a warning:

"Beware the guardian of the depths. Only the wise may pass."

Beside the entrance, you notice an old iron sconce. It's empty, but appears functional.

prepare_torch {"hidden": true, "removeAfterVisit": true}

You take time to properly prepare your torch, wrapping it with oil-soaked cloth from your pack. The flame burns brighter now, casting long shadows across the ancient stone.

You feel more confident with better light.

storyState.hasTorch = true
storyState.torchQuality = "bright"

hall_of_statues {"hidden": true}

You step into a vast hall supported by crumbling pillars. Three stone statues stand guard, each depicting a different warrior from a forgotten age. Their hollow eyes seem to follow you as you move.

Passages branch off in three directions:

  • To the north, you hear the sound of rushing water
  • To the east, a faint blue glow emanates from the darkness
  • To the west, you smell something acrid and unpleasant

The main entrance lies behind you.

examine_statues {"hidden": true}

You approach the statues carefully. Each warrior is carved in exquisite detail:

The first statue holds a sword pointed downward, its face serene.
The second statue clutches a shield, face twisted in rage.
The third statue bears a broken chain, face sorrowful.

At the base of the third statue, you notice something glinting in the torchlight.

find_key {"hidden": true, "removeAfterVisit": true}

You reach down and pick up a small, tarnished brass key. It's surprisingly heavy for its size, and covered in the same ancient runes you saw at the entrance.

This might unlock something important.

Return to the hall

storyState.hasKey = true

underground_river {"hidden": true}

The passage opens into a cavern split by a rushing underground river. The water is black as ink and moves with frightening speed. A narrow stone bridge crosses the chasm, but it looks ancient and unstable.

On the far side, you can see a doorway carved into the rock.

cross_bridge {"hidden": true}

You step onto the stone bridge. It groans under your weight, and small chunks of stone crumble into the dark water below. Halfway across, you freeze as a loud CRACK echoes through the cavern.

But the bridge holds. Barely.

You make it to the other side, heart pounding.

search_riverbank {"hidden": true}

You search along the riverbank, looking for another way across. Behind a fallen column, you discover an old rope tied to an iron ring. Following it up, you see it leads to a natural rock shelf that crosses above the river.

A safer path, if you're willing to climb.

crystal_chamber {"hidden": true}

You follow the blue glow into a chamber filled with luminescent crystals growing from the walls and ceiling. They pulse with an eerie inner light, casting everything in shades of azure and violet.

In the center of the room stands a stone pedestal. Resting atop it is a beautiful silver amulet, set with a matching blue crystal.

The chamber has two other exits: one to the north and one continuing east.

take_amulet {"hidden": true, "removeAfterVisit": true}

You reach for the amulet. The moment your fingers touch the cold silver, the crystals around you flare brilliantly. You feel a surge of warmth spread through your body.

The amulet pulses with protective magic.

storyState.hasAmulet = true

library {"hidden": true}

You enter what must have once been a library. Ancient books line rotting shelves, most crumbling to dust. In the center of the room, a single tome rests on a reading stand, somehow preserved.

You open the book. The pages are filled with riddles and wisdom of the ancients. One passage catches your eye:

"The guardian seeks not strength, but humility. The warrior who bows is greater than one who strikes."

storyState.visitedLibrary = true
storyState.knowsRiddle = true

alchemist_lab {"hidden": true}

The acrid smell leads you to an old laboratory. Broken glass and ceramic vessels litter the floor. Strange stains mark the walls. Whatever happened here, it wasn't pleasant.

Among the debris, you find a workbench with several intact bottles. One contains a glowing green liquid labeled "Essence of Light" in faded script.

take_essence {"hidden": true, "removeAfterVisit": true}

You carefully pocket the glowing essence. It feels warm through the glass.

This might prove useful.

storyState.hasEssence = true

search_lab {"hidden": true, "removeAfterVisit": true}

Searching more carefully, you find the alchemist's journal beneath some rubble. The final entry reads:

"My experiments with the guardian have failed. It cannot be destroyed, only understood. I leave this place to whatever fate awaits. May those who follow be wiser than I."

guardian_chamber {"hidden": true}

You enter a vast circular chamber. At its center stands a towering figure of living stone—the Guardian of Khel-Daran. Its eyes glow with ancient intelligence.

The Guardian speaks, its voice like grinding boulders:

"Who dares disturb my eternal vigil? Prove your worth, or be destroyed!"

Three pedestals surround the guardian, each marked with a symbol: Sword, Shield, and Chains.

guardian_attack {"hidden": true}

You draw your weapon and charge at the stone guardian. It doesn't even move.

Your blade strikes the living stone and shatters. The guardian's fist comes down like a falling boulder. Everything goes dark.

Perhaps violence wasn't the answer.

guardian_reason {"hidden": true}

You lower your weapon and address the guardian with respect:

"I seek not to conquer, but to understand. I come in peace."

The guardian tilts its massive head, considering. Then it speaks:

"Wisdom... rare among your kind. But words alone are insufficient. Show me you understand the truth of strength."

guardian_fail {"hidden": true}

You place your offering on the pedestal. The guardian's eyes flare angry red.

"You understand nothing! Strength and defense are the tools of the proud. True power lies in freedom and sacrifice!"

The chamber begins to shake violently.

guardian_success {"hidden": true}

You approach the pedestal marked with broken chains and bow your head. The gesture of humility and understanding resonates through the chamber.

The guardian's eyes shift from threatening red to a calm golden glow.

"You comprehend the ancient wisdom. Strength is nothing without the wisdom to bind it. You may pass."

The guardian steps aside, revealing a passage to the Treasure Vault.

if storyState.knowsRiddle then
  -- Player read the library book
end

treasure_vault {"hidden": true}

You enter the fabled treasure vault of Khel-Daran. Gold coins spill across the floor, gems glitter in the light of your torch, and ancient weapons line the walls.

But your eyes are drawn to the center of the room, where a magnificent sword rests on an altar, bathed in a beam of light from above. This is the legendary Blade of Khel-Daran, said to have defended these lands centuries ago.

The inscription on the altar reads: "To those who brave the depths with wisdom and courage, this is your reward."

Congratulations! You have completed the adventure!

victory {"hidden": true}

You lift the Blade of Khel-Daran from its altar. The weapon feels perfectly balanced in your hand, and seems to hum with ancient power.

As you make your way back through the dungeon, you notice the guardian watching you with what might be... respect? The stone colossus bows its head slightly as you pass.

Emerging into the daylight, you shield your eyes against the sun. The ruins of Khel-Daran stand behind you, their secrets revealed.

Your adventure is complete! You are victorious!

The legend of Khel-Daran will be told for generations.

Explore more endings?

take_sconce {"hidden": true, "removeAfterVisit": true}

You remove the iron sconce from the wall. It's heavier than it looks and has a wicked pointed end. In a pinch, this could serve as a makeshift weapon.

Might be useful in the dark.

storyState.hasWeapon = true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment