Looking for feedback on minimalist DSL for IF

I’ve been playing with this idea for a while now, it’s a sort of esoteric language geared toward authoring IF. The language itself looks similar to Inform7, but less magical. I’d love to get feedback on this thing from the IF community. I’ve tried posting this to various places on the internet, but without any response, so now I’m trying here.

Below is a description of the language, along with a reference implementation written in Lua, a set of core rules, and a port of Roger Firth’s Cloak of Darkness to the language.

The project is tentatively entitled “RIFE,” for “Rules-based Interactive Fiction Engine.”

First, here’s a writeup describing the language. It aims to be thorough, but not overwhelming. Let me know if anything is confusing or should be described in more detail.


Overview

A program is composed of statements and rules. Statements define the current state of the program, and rules describe state transformations which may occur as the program runs, when the appropriate conditions are met. State transformations are realized by creating new statements, or removing old ones, or both.

Statements

Each statement is represented as a tuple, composed of elements. There are two kinds of syntax for elements in a statement tuple: words are sequences of non-space characters, and
phrases are sequences of characters enclosed by double quotes. There is no difference between them other than the more flexible syntax for phrases, which may span multiple lines and include spaces.

Each statement tuple contains at least one element. Elements in a tuple are separated by space characters. Statement tuples are separated from one another either with line breaks, or with the . character. A line containing statements must not have any leading white space.

Here’s an example of four statements:

player is in foyer. cloakroom is west of foyer.
cloakroom is called "the cloakroom"
player moves west

Rules

Each rule has two parts, a query part followed by a result part. The query and result parts each contain a list of tuples, similar to the tuples described above.

  • In addition to words and phrases, these tuples may contain variables:

    • Scalar variables look like regular words, prefixed with a $.

    • List variables also look like regular words, prefixed with a @.

  • Each tuple in the query part is terminated by either ,, ; or ?.

    • Tuples terminated by , are called reactants.

    • Tuples terminated by ; are called reagents.

    • Tuples terminated by ? are called catalysts.

The query part of a rule starts at the beginning of a line, and the result part has leading whitespace. The result part of a rule contains zero or more products. Each product looks similar to a statement, except that it must be indented and may contain variables. If a rule contains no products, a single dot should be written in the result part to indicate the end of the rule.

Here’s an example of a rule:

player moves $direction, player is in $place,
$destination is $direction of $place?
	player is in $destination.

Scalar Variables

The example rule above matches the example statements described earlier. Here’s how it works.

Tuples in the query are matched in left-to-right order. The player moves $direction tuple matches player moves west, and the $direction variable is bound to the word west. Similarly, player is in $place matches, leaving $place bound to foyer.

The last tuple in the query is $destination is $direction of $place. Since $direction is now bound to west, and $place is bound to foyer, this is equivalent to $destination is west of foyer, which matches the cloakroom is west of foyer statement, binding $destination to cloakroom.

Since every part of the query matched a statement, the rule is successfully applied.

List Variables

List variables work in a similar way to scalar variables, but they can successfully match any number of elements (including zero). No more than one list variable is allowed per query tuple (that is, one per reactant, reagent, or catalyst).

Here’s an example of a rule containing a list variable:

you @act,
    player wants to @act.

This will match statements like you look, you look at message, or simply you.

Reactants and Catalysts

When a rule is successfully applied, all statements matching reactants (query parts terminated by ,) are removed from the program. Statements matching catalysts (query parts terminated by ?) remain in the in the program. The results of the rule are appended as new statements,
with any variables replaced by their bound values.

Use a catalyst to check for the existence of a statement without removing it. Use a reactant to remove the matched statement on successful rule application.

Continuing with our example, applying the rule results in the removal of the player moves west and player is in foyer statements, and the addition of a new player is in cloakroom statement.

Multiple variables may exist in a single element of a result; all of them will be replaced with their bound values. This is typically used to build dynamic phrases, but is also allowed in word elements.

Reagents

Reagents (query parts terminated by ;) are similar to reactants, except that when a rule containing reagents is successfully applied, the rule is immediately applied again repeatedly, until it can no longer match anything new.

Statements matching query parts containing reagents are not removed until after these repeated applications of the rule. When used together with catalysts, reagents are suitable for iterating over multiple similar statements.

Here’s an example of a rule containing a reagent:

$somebody tries to drop all; $somebody has $item?
    $somebody wants to drop $item.

Suppose Bob tries to drop all, Bob has apple, and Bob has banana
are all existing statements. When the rule is applied,
Bob wants to drop apple and Bob wants to drop banana are created,
and finally Bob tries to drop all is removed.

Default Values

It’s possible to assign a default value to a scalar variable. Consider the following rules:

game ends because you $left, turn count is $turns, your score is $score,
    say "You $left the game after $turns turns with a final score of $score."
    issue game command quit

game ends because you $left, turn count is $turns,
    say "You $left the game after $turns turns with a final score of 0."
    issue game command quit

game ends because you $left, your score is $score,
    say "You $left the game after 0 turns with a final score of $score."
    issue game command quit

game ends because you $left,
    say "You $left the game after 0 turns with a final score of 0."
    issue game command quit

It’s possible that no statements exist matching turn count is $turns or your score is $score, so we need four rules to handle all possible combinations. If we needed to test for a third statement that may not exist, the number of rules would double.

To handle this, variables can be given a default value by placing the value after the variable name, separated by a | character.

Here’s the previous example, using a single rule:

game ends because you $left, turn count is $turns|0, your score is $score|0,
    say "You $left the game after $turns turns with a final score of $score."
    issue game command quit

A default value should only be placed on the first appearance of a variable in a rule. When a part of a rule’s query fails to match anything, if it contains variables with default values, the default values are bound to those variables and that part of the match is considered successful.

Variable Expansion

Normally, a variable appearing in a product of a rule expands to whatever value it is bound to. This behavior can be modified with some special syntax:

  • $var# expands to the decimal representation of the number of characters in the string bound to $var. This can be used for unary to decimal conversion.

  • $var#2 expands to the second character of the string bound to $var.

  • $var+2 expands to all characters from the second character in $var; it trims off the first character.

  • $var*c where $var is bound to a value representing a positive integer expands to a string containing that number of “c” characters. This can be used for decimal to unary conversion.

Program Execution

A program executes from within a host application. The host application is responsible for loading and running the program. Typically the host application will load a program once, and then run it many times in a loop.

When the program loads, all statements and rules are parsed. These are stored in two separate lists, in the order they appear in the source file. When the program runs, each rule is tested against each statement. When a rule matches a statement successfully, it is applied. If the rule contains no reagents, then application is simple. Statements which matched reactants are removed, and products (result parts) of the rule are placed at the end of the list of statements.

If the rule contains any reagents:

  1. Any statements matching catalysts are removed from their current positions, and placed at the end of the list of statements.

  2. The products of the rule are placed at the end of the list of statements.

  3. Another match is attempted with the same rule. If it matches something new, go back to step 1. Otherwise, continue to step 4.

  4. Finally, remove any statements matching the rule’s reagents.

