Skip to content

Fenia Language

Kit Oliynyk edited this page Nov 2, 2026 · 2 revisions

TL;DR: Fenia is an untyped, JavaScript-like language. A script is one expression. It has six value types, C-style for, for ... in, try/catch, and first-class functions. It has no while, no ternary, no ++ or +=, and no floats. Use when: you write, review or generate Fenia code, or need to know whether a construct parses and what it does at runtime. Source of truth: dreamland_code/src/fenia/scan.l++ (tokens), parse.y++ (grammar), register-impl.h (operators, casts), stmt-tree.cpp, scope.cpp, closure.cpp, plug-ins/feniaroot/feniastringext.cpp, regcontainerext.cpp, reglistext.cpp Verified: 2026-11-02 against dreamland_code 88da8302, dreamland_world 43880467

Everything on this page was read from the parser and the interpreter source unless marked (measured live ...) or (unverified). The formal grammar is on Fenia-Grammar. Runtime traps that come out of these rules are collected on Fenia-Gotchas.

A script is one expression

The grammar entry rule is input: exp. A file posted with cs post or cs load, and a line typed after eval, must each be exactly one expression. Statements only exist inside a function body.

  • Wrap anything with more than one statement in .apply(function() { ... }).
  • Put no ; after the closing }). A trailing ; is a parse error and the whole file is rejected. The previous version keeps running and nothing tells the player or the log reader why.
  • Inside the function body every statement ends with ;, including the last one.
  • A top-level { ... } block is not an expression and does not parse.
.apply(function() {
    .get_mob_index(3090).onSpec = function(mob) {
        mob.interpret("emote purrs.");
    };
})

A single assignment is already one expression, so a file that holds only .get_mob_index(3090).onSpec = function(mob) { ... } also works without .apply.

eval <expression> runs one expression with this bound to the acting character. Assignments work (eval hit = max_hit). var, statements and a trailing ; do not.

Value types

The interpreter calls a value a register. A register has one of six types.

Type Holds Notes
NONE nothing The value of null, of a missing key, of an unset variable, of a function without return.
NUMBER 32-bit signed integer No floats. true is 1, false is 0.
STRING byte string Single or double quotes. Methods are listed below.
OBJECT a handle to an engine or script object Characters, rooms, items, .List(), .Array(), .Map(), affects, and so on. Compared by identity.
FUNCTION a closure First-class. Can be stored in fields, passed and returned.
IDENTIFIER an interned name Used internally as a key type. Scripts rarely see it.

Lexical rules

  • Identifiers start with a Latin letter, an underscore or a Cyrillic letter, and continue with those plus digits. Cyrillic identifiers are legal.
  • Numbers are decimal digits only. The lexer calls atoi, so a literal above 2147483647 silently truncates. Long ids (14 digits) cannot be written as numbers, keep them as strings.
  • Keywords: null if in else for break continue return function var try catch throw, plus this, true and false. There is no while, do, switch, case, new or typeof.
  • Strings use '...' or "...". The escapes are \n, \r, \t. Any other \x yields x. A literal newline inside a string is allowed. Two string literals written next to each other are joined into one.
  • Comments: // to end of line and /* ... */. Block comments nest.
  • Stray characters such as ?, :, #, @, $ and a backtick outside strings and comments are a lexer error (unexpected char).
  • Encoding: script text is converted from UTF-8 to KOI8-U when it is loaded. Stay with ASCII and Cyrillic. A character with no KOI8-U form, an emoji for example, made a whole file fail to post even when it sat in a comment (measured live).
  • Operators and punctuation: + - * / % & | ^ ! ~ < > = == != -> && || . , ; ( ) { } [ ]. >= and <= are lexed as two tokens, so > = with a space is also accepted.

Statements

Form Notes
var a, b; Declares names, value NONE. No initializer: var x = 5; does not parse. Write var x; x = 5;.
if (cond) stmt / if (cond) stmt else stmt No else if keyword, nest an if after else.
for (init; cond; step) stmt init and step are comma-separated expressions, var is not allowed in init. cond may be empty. It is re-evaluated each iteration.
for (name in expr) stmt Calls expr.iterator(), which lists have. To walk an .Array() use a.keys or a.values, for a .Map() use m.fieldKeys. name needs no var.
break; continue; continue in a C-style for still runs the step expression.
return; return expr; A function that falls off the end returns NONE.
throw expr; Throws the value as a message.
try stmt catch (e) stmt e is the message as a STRING.
{ ... } Block. Opens a new scope.
expr; Expression statement. A call needs the ;.

Operators

From lowest to highest precedence. Within a row, grouping is left to right unless noted.

Operators Meaning
-> Lambda, see Functions.
= Assignment. Right to left. a = b = 0 works. if (a = b) assigns and tests, it is not a comparison.
&& || Logical, short-circuit, result is 1 or 0. See the note below on how they group.
| Bitwise or.
^ Bitwise xor.
& Bitwise and.
== != Equality.
< > <= >= Order. Defined for numbers and strings only.
+ - Add, subtract.
* / % Multiply, integer divide, remainder. Division or remainder by zero throws.
! ~ - Unary not, bitwise not, minus.
.name [expr] (args) Member, index, call.

