Repository navigation
Fenia Gotchas
TL;DR: Traps that make Fenia code fail without a stack trace, each with its fix. Most of them are silent: the handler stops, the log gets one line, the player sees nothing. Use when: you write or review Fenia, or a script "loads fine and does nothing". Source of truth:
dreamland_code/src/fenia/(parser, registers, scopes),plug-ins/feniaroot/(wrappers),plug-ins/skills_impl/feniaskillaction.cpp,plug-ins/output/msgformatter.cppVerified: 2026-11-02 against dreamland_code a7c4fd10, dreamland_world a35c62ce, dreamland_fenia_public c28874b. Entries marked(measured live ...)came from a test on the running game. Entries marked(unverified)are carried over from maintainer notes.
Fenia gives no stack traces. When a handler throws, the engine logs Exception calling Fenia prog <name> and goes on. After any deploy of risky code, trigger the path once and look for that line in the log. A parse check alone does not catch runtime errors.
Language rules behind many entries are on Fenia-Language. Style rules are on Code-Style.
| Trap | Fix |
|---|---|
while (...) is not a keyword. It lexes as a call to a function named while, the file does not parse, and the whole file is rejected. The previous version keeps running. |
Use for (i = 0; i < n && i < 1000; i = i + 1). Declare var i; above it. |
cond ? a : b fails to parse. |
var t; if (cond) t = a; else t = b; |
i++, x += 1, x -= 1 do not exist. |
i = i + 1; |
var x = 5; does not parse. var takes names only. |
var x; x = 5; |
A trailing ; after the closing }) of the file is a parse error. Several top-level statements are also an error, a file is one expression. |
One .apply(function() { ...; }). Every statement inside ends with ;. No ; after the last ). |
An emoji or other character with no KOI8-U form made a whole file fail to post, even inside a comment (measured live). |
ASCII and Cyrillic only. Write ... for an ellipsis, -- for a dash, straight quotes. |
if (x = y) assigns and tests. It is not a comparison. |
Write ==. Before "fixing" an assignment inside a condition, check what depends on its side effect. |
a && b || c groups as a && (b || c) (derived from the grammar, unverified on the engine). |
Parenthesize every mix of && and ||. |
[1, 2, 3] is not a list. It evaluates all three and gives the last one. |
var l; l = .List(); l.add(1, 2, 3); |
f(x)(y) and (function(){ ... })() do not parse. A call needs a name or member on the left. |
Store the function in a variable, or use .apply(fn, this, argList). |
=> arrows do not parse. x -> expr parses as a lambda but no shipped script uses it. |
Use function() { }. |
for (var i = 0; ...) does not parse. A bare for (i = 0; ...) without var i; writes i into this. |
Declare the variable above the loop. |
continue in a C-style for still runs the step. The retry idiom i = .max(0, i - 1); continue; skips an item when i is 0. |
if (i > 0) i = i - 1; before continue. |
| Trap | Fix |
|---|---|
if (obj) throws for null and for any object. Only numbers and strings convert to boolean. |
if (obj != null). |
count + "x " throws, because the number on the left tries to convert the string. "2" + 2 is "22" and 2 + "2" is 4. |
Start with a string: "" + count + "x ", or .fmt("%dx %s", count, name). |
ptc(x) throws when x is null or an object. A number prints fine. |
Check != null. Convert with "" + x. |
Numbers are 32-bit integers. / truncates. A float literal does not exist, and .config(...) data has no floats. 0.5 reads as 0. |
Scale up: centi-units, permille. |
ch.id and obj.id are decimal strings. A literal of 14 digits is truncated to garbage by the lexer, and comparing a number to a string id throws. |
Keep ids as strings: "" + id. Never filter by numeric id. |
Comparing a Cyrillic literal from a UTF-8 file with a runtime name stored in KOI8-R is never equal (measured live 2026-09-10). |
Match on a non-name property, or on keywords with is_name. |
Naming a method without () does not call it and does not throw. eval ptc(mortality) printed 0, which looks like a real field. |
Check NMI_GET versus NMI_INVOKE in plug-ins/feniaroot/*wrapper.cpp before trusting a bare name. |
A bare string "field" is read as an index: "abc".size throws Index out of bounds. |
Call the method: "abc".size(). |
"abcdef".contains("cde") is false. contains matches whole keywords. |
s.match("cde") for a substring test. |
a.is_name(b) and a.isName(b) differ. Using isName for command-word dispatch is always false for a single word against a list. |
cmd.is_name("quaff drink") for verb tests. |
is_name returns true when the first token of the needle is empty after colour stripping. A needle such as "{/" or a lone tab then matches every prototype. |
Reject input whose stripped first token is empty before using it as a needle. |
.replace(old, new) replaces every occurrence. |
Slice with substr if you need only the first. |
| Trap | Fix |
|---|---|
A function() { } captures nothing. A local of the enclosing function reads as null or throws inside it. Inside a spell's damageRoom callback the throw skips every remaining victim on every cast. |
Pass values as parameters, via forEach(fn, arg1, arg2) (the element is this), or through state.x in spell hooks. |
Assigning a name you did not declare writes a field on this. Inside a trigger this is the mob, item or room, so the field persists in the database. Reading such a name gives null on a character, item, room or map (it throws when this is null or an engine object such as the spell context), so a typo is silent inside triggers. |
Declare every local with var. |
| Arity is strict both ways. Too few arguments throws, too many throws. There are no defaults. A helper whose parameter list changes breaks every caller that still passes the old count. | Post the helper and every caller together, or add the parameter under a new helper name and leave the old one alone. Pass an explicit sentinel for an optional parameter. |
.scheduler.sleep(n) takes pulses, a quarter of a second each. sleep_online(ch, 12) is 3 seconds. |
Divide by 4 before judging a pause. |
.scheduler.sleep inside an on handler that C++ calls directly throws (yielding allowed from fenia process only or yielding not allowed from native code). Only post handlers and threads can sleep. |
Move the pauses into a post... trigger or .scheduler.Thread(fn, args).start(). |
| After a sleep or inside a thread the actor may be gone. | Check .tmp.mob.valid(ch) after each sleep. .tmp.object.valid(obj) for items. |
start(fn) works only inside a spell hook. In a helper the word resolves to a method of the surrounding object and fails on a signature mismatch. |
Outside spells use .scheduler.Thread(fn, args).start() and pass arguments explicitly. |
A helper called as .Spell("x").helper(...) gets the spell wrapper as this. It has skill, not ch, obj or level. The crash shows only on the rarely hit branch that uses the missing name. |
Take every actor as a parameter: helper(ch, obj). |
| An unbounded loop freezes the whole server, there is one game thread. | Always bound for loops. Prefer onSpec to a hand-written loop. Yield in threads. |
A try/catch swallows the engine's "victim is dead" unwinding. A handler that kills inside try leaves the fight round running on a corpse. |
Keep kills outside try. |
Inside .apply(function() { ... }) at the top of a file this is null, so an undeclared assignment throws. Helper files set this = .tmp.ns; first on purpose. |
Declare locals. Assign this only when you mean to fill a namespace. |
| Trap | Fix |
|---|---|
.List(a, b) ignores its arguments. |
Create it empty and call add or push_back. |
A list cannot be indexed with l[i], with a variable or with i + 0 (measured live). |
l.at(i) or for (x in l). |
.config(name) arrays are the opposite: for (v in cfg.values) throws and arr[i] with a bare variable throws, but arr[i + 0] works (measured live). |
Walk with for (i = 0; i < arr.size(); i = i + 1) { v = arr[i + 0]; }. A helper shared by lists and config arrays needs two versions. |
.Map() with a numeric key throws: m[vnum]. |
Use a string key m["" + vnum], or .Array(). |
A .Map() with a key that is not a legal XML name (starts with a digit, has a space or Cyrillic) saves, then fails to load at the next boot. Every read throws Object not recovered (measured live). It does not show on a hot reload. |
Keep .Map() keys Latin identifiers. Use .Array() for anything else. |
.Map() has no keys. |
.Array() has keys and values. .Map() has fieldKeys. |
Built-in members win over data. A key or helper named clear, size or api in a .Map(), or clear, keys, size in an .Array(), is shadowed. .tmp.ns.clear() wipes the whole namespace. |
Never name a helper clear. |
.Array() removal: a[key] = null removes the key. Calling a.clear() to undo one entry wipes every record on a shared array. |
Assign null to the one key. |
list.sub(x) mutates the list in place. |
Use it on the fresh list that room.ppl returns, which is a copy. |
index.instances and room.ppl are snapshots. Extracting items while walking them is safe for the list, but items extracted earlier in the loop are still in it. |
Check each element with .tmp.object.valid or .tmp.mob.valid before you touch it. |
A chained .List().add(a, b, c).forEach(...) failed at runtime in one script (unverified). |
Build the list in a variable first. |
| Trap | Fix |
|---|---|
Everything under .tmp and every field on a character, item or room persists in the Fenia database and survives reboots. A debug probe stays active forever. |
Annul probes before you finish: .tmp.test.x = null;, remove temporary onSpec hooks. |
| Deleting code first leaves the old binding in the database and a duplicate script is created on the next load. | Annul first (= null), then delete. See Fenia-Scripts. |
ch.attribute("x", v) throws if the attribute exists. It works for players only. The value you read is a copy. |
eraseAttribute then set. Never write into the returned value. |
| A number attribute is dropped at remort. A string attribute survives. | Store durable values as strings. |
| Fenia fields on a character are not in the player file. Searching player files for them proves nothing. | Probe the live wrapper. |
.AreaQuest(vnum) throws for a deleted quest, while the player attribute still lists it. .Religion(name), .Skill(name) and similar handles keep only the name and throw on the first field read if it no longer resolves. |
Wrap the first read in try/catch and continue or return. ch.godReligion already returns null for a missing god. |
obj.gender is null on instances that did not set their own. The prototype holds the value. if (obj.gender == "f") is then silently false. |
g = obj.gender; if (g == null && obj.pIndexData != null) g = obj.pIndexData.gender; |
obj.props on an instance is the instance's own properties, not the behavior properties of the prototype. |
Read obj.pIndexData.props["behavior name"]. |
mob.pc on a mob is not always null. It is the player currently possessing the mob. |
Treat mob.pc == null as "nobody is riding this body". Do not replace it with mob.master. |
A negative vnum passed to .get_obj_index or .get_mob_index crashed the server. The engine now returns null for it (fixed in code on 2026-10-23). |
Guard against negative vnums anyway on a server built before that fix. |
ch.dead is true for an extracted character or a missing target and never throws. |
Use it as a cheap liveness test where .tmp.mob.valid is not available. |
Reading a prototype property that is absent gives NONE. Feeding it to is_name or .get_obj_index throws and aborts the whole loop over all recipes. |
Check every property(...) result with != null, or gate with a type test first using &&. |
ch.getClan() returns the clan's display short name as a string, not an id, and getClan().name throws (unverified). |
Compare ch.clan.name. |
.get_area_index(name) wants the name as listed in the area list, "midgaard.are". A disk file name returns null without an error, so a guarded loop over it silently does nothing (unverified). |
Use the list form of the name. |
The room field is sector_type. room.sect reads null, so a comparison with .tables.sector_table never matches (unverified). |
Use room.sector_type. |
.get_obj_index(v).value0 = n does not edit the prototype. It creates a persistent Fenia field. On a weapon, obj.value0 = and obj.value3 = are ignored because the engine reads the class and attack from the prototype (unverified). |
Edit prototypes in oedit. Use weapon class and attack affects on weapons. Annul a stray field with = null. |
ch.isAffected(name) also counts affects granted by worn gear, so an "already affected?" guard for an item-granted skill is always true (unverified). |
Test for the real affect, or for the item. |
mob.race = ... swaps the race reference only. Immunities, resistances, parts and form are not copied. mob.sex = ... leaves %G and %P texts baked into the names and descriptions from the prototype's sex (unverified). |
Set the dependent fields by hand and re-render the texts. |
obj.hasBehavior() and similar per-item calls cost about 170 to 250 ms per 1000 items on the live server (unverified). |
Scan a large container once, not once per row. |
The engine calls some .tmp.<namespace>.<function> slots by name. If the namespace is missing, dereferencing it throws on every call and each call is logged. |
Define the namespace as an object first. See the slots table on Fenia-Triggers. |
| Trap | Fix |
|---|---|
x.trigger("Name", a, b) passes the arguments verbatim to the instance handler. The entity is prepended only for the prototype handler. obj.trigger("Empty", obj, ...) shifts every argument by one. |
Pass exactly the instance arguments. Only Get, Give and Drop expect the item first. |
post<X> is queued even when on<X> returned true, and for x.trigger(...) it is queued before on<X> runs. So an on guard cannot stop its own post, and spamming a command queues one post per press. A plain boolean "running" flag is still true for every extra post. |
Use a claim token the post consumes. Test if (obj.runPending != true) return false;, never !obj.runPending, because reading a never-set field as boolean throws. |
A behavior with only a post<X> trigger cannot suppress the C++ command whatever it returns. |
Define the on<X> trigger. |
Not every trigger reaches behaviors. A behavior onTell, onDeath, onCommand or onExamine loads and never fires. |
See the table on Fenia-Behaviors. |
onExtraDescr is polled for every carried item before the room. A handler that answers any word steals look sword from another item. |
Answer only your own keywords. Put searching on use <item> <word>. |
A room onSpec or onDiveUpdate posted live may start firing only after the room registry is rebuilt, about every 10 minutes. |
Wait, or reload after a restart. |
A behavior onSpec is skipped for mobs in an area marked empty. A prototype onSpec is not. |
Put logic that must run without players on the prototype. |
A handler returning a non-empty string or a non-zero number from a bool trigger counts as true. Returning an object throws. |
Return true, false or nothing. |
A command override (.FeniaCommand(x).runFunc) makes the C++ command body dead code, for players and mobs. The override can also fail to bind after a reboot because of plugin load order. |
Look for a Fenia override before you read or patch the C++ command. Reposting each boot piles up orphans. |
Posting a .SkillCommand(x).run for a skill that has a C++ body replaces the body at once, even if the XML still has the old argument type. With no argtype, vict is null. |
Resolve the target from argOne or ch.fighting, or do not post until the XML change is live. |
A skill command run that throws is caught, counted as handled, and the C++ body does not run. The skill is dead and looks merely unused. |
Exercise every branch. A call such as eval ptc(helper(...)) on a live instance is a safe read-only check. |
A skill command apply must return a boolean. An object throws on every success. |
Return true or false. Pass other values through a temporary field. |
A spell dam is pre-filled from the <tier>. damageRoom refills it before each victim. A spell with no tier has dam 0. |
Keep your roll in state and copy it to dam inside the callback. |
room in a spell context is null unless the target is a room or exit. Helpers that open with if (room == null) return; then silently do nothing. |
Use ch.in_room. |
An affect keeps one bitvector slot. A second af.bitvector(...) call replaces the first. |
One affectAdd per table, reusing af. |
A handler on a dying character prints nothing. While make_corpse runs, ch.dying is true and ch.act, recho, rvecho and ptc of that character are muted. onRemove handlers still run. |
Guard output sent through other wrappers (room.echo, killer.act) with if (ch.dying) return;. Never leave dying set. |
| Identical stacked affects fire an affect handler once, not once per stack. | Do not count on one call per stack. |
onDescr on an affect replaces the room description. Returning only your fragment wipes the text. |
Return description + addition. |
| Trap | Fix |
|---|---|
ch.pecho is not a Fenia method. It throws Method not found when it fires, which can be rarely. |
Use ch.act(fmt, ...) for a line with a newline or ch.ptc(str) for a raw string. |
ch.print(...) formats and returns a string. It sends nothing. return ch.print(...) at a call site is a silent failure. |
Use it as a predicate helper ("" means ok). Send with ch.act. |
ch.act appends a newline. ch.ptc does not. ptc does not collapse %%. |
Add "\n" to ptc text. In a format string use %%. In a raw string use %. |
act, fmt, recho: more placeholders than arguments gives Not enough arguments. Unnumbered placeholders each take the next argument, so mixing numbered and unnumbered references to one entity fails. |
Number every placeholder, %1$^C1 ... %1$C2. Run the placeholder linter after editing. |
In act, a character is %N$C. Lower-case %N$c formats a string, and a character there throws. |
Never port $c from a C++ string to %c. |
%N$G<neuter>|<masc>|<fem>|<plural> branches start with neuter. |
Put the branches in that order. |
%N$n<sing>|<plur> only works for a verb whose subject is argument N. |
Leave other nominatives in their own number. |
%P1...%P6 and the %T family index a table of Russian pronouns. In an English or Ukrainian slot they print Russian. |
Avoid them outside Russian slots, or pick the pronoun yourself with the same prototype fallback. See Output-Format. |
ch.recho(victim, fmt, ...) throws, the first argument must be the format. recho also skips viewers who cannot sense the actor. |
ch.rvecho(victim, fmt, ...) excludes the actor and the victim. |
._() returns a catalog object, not a string. Comparing it to "" throws and aborts the whole handler. |
Keep a formatted string (ch.print(._(...))) when you need to test it. |
.tmp.system.lmsg(lang, en, ru, ua) returns the slot for the language and does not fall back. An empty English slot gives an empty string, not Russian. |
Fill all three slots, or accept empty on purpose. |
Messages built outside act and ._() are not translated. |
Wrap every player-facing string. Do not translate composed strings. See L10n-Fenia. |
| Trap | Fix |
|---|---|
.chance(x) is true when x >= number_percent(), so .chance(50) is a 50% roll and .chance(0) never fires. |
Use .chance only for luck-free rolls. |
.tmp.chance(base, ch) adds the character's luck bonuses and clamps to 1..99, so there is no guaranteed 0% or 100%. |
For a certain outcome do not roll at all. |
Avoidance rolls read as if (.tmp.chance(100 - eff, ch)) return;. If ch is the actor and not the one avoiding, the same line hands the actor's luck to the other side. |
Check whose luck it is before reusing the idiom. |
cond_hunger, cond_thirst, cond_drunk and cond_bloodlust are setters that add a delta, and they throw on mobs. A penalty must be negative. |
if (!ch.is_npc()) ch.cond_thirst = -1 * n;, never ch.cond_x = ch.cond_x + n. |
x.wait = n assigns as is, so it can also shorten lag. |
Raise with x.wait = .max(x.wait, n);. |
A weapon generated with the wrong function keeps a placeholder name for good. .randomizeWeapon names and stats a weapon. .generateWeapon only stats. |
Use .randomizeWeapon for new drops. (unverified)
|
Start here
Get running
Contribute
- Contribute
- Contributing Workflow
- Code Style
- Accessibility
- Writing Player Text
- Game Design Principles
Build areas
- Building
- OLC
- OLC Rooms
- OLC Mobiles
- OLC Objects
- OLC Resets
- OLC Line Editor
- OLC Other Editors
- Area Format
- Area Quests
- Mob Forms and Parts
Fenia
- Fenia
- Fenia Language
- Fenia Triggers
- Fenia Gotchas
- Fenia API
- Fenia Scripts
- Fenia Behaviors
- Fenia Skills and Spells
- Fenia Cookbook
- Fenia Grammar
Engine
- Engine Architecture
- Engine Time and Pulses
- Output Format
- Mechanics Combat
- Mechanics World
- Data Configs
- Item Power
Localization
Clients
More