Repository navigation
Fenia Cookbook
TL;DR: Worked examples and short recipes for Fenia in the current style: a mob with random actions, an exit redirect, a delayed conversation, a global trigger, a thread, and about twenty one-screen tasks. Vnums are public Midgaard and library vnums or marked placeholders. Use when: you know what you want a mob, item or room to do and need a working shape to start from. Source of truth: the examples were checked against the API (
dreamland_code/plug-ins/feniaroot/*wrapper.cpp) and the public repositoriesdreamland_world/areas.local,dreamland_fenia_publicVerified: 2026-11-02 against dreamland_code 88da8302, dreamland_world 43880467, dreamland_fenia_public c28874b. The examples were read against the source and the API lookup. They were not all run on a live server.
Conventions. Strings in the examples are plain English so they stay readable. Shipped scripts wrap every player-facing string in ._("source text"), where the source is Russian and the EN and UA texts come from the translation catalog, see L10n-Fenia. The first example shows the explicit form .mm(en, ru, ua), which needs no catalog. Message formats are explained on Output-Format. Every name used here was checked with the API lookup on Fenia-API. Pauses are in pulses, four to the second.
Goal: the kitten (mob 3090, Midgaard) now and then meows, yawns, or tears up a piece of furniture.
Pick the trigger. onSpec runs every 4 seconds for every mob, onArea every minute. onSpec with a small chance gives the unpredictable feel. Triggers on a prototype receive the instance as the first argument, here mob.
Roll a chance. .chance(x) is true with probability x percent. .chanceOneOf(n) is true once in n. Test with a high value, then lower it.
Find furniture. room.get_obj_type(type) gives the first item of a type. Item types are in .tables.item_table, so furniture is .tables.item_table.furniture. List a table with eval ptc(.tables.item_table.api()). Midgaard has a bench (item 3134) to test with.
.apply(function() {
.get_mob_index(3090).onSpec = function(mob) {
// Dead, asleep, fighting or feared mobs do nothing ambient.
if (!.tmp.mob.canActAmbient(mob))
return false;
if (!.chance(1)) // use 20 while testing
return false;
if (.chanceOneOf(3)) {
mob.interpret("emote meows loudly.");
} else if (.chanceOneOf(2)) {
mob.interpret("emote yawns and stretches gracefully.");
} else {
var furniture;
furniture = mob.in_room.get_obj_type(.tables.item_table.furniture);
if (furniture == null)
return false;
// %1$ is the cat, %2$ the furniture. ^ capitalizes.
mob.recho(.mm("%1$^C1 claws at %2$O4.",
"%1$^C1 царапает когтями %2$O4.",
"%1$^C1 дере кігтями %2$O4."), mob, furniture);
}
return true;
};
})Why it looks like this: every placeholder is numbered, so the cat can be referenced twice. The .apply wrapper makes the file one expression. See Fenia-Language.
Goal: in a group of "forest" rooms, a walker who leaves at night ends up in a random neighbouring room.
The room trigger onExitLocation(walker, targetRoom, door) may return a vnum as a string to redirect the move. .sunlight is 0 at night, 1 dawn, 2 day, 3 dusk (.hour is the exact hour, but dark begins at different hours in different seasons). this.exits(walker) lists the exits the walker may use, and this.getRoom(door) is the room behind one.
Assign the same trigger to several rooms with forEach. The callback is a plain function, so it cannot see outer locals. The element is this.
.apply(function() {
var rooms; rooms = .List();
rooms.add(20001, 20002, 20003, 20004); // placeholder vnums: use your own rooms
rooms.forEach(function() {
.get_room_index(this).onExitLocation = function(walker, targetRoom, door) {
if (.sunlight != 0)
return "";
// exits() is never empty here: the exit being used exists.
var pick; pick = this.exits(walker).random();
return "" + this.getRoom(pick).vnum;
};
});
})Check that it landed with eval ptc(.get_room_index(20001).rtapi()). It prints onExitLocation. The engine treats an empty string, a non-number, or 0 as "no redirect".
Goal: the librarian (mob 28800) answers "coffee please" by pouring a cup (item 3101) and handing it over, with pauses between the steps.
No pauses first. onSpeech(mob, talker, msg) runs inside the say command, so every action happens at once.
Add pauses. post... triggers run after the command in their own thread, and only a thread can sleep. Replace onSpeech with postSpeech, and set the old onSpeech to null, or both run. .scheduler.sleep(n) takes pulses.
Check who is still there. After every pause the mob or the speaker may be gone. .tmp.mob.valid(x) is false for a dead, extracted or disconnected character. Immortals should not wait, so the delay is 0 for them (see Code-Style).
Match loosely. msg.match(regex) tests a regular expression, so the words may come in any order and in two languages.
.apply(function() {
.get_mob_index(28800).onSpeech = null;
.get_mob_index(28800).postSpeech = function(mob, talker, msg) {
if (!msg.match("coffee|кофе"))
return;
if (!msg.match("please|pls|пожалуйста"))
return;
var d; d = 4; // one second
if (talker.is_immortal())
d = 0;
.tmp.system.sleep(d);
if (!.tmp.mob.valid(mob) || !.tmp.mob.valid(talker))
return;
mob.interpret("emote takes the kettle off the stove.");
.tmp.system.sleep(d);
if (!.tmp.mob.valid(mob) || !.tmp.mob.valid(talker))
return;
mob.interpret("emote pours a cup of coffee.");
.tmp.system.sleep(d);
if (!.tmp.mob.valid(mob) || !.tmp.mob.valid(talker))
return;
.get_obj_index(3101).create().obj_to_char(mob); // a new item must go somewhere at once
mob.interpret("give coffee " + talker.getName());
mob.interpret("emote smiles warmly.");
};
}).get_obj_index(vnum).create() makes an instance that exists nowhere until you give it to a character or put it in a room. Hand it over in the same expression.
The engine fires global triggers through a function .tmp.trigger_handler. A server built only from the public repositories does not have one, so global triggers do nothing until you define it. This is a minimal version of the dispatcher, plus one handler:
.apply(function() {
if (.tmp.gtrig == null)
.tmp.gtrig = .Array();
.tmp.trigger_handler = function(trigName, args) {
var handlers, result;
result = false;
handlers = .tmp.gtrig[trigName];
if (handlers == null)
return false;
for (label in handlers.keys) {
if (.apply(handlers[label], null, args) == true)
result = true;
}
return result;
};
if (.tmp.gtrig["onDeath"] == null)
.tmp.gtrig["onDeath"] = .Array();
// Arguments: see the global trigger table on Fenia-Triggers.
.tmp.gtrig["onDeath"]["my label"] = function(victim, killer, part, label, damtype) {
if (killer != null && !killer.is_npc())
killer.act("You feel stronger.");
return false; // true would interrupt the death
};
})A second handler, this time on speech, registered the same way:
.apply(function() {
if (.tmp.gtrig["onSpeech"] == null)
.tmp.gtrig["onSpeech"] = .Array();
.tmp.gtrig["onSpeech"]["echo test"] = function(ch, msg) {
if (msg.match("^ping$"))
ch.act("pong");
return false;
};
}).Array() accepts any key, including a label with a space, and survives reboots. Several labels can listen to the same trigger. gprog_registered(name) in the engine checks .tmp.gtrig[name], so an event nobody listens to costs almost nothing.
// Print a message three times, 2 seconds apart, even if the trigger already returned.
.scheduler.Thread(function(ch) {
var i;
for (i = 0; i < 3; i = i + 1) {
if (!ch.is_immortal())
.scheduler.sleep(8);
.scheduler.yield();
if (!.tmp.mob.valid(ch))
return;
ch.act("Tick.");
}
}, ch).start();Pass everything the thread needs as arguments. Locals of the caller are not visible. .scheduler.Thread(fn, args) builds the thread, start() runs it. .scheduler.yield() hands control to the next thread until the next pulse. The immortal guard keeps gods from waiting while they test.
Flags are numbers made of bits. Tables hold the bit values.
if (.isset_bit(ch.affected_by, .tables.affect_flags.sanctuary))
ch.act("You glow softly.");
if (ch.res_flags & .tables.res_flags.wood)
ch.act("Wood does not scare you.");Clear a bit with .unset_bit(mask, bit), set one with .set_bit(mask, bit). Names of the bits in a mask: .tables.res_flags.names(ch.res_flags). mask & ~bit also works, but .unset_bit reads clearer.
An item is in another item, carried by someone, or lying in a room:
if (item.in_obj == container)
item.getRoom().echo("%1$^O1 is inside %2$O2.", item, container);
else if (item.carried_by != null)
item.getRoom().echo("%1$^C1 carries %2$O4.", item.carried_by, item);
else if (item.in_room != null)
item.getRoom().echo("%1$^O1 lies on the floor of '%2$s'.", item, item.in_room.name);room.ppl is a fresh list on every read, so subtracting from it is safe.
var other; other = ch.in_room.ppl.sub(ch).random();
if (other != null)
other.act("%1$^C1 winks at you.", ch);
for (m in ch.in_room.ppl.sub(ch)) { // m needs no var
if (.chance(10))
m.interpret("emote giggles.");
}var n; n = 0;
for (g in ch.in_room.ppl) {
if (ch.is_same_group(g))
n = n + 1;
}
ch.act("%1$d of your group are here, counting you.", n);.sky is 0 clear, 1 cloudy, 2 rain, 3 thunderstorm.
if (.isset_bit(ch.in_room.room_flags, .tables.room_flags.indoors))
ch.act("Go outside first.");
else if (.sky == 0)
ch.act("The sky is clear.");
else if (.sky == 2)
ch.act("It is raining.");if (ch.in_room.sector_type == .tables.sector_table.forest)
ch.act("At last, a forest.");
var s; s = .tables.sector_table.name(ch.in_room.sector_type); // "forest", "city", ....tables.<table>.name(v) turns one value into its name, .names(mask) turns a mask into names, .value("name") goes back. .tables.<table>.api() lists every entry.
var w; w = ch.get_eq_char("wield");
if (w != null && w.item_type == .tables.item_table.weapon) {
ch.act("%1$^O1: %2$s, average damage %3$d.", w,
.tables.weapon_flags.name(w.weapon_attack), w.ave);
if (w.weapon_attack == .tables.weapon_flags.pierce
|| w.weapon_attack == .tables.weapon_flags.stab)
ch.act("This is a good weapon for a backstab.");
}
if (w != null && w.hasWeaponFlag("flaming shocking frost"))
return;var af; af = .Affect();
af.type = "winters touch";
af.level = 50;
af.duration = 10; // ticks
af.bitvector("weapon_type2", "frost"); // the table name picks what the bits mean
obj.affectAdd(af);The .Affect(skill, level, duration) form creates the same thing in one call and sets only the first three fields. The where field is not for bitvectors. It only accepts wearlocations, liquids, skills and skill_groups and throws on anything else.
An affect with no numeric effect still counts for ch.isAffected("backguard") checks:
ch.affectAdd(.Affect("backguard", ch.modifyLevel, 2));ch.damage(victim, dam, skillName, damType, damFlags[, show]) needs all five arguments.
var vict; vict = ch.fighting;
if (vict == null)
return;
ch.act("You strike %1$C4 with terrible force.", vict);
ch.rvecho(vict, "%1$^C1 strikes %2$C4 with terrible force.", ch, vict);
vict.act("%1$^C1 strikes you with terrible force.", ch);
var dam; dam = .dice(ch.modifyLevel, 18);
ch.damage(vict, dam, "earthquake", "none", "other");match tests a POSIX extended regular expression and ignores case (matchCase does not). matchGroups(pattern) returns the captured groups as a list, last group first.
.get_mob_index(3000).onSpeech = function(mob, speaker, message) {
var pattern; pattern = "can you (give|return|hand) ([^ ]+)";
if (!message.match(pattern))
return false;
var g; g = message.matchGroups(pattern);
// g.at(1) is the first group (the verb), g.at(0) the second (the thing).
mob.say("I never meant to " + g.at(1) + " you any " + g.at(0) + ".");
return true;
}if (!victim.isImmune(.tables.damage_table.poison, .tables.damage_flags.magic)) {
if (!victim.isAffected("poison")) {
ch.spell("poison", ch.modifyLevel, victim);
return;
}
}ch.religion is a religion struct. Its name is the English name, and none means no god. .Religion("name") builds one by name. The name must exist, or reading it throws, see Fenia-Gotchas.
if (ch.is_npc())
return false;
if (ch.religion.name == "none")
mob.say("Welcome, unbeliever.");
else
mob.say("Welcome, follower of " + ch.religion.name + ".");onExtraDescr(obj, ch, arg) returns the text to show for a keyword, or null to let the search go on. Answer only your own keywords: carried items are asked before the room.
.get_obj_index(30001).onExtraDescr = function(obj, ch, arg) { // placeholder vnum
if (!arg.is_name("telescope"))
return null;
if (obj.state == "galaxy")
return "In the eyepiece you see a mysterious galaxy.";
if (obj.state == "meadows")
return "In the eyepiece you see green meadows.";
return null;
}Attributes are typed values stored in the player file. Read with attribute(name). Create once, erase before you change:
var cry; cry = ch.attribute("warcry");
if (cry != null)
ch.interpret_raw("yell", cry);
else
ch.act("You let out a battle cry.");
if (ch.hasAttribute("visits"))
ch.eraseAttribute("visits");
ch.attribute("visits", "" + 5); // string: survives remortStart 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