Operator behaviour by type, from register-impl.h:

  • + looks at the left operand. NUMBER + x converts x to a number. STRING + x converts x to a string and concatenates. Anything else throws. So "2" + 2 is "22" and 2 + "2" is 4. A string that does not start with digits throws when converted to a number ("abc", ""). "12abc" converts to 12.
  • - * / % & | ^ need a NUMBER on the left, otherwise they throw Operation ... is not defined.
  • == and !=: if either side is NONE, the result only compares the two types. Numbers compare numerically, strings by bytes, objects and functions by identity. A NUMBER compared with a STRING converts the right side to the type of the left one.
  • ! and conditions use truthiness, see below.

&& and || do not group like C. The grammar gives the one-character tokens | and & a higher precedence than the logical rules, so a chain groups to the right: a && b || c parses as a && (b || c), and a | b && c as a | (b && c). This comes from replaying the precedence table of parse.y++ in an LALR parser, not from running the engine (unverified on the engine). Always parenthesize mixed && and ||.

~: bitwise not, as in C. ~4 is -5 and 12 & ~4 is 8 (measured live 2026-11-02), so flags & ~bit clears a bit. .unset_bit(mask, bit) does the same and reads clearer.

Truthiness

A condition converts its value to a boolean. Only two types convert: a NUMBER is true when it is not 0, and a STRING is true when it is not empty. NONE, OBJECT and FUNCTION throw Invalid cast: expected 'boolean'. So if (obj) throws when obj is null and also when it is a live object.

Write explicit comparisons: if (obj != null), if (obj == null). Truthiness is safe on comparisons, .is_npc(), and other calls that return a number.

Scope and this

  • Each function call starts a fresh root scope. The scope holds the parameters, this, and the var names declared in it. It does not hold the caller's locals.
  • Each { } block, and each for ... in loop, opens a nested scope. A var inside a block is not visible after it.
  • Redeclaring a var in a loop body resets it to NONE on each pass.
  • Reading a name nobody declared looks for a field of this with that name. It gives NONE on a character, item, room or .Map() without that field, so a typo in a variable name is silent there. Engine objects such as the spell context throw Get method not found instead. If this is NONE, as in the body of a bare .apply(function() { ... }), the read throws.
  • Assigning a name nobody declared writes a field on this. Inside a trigger that is the mob, item or room, and the field persists in the Fenia database. Declare every local with var.
  • this is a local. You can read it, pass it, or assign to it. Helper files use this = .tmp.system; and then write sleep = function(t) { ... }; to define fields on that map.
  • Inside spell and skill hooks, this is a context object. The names ch, vict, level and so on resolve through the same field fallback. See Fenia-Skills-and-Spells.
  • origin is the object a top-level expression was evaluated against. In eval it is the acting character.
  • A leading . names a field of the root object: .get_mob_index(3090), .tables.item_table.furniture, .tmp.

Functions

var add;
add = function(a, b) { return a + b; };
  • Arity is strict in both directions. Too few arguments throws NotEnoughArguments, too many throws TooManyArguments. There are no defaults and no arguments object.
  • A call needs a reference on its left. f(x), obj.method(x) and .root(x) are calls. f(x)(y) and (function(){})() are not valid. Use .apply(fn, this, argList) to call a function value with an explicit this and a .List() of arguments.
  • function() { } captures nothing. It sees its own parameters and locals, then falls back to fields of this. A local of the enclosing function is not visible inside it.
  • x -> expr, () -> expr and (a, b) -> { ... } parse as lambdas. A lambda copies the enclosing scope's variables when it is created. No script in the shipped repositories uses lambdas, and their runtime behaviour has not been checked on the running game (unverified).
  • Numbered forms such as function 12 (a) { } appear when the engine prints a stored function. You do not write them.
  • A function value cast to a string prints its decompiled source.

Errors

throw "text"; throws a message. catch (e) receives the message as a string for scripted throws and for engine exceptions such as a missing method or a bad cast.

An exception that nobody catches ends the handler. The engine logs it and warns online immortals. The command or trigger that called the handler carries on as if nothing happened. In a skill's run the exception is swallowed and the skill is still counted as handled, see Fenia-Skills-and-Spells.

A try also catches the engine's "victim is dead" unwinding in combat code. A handler that kills its target inside a try can leave the fight round running on a corpse. Keep kills outside try blocks (see the ai_trigger comment in plug-ins/ai/aitrigger.h).

Reading a field of NONE throws Cannot call operator * on NONE. Calling a method on NONE throws Null pointer dereference. Assigning a field on a non-object throws.

Casting rules

Operation From Result
to number NUMBER itself
to number STRING leading integer, or throws
to number other throws
to string NUMBER decimal text
to string STRING itself
to string FUNCTION decompiled source
to string NONE, OBJECT throws
to boolean NUMBER, STRING see Truthiness
to boolean other throws

