Experimental parser-based text adventure engine with Odin+Janet
  • Odin 76.6%
  • Janet 11%
  • HTML 9.3%
  • Shell 2.9%
  • JavaScript 0.2%
Find a file
magicalhack 3cee789cfc move undo to Janet, configurable undo depth, simplify from_pv
- undo is now a Janet action instead of hardcoded in command.odin, with randomized flavour messages
- undo depth configurable at runtime via (undos n), default 5, persisted in save files (save version bumped to 3)
- from_pv no longer auto-calls functions; callers (x, ax, tx) handle function invocation explicitly
- ax is now variadic, forwards extra args to property functions
- dispatcher reads no-tick? before execute to avoid use-after-free when undo restores state mid-dispatch
2026-02-15 09:00:28 -05:00
bin a whole bunch of refactors and fixes and improvements 2026-02-12 17:05:00 -05:00
config wasm for real 2026-02-05 07:43:50 -05:00
jengine move undo to Janet, configurable undo depth, simplify from_pv 2026-02-15 09:00:28 -05:00
lib a whole bunch of refactors and fixes and improvements 2026-02-12 17:05:00 -05:00
src move undo to Janet, configurable undo depth, simplify from_pv 2026-02-15 09:00:28 -05:00
.gitignore rejigger bootstrapping + package/restore 2026-02-04 17:25:00 -05:00
odinfmt.json fix memory leaks; add script prompt responses 2026-01-31 17:21:52 -05:00
README.md move undo to Janet, configurable undo depth, simplify from_pv 2026-02-15 09:00:28 -05:00

JOTA

A parser-based interactive fiction engine. Odin backend, Janet scripting.

Status

Working:

  • Natural language parser (articles, adjectives, prepositions, multi-word verbs)
  • Rooms, objects, traits, actions
  • Exit handling with synonyms and checks
  • Before/on hooks with (block) and (super)
  • Sequences (turn-based coroutines)
  • Several built-in traits: :takeable, :readable, :lightable, :dark, :lockable

Missing:

  • More useful built-in traits & actions
  • NPCs and conversation
  • Score tracking

Building

bin/setup             # install dependencies
bin/build             # debug build
bin/build release     # release build
bin/build debug wasm  # WASM build

Requires Odin compiler. Vendors Janet and MPC. WASM build requires Emscripten.

Web (WASM)

bin/build debug wasm
build/debug/jota path/to/game --package build/wasm/package.jota
python -m http.server -d build/wasm

Open http://localhost:8000 in a browser.

Running

build/debug/jota path/to/game                 # interactive, play the game
build/debug/jota --restore game.jota          # run from package or savegame
build/debug/jota path/to/game script.txt      # run test script
build/debug/jota path/to/game --package game  # create packaged game state for distribution

Your game directory needs a main.janet file. You can (require "otherfile") for organisation.

Parser & Input

The parser understands natural language commands in several forms:

verb                        look, inventory, north
verb noun                   take lamp, read book
verb noun prep noun         give bone to poodle, unlock door with key

Articles ("the", "a", "an") are stripped automatically:

take the lamp       →  take lamp
read a book         →  read book

Adjectives disambiguate objects when multiple match:

take lamp           # ambiguous if brass lamp and oil lamp exist
take brass lamp     # specific

Direction abbreviations are expanded:

n/s/e/w             →  north/south/east/west
u/d                 →  up/down
ne/nw/se/sw         →  northeast/northwest/southeast/southwest

Movement can be a bare direction or use "go"/"walk"/"enter":

north               # direction as verb
go north            # movement verb + direction
enter house         # movement verb + exit synonym

Special commands:

wait, z             # pass one turn (advances sequences)
again, g            # repeat last command
undo                # undo last action (restores previous game state)
quit, quit!         # end game (quit! skips confirmation)
save [name]         # save game state (default: `save` => `save.jota`)
restore [name]      # restore game state (alias: load)

Writing Games

Games are written in Janet.

(start |(move PLAYER :kitchen))

(room :kitchen
  :name "Kitchen"
  :desc "A small kitchen with a checkered floor."
  :exits {:north :hallway})

(room :hallway
  :name "Hallway"
  :exits {:south :kitchen})

(object :knife
  :name "rusty knife"
  :synonyms ["blade"]
  :desc "a rusty old knife"
  :location :kitchen
  :is [:takeable :droppable])

Rooms

(room :id
  :name "Display Name"
  :desc "Description shown on look."
  :long-desc "Longer description."      # optional
  :exits {:north :other-room}
  :on-enter |(say "You enter.")         # hook
  :on-exit |(say "You leave."))         # hook

Exits

(room :myroom
  # Shorthand
  :exits {
    :north :hall
    :east :garden
  }

  # Expanded (with synonyms and checks)
  :exits {
    :west (exit :house
      :synonyms ["door" "building"]
      :desc "wooden door"
      :check |(when (x :door :locked?)
        (say "It's locked.")
        (block)))
  }

  # before-exit runs for any exit
  :before-exit |(do (kill :grue) (super)))