Note that in step 3, “matches something new” means that the rule matches something different from the statements matched with catalysts on the first iteration of the current rule application.

This process is repeated for each rule until no more rules can be applied.

At this point, control is returned to the host application, which will typically extract a few statements, output something based on those statements, poll for user input, create new statements from the input, and run the program again.


To try the game out, grab a copy of Lua or LuaJIT, save all four files using the indicated filenames, and run lua rife.lua in your terminal. I’ve hit the character limit here, so I’ll post the files next.

I feel like this language is close to being very useful for general IF authoring, but still has some rough edges. My goal is to bang out any dents in the language design first, then improve the interpreter (writing a “proper” parser, giving more useful error messages for syntactically incorrect code, look at ways to improve performance, and so on). I would really appreciate any feedback from more experienced IF authors!

7 Likes

cloak.rife

A port of Roger Firth’s Cloak of Darkness to the language, with a few extra props and items thrown in for testing purposes.

# Cloak of Darkness
#
# This is a third-party port of Roger Firth's Cloak of Darkness example,
# which has been described as the "Hello World" of interactive fiction.
#
# This port exists to demonstrate an experimental programming language
# and minimal platform for authoring interactive fiction (RIFE).
#
# Roger Firth is the original author of most prose in this file.
# His Cloak of Darkness ports are not distributed with any license.
# Any use of copyrighted material here is intended as "fair use,"
# as described in Title 17 U.S.C. Section 107.
#
# See http://www.firthworks.com/roger/cloak/index.html

say "Cloak of Darkness: A basic IF demonstration."

# Foyer

player is in foyer
foyer is called "Foyer of the Opera House"
foyer is described as "
    You are standing in a spacious hall,
    splendidly decorated in red and gold,
    with glittering chandeliers overhead.
    The entrance from the street is to the north,
    and there are doorways south and west."

player tries to move north, player is in foyer?
    say "You've only just arrived, and besides, 
        the weather outside seems to be getting worse."

# Cloakroom

cloakroom is west of foyer. cloakroom is called "The Cloakroom"
cloakroom is described as "
    The walls of this small room
    were clearly once lined with hooks,
    though now only one remains.
    The exit is a door to the east."

cloakroom features hook.
hook is called "small brass hook"
hook is described as "It's just a small brass hook, screwed to the wall."
garment can be hanging on hook.

player examines hook,
    summarize things on hook as hooked.

after summarizing hooked list, hooked list summary is $stuff,
    say "It's just a small brass hook, with $stuff hanging on it."

after summarizing hooked list, hook is described as $description?
    say $description
    
# an extra container, for testing

cloakroom features barrel.
barrel is called "wooden barrel"
barrel is described as "The barrel seems out of place here."
anything can be sitting in barrel.

potato is called an "old potato"
potato is described as "It looks back at you."
potato is in cloakroom.
potato is in barrel.
potato is a type of food.

banana is called a "brown banana"
banana is described as "It's appealing."
banana is in cloakroom.
banana is on ground.
banana is a type of food.

# Bar

bar is south of foyer. bar is dark. bar is called "Foyer Bar"
bar is described as "
    The bar, much rougher than you'd have guessed
    after the opulence of the foyer to the north,
    is completely empty.
    There seems to be some sort of message
    scrawled in the sawdust on the floor."
    
bar features message
message is neat

you read message,
    you examine message

player examines message, player is in bar? bar is lit? message is trampled?
    say "The message has been carelessly trampled,
        making it difficult to read.
        You can just distinguish the words..."
    game ends because you lost.

player examines message; player is in bar? bar is lit?
    score 1 points.
    say "The message, neatly marked in the sawdust, reads..."
    game ends because you won.

player fails to move $direction, player is in bar? bar is dark?
    say "Blundering around in the dark isn't a good idea!"
    message is disturbed.

message is disturbed, message is neat,
    message is scuffed.
    
message is disturbed, message is scuffed,
    message is trampled.

message is disturbed,
    .
    
# Inventory

player has cloak.

cloak is a type of garment. cloak is called a "velvet cloak"
cloak is described as "A handsome cloak, of velvet trimmed with satin,
    and slightly splattered with raindrops. Its blackness is so deep that
    it almost seems to suck light from the room."
    
# Cloak fiddling

cloak has not been hung.

you hang cloak on hook,
    you put cloak on hook.

cloak has not been hung, cloak is on hook?
     score 1 points.

player puts cloak on hook? bar is dark,
    bar is lit.

player takes cloak? bar is lit,
    bar is dark.

player wants to drop cloak, player is in cloakroom?
    player tries to drop cloak.
    
player wants to drop cloak,
    decide to keep cloak.
    
player wants to put cloak @somewhere, player is in cloakroom?
    player tries to put cloak @somewhere.
    
player wants to put cloak @somewhere,
    decide to keep cloak.

decide to keep cloak,
    say "This isn't the best place to leave a smart cloak lying around."

# More random inventory stuff, for testing

player has onion.

onion is called an "odorous onion"
onion is described as "This onion smells pretty bad."

player has pants.

pants is a type of garment. pants are called some "leather pants"
pants is described as "Those are some snappy leather pants."

# Intro text

say "Hurrying through the rainswept November night,
    you're glad to see the bright lights of the Opera House.
    It's surprising that there aren't more people about but, hey,
    what do you expect in a cheap demo game...?"
    
# Load general data and rules from core.rife (always do this last).

[load core]


core.rife

General game rules and data, loaded by cloak.rife.

# General rules and statements.

# Always load this last; some routines depend on it.
# For example, some routines may clean up after themselves
# if earlier (game) rules don't intercept the data first.

# Directions.

east is a direction
west is a direction
north is a direction
south is a direction

northeast is a direction
northwest is a direction
southeast is a direction
southwest is a direction

east is the opposite of west
west is the opposite of east
north is the opposite of south
south is the opposite of north

northeast is the opposite of southwest
northwest is the opposite of southeast
southeast is the opposite of northwest
southwest is the opposite of northeast

# Prepositions.

in is the opposite of out
out is the opposite of in
off is the opposite of on
on is the opposite of off

# Global containers.

everywhere features ground
anything can be lying on ground

# Aliases.

# Most of the old Infocom shortcuts are here,
# except `g` for repeating the last command.
# Launch under `rlwrap` for command history.

e means east
w means west
n means north
s means south

ne means northeast
nw means northwest
se means southeast
sw means southwest

u means up
d means down

i means inventory
x means examine
l means look
q means quit

#
# First-person commands for player.
#

# Game commands.

you debug @level,
    issue game command debug @level.
    
you dump $info,
    issue game command dump $info.

you save $path,
    issue game command save $path.
    
you load $path,
    issue game command load $path.
    
you quit,
   game ends because you quit.

# Intend to do stuff before attempting to do it,
# so we can intercept desires, impose extra conditions,
# then begin attempts if the desires aren't rejected.
    
you $direction, $direction is a direction?
    player wants to move $direction.

you go $direction,
    player wants to move $direction.
    
you get $something,
    player wants to take $something.

you pick up $something,
    player wants to take $something.

you pick $something up,
    player wants to take $something.

you put down $something,
    player wants to drop $something.

you put $something down,
    player wants to drop $something.
    
you examine,
    player wants to look.
    
you look at $something,
    player wants to examine $something.
    
you inventory,
    player wants to check inventory.
    
you $alias @stuff, $alias means $do?
    you $do @stuff.

you @act,
    player wants to @act.

# If we still intend to do something, attempt to do it.

# Instant actions. No time passes, but these can still fail.

player wants to look,
    player tries to look.
    
player wants to examine $something,
    player tries to examine $something.
    
player wants to check inventory,
    player tries to check inventory.
        
# Attempting all other actions takes a turn.

player wants to @act,
    player tries to @act.
    time passes.
    
$somebody wants to @act,
    $somebody tries to @act.

# Travel.

$somebody tries to move $direction, $somebody is in $place,
$destination is $direction of $place?
    $somebody travels to $destination.

$somebody tries to move $direction, $somebody is in $place,
$direction is the opposite of $opposite?
$place is $opposite of $destination?
    $somebody travels to $destination.

$somebody tries to move $direction,
    $somebody fails to move $direction.

$somebody travels to $destination,
    $somebody is in $destination.
    $somebody tries to look.

player fails to move $direction,
    say "You can't go $direction from here."
    
$somebody fails to move $direction,
    .

# Examining things / looking around.

player tries to look, player is in $place? $place is dark?
    say "A Dark Place"
    say "It's too dark to see anything here."

player tries to look, player is in $place?
$place is called $name?
$place is described as $description?
    say $name. say $description.
    see containers in $place.
    see global containers in $place.

see global containers in $here; everywhere features $container?
$stuff can be $placed $in $container?
    regarding things $placed $in $container
    summarize things $in $container $here as "things $placed $in $container"

see global containers in $here,
    .
    
see containers in $here; $here features $container?
$stuff can be $placed $in $container?
    regarding things $placed $in $container
    summarize things $in $container $here as "things $placed $in $container"

see containers in $here,
    .

# containers with a single item
regarding things $placed $in $container,
after summarizing "things $placed $in $container" list,
$something is $in $container? $something $is called $a $thing?
"things $placed $in $container" list summary is "$a $thing",
    say "$a $thing $is here, $placed $in the $container."

# containers with multiple items
regarding things $placed $in $container,
after summarizing "things $placed $in $container" list,
"things $placed $in $container" list summary is $things,
    say "$things are here, $placed $in the $container."

# containers with no items
regarding things $placed $in $container,
after summarizing "things $placed $in $container" list,
    .

player tries to examine $something,
player is in $place? $place is dark?
    say "It's too dark to see anything here."

$somebody tries to examine $something,
$somebody has $something?
    $somebody examines $something.

$somebody tries to examine $something,
$somebody is in $place? $something is in $place?
    $somebody examines $something.

$somebody tries to examine $something,
$somebody is in $place? $place features $something?
    $somebody examines $something.

player tries to examine $something,
    say "You don't see anything like that here."

player examines $something, $something is described as $description?
    say $description.
    
player examines $something,
    say "You don't see anything special about it."

# Drop all items.

$somebody tries to drop all; $somebody has $item?
    $somebody wants to drop $item.

player tries to drop all,
    say "You don't have anything."
    
# Take all items.

$somebody tries to take all; $somebody is in $place?
$item is in $place? $item $is called $a $name?
    $somebody wants to take $item.
    
player tries to take all,
    say "You don't see anything useful here."
    
# Drop item.

$somebody tries to drop $item, $somebody has $item,
    $somebody drops $item.

player tries to drop $item,
    say "You don't have that."
    
player drops $item, player is in $place? $item $is called $a $name?
    $item is in $place. $item is on ground.
    say "You drop the $name."

# Put item in container.

player tries to put $item $in $container, player has $item?
everywhere features $container?
    player starts to put $item $in $container.
    
player tries to put $item $in $container, player has $item?
player is in $place? $place features $container?
    player starts to put $item $in $container.
    
player starts to put $item $in $container,
anything can be $placed $in $container?
    player puts $item $in $container

player starts to put $item $in $container,
$item is a type of $stuff? $stuff can be $placed $in $container?
    player puts $item $in $container

player starts to put $item $in $container,
    say "You consider trying to put the $item $in the $container,
        but decide against it."

player tries to put $item $in $container, player has $item?
    say "You don't see anything like that here."
    
player tries to put $item $in $container,
    say "You don't have that."
    
player puts $item $in $container, $item $is called $a $name?
player has $item, player is in $place?
    $item is in $place. $item is $in $container.
    say "You put the $name $in the $container."
    
# Take item.

$somebody tries to take $item, $somebody is in $place?
$item is in $place, $item $is called $a $name?
    $somebody takes $item.

$somebody takes $item, $item $is called $a $name?
     $somebody has $item. $somebody takes $item called $name.

$somebody takes $item,
     $somebody has $item. $somebody takes $item called $item.
     
player takes $item called $thing, $item is $in $container,
$stuff can be $placed $in $container?
$out is the opposite of $in?
    say "You take the $thing $out of the $container."
    
player takes $item called $thing, $item is $in $container,
$stuff can be $placed $in $container?
    say "You take the $thing from $in the $container."

player takes $item called $thing,
    say "You take the $thing."

player tries to take $feature,
player is in $place? $place features $feature?
    say "You can't take that with you."

player tries to take $item,
    say "You don't see anything like that here."

$somebody takes $item called $name,
    .

# List inventory

player tries to check inventory,
    inventory list is
    build inventory list
    summarize inventory list
    
build inventory list; inventory list is @list,
player has $item? $item $is called $a $name?
    inventory list is "$a $name" @list

build inventory list, inventory list is @list, summarize inventory list,
    say "You aren't carrying anything."

after summarizing inventory list, inventory list summary is $stuff,
    say "You are carrying $stuff."
    
# List things in container
# TODO: for portable containers, do the same thing without $here

summarize things $in $container as $contents,
player is in $here? $here features $container?
    summarize things $in $container $here as $contents

summarize things $in $container $here as $contents,
    $contents list is
    summarize $contents list
    build list of $contents $in $container $here

build list of $contents $in $container $here; $contents list is @list,
$item is $in $container? $item $is called $a $name? $item is in $here?
    $contents list is "$a $name" @list

build list of $contents $in $container $here, $contents list is @list,
$contents list summary is $stuff,
    .
    
# Summarize a list of things.

summarize $thing list, $thing list summary is $summary|,
    $thing list summary is ""
    continue summarizing $thing list

continue summarizing $thing list?
$thing list is $first, $thing list summary is "",
    $thing list summary is "$first"
    
continue summarizing $thing list?
$thing list is $first $second, $thing list summary is "",
    $thing list summary is "$first and $second"
    
