jengine-realm
An Ultima-style realm: a world of linked maps, a clock the people live by, conversation you type at, and tactical grid combat.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-realm |
| Version | 0.1.0 |
| Entry point | jengine_realm::RealmPackage |
| Script namespace | Realm |
| Listed by | projects/arcade-classics, projects/rpg-classics |
Using it
Section titled “Using it”List it in the top-level "packages" array of project.jengine:
{ "packages": [ { "name": "jengine-realm", "source": { "type": "path", "path": "../../packages/jengine-realm" } } ]}Then rebuild the editor viewport for the project (jengine viewport-build projects/<name>, or the
editor’s Rebuild viewport banner). Native and web builds link it on their own.
A scene that uses its components must be a v2 scene ("version": 2): a v1 scene has no field
for a package component and keeps it as inert custom data.
A script can check that the package is linked with if Realm == nil then return end.
Components
Section titled “Components”| Scene key | Rust type | |
|---|---|---|
realm_game | RealmGame | package only: needs a v2 scene |
Script commands
Section titled “Script commands”Commands run in every runtime and return nothing; a package publishes what scripts read onto components (see the notes below). The flat name also works.
| Call | Flat name |
|---|---|
Realm.new_game(e) | realm_new_game |
Realm.move(e, dx, dy) | realm_move |
Realm.talk(e) | realm_talk |
Realm.say(e, word) | realm_say |
Realm.end_talk(e) | realm_end_talk |
Realm.rest(e, hours) | realm_rest |
Realm.travel(e, map, x, y) | realm_travel |
Realm.battle_move(e, dx, dy) | realm_battle_move |
Realm.battle_attack(e, target) | realm_battle_attack |
Realm.battle_approach(e, target) | realm_battle_approach |
Realm.battle_guard(e) | realm_battle_guard |
Realm.battle_flee(e) | realm_battle_flee |
Realm.battle_end_turn(e) | realm_battle_end_turn |
Realm.battle_ai(e) | realm_battle_ai |
Realm.battle_auto(e) | realm_battle_auto |
Realm.pause(e, on) | realm_pause |
Realm.effect(e, effects) | realm_effect |
Realm.load(e, json) | realm_load |
Scenes that use it
Section titled “Scenes that use it”projects/arcade-classics/assets/scenes/crown.sceneprojects/rpg-classics/assets/scenes/realm.scene
From packages/jengine-realm/README.md.
An Ultima-style realm: a world of linked maps, a clock the people live by, conversation you type at, and tactical combat on a grid.
Used by projects/arcade-classics (Hollow Crown) and projects/rpg-classics
(ASHEN VIRTUE, an Ultima IV: eight virtues, a shrine, companions, a dungeon -
built entirely from the data-driven parts below, with no game-specific code
in the package).
| Module | What it is |
|---|---|
clock | The hour, and what it does to the light and to who is awake. |
people | Daily rounds and keyword conversation, both data-table rows. |
realm | The maps (ASCII drawings in a table), travel, wandering monsters, the save. |
battle | A fight on a grid, one turn at a time. |
Three things, and none of them is the combat
Section titled “Three things, and none of them is the combat”The clock. Everything else asks it questions. Shops keep hours, the
light changes across dawn and dusk (daylight eases 0..1 rather than
stepping, because a world that snaps from day to night at the top of the
hour looks broken even when it is correct), and a person is in the tavern
at eight and asleep at midnight. A step on the map is a minute, so a long
walk costs a morning and a town errand costs nothing.
Typed conversation. You ask a person about a word. A row’s keywords
map words to answers and its grants map a word to a flag — which is the
entire quest system:
"keywords": { "crown": "It was taken. The CAPTAIN saw it go." },"grants": { "crown": "heard_of_crown:1" }The answers name the next word in capitals, so the trail is followed rather than guessed at. Matching is deliberately loose (trimmed, lowercased), because a conversation that only answers exact lowercase words is a conversation with a parser. Nobody answers questions in their sleep, and asking at three in the morning gets a mumble rather than a quest.
Tactics on a map. A fight is eleven by nine tiles, one turn at a time in initiative order. A character walks some tiles and strikes what it can reach, so where everyone is standing is the game: an archer with a reach of four behind a knight with a reach of one is a different situation from the two of them side by side. Flight is a position, not a dice roll — reach the edge of the field and the party leaves.
Monsters path with a breadth-first sweep outward from their target rather than stepping whichever way looks closer. The greedy version oscillates the moment there is a wall: it sidesteps round the obstacle, and then the sidestep is the thing it wants to undo, so the monster jitters on the spot for the whole fight.
Maps are ASCII drawings in a data table
Section titled “Maps are ASCII drawings in a data table”The same trick the endless runner uses for its chunks, and it matters more here, because a game like this is mostly maps.
HHHHHHHHHHHHHHHHHHHHH__________________HH_HHHHH____HHHHHH__HH________!_________HHHHHHHHHH+HHHHHHHHHH + is a door outA door is "x,y>map,x,y". Both ends are checked when the tables load,
along with every scheduled position and every monster a map can summon, so
a door into a wall fails loudly at startup instead of stranding the party
later.
The package writes whichever map the party is on straight into the scene’s tilemap layer — walking through a door is one component write rather than a thousand script commands.
A realm made of data
Section titled “A realm made of data”Everything Hollow Crown hard-coded is a table row now, and every new field has a default, so Hollow Crown’s tables load and play exactly as before.
| Table / field | What it adds |
|---|---|
tiles table (optional) | the map characters: glyph, gid, solid, battlefield clutter, talk_over (a shop counter a person is talked across). Without it the built-in nine apply; with it every character of every map must be a row |
config table (optional, row realm) | virtues (names, in display order), virtue_start, virtue_max, start_gold, start_map, party_max, reveal_topics, xp_per_level |
person answers | answers that need things and do things: { text, needs, else, does, done, again }. A need may carry its own refusal after a ` |
person absent_if | conditions that take a person off the map ("member:wren" - a companion who joined) |
map items | "x,y:flag:Name" - stepping on the tile picks it up (sets the flag, once) |
map fights | "x,y:foe:flag" - a guarded tile: a fight on stepping onto it until it is won |
door ?cond | "x,y>map,x,y?lantern" - a door that opens only while the condition holds; the party is kept off it (locked event) |
foe xp, on_win, on_flee | experience for whoever is standing, and effects of winning or running - a fair fight fled is how valor is lost |
Conditions (Realm::check): a flag name, !cond, member:id,
heard:word, or <quantity><op><n> over gold, party, hour, day,
level, steps, virtue.<name>, flag.<name> (ops >= <= > < == !=).
Effects (Realm::apply): gold+N / gold-N, virtue.<name>+N,
xp+N, <flag>+N, flag:name[=N], clear:name, join:id, leave:id,
heal, hurt:N, teach:word, wait:N (minutes), battle:foe,
travel:map,x,y, event:name (a { kind: "event", name } for the script).
Topics are learned when reveal_topics is off: a conversation offers
name, job and every word the party has heard someone say in CAPITALS
(kept in the save as heard). Typing any word still works - that is the
point of a text field.
Levels: with xp_per_level set, level n costs n * xp_per_level; a
level adds hp / 6 (at least 3) to health, and attack and defence grow with
it in battle (Realm::fighter_of). A companion joins at the leader’s level.
The component
Section titled “The component”realm_game on one entity, in a "version": 2 scene.
"realm_game": { "tables": { "maps": "data/realm_maps", "people": "data/realm_people", "party": "data/realm_party", "foes": "data/realm_foes" }, "map_entity": "Ground", "map_layer": "ground", "seed": 7}Script commands
Section titled “Script commands”| Call | Does |
|---|---|
Realm.new_game(e) | a fresh party at the start |
Realm.move(e, dx, dy) | walk a tile; a door travels, and the road is dangerous |
Realm.talk(e) | talk to whoever is next to you |
Realm.say(e, word) | ask them about a word |
Realm.end_talk(e) | walk away |
Realm.rest(e, hours) | sleep: heals the party, moves the clock |
Realm.travel(e, map, x, y) | go somewhere directly |
Realm.battle_move(e, dx, dy) | walk, on the battlefield |
Realm.battle_attack(e, target) | strike, and end the turn |
Realm.battle_approach(e, target) | one step along the way to it |
Realm.battle_guard(e) / Realm.battle_end_turn(e) | stand fast / pass |
Realm.battle_flee(e) | leave, from the edge of the field |
Realm.battle_ai(e) | let the monster whose turn it is take it |
Realm.battle_auto(e) | let whoever is up take their turn - the party too (an “auto” button) |
Realm.pause(e, on) | hold the clock and the walking (a pause menu) |
Realm.effect(e, "a;b") | apply effects from the script (the words above) |
Realm.load(e, json) | restore a save |
What scripts read
Section titled “What scripts read”RealmStateevery frame: the clock and itsdaylight, where the party is, the party’s health (andlevel,xp,attack,defense,reach), gold, the flags it has collected,virtues([{ name, value }]) andvirtue_max, theheardwords,marks(pickups and guarded tiles still on this map),paused, who is being talked to - withtopics(what the conversation offers),all(every word they know) andlast(the last reply) - and the whole battle when there is one, includingcan_act,nearestandground, so a game never has to work out whether a turn can be taken.RealmPeople: everyone on this map, and whether they are awake.RealmMap: once per map, as rows of characters.RealmEvents.events:travel,hour,battle,hit,down,over,battle_over,spoils,flag,rested, andreply(who, word, text, known),heard,virtue(virtue, delta, value),joined,left,party_full,level,found,locked,gold,healed,hurt,waited,event.
Note reply: before it existed the package worked a conversation’s answer
out and dropped it, so a script had no way to show what a person said.