Players can type: north, n, go north, enter house, go in door

Objects

(object :lamp
  :name "brass lamp"
  :synonyms ["lamp" "lantern"]
  :adjectives ["brass" "old"]
  :desc "a battered brass lamp"
  :location :kitchen
  :is [:takeable :droppable :lightable]   # traits
  :fuel 10                                # custom property
  :on-take |(say "Got it."))              # action hook

Standard properties:

Property Description
:name Display name (e.g. "brass lamp")
:synonyms Words the parser recognizes (e.g. ["lamp" "lantern"])
:adjectives For disambiguation (e.g. ["brass" "old"])
:location Where the object is (room id, object id, PLAYER, or VOID)
:is List of traits (e.g. [:takeable :lightable])
:desc Full description shown when examining
:proper? Suppress article for proper nouns (e.g. "Mr. Crisp" not "the Mr. Crisp")
:quiet? Hide from room listings (object won't be listed even if room lists objects)

Objects can have any custom properties (like :fuel above). Access them with (x obj :prop).

Traits

Traits add behavior to objects:

(trait :breakable
  :broken? false
  :actions [(action :break :synonyms ["smash" "wreck"])]
  # or shorthand - :actions [:break]
  :on-break |(do
    (x $ :broken? true)
    (say "It shatters!")))

(object :vase
  :is [:takeable :droppable :breakable]
  :location :hallway)

Built-in traits:

Trait Properties Hooks
:takeable can-take?, msg-take, msg-cant-see on-take
:droppable can-drop?, msg-drop on-drop
:readable read-desc on-read
:lightable lit?, emits-light, can-light? on-light, on-unlight
:lockable locked?, locked-by, unlocked-by on-lock, on-unlock
:dark dark-msg, lit?, total-light-level (overrides :visible-objects, :look-msg)

Note on :dark: This trait works by overriding room properties rather than using hooks. When a dark room has no light, :visible-objects returns empty and :look-msg returns the darkness message. Light sources in the player's inventory count toward the room's light level.

Actions

(action :dance
  :synonyms ["boogie" "groove"]
  :execute |(say "You dance awkwardly."))

(action :throw
  :execute |(do
    (if (not (? $))
      (say "Throw what?")
      (say "You throw the " (x $ :name) "."))))

Hooks

Objects respond to actions via before-* and on-* hooks:

(object :button
  :before-push |(say "Are you sure?")
  :on-push |(do
    (say "Click!")
    (x $ :locked? false)))

Hook flow:

  1. before-<action> runs first (for precondition checks)
  2. If it returns (block), stop - action is prevented
  3. Otherwise, on-<action> runs (the actual action)

Return values:

  • (block) - stop processing, prevent the action
  • (super) - call the next trait handler up the chain
  • anything else - continue normally

Overriding traits:

If your object has a trait like :readable, the trait provides default hooks. To override:

# Override the check (before-read) - must return (block) to prevent
(object :magic-book
  :is [:readable]
  :before-read |true)  # allow reading even if not held

# Override the action (on-read) - replaces trait behavior
(object :magic-book
  :is [:readable]
  :already-read? false
  :on-read |(if (x $ :already-read?)
    (do (say "The pages are all blank!") (block))
    (do (x $ :already-read? true) (say "The words shimmer and change..."))))

# Extend trait behavior - do something then delegate
(object :magic-book
  :is [:readable]
  :on-read |(do
    (say "The book glows!")
    (super)))  # then run trait's on-read

Indirect Objects

Commands like "give bone to poodle" or "unlock door with key" are parsed with prepositional phrases. Handlers receive the full command struct as their second argument:

# "give bone to poodle" parses as:
{:action "give"
 :objects [{:id :bone :raw "bone" ...}]
 :indirect [{:prep "to" :objects [{:id :poodle ...}]}]}

Use the helper functions to extract objects:

(cmd-object-id cmd)       # first direct object (:bone)
(cmd-indirect-id cmd)     # first indirect object (:poodle)
(cmd-object-ids cmd)      # all direct objects
(cmd-indirect-ids cmd)    # all indirect objects

Example actions:

(action :give
  :execute (fn [id cmd]
    (let [recipient (cmd-indirect-id cmd)]
      (if (nil? recipient)
        (say "Give to whom?")
        (do
          (move id recipient)
          (say "Given."))))))

(action :put
  :synonyms ["place"]
  :execute (fn [id cmd]
    (let [container (cmd-indirect-id cmd)]
      (if (nil? container)
        (say "Put it where?")
        (do
          (move id container)
          (say "Done."))))))

Namespaces

Optional. Group related definitions under a namespace to avoid id collisions. Keywords starting with :/ get the namespace prefix; everything else is left alone:

# elsewhere -> (room :courtyard ...)

(namespace :dungeon
  (room :/entrance
    :name "Dungeon Entrance"
    :exits {
      :north :/hall        # :dungeon/hall
      :south :courtyard    # :courtyard
    })

  (room :/hall
    :name "Great Hall"
    :exits {:south :/entrance})

  (object :/torch
    :name "wall torch"
    :synonyms ["torch"]
    :location :/entrance))

This creates ids :dungeon/entrance, :dungeon/hall, :dungeon/torch. Property keys (:name, :exits), directions (:north, :south), traits (:takeable), and references to things outside the namespace (:courtyard) are unchanged.

Multiple objects can share synonyms across locations — the engine resolves "torch" to whichever one is in the player's current room.

Core Functions

# Properties
(x obj :prop)              # get property
(x obj :prop value)        # set property
(prop ...)                 # synonyms you can use for
(call ...)                 # semantic significance if you want

# Movement
(move obj :dest)           # move object (alias: give)
VOID                       # "nowhere" - move objects here to remove them from play
PLAYER                     # the player object
(current-room)             # the room the player is in

# Queries
(is? obj :trait)           # has trait?
(location? obj place)      # is obj's location equal to place? (alias: at?)
(exists? obj)              # object exists? (alias: ?)
(objects-in place)         # list objects in location

# Output
(say "text")               # print (supports ~b bold, ~r reset, ~n newline)
(say :center "text")       # center text on screen
(yn "question")            # yes/no prompt, returns bool
(a-an obj)                 # "a lamp" or "an apple" (with article)
(the obj)                  # "the lamp" (respects :proper?)

# Flow control
(block)                    # stop action processing
(super)                    # call the next trait handler up the chain
(quit)                     # end game

Sequences

Turn-based coroutines for timed events:

(sequence :flood |(do
  (say "Water is rising...")
  (wait 3)
  (say "The room floods!")
  (quit)))

(spawn :flood)     # start sequence
(kill :flood)      # stop sequence
(running? :flood)  # is it running?

# sequence with owner
(sequence :bomb-tick
  :owner :bomb
  |(do
    (wait 10)
    (say (x $ :name) " explodes!")
    (quit)))

(wait n) pauses for n turns.

Object Updates

Objects can have an :update key, a convenience for an auto-spawned sequence owned by the object:

(object :lantern
  :lit? false
  :fuel 10
  :update |(do
    (cond
      (= (x $ :fuel) 0) (do
        (say "The lantern goes out.")
        (x $ :lit? false))
      (x $ :lit?) (x $ :fuel (dec (x $ :fuel))))
    (yield))

(object :bomb
  :update |(do (wait 10) (say "BOOM!") (quit)))

Text Formatting

(say "This is ~bbold~r and this is normal.")
Code Effect
~b bold
~d dim
~i italic
~u underline
~r reset
~n newline
~t tab

Example: Complete Mini-Game

(title "Prison Escape")

(start |(do
  (say "~bWelcome to JOTA!~r")
  (say "You are about to experience a harrowing tale of escape from prison.~n")
  (press-any-key)
  (say "You wake up in a cell.~n")
  (move PLAYER :cell)))

(room :cell
  :name "Prison Cell"
  :desc "Cold stone walls. A rusty door leads out. Maybe if you ~isearch~r carefully you'll find something useful..."
  :is [:searchable]
  :search-reveals [:hairpin]
  :msg-search-found "You find a bent hairpin jammed in between two bricks. Could be useful!"
  :exits {
    :out (exit :corridor
      :synonyms ["door" "lock"]
      :check |(when (x :cell-door :closed?)
        (say (if (x :cell-door :locked?) "The door is locked." "It's not open."))
        (block)))
  })

(room :corridor
  :name "Corridor"
  :desc "Freedom! ...or is it?"
  :on-enter |(do (spawn :death) (super)))

(sequence :death |(do
  (x :corridor :before-look |(block))
  (wait 1)
  (say `
    ~nUnfortunately, as you wander around the corridor you trip and fall and land in a pile of rusty metal. Why was there a pile of rusty metal in the prison corridor? Who can say! The point is...
    `)
  (say :center "~n~i** YOU ARE DEAD **~r")
  (game-over)))

(object :cell-door
  :name "cell door"
  :synonyms ["door" "rusty door"]
  :location :cell
  :locked? true
  :is [:openable :lockable]
  :unlocked-by [:hairpin])

(object :hairpin
  :visible? false
  :name "hairpin"
  :synonyms ["pin"]
  :desc "It's a bent hairpin. Useful for picking locks?"
  :location :cell
  :is [:takeable :droppable])

Save, Restore & Undo

Game state is fully serialized using Janet's marshal/unmarshal — objects, traits, actions, sequences, running fibers, closures, turn count, and prompt are all preserved.

Save format is section-based (v2) for extensibility:

  • STATE section: marshaled game state
  • UNDOSTACK section: previous state snapshots for undo

Undo snapshots the marshaled game state before each state-changing command. Observation commands (inventory, look, etc.) don't consume the undo slot. The undo stack is persisted in save files so saverestoreundo works as expected.

Testing

Create a script file with commands, one per line:

take hairpin
unlock door with hairpin
out
quit!

Run: build/debug/jota path/to/game script.txt

  • Comments with #
  • Use ;response for prompts: quit ;yes
  • assert (= something something-else) is very helpful in scripts