Because ptc and string concatenation need a string, ptc(null) throws. Test with != null before printing a value that may be missing.

Strings

A string has methods and an index, no fields. Always call a method with parentheses: "abc".size(). A bare s.size is read as a character index and throws Index out of bounds.

  • s[i] and s.at(i) give the character at position i.
  • == is an exact byte comparison. A Cyrillic literal in a UTF-8 script file and a runtime name stored in KOI8-R do not compare equal (measured live 2026-09-10). Match players by a non-name property, or let engine helpers compare.
  • Methods in the engine (from feniastringext.cpp): size, empty, at, substr(offset[, length]), split(sep) (returns a list, default separator is a newline), replace(old, new) (replaces every occurrence), trim, capitalize, upperFirstChar, toLower, toUpper, format(width), quote, noun, ruscase(case), arguments, getOneArgument, cutOneArgument, strPrefix(str), match(regex), matchCase(regex), matchGroups(pattern), is_name(arg), isName(arg), contains(words), stripColour, stripTags, isCyrillic, isRussian (deprecated), isNumber, api.
  • There is no substring search. contains(words) treats its argument as a list of keywords and checks word prefixes, so "abcdef".contains("cde") is false. Use match("regex") to test for a substring.
  • is_name and isName are different. a.is_name(b) asks whether the words of a are prefixes of words in b (the keyword test used for command words). a.isName(b) asks whether the words of b are whole words in a.
  • Regular expressions are POSIX extended. match ignores case, matchCase does not. matchGroups(pattern) returns groups 1 to n in reverse order, the last group first.
  • A string method that is not in this list fails with Invoke method not found.

Messages

._("text") and .mm(en, ru, ua) return a message object, not a string. The object is rendered in each reader's language when it reaches act, say, recho and the other output functions. Do not compare it with ==, concatenate it, or print it with ptc: comparing it with "" throws and ends the handler. To keep a refusal text for a later test, store a formatted string, for example msg = ch.print(._("...")). Details are on L10n-Fenia.

Collections

Constructor Kind Use
.List() ordered list Iterate with for (x in list).
.Array() key to value table Any key type. Safe to persist.
.Map() struct with named fields Keys must be names. Persisted as XML.

.List()

  • Create it empty and fill it. The constructor takes no arguments, so .List(a, b) does not put a and b in the list.
  • Methods: add(a, b, ...), addAll(list), push_back, push_front, pop_back, pop_front, front, back, at(n), random, size, has(x), find(x), find_reverse(x), sub(a, ...), unique, clone, clear, join(sep), sort([fn]), filter(fn), forEach(fn[, args]), api.
  • Index a list with at(n) or for ... in. Square-bracket indexing of a list throws (measured live). Config arrays returned by .config(...) are the opposite: they take arr[i + 0] but cannot be walked with for ... in.
  • sub(a, ...) removes every occurrence of the given elements in place and returns the list. Getters such as room.ppl return a fresh list on each read, so room.ppl.sub(ch) is a safe copy without ch.
  • forEach(fn, extra...) calls fn once per element with this set to the element and the extra arguments passed as parameters. The count of extra arguments must match the parameter list. It returns a list of the non-null results.
  • add(a, b, c) returns the list. A maintainer note says a chained .List().add(a, b, c) failed at runtime in one script (unverified). Build the list in a variable, then iterate.

.Array() (RegContainer)

  • Read or write any key: a["key"], a[ch.id]. A missing key reads NONE. Assigning null removes the key.
  • Members: keys, values (lists), size(), add(k, v, ...), addAll(array), clone(), clear(), api().
  • The built-in names win over data. A key called keys, size or clear is shadowed by the member.

.Map() (IdContainer)

  • Fields by name: m.hp = 5, m["some key"], m[nameVar] with a string variable.
  • A missing field reads NONE. Members: fieldKeys, size(), clear(), api(). There is no keys. Use .Array() when you need to list keys.
  • Numeric keys (m[vnum]) throw Invalid cast: expected 'number' got 'IDENTIFIER'. Convert to a string first, or use .Array() (measured live).
  • A .Map() is saved as XML element names. A key that is not a legal XML name (starts with a digit, contains a space or Cyrillic) saves fine but fails to load after a reboot. The object then reads as Object not recovered (measured live). This does not show on a hot reload. Use only Latin identifier keys in a persisted .Map(), and .Array() for everything else.

Not in the language

Missing Use instead
while, do for (i = 0; i < n; i = i + 1) with an explicit bound
a ? b : c var t; if (a) t = b; else t = c;
i++, x += 1 i = i + 1;
switch chained if / else
floats integers, scaled up (centi-units). Division truncates.
var x = 1; var x; x = 1;
new, classes .Map() and closures
template strings "" + count + "x " + name or .fmt("%dx %s", count, name)

A construct the parser does not know is not always a loud error. while (cond) { ... } lexes as a plain call to a function named while, fails to parse, and the file is rejected as a whole.

See also

Clone this wiki locally