{"id":13525338,"url":"https://github.com/astrochili/narrator","last_synced_at":"2025-10-30T03:59:09.969Z","repository":{"id":43206859,"uuid":"229589446","full_name":"astrochili/narrator","owner":"astrochili","description":"The Ink language parser and runtime implementation in Lua","archived":false,"fork":false,"pushed_at":"2024-05-09T09:12:43.000Z","size":361,"stargazers_count":154,"open_issues_count":15,"forks_count":12,"subscribers_count":12,"default_branch":"master","last_synced_at":"2025-10-10T20:48:19.709Z","etag":null,"topics":["defold","ink","interactive-fiction","lpeg","lua","narrative"],"latest_commit_sha":null,"homepage":"","language":"Lua","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/astrochili.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2019-12-22T15:31:53.000Z","updated_at":"2025-10-10T03:19:35.000Z","dependencies_parsed_at":"2024-04-12T01:55:28.413Z","dependency_job_id":"27d08bab-22f6-428d-820f-40ce112bd924","html_url":"https://github.com/astrochili/narrator","commit_stats":null,"previous_names":[],"tags_count":9,"template":false,"template_full_name":null,"purl":"pkg:github/astrochili/narrator","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/astrochili%2Fnarrator","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/astrochili%2Fnarrator/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/astrochili%2Fnarrator/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/astrochili%2Fnarrator/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/astrochili","download_url":"https://codeload.github.com/astrochili/narrator/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/astrochili%2Fnarrator/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":281742378,"owners_count":26553656,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-10-30T02:00:06.501Z","response_time":61,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["defold","ink","interactive-fiction","lpeg","lua","narrative"],"created_at":"2024-08-01T06:01:17.820Z","updated_at":"2025-10-30T03:59:09.935Z","avatar_url":"https://github.com/astrochili.png","language":"Lua","funding_links":["https://buymeacoffee.com/astrochili"],"categories":["Lua","Libraries","Helpers"],"sub_categories":["Programming Language"],"readme":"![logo](https://user-images.githubusercontent.com/4752473/85455900-141f8f80-b5a7-11ea-8cd7-b441d662b361.png)\n\n# Narrator\n\n[![Release](https://img.shields.io/github/v/release/astrochili/narrator.svg?include_prereleases=\u0026sort=semver\u0026color=blue)](https://github.com/astrochili/narrator/releases)\n[![License](https://img.shields.io/badge/License-MIT-blue)](https://github.com/astrochili/narrator/blob/master/LICENSE)\n[![Website](https://img.shields.io/badge/website-gray.svg?\u0026logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSIgdmlld0JveD0iMCAwIDE4IDE2Ij48Y2lyY2xlIGN4PSIzLjY2IiBjeT0iMTQuNzUiIHI9IjEuMjUiIGZpbGw9InVybCgjYSkiLz48Y2lyY2xlIGN4PSI4LjY2IiBjeT0iMTQuNzUiIHI9IjEuMjUiIGZpbGw9InVybCgjYikiLz48Y2lyY2xlIGN4PSIxMy42NSIgY3k9IjE0Ljc1IiByPSIxLjI1IiBmaWxsPSJ1cmwoI2MpIi8+PHBhdGggZmlsbD0idXJsKCNkKSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNNy42MyAxLjQ4Yy41LS43IDEuNTUtLjcgMi4wNSAwbDYuMjIgOC44MWMuNTguODMtLjAxIDEuOTctMS4wMyAxLjk3SDIuNDRhMS4yNSAxLjI1IDAgMCAxLTEuMDItMS45N2w2LjIxLTguODFaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIyLjQxIiB4Mj0iMi40MSIgeTE9IjEzLjUiIHkyPSIxNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNGRDhENDIiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGOTU0MUYiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9IjcuNDEiIHgyPSI3LjQxIiB5MT0iMTMuNSIgeTI9IjE2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agc3RvcC1jb2xvcj0iI0ZEOEQ0MiIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI0Y5NTQxRiIvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJjIiB4MT0iMTIuNCIgeDI9IjEyLjQiIHkxPSIxMy41IiB5Mj0iMTYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjRkQ4RDQyIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjRjk1NDFGIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImQiIHgxPSIuMDMiIHgyPSIuMDMiIHkxPSIuMDMiIHkyPSIxMi4yNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNGRkU2NUUiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGRkM4MzAiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4=)](https://astronachos.com/)\n[![Mastodon](https://img.shields.io/badge/mastodon-gray?\u0026logo=mastodon)](https://mastodon.gamedev.place/@astronachos)\n[![Twitter](https://img.shields.io/badge/twitter-gray?\u0026logo=twitter)](https://twitter.com/astronachos)\n[![Telegram](https://img.shields.io/badge/telegram-gray?\u0026logo=telegram)](https://t.me/astronachos)\n[![Buy me a coffee](https://img.shields.io/badge/buy_me_a_coffee-gray?\u0026logo=buy%20me%20a%20coffee)](https://buymeacoffee.com/astrochili)\n\n## Overview\n\nThe [Ink](https://www.inklestudios.com/ink/) language parser and runtime implementation in Lua.\n\nInk is a powerful narrative scripting language. You can find more information about how to write Ink scripts [here](https://github.com/inkle/ink/blob/master/Documentation/WritingWithInk.md). There is also [Inky](https://github.com/inkle/inky) editor with useful features to test and debug Ink scripts.\n\nNarrator allows to convert raw Ink scripts to the book (a lua table) and play it as story.\n\n- 📖 A book is a passive model on the shelf like a game level.\n- ✨ A story is a runtime state of the book reading like a game process.\n\n## Quick example\n\n```lua\nlocal narrator = require('narrator.narrator')\n\n-- Parse a book from the Ink file.\nlocal book = narrator.parse_file('stories.game')\n\n-- Init a story from the book\nlocal story = narrator.init_story(book)\n\n-- Begin the story\nstory:begin()\n\nwhile story:can_continue() do\n\n  -- Get current paragraphs to output\n  local paragraphs = story:continue()\n\n  for _, paragraph in ipairs(paragraphs) do\n    local text = paragraph.text\n\n    -- You can handle tags as you like, but we attach them to text here.\n    if paragraph.tags then\n      text = text .. ' #' .. table.concat(paragraph.tags, ' #')\n    end\n\n    -- Output text to the player\n    print(text)\n  end\n\n  -- If there is no choice it seems like the game is over\n  if not story:can_choose() then break end\n\n  -- Get available choices and output them to the player\n  local choices = story:get_choices()\n  for i, choice in ipairs(choices) do\n    print(i .. ') ' .. choice.text)\n  end\n\n  -- Read the choice from the player input\n  local answer = tonumber(io.read())\n\n  -- Send answer to the story to generate new paragraphs\n  story:choose(answer)\nend\n```\n\n## Alternatives\n\n- [defold-ink](https://github.com/abadonna/defold-ink) — The Ink language runtime implementation in Lua based on parsing compiled JSON files.\n\n## Showcase\n\n- [Cat's Day](https://astronachos.com/catsday/) — A short card game about one furry.\n- [Rare Pets](https://jetpackcollective.games/rarepets/) — A merge game for mobile about pets that become what they eat.\n- [Sensual Hunting](https://store.steampowered.com/app/1967470/Sensual_Haunting/) (NSFW) — An adult only game where all the navigation and dialogs made with this library.\n- [The Secret Laboratory](https://astrochili.itch.io/the-secret-laboratory) — A short card game about the labaratory director.\n\n## Features\n\n### Supported\n\n- [x] Comments: singleline, multiline, todo's\n- [x] Tags: global tags, knot tags, stitch tags, paragraph tags\n- [x] Paths and sections: inclusions, knots, stitches, labels\n- [x] Choices: suppressing and mixing, labels, conditions, sticky and fallback choices, tags\n- [x] Branching: diversions, glues, gathers, nesting\n- [x] Tunnels\n- [x] Alternatives: sequences, cycles, once-only, shuffles, empty steps, nesting\n- [x] Multiline alternatives: all the same + shuffle options\n- [x] Conditions: logical operations, string queries, if and else statements, nesting\n- [x] Multiline conditions: all the same + elseif statements, switches, nesting\n- [x] Variables: assignments, constants, global variables, temporary variables, visits, lists\n- [x] Lists: logical operations, multivalued lists, multi-list lists, all the queries, work with numbers\n- [x] Game queries: all the queries without `TURNS()` and `TURNS_SINCE()`\n- [x] State: saving and loading\n- [x] Integration: external functions, variables observing, jumping\n- [x] Migration: the ability to implement the migration of player's saves after the book update\n- [x] Internal functions\n\n### Unsupported\n\n- [ ] [Threads](https://github.com/astrochili/narrator/issues/22)\n- [ ] [Divert target as variable type](https://github.com/astrochili/narrator/issues/23)\n- [ ] [Assigning string evaluations to variables](https://github.com/astrochili/narrator/issues/24)\n- [ ] [Multiple parallel flows](https://github.com/astrochili/narrator/issues/25)\n\nAlso there is a list of [known limitations](https://github.com/astrochili/narrator/labels/known%20limitation) on the issues page.\n\n## Installation\n\n### Common case (Löve, pure Lua, etc.)\n\nDownload the latest [release archive](https://github.com/astrochili/narrator/releases) and require the `narrator` module.\n\n```lua\nlocal narrator = require('narrator.narrator')\n```\n\nNarrator requires [lpeg](http://www.inf.puc-rio.br/~roberto/lpeg/) as dependency to parse Ink content. You can install it with [luarocks](https://luarocks.org/).\n\n```shell\n$ luarocks install lpeg\n```\n\nIn fact, you don't need `lpeg` in the release, but you need it locally to parse Ink content and generate lua versions of books to play in your game. Use parsing in development only, prefer already parsed and stored books in production.\n\n### Defold\n\nAdd links to the zip-archives of the latest versions of [narrator](https://github.com/astrochili/narrator/releases) and [defold-lpeg](https://github.com/astrochili/defold-lpeg/releases) to your Defold project as [dependencies](http://www.defold.com/manuals/libraries/).\n\n```\nhttps://github.com/astrochili/narrator/archive/master.zip\nhttps://github.com/astrochili/defold-lpeg/archive/master.zip\n```\n\nThen you can require the `narrator` module.\n\n```lua\nlocal narrator = require('narrator.narrator')\n```\n\n## Documentation\n\n### narrator.parse_file(path, params)\n\nParses the Ink file at path with all the inclusions and returns a book instance. Path notations `'stories/game.ink'`, `'stories/game'` and `'stories.game'` are valid.\n\nYou can save a parsed book to the lua file with the same path by passing `{ save = true }` as `params` table. By default, the `params` table is `{ save = false }`.\n\n```lua\n-- Parse a Ink file at path 'stories/game.ink'\nlocal book = narrator.parse_file('stories.game')\n\n-- Parse a Ink file at path 'stories/game.ink'\n-- and save the book at path 'stories/game.lua'\nlocal book = narrator.parse_file('stories.game', { save = true })\n```\nReading and saving files required `io` so if you can't work with files by this way use `narrator.parse_content()`.\n\n### narrator.parse_content(content, inclusions)\n\nParses the string with Ink content and returns a book instance. The `inclusions` param is optional and can be used to pass an array of strings with Ink content of inclusions.\n\n```lua\nlocal content = 'Content of a root Ink file'\nlocal inclusions = {\n  'Content of an included Ink file',\n  'Content of another included Ink file'\n}\n\n-- Parse a string with Ink content\nlocal book = narrator.parse_content(content)\n\n-- Parse a string with Ink content and inclusions\nlocal book = narrator.parse_content(content, inclusions)\n```\n\nContent parsing is useful when you should manage files by your engine environment and don't want to use `io` module. For example, in Defold, you may want to load ink files as custom resources with [sys.load_resource()](https://defold.com/ref/sys/#sys.load_resource:filename).\n\n### narrator.init_story(book)\n\nInits a story instance from the book. This is aclual to use in production. For example, just load a book with `require()` and pass it to this function.\n\n```lua\n-- Require a parsed and saved before book\nlocal book = require('stories.game')\n\n-- Init a story instance\nlocal story = narrator.init_story(book)\n```\n\n### story:begin()\n\nBegins the story. Generates the first chunk of paragraphs and choices.\n\n### story:can_continue()\n\nReturns a boolean, does the story have paragraphs to output or not.\n\n```lua\nwhile story:can_continue() do\n  -- Get paragraphs?\nend\n```\n\n### story:continue(steps)\n\nGet the next paragraphs. You can specify the number of paragraphs that you want to pull by the `steps` param.\n- Pass nothing if you want to get all the currently available paragraphs. `0` also works.\n- Pass `1` if you want to get one next paragraph without wrapping to array.\n\nA paragraph is a table like `{ text = 'Hello.', tags = { 'tag1', 'tag2' } }`. Most of the paragraphs do not have tags so `tags` can be `nil`.\n\n\n```lua\n-- Get all the currently available paragraphs\nlocal paragraphs = story:continue()\n\n-- Get one next paragraph\nlocal paragraph = story:continue(1)\n```\n\n### story:can_choose()\n\nReturns a boolean, does the story have choices to output or not. Also returns `false` if there are available paragraphs to continue.\n\n```lua\nif story:can_choose() do\n  -- Get choices?\nend\n```\n\n### story:get_choices()\n\nReturns an array of available choices. Returns an empty array if there are available paragraphs to continue.\n\nA choice is a table like `{ text = 'Bye.', tags = { 'tag1', 'tag2' } }`. Most of the choices do not have tags so `tags` can be `nil`.\n\nChoice tags are not an official feature of Ink, but it's a Narrator feature. These tags also will appear in the answer paragraph as it works in Ink by default. But if you have a completely eaten choice like `'[Answer] #tag'` you will receive tags only in the choice.\n\n```lua\n  -- Get available choices and output them to the player\n  local choices = story:get_choices()\n  for i, choice in ipairs(choices) do\n    print(i .. ') ' .. choice.text)\n  end\n```\n\n### story:choose(index)\n\nMake a choice to continue the story. Pass the `index` of the choice that you was received with `get_choices()` before. Will do nothing if `can_continue()` returns `false`.\n\n```lua\n  -- Get the answer from the player in the terminal\n  answer = tonumber(io.read())\n\n  -- Send the answer to the story to generate new paragraphs\n  story:choose(answer)\n\n  -- Get the new paragraphs\n  local new_paragraphs = story:continue()\n```\n\n### story:jump_to(path_string)\n\nJumps to the path. The `path_string` param is a string like `'knot.stitch.label'`.\n\n```lua\n  -- Jump to the maze stitch in the adventure knot\n  story:jump_to('adventure.maze')\n\n  -- Get the maze paragraphs\n  local maze_paragraphs = story:continue()\n```\n\n### story:get_visits(path_string)\n\nReturns the number of visits to the path. The `path_string` param is a string like `'knot.stitch.label'`.\n\n```lua\n-- Get the number of visits to the maze's red room\nlocal red_room_visits = story:get_visits('adventure.maze.red_room')\n\n-- Get the number of adventures visited.\nlocal adventure_visits = story:get_visits('adventure')\n```\n\n### story:get_tags(path_string)\n\nReturns tags for the path. The `path_string` param is a string like `'knot.stitch'`. This function is useful when you want to get tags before continue the story and pull paragraphs. Read more about it [here](https://github.com/inkle/ink/blob/master/Documentation/RunningYourInk.md#knot-tags).\n\n```lua\n-- Get tags for the path 'adventure.maze'\nlocal mazeTags = story:get_tags('adventure.maze')\n```\n\n### story:save_state()\n\nRaturns a table with the story state that can be saved and restored later. Use it to save the game.\n\n```lua\n-- Get the story's state\nlocal state = story:save_state()\n\n-- Save the state to your local storage\nmanager.save(state)\n```\n\n### story:load_state(state)\n\nRestores a story's state from the saved before state. Use it to load the game.\n\n```lua\n-- Load the state from your local storage\nlocal state = manager.load()\n\n-- Restore the story's state\nstory:load_state(state)\n\n```\n\n### story:observe(variable, observer)\n\nAssigns an observer function to the variable's changes.\n\n```lua\nlocal function x_did_change(x)\n  print('The x did change! Now it\\'s ' .. x)\nend\n\n-- Start observing the variable 'x'\nstory:observe('x', x_did_change)\n```\n\n### story:bind(func_name, handler)\n\nBinds a function to external calling from the Ink. The function can returns the value or not.\n\n```lua\nlocal function beep()\n  print('Beep! 😃')\nend\n\nlocal function sum(x, y)\n  return x + y\nend\n\n-- Bind the function without params and returned value\nstory:bind('beep', beep)\n\n-- Bind the function with params and returned value\nstory:bind('sum', sum)\n```\n\n### story.global_tags\n\nAn array with book's global tags. Tags are strings of course.\n\n```lua\n-- Get the global tags\nlocal global_tags = story.global_tags\n\n-- A hacky way to get the same global tags\nlocal global_tags = story:get_tags()\n```\n\n### story.constants\n\nA table with book's constants. Just read them, constants changing is not a good idea.\n\n```lua\n-- Get the theme value from the Ink constants\nlocal theme = story.constants['theme']\n```\n\n### story.variables\n\nA table with story's variables. You can read or change them by this way.\n\n```lua\n-- Get the mood variable value\nlocal mood = story.variables['mood']\n\n-- Set the mood variable value\nstory.variables['mood'] = 'sunny'\n```\n\n### story.migrate\n\nA function that you can specify for migration from old to new versions of your books. This is useful, for example, when you don't want to corrupt player's save after the game update.\n\nThis is the place where you can rename or change variables, visits, update the current path, etc. The default implementation returns the same state without any migration.\n\n```lua\n-- Default implementation\nfunction(state, old_version, new_version) return state end\n```\n\nThe `old_version` is the version of the saved state, the `new_version` is the version of the book. You can specify the verson of the book with the constant `'version'` in the Ink content, otherwise it's equal to `0`.\n\n```lua\n-- A migration function example\nlocal function migrate(state, old_version, new_version)\n\n  -- Check the need for migration\n  if new_version == old_version then\n    return state\n  end\n\n  -- Migration for the second version of the book\n  if new_version == 2 then\n\n    -- Get the old value\n    local old_mood = state.variables['mood']\n\n    -- If it exists then migrate ...\n    if old_mood then\n      -- ... migrate the old number value to the new string value\n      state.variables['mood'] = old_mood \u003c 50 and 'sadly' or 'sunny'\n    end\n  end\n\n  return state\nend\n\n-- Assign the migration function before loading a saved game\nstory.migrate = migrate\n\n-- Load the game\nstory:load_state(saved_state)\n```\n\n## Contribution\n\n### Development\n\nThere are some useful extensions and configs for [VSCode](https://code.visualstudio.com/) that I use in development of Narrator.\n\n- [Local Lua Debugger](https://github.com/tomblind/local-lua-debugger-vscode) by [tomblind](https://github.com/tomblind/).\n- [Lua Language Server](https://github.com/sumneko/lua-language-server) by [sunmeko](https://github.com/sumneko).\n- A task named `Busted` runs tests with `tests/run.lua`.\n- A lunch configuration named `Busted` runs the debugger with `tests/run.lua`.\n- A lunch configuration named `Debug` runs the debugger with `debug.lua`.\n\n### Testing\n\nTo run tests you need to install [busted](https://github.com/Olivine-Labs/busted).\n\n```shell\n$ luarocks install busted\n```\n\nDon't forget also to install `lpeg` as described in [Common case](#common-case-löve-pure-lua-etc) installation section.\n\nAfter that you can run tests from the terminal:\n```shell\n$ busted test/run.lua\n```\n\n## Third Party Libraries\n\n- [LPeg](http://www.inf.puc-rio.br/~roberto/lpeg/) by [Roberto Ierusalimschy](http://www.inf.puc-rio.br/~roberto/) (MIT Licence).\n- [classic](https://github.com/rxi/classic) by [rxi](https://github.com/rxi) (MIT Licence).\n- [lume](https://github.com/rxi/lume) by [rxi](https://github.com/rxi) (MIT Licence).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fastrochili%2Fnarrator","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fastrochili%2Fnarrator","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fastrochili%2Fnarrator/lists"}