continue summarizing $thing list?
$thing list is $first, $thing list summary is $stuff,
    $thing list summary is "$stuff, and $first"
    
continue summarizing $thing list?
$thing list is $first @rest, $thing list summary is "",
    $thing list is @rest
    $thing list summary is "$first"
    
continue summarizing $thing list?
$thing list is $first @rest, $thing list summary is $stuff,
    $thing list is @rest
    $thing list summary is "$stuff, $first"
    
continue summarizing $thing list,
    after summarizing $thing list
    
after summarizing $thing list,
    .

# Scoring.

new score tally is $tally,
    your score is $tally#

score $points points, your score is $score|0,
    new score tally is $points*I$score*I
    say "Your score increased by $points."

# Turn count.

new turn count tally is $tally,
    turn count is $tally#

time passes, turn count is $turns|0,
    new turn count tally is I$turns*I
    
# Remove unhandled commands.

player tries to @act,
    say "Huh?"
    
$somebody tries to @act,
    .

# Game commands.

say $something,
    issue game command print $something

game ends because you $left, turn count is $turns|0, your score is $score|0,
    say "You $left the game after $turns turns with a final score of $score."
    issue game command quit

rife.lua

The game engine.

-- RIFE: a Rule-based Interactive Fiction Interpreter
-- Game launcher.

-- Protect against accidental globals.
setmetatable(_G, {
    __index = function(_, v) error('Referenced undefined global: ' .. v) end,
    __newindex = function(_, v) error('Attempt to create global: ' .. v) end,
})

local Interpreter = require 'interpreter'

-- Game class. Sets up interpreter and loads a program.
local Game = Interpreter.Class()

function Game:init(path)
    self.interpreter = Interpreter()
    local loaded, message = self.interpreter:loadPath(path)
    if not loaded then
        self:print("Can't load game " .. message)
        os.exit(1)
    end
    self.commandRule = Interpreter.Rule()
    self.commandRule:parseQueryLine('issue game command $name @args,')
    self.gameOver = false
    return self
end

-- Set interpreter debug level. Called from extracted game command.
function Game:debug(level)
    level = tonumber(level) or level == 'off' and 0 or 1
    self.interpreter.debugLevel = level
    self:print(level == 1 and 'debug mode on.' or 'debug mode off.')
end

-- Dump program rules or data. Called from extracted game command.
function Game:dump(what)
    if what == 'rules' then
        self.interpreter:dumpRules()
    elseif what == 'data' then
        self.interpreter:dumpData()
    end
end

local function printLine(line)
    io.stdout:write('  ', line, '\r\n')
end

local WRAP_COL = 70
local SPACE = 32
local BREAK = 10

