- Odin 76.6%
- Janet 11%
- HTML 9.3%
- Shell 2.9%
- JavaScript 0.2%
- 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 |
||
|---|---|---|
| bin | ||
| config | ||
| jengine | ||
| lib | ||
| src | ||
| .gitignore | ||
| odinfmt.json | ||
| README.md | ||
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:
before-<action>runs first (for precondition checks)- If it returns
(block), stop - action is prevented - 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:
STATEsection: marshaled game stateUNDOSTACKsection: 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 save → restore → undo 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
;responsefor prompts:quit ;yes assert (= something something-else)is very helpful in scripts