-- Print a message.
-- Use word wrap and markdown-style line breaks and whitespace.
-- Called from extracted game command.
function Game:print(message)
    message = (message or ''):gsub('\r', '')
        :gsub('^.', string.upper):gsub('\t', ' ')
        :gsub('\n +', '\n'):gsub('\n\n+', '\r')
        :gsub('\n', ' '):gsub('\r', '\n')
        :gsub(' +', ' '):gsub('^ +', '')
        
    printLine('')
    while true do
        for i = 1, math.min(#message, WRAP_COL) do
            if message:byte(i) == BREAK then
                printLine(message:sub(1, i))
                message = message:sub(i + 1, -1)
                i = 1
            end
        end
        if #message < WRAP_COL then
            printLine(message)
            return
        end
        local i = WRAP_COL
        while i > 0 and message:byte(i) ~= SPACE do
            i = i - 1
        end
        printLine(message:sub(1, i))
        message = message:sub(i + 1, -1)
    end
end

-- Save game. Called from extracted game command.
function Game:save(name)
    local path = name .. '.save'
    local file = io.open(path, 'w')
    if file then
        local data = self.interpreter.data
        for i = 1, #data do
            file:write('"', table.concat(data[i].value, '" "'), '"\n')
        end
        self:print('Game "' .. name .. '" saved.')
    else
        self:print('Oops, save failed!')
    end
end

-- Load game. Called from extracted game command.
function Game:load(name)
    local path = name .. '.save'
    local file = io.open(path)
    if file then
        self.interpreter.data = {}
        self.interpreter:loadFile(file)
        self:print('Game "' .. name .. '" loaded.')
        self:issuePlayerCommand('look')
    else
        self:print('Game "' .. name .. '" not found.')
    end
end

-- Quit game. Called from extracted game command.
function Game:quit()
    self.gameOver = true
end

-- Exctract, execute, and remove game commands.
function Game:invokeCommands()
    local _, binds, i = self.interpreter:matchRule(self.commandRule)
    while binds do
        table.remove(self.interpreter.data, i)
        local args = binds['@args']
        self[binds['$name']](self, args[1], args[2], args[3])
        _, binds, i = self.interpreter:matchRule(self.commandRule)
    end
end

-- Handle player input, then apply rules and extract game commands.
-- Player input is converted to a tuple and appended to the dataset.
-- A `you` is inserted at the front of the tuple to prevent cheating.
function Game:issuePlayerCommand(text)
    self.interpreter:createData('you ' .. (text or ''))
    self.interpreter:applyRules()
    self:invokeCommands()
end

-- Warmup and main loop.
function Game:run()
    -- Apply rules first to print title screen info, etc.
    -- Wait for player to hit enter; discarding input...
    self.interpreter:applyRules()
    self:invokeCommands()
    io.stdout:write('\r\n  (press enter) ')
    io.stdin:read()
    
    -- ...then issue a "look" command to show the first room.
    self:issuePlayerCommand('look')
    
    -- Main loop.
    while not self.gameOver do
        io.stdout:write('\r\n> ')
        self:issuePlayerCommand(io.stdin:read())
    end
    io.stdout:write('\r\n')
end

-- Load and run the game specified on command line, or "cloak."
Game((...) or 'cloak.rife'):run()



interpreter.lua

The language interpreter. Loaded by rife.lua.

-- RIFE: a Rule-based Interactive Fiction Interpreter
-- Language interpreter

local _ -- Dummy variable

-- Simple class implementation.
local classMeta = {
    __call = function(proto, ...)
        local object = setmetatable({}, { __index = proto })
        if object.init then object:init(...) end
        return object
    end
}

local function Class()
    return setmetatable({}, classMeta)
end

-- "Parser" helper functions

local sentinelPattern = '~`%s`~'
local sentinelsBySymbol = {}
local symbolsBySentinel = {}
local sentinelMatcher = sentinelPattern:format('%d+')
local symbolMatcher

local function defineSentinels(t)
    for i = 1, #t do
        local sentinel = sentinelPattern:format(i)
        local symbol = t[i]
        sentinelsBySymbol[symbol] = sentinel
        symbolsBySentinel[sentinel] = symbol
    end
    symbolMatcher = ('[%s]'):format(table.concat(t))
end

defineSentinels { ' ', ',', ';', '?', '.' }

local function escapeCb(text)
    return text:gsub(symbolMatcher, sentinelsBySymbol)
end

local function unescapeCb(text)
    return text:sub(2, -2):gsub(sentinelMatcher, symbolsBySentinel)
end

local function escape(text)
    return text:gsub('%b""', escapeCb)
end

local function unescape(text)
    return text:gsub('%b""', unescapeCb)
end

local function split(text, type)
    local r = { type = type }

    for word in text:gmatch('[^ ]+') do
        r[#r + 1] = unescape(word)
    end
    return r
end

-- Check if some text represents a scalar variable
local function isScalarVariable(text)
    return text:find('^%$[%w_]+$') and true or false
end

-- Check if some text represents a list variable
local function isListVariable(text)
    return text:find('^@[%w_]+$') and true or false
end

-- Get indices for table.concat, using rules like string.sub
local function tableSubBounds(t, i, j)
    local n = #t
    i, j = tonumber(i) or 1, tonumber(j) or n
    if i < 0 then i = n + 1 + i end
    if j < 0 then j = n + 1 + j end
    if i < 1 then i = 1 end
    if j > n then j = n end
    return i, j
end

-- Like table.concat, but support negative indices
-- and correct out-of-bounds indices like string.sub
local function tableSub(t, sep, i, j)
    i, j = tableSubBounds(t, i, j)
    if i > j then return '' end
    
    return table.concat(t, sep, i, j)
end

-- Expand all variables in some text, using values in `binds`
local function expandVariables(text, binds)
    return text
        -- variable count or index
        :gsub('([@$][%w_]+)(#)(%d*)', function(m, num, index)
            local value = binds[m]
            if not value then return '' end
            local n = #value
            if index == '' then return n end
            if type(value) == 'table' then
                return value[index]
            else
                return value:sub(index, index)
            end
        end)
        -- convert to unary
        :gsub('(%$[%w_]+)%*(.)', function(m, char)
            local value = tonumber(binds[m]:match('^%d+$'))
            if not value then return '' end
            return char:rep(value)
        end)
        -- variable, optionally trimmed
        :gsub('([@$][%w_]+)([+-]?%d*)([+-]?%d*)', function(m, from, to)
            local value = binds[m]
            if not value then return '' end
            if type(value) == 'table' then
                return tableSub(value, '', from, to)
            else
                return value:sub(tonumber(from) or 1, tonumber(to) or -1)
            end
        end)
end

-- Concatenate line of text to a field of table `t`.
local function appendFieldText(t, field, text)
    t[field] = (t[field] and (t[field] .. '\n') or '') .. text
end

-- Rule class.
-- A rule represents a possible state transformation of the program data.
-- It has two parts, a query and a result. The query part is used to
-- check if a rule should be applied to the current program data, and to
-- determine any data that should be removed on successful application.
-- The result part represents new data produced on successful application.
local Rule = Class()

function Rule:init()
    self.query = {}
    self.result = {}
    self.hasReagents = false
end

function Rule:isEmpty()
    return #self.query == 0 and #self.result == 0
end

-- Parse a line of text representing part of a query.
-- Update this rule with the extracted information.
function Rule:parseQueryLine(text)
    appendFieldText(self, 'queryText', text)
    for tuple, terminator in escape(text):gmatch('([^,;?]+)(.?)') do
        if terminator == ',' then
            self:addReactant(tuple)
        elseif terminator == ';' then
            self:addReagent(tuple)
        elseif terminator == '?' then
            self:addCatalyst(tuple)
        else
            error('Invalid rule: ' .. text)
        end
    end
end

-- Parse a line of text representing part of a result.
-- Update this rule with the extracted information.
function Rule:parseResultLine(text)
    appendFieldText(self, 'resultText', text)
    for tuple in escape(text):gmatch('[^.]+') do
        self:addProduct(tuple)
    end
end

-- Check query for list matcher; store index if it exists.
-- Store default values for variables.
-- Throw error if multiple list matchers exist.
-- Throw error if default value appears twice for same variable.
local function prepareQuery(query)
    local listMatcherCount = 0
    query.default = {}
    for i = 1, #query do
        if isListVariable(query[i]) then
            listMatcherCount = listMatcherCount + 1
            query.listMatcherIndex = i
        end

        if listMatcherCount > 1 then
            error("Multiple list matchers in query:\n" ..
                table.concat(query, ' '))
        end
        
        local var, default = query[i]:match('^(%$[%w_]+)|(.*)$')
        if default then
            if query.default[var] then
                error("Default value appears twice for same variable:\n" ..
                    table.concat(query, ' '))
            end
            query.default[var] = default
            query[i] = var
            query.hasDefaults = true
        end
    end
    return query
end

-- Extract a "reactant" from `text`, and add it to the rule's query part.
-- Reactants are parts of a query which are removed from the dataset
-- immediately during the successful application of a rule.
function Rule:addReactant(text)
    self.query[#self.query + 1] = prepareQuery(split(text, 'reactant'))
end

-- Extract a "reagent" from `text`, and add it to the rule's query part.
-- Reagents are parts of a query which are removed from the dataset
-- after a rule is fully applied.
function Rule:addReagent(text)
    self.query[#self.query + 1] = prepareQuery(split(text, 'reagent'))
    self.hasReagents = true
end

-- Extract a "catalyst" from `text`, and add it to the rule's query part.
-- Catalysts are parts of a query which are not removed from the dataset
-- when a rule is sucessfully applied.
function Rule:addCatalyst(text)
    self.query[#self.query + 1] = prepareQuery(split(text, 'catalyst'))
end

-- Extract a "product" from `text`, and add it to the rule's result part.
-- Products represent new tuples to be appended to the dataset
-- when a rule is sucessfully applied.
function Rule:addProduct(text)
    self.result[#self.result + 1] = split(text, 'data')
end


-- Data class.
-- A tuple representing a single datapoint in the program data.
-- Used for statements appearing in the program, and for
-- products resulting from the successful application of rules.
local Data = Class()

function Data:init(value)
    self.value = value
end

function Data:isEmpty()
    return #self.value == 0
end

-- Expand all variables in the data, based on the values in `binds`.
-- Expansion of list matcher variables may result in a change in the number
-- of elements in the resulting tuple.
function Data:expandVariables(binds)
    local oldValue = self.value
    self.value = {}
    local k = 0
    for i = 1, #oldValue do
        local m, from, to = oldValue[i]
            :match('^(@[%w_]+)([+-]?%d*)([+-]?%d*)$')
        if m and binds[m] then
            -- list matcher, single element
            from, to = tableSubBounds(binds[m], from, to)
            for j = from, to do
                k = k + 1
                table.insert(self.value, k, binds[m][j])
            end
        else
            -- normal element
            k = k + 1
            self.value[k] = expandVariables(oldValue[i], binds)
        end
    end
    return self
end

-- Parse a line of text containing data statements.
-- Extract each statement and create a new data tuple from it.
-- Return a list of extracted statements.
function Data.parseLine(text)
    local statements = {}
    for statement in escape(text):gmatch('[^.]+') do
        local data = Data(split(statement, 'data'))
        statements[#statements + 1] = data
    end
    return statements
end

-- Interpreter class. Loads and runs code written in our DSL.
local Interpreter = Class()

function Interpreter:init()
    self.rule = {}
    self.data = {}
    self.debugLevel = 0
end

-- Load and parse a program given a file path.
-- Populates the list of rules and initial program dataset.
function Interpreter:loadPath(path)
    local file, message = io.open(path, 'r')
    if not file then return nil, message end
    self:loadFile(file)
    return true
end

-- Load and parse a program given a valid file handle.
-- Populates the list of rules and initial program dataset.
function Interpreter:loadFile(file)
    local section = 'rules'
    local part = 'query'
    local currentRule = Rule()

    -- Very quick and dirty line-by-line parsing.
    local line = file:read('*l')
    while line do
        line = line:gsub('\r', '')
        -- while line has unbalanced quotes, append next line
        -- (allows multi-line quoted phrases).
        while select(2, line:gsub('"', {})) % 2 == 1 do
            line = line .. '\n' .. file:read('*l')
        end
        
        -- preprocessor directive
        if line:find('^%s*%[') then
            self:addRule(currentRule)
            currentRule = Rule()
            local command, arg = line:match('^%s*%[([%w_]+)%s+(.*)]%s*$')
            if command == 'load' then
                assert(self:loadPath(arg .. '.rife'))
            else
                error('Bad preprocessor directive: ' .. line)
            end
        -- ignore comment
        elseif line:find('^%s*#') then
        -- ignore blank line
        elseif line:find('^%s*$') then
        -- query parts have special terminators
        elseif line:find('[,;?]%s*$') then
            if part ~= 'query' then
                self:addRule(currentRule)
                currentRule = Rule()
            end
            part = 'query'
            currentRule:parseQueryLine(line)
        -- result parts have leading whitespace
        elseif line:find('^%s') then
            part = 'result'
            currentRule:parseResultLine(line)
        -- anything else is a statement
        else
            if part ~= 'data' then
                self:addRule(currentRule)
                currentRule = Rule()
            end
            part = 'data'
            local statements = Data.parseLine(line)
            for i = 1, #statements do
                self:addData(statements[i])
            end
        end
            
        line = file:read('*l')
    end
    
    self:addRule(currentRule)
end

-- Add a rule to the list of rules.
-- Called as program is being loaded.
function Interpreter:addRule(rule)
    if rule:isEmpty() then return rule end
    self.rule[#self.rule + 1] = rule
    return rule
end

-- Add a data statement to the current dataset.
-- Called as program is being loaded; may be called from host application.
function Interpreter:addData(data)
    if data:isEmpty() then return data end
    self.data[#self.data + 1] = data
    return data
end

-- Convert text to a data statement and add it to the current dataset.
-- May be called from host application.
function Interpreter:createData(text)
    local data = Data()
    data.value = split(escape(text), 'data')
    self:addData(data)
    return data
end

-- Debug utility; dump all rules.
function Interpreter:dumpRules()
    for k, v in ipairs(self.rule) do
        print('')
        for k, v in ipairs(v.query) do
            print(v.type .. ' ', table.concat(v, ' / '))
        end
        for k, v in ipairs(v.result) do
            print('produces ', table.concat(v, ' / '))
        end
    end
end

-- Debug utility; dump all data.
function Interpreter:dumpData()
    for k, v in ipairs(self.data) do
        print(table.concat(v.value, ' / '))
    end
end

-- Apply a single query to a single data statement.
-- Update binds as free variables are resolved.
-- Return true if query matches statement, else false.
local function matchQuery(query, binds, statement)
    
    local countDiff = #statement.value - #query
    
    -- Query and statement tuples must have same element count to match
    -- when query doesn't have a list matcher.
    if not query.listMatcherIndex and countDiff ~= 0 then return false end
    
    -- When query has a list matcher, can have at most statement-1 elements
    -- (-1 allows matching empty list).
    if countDiff < -1 then return false end

    local j = 0
    for i = 1, #query do
        j = j + 1
        -- list matcher free variable
        if query.listMatcherIndex == i and not binds[query[i]] then
            local t = {}
            for k = 1, countDiff + 1 do
                t[k] = statement.value[j]
                j = j + 1
            end
            j = j - 1
            binds[query[i]] = t
        -- bound list matcher
        elseif query.listMatcherIndex == i then
            for k = 1, countDiff + 1 do
                if binds[query[i]][k] ~= statement.value[j] then
                    return false
                end
                j = j + 1
            end
            j = j - 1
        -- scalar free variable
        elseif isScalarVariable(query[i]) and not binds[query[i]] then
            binds[query[i]] = statement.value[j]
        -- fail if not matched after variable expansion
        elseif expandVariables(query[i], binds) ~= statement.value[j] then
            return false
        end
    end

    return true
end

-- Test a single query against the entire program dataset.
-- If a match is found, return the program data offset and new binds.
-- Otherwise, return nil and the current binds.
function Interpreter:query(query, binds, offset)
    local bindsMeta = { __index = binds }

    for i = offset or 1, #self.data do
        local statement = self.data[i]
        local newBinds = setmetatable({}, bindsMeta)
        local matched = matchQuery(query, newBinds, statement)

        if matched then return i, newBinds end
    end

    return nil, binds
end

function Interpreter:queryDefault(query, binds)
    local newBinds = setmetatable({}, { __index = binds })

    for i = 1, #query do
        local default = query.default[query[i]]
        -- list matcher free variable
        if query.listMatcherIndex == i and not newBinds[query[i]] then
            if default then
                newBinds[query[i]] = default
            else
                return nil, binds
            end
        -- bound list matcher
        elseif query.listMatcherIndex == i then
        -- scalar free variable
        elseif isScalarVariable(query[i]) and not newBinds[query[i]] then
            if default then
                newBinds[query[i]] = default
            else
                return nil, binds
            end
        end
    end
    return 0, newBinds
end

-- Test a rule against the dataset. Calls Interpreter:query.
-- If the rule matches, return the binds, matched data, and data index.
-- Otherwise, return false.
function Interpreter:matchRule(rule)
    local dataIndex
    local binds = {}
    local matchedIndices = {}
    local matchedData = {}
    local offset = 1

    local i = 1
    while i <= #rule.query do
        local query = rule.query[i]
        dataIndex, binds = self:query(query, binds, offset)
        if not dataIndex and query.hasDefaults then
            dataIndex, binds = self:queryDefault(query, binds)
        end
        offset = 1
        if dataIndex then
            matchedIndices[i] = dataIndex
            matchedData[i] = self.data[dataIndex]
        else
            -- backtracked all the way, nothing matched
            if i <= 1 then return false end

            -- backtrack
            local m = getmetatable(binds)
            binds = m and m.__index or binds
            i = i - 1
            offset = matchedIndices[i] + 1
            matchedIndices[i] = 0
            i = i - 1
        end
        i = i + 1
    end

    if #matchedIndices == #rule.query then
        _ = self.debugLevel >= 1 and print('query matched: ', rule.queryText)
        return matchedData, binds, dataIndex
    end

    _ = self.debugLevel >= 1 and print 'no query result.'
    _ = self.debugLevel >= 1 and print(lastQueryPart, lastDataMatch)
    return false
end

-- Removes matched reagent data after applying a rule.
function Interpreter:removeReagents(matchedData)
    for i = #self.data, 1, -1 do
        for j = 1, #matchedData do
            if matchedData[j] == self.data[i] then
                _ = self.debugLevel >= 1 and print('data modified:',
                    '---', table.concat(self.data[i].value, ' / '))
                table.remove(self.data, i)
                break
            end
        end
    end
end

local function shouldRemoveType(queryType, removeCatalysts)
    if removeCatalysts then
        return queryType ~= 'reagent'
    else
        return queryType == 'reactant'
    end
end

-- Finalizes a matched rule, and appends new result data.
-- Move data matching catalysts to the back if rule includes reagents.
function Interpreter:finalizeRule(rule, binds, matchedData)
    -- remove reactants, and catalysts if we're moving those.
    for i = #self.data, 1, -1 do
        for j = 1, #matchedData do
            if matchedData[j] == self.data[i]
            and shouldRemoveType(rule.query[j].type, rule.hasReagents) then
                _ = self.debugLevel >= 1 and print('data modified:',
                    '---', table.concat(self.data[i].value, ' / '))
                table.remove(self.data, i)
                break
            end
        end
    end

    -- move catalysts to back of dataset if rule has reagents.
    if rule.hasReagents then
        for i = 1, #rule.query do
            if rule.query[i].type == 'catalyst' then
                self:addData(matchedData[i])
                _ = self.debugLevel >= 1 and print('data shuffled:',
                    '>>>', table.concat(matchedData[i].value, ' / '))
            end
        end
    end

    -- create data statements from products of query result,
    -- expanding any variables, and append them to the dataset.
    for i = 1, #rule.result do
        local data = Data(rule.result[i]):expandVariables(binds)
        self:addData(data)
        _ = self.debugLevel >= 1 and print('data modified:',
            '+++', table.concat(data.value, ' / '))
    end
end


-- Check if two lists of datapoints are similar, based on a query.
-- This is used to determine whether a query with reagents has matched
-- all possible catalysts and wrapped back around to the first match.
-- If two datapoints at the same index in each list are different, and
-- the part of the query at that index is a catalyst, they're dissimilar.
-- Otherwise, they're similar.
local function compareDataLists(a, b, query)
    if not (a and b) then return false end
    if #a ~= #b then return false end
    for i = 1, #a do
        if query[i].type == 'catalyst' and a[i] ~= b[i] then
            return false
        end
    end
    return true
end

-- Build a list of datapoints that were matched by a reagent,
-- so they can be removed after applying a rule.
local function collectReagentData(rule, matchedData, reagentData)
    for i = 1, #matchedData do
        if rule.query[i].type == 'reagent' then
            reagentData[#reagentData + 1] = matchedData[i]
        end
    end
    return reagentData
end

function Interpreter:applyRuleWithReagent(rule)
    local reagentData = {}
    local matchedData, binds = self:matchRule(rule)
    local first = matchedData
    while matchedData do
        collectReagentData(rule, matchedData, reagentData)
        self:finalizeRule(rule, binds, matchedData)
        matchedData, binds = self:matchRule(rule)
        -- IMPORTANT: If the first match shows up again, stop now.
        -- This makes iteration like `inventory` command possible,
        -- and greatly simplifies commands like `drop all`.
        -- This is why catalyst matches are shuffled to the back!
        if matchedData and compareDataLists(matchedData, first, rule.query) then
            self:removeReagents(reagentData)
            return true
        end
    end
    if first then
        self:removeReagents(reagentData)
        return true
    end
    return false
end

function Interpreter:applyRuleWithoutReagent(rule)
    local matchedData, binds = self:matchRule(rule)
    if not matchedData then return false end
    --while rule do
    --    self:finalizeRule(rule, binds, matchedData)
    --    rule, binds, matchedData = self:matchRule(rule)
    --end
    self:finalizeRule(rule, binds, matchedData)
    return true
end

-- Apply the first rule that matches.
-- Returns true if a rule matched. Otherwise, return false.
function Interpreter:applyFirstMatchingRule()
    for i = 1, #self.rule do
        local rule = self.rule[i]
        if rule.hasReagents then
            if self:applyRuleWithReagent(rule) then return true end
        else
            if self:applyRuleWithoutReagent(rule) then return true end
        end
    end
    return false
end

-- Apply all applicable rules. Repeat until no more rules can be applied.
function Interpreter:applyRules()
    while self:applyFirstMatchingRule() do end
end

Interpreter.Class = Class
Interpreter.Rule = Rule
Interpreter.Data = Data

return Interpreter

1 Like

This looks cool! I’m particularly interested in where you go with the parser. From what I understand here, the parser is currently grabbing a command with an @act variable?

What many people will tell you, and it’s good advice, is that one of the best things you can do to spur interest in your system is to write a good game in it that people will get to see. (I confess I find the download lua/run from the command line thing kind of intimidating for starters, so one other thing that might help–when you’re ready–might be coming up with a simpler way to package things?)

1 Like

The very last part of that “program execution” section describes how IO is handled:

Basically whatever the user typed is placed into the program dataset with “you” on the front… so a new tuple like you/look/at/troll is created, and then the rules in core.rife have their way with it. A similar thing happens for output and other “special” commands like “quit,” but in reverse.

To clarify that a bit, there are two parts to the thing I posted here: the “interpreter” part is just an interpreter for the language and doesn’t know anything about IF, and also doesn’t handle IO. The “game” part (referred to as “host application” in OP) takes the input and feeds it into the program dataset, then runs the interpreter, then when the interpreter is finished doing whatever it’s gonna do, control is returned to the “game” part, it inspects the dataset to see if anything represents output it should display or commands it should execute; if so it extracts that stuff from the dataset and does its thing, then rinse and repeat.

The interesting thing about this is the language doesn’t have any special keywords at all. Any meaning given to words is either (1) written in the language itself, either in the “core” or in the script for an individual game, or (2) dealt with at the “host application” level, where the program state is examined in between interpreter runs (the latter case is used sparingly, for stuff like output or quitting the game). The only things in the language that have intrinsic meaning are punctuation, spaces between words, line breaks and indentation (presence or lack of it, no multiple levels).

Also, I’m blown away to get such a quick reply. I should have posted this here to begin with. It just sat there collecting dust everywhere else.

I have this up on gitlab, could update and make it public, but not sure if that’s any better for distribution. Will have to think about that, maybe can package it up with a decent console for Windows. Another idea might be porting the interpreter to JavaScript and setting it up as an online thing for people to play with.

2 Likes

This is an interesting approach. A bit memory-hungry for my personal taste, but fascinating and very data-driven.

Is there a way to react to the absence of a statement? For instance (using ~ to illustrate):

you take $x, ~ $x is in scope?
	say "you don't see that here"

How do you deal with backtracking? Skimming the interpreter code, it looks like you’ve got something in there, although I could be mistaken. Does the engine handle a situation like the following:

milk is in fridge
apple is in fridge

$x is in $y, you take $x,
	player has $x. say "You take the $x out of the $y."

you take apple

For complex evaluation, you introduce statements that represent subgoals. Have you attempted recursion? Recursion turns up a lot when dealing with nested objects, e.g. to determine whether the apple is reachable given that the apple is in the fridge, the fridge is in the kitchen, and the player is in the kitchen. But also for things like reversing a list.

Representing integers in unary seems a bit… inefficient. And how would you e.g. decrease the current score?

2 Likes

@lft thanks for the reply! You’ve hit a few things that have been bugging me right on the head.

There isn’t. I feel like there should be, but haven’t thought of any good syntax. I may try it with tilde and see how it goes.

Since priority is top-down, this can be worked around by first handling the condition where something does appear, then handling a similar condition without the check for the the thing that’s expected to be missing. A negation operator would be an improvement.

It’s a bit hard to describe how it’s handled exactly, but the example you wrote there will work like you expect. I make use of Lua’s prototype-based inheritance to store each level of variable bindings, and backtracking works by throwing out the “child” binds object when something fails to match, and trying to match the next thing with its “parent.” Backtracking is by far the most involved part of this thing.

I haven’t. I think I know what you mean but I’m not sure how it would look. I’d love to hear your ideas if you have time to sketch up an example of how it might look (in the language, I mean).

Edit: I may have misunderstood what you meant here. Recursion is currently possible; see the multiplication example linked towards the bottom of this post. But there’s no special syntax for recursion in general (there’s the semicolon terminator, but that’s sort of a special case).

Yeah, that was a tough choice. One of the goals was to make implementation of the interpreter dead-simple, so I didn’t want to include a bunch of math operators and things. I figure decimal-to-unary and unary-to-decimal might be enough, but not sure.

For decrementing a unary tally, $var+2 expands to all characters from the second character in $var ; it trims off the first character (from Variable Expansion section in OP).

I’ve managed to write a routine to multiply two signed integers; it’s about 75 lines long. The plan here is, if we can do math somehow, no matter how ugly and slow, then we can include it as a baseline implementation, for compatibility, and then interpreters can replace the implementation with interpreter extensions behind the same interface (sometimes called “jets”).

I’m definitely open to alternate suggestions here. Unary math is awkward as hell, but I still want to keep implementation simple and be able to do as much as possible in the language itself (even if it’s rendered moot by interpreter extensions later).

Thanks for taking a look!

(Oh man… I just found your Dialog language. Inspired by Inform7 and Prolog. Finally I am in the right place.)

1 Like

My guess is that the online thing would be more accessible than the Windows console. I don’t want to make any promises, because I’m probably not going to be able to tinker with any new languages anytime soon anyway, but I use a Mac and wouldn’t be able to do the Windows thing. Although if the Javascript port is a lot of extra effort you might not want to do that first!

2 Likes

Yeah, a JS version would definitely be more accessible. What’s there now would run in your Mac terminal, but you’d still need a copy of Lua, and they’re not exactly known for making binary distributions very visible.

I don’t think it’ll be much effort, the languages are similar enough that I could probably do a straight port. The JS version might also be easier for people to grok.

I don’t know if there’s any particular reason to use this language over anything else. Right now, there’s almost certainly not (very WIP, no debug tools, etc.). But something about the minimalist nature of it appeals to me; I’ve always been interested in the idea of creating a language with a bare minimum of syntax, and seeing if it’s possible to build something expressive and useful out of it. I feel like it could get there eventually, maybe with a few small tweaks.

It’s cool that it works, but that multiplication routine isn’t exactly easy to follow. In a sense you’re trading readability of code written in your language for readability of the language definition.

There’s nothing inherently bad about this. Minimal languages are easier to analyze, in case you want to prove theorems about them, and they are intellectually interesting in their own right.

But if you want to make a language that’s useful in practice, one that people might want to author a story in (which is kind of suggested by the term DSL), then I believe one of your main jobs is to hide complexity from the programmer, by incorporating it into language primitives.

Striking a balance between these two concerns is also an interesting—and very creative—challenge.

1 Like

Very good points. The only thing I’m not sure about is whether complexity needs to be hidden by incorporating it into language primitives. I’m hoping that it can be hidden just as well by writing a standard library of sorts – something more low-level than a typical standard library, including what would usually be language primitives.

So for example with that multiplication routine we can write this:

multiply 3 and -16 as foo

result foo is $n,
   say "3 * -16 = $n"

We can multiply stuff and not care about the complexity under the hood, or where exactly it’s implemented.

Supposing the all of the usual arithmetic ops are implemented in similar fashion, a routine could be built on top of these to evaluate arbitrary calculations. It would be easiest to do this in postfix notation (RPN), iteratively replacing the first three values in a list of operations with the result of the calculation, until only one value remains.

# in infix notation: (1 + 2) * 3 / 9

calculate 1 2 + 3 * 9 / as foo

result foo is $n,
   say "result of calculation is $n"

Here’s what I’m not sure about, though. This would be good enough for me, but are IF authors generally going to be comfortable with postfix notation? Do they need to perform calculations at all?

My gut feeling is that the most important thing is to be able to do the typical IF stuff easily, and to override baked-in behaviors easily, and beyond that, any out-of-the-box stuff should be possible somehow, but hopefully I’d be forgiven if it’s not exactly straightforward. So that’s part of the challenge; what kind of stuff should the standard library anticipate the need for, and to what degree. I may look at TADS for inspiration here, since they seem to have anticipated just about everything under the sun.


To circle back around to the “jets” concept – the idea there is that some facility could be provided for doing things in the host language. Let’s say everything we’re discussing now is in a file called “math.” An interpreter written in JS could include both the standard “math,” written in the language as it is here, and a special “math” that does everything in JS (probably through eval). For example, maybe the implementation uses @ to signal some JS code to be evaluated, rather than a normal product of the rules:

multiply $a and $b as $c,
    @ appendData('result', $c, 'is', $a * $b);

In this way, I’m hoping to get the best of both worlds… multiplication is either written in the language, or it’s (closer to) a language primitive, depending on which “math” you include. Either way, the interface is the same.

This is easy in languages like Lua and JS. In not-interpreted languages, it’s harder, but maybe still possible (for example things like CExEv exist, for this particular case).

Of course, the other advantage of host-language evaluation is that authors could simply “cop out” and do harder things in the host language, at the expense of portability.


Unrelated, but I don’t want to add a new post for this: I’ve implemented your negation operator using the syntax you suggested (only added about 4 LOC). I think this will be immensely useful and am looking at rewriting parts of the “core” now. For example the “global ground” hack was basically a workaround for not having a decent way to single out things that were not in containers. My solution was to have “ground everywhere” as a catch-all container, so that everything could always be in a container. With this negation operator, I think I can scrap that hack now.