Birdoggydog's Builds

Letting NPCs die, leave bodies and hand out notes in Godot

Story effects that kill or eat a character, a saved fate, a searchable body, readable notes, juice injectors and a chest stocked from its party.

The starting dungeon in Blobber has its people now. An NPC can die at the end of a talk, get killed by a choice in it, get eaten for experience, or end himself, and he leaves a body that I can search. Items can be notes that open to be read. Experience is called juice everywhere the player sees it, a new game can start with nothing, and the first chest is stocked from whichever party opens it.

60 seconds in a gray test room. A party with nothing opens the starting locker, Chief Scientist Carl gives a quest and a key and dies, his body is searched, Subject 29 is eaten for 120 juice, Helena gives a quest and one of her endings plays, Carl’s papers are read, and an injector puts 200 juice into one character.

This is the other half of the slice I wrote about in stacking dungeon floors and building lifts. It’s not joined to that dungeon yet. The people are in a sealed gray-box room built only for this, because the dungeon was still being imported on another branch when this was written. Seating them in the real dungeon is being built now.

Why did the story need new systems?

I wrote notes on my map of the dungeon for each person. Carl, the chief scientist, is dying and dies after talking to the party. Subject 29 recognizes the party as GOD and offers to feed himself to them, which gives experience. Helena is half consumed by meat and begs the party to kill her, and asks them to find her brother in the town. The party climbs out of the vat “fully formed but naked”, and the first chest has potions plus “basic weapons and spells based on the choices they made at party creation”.

The story system could give items, start quests and award experience. It couldn’t kill anybody, there was no such thing as a body or a note, and every chest had a fixed list of contents. I wanted each of those built as a system that any dungeon’s content can use, so nothing about one character is in code.

Adding effects that kill or eat an NPC

All story content is JSON, one file per character, checked against one schema. Dialog is a map of named nodes, and an answer can run a list of effects. I added three effects and two conditions:

KindNameWhat it takes
effectkill_npchow: died, killed or ended_self, and body: true or false
effecteat_npcjuice: the experience to share out
effectread_noteitem: opens a note the party isn’t carrying
conditionnpc_fateis: alive, dead, or one of the four ends
conditionhas_readitem

In dialog the effect means whoever is speaking, so the content doesn’t name him. Here’s Subject 29’s second dialog node. Look at the then lists:

"offer": {
  "say": "To be useful. I am mostly juice by now; they made sure of it. Eat me. Please. It is the only door in this place that opens from my side.",
  "options": [
    {"text": "Eat him.", "reply": "Yes. Yes. Thank you. Start anywhere.",
     "then": [{"type": "eat_npc", "juice": 120}], "ends": true},
    {"text": "No. We will not.", "reply": "...",
     "then": [{"type": "kill_npc", "how": "ended_self"}], "ends": true},
    {"text": "Not yet.", "reply": "I will keep. I have kept this long.", "ends": true}
  ]
}

There’s no “are you sure?” box on eating or killing. I put his two endings on a second node that the party has to ask for, with a “Not yet.” answer, so nobody picks one by a slip of the hand. Helena’s is one plainly worded option.

How is a death stored and saved?

The story’s saved state got a new dictionary called npcs, and it only has entries for the dead:

"npcs": {
  "subject_29": {"fate": "eaten", "body": false, "at": [-76.0, -30.0, 76.5], "yaw": 0.0}
}

at and yaw are where he was when it happened. Once a body has been searched, the entry also gets holds, the items still on it. A save from before this work has no npcs at all, and it loads with everybody alive.

Settling a fate happens in two steps, because I wanted the NPC to finish his last line. The fact is written into the state right away, so a save made mid-sentence already has it. What the player sees waits for the conversation to close:

story.state.note_npc(npc_id, {"fate": how, "body": leaves_body and how != EATEN,
        "at": [at.x, at.y, at.z], "yaw": yaw})
if node != null and node.is_talking():
    # They finish what they are saying first.
    node.talk_ended.connect(func() -> void: _carry_out(story, npc_id, true), CONNECT_ONE_SHOT)
else:
    _carry_out(story, npc_id, true)

talk_ended is a new signal on the NPC. _carry_out takes the node out of its groups, hides it, calls queue_free(), prints one line (“Chief Scientist Carl has stopped.”) and lays a body if the entry says so. It leaves the groups before it’s freed because queue_free() only frees at the end of the frame, and everything else in the game finds NPCs by group.

After a load, a restore function walks every content NPC. The dead ones are removed and their bodies laid down with whatever holds says is left, and anyone alive with no node is spawned again. So loading a save from before a death brings him back.

Nothing else follows a death automatically. Carl gives his quest and dies in the same talk, so a quest whose giver dies stays open. A quest that should fail when somebody dies says so in content with the npc_fate condition.

Building a body out of the NPC’s own model

A body is a stand-in for now. It’s a Node3D with the NPC’s own model as a child, rotated 90 degrees onto its back, squashed to 0.7 of its thickness and tinted dark gray:

var holder := Node3D.new()
holder.rotation_degrees.x = -90.0                 # on its back
holder.position = Vector3(0, 0.16, height * 0.5)
holder.scale = Vector3(1.0, 1.0, FLATTEN)         # 0.7
add_child(holder)
var model := ModelUtil.instantiate_fitted(model_scene, height)
holder.add_child(model)
WorldKit.tint_model(model, PALLOR, PALLOR_STRENGTH)   # Color(0.3, 0.29, 0.3) at 0.7

It has no collision, so the party walks through it. It has its own inventory, filled from a carries list in the NPC’s file, and searching it opens the same container screen a chest uses. The first look prints a line from the file: “Carl, still pointing at the door. He has the face of a man who was right.”

A monster’s body goes away, but this one stays for good, because a dead person is a fact of the place. An NPC file can also say "dead": true, which makes a body from the start. That’s how a key ends up on a corpse: a dead ward orderly has the ward key on him. Eaten leaves nothing.

Making an item open as a note

A note is an ordinary item with a read string and an optional on_read list of effects:

{"id": "carls_papers", "name": "The Chief Scientist's Papers", "kind": "quest",
 "read": "PROJECT MINUTES. Corrected by hand. C.\n\nItem 1. Waking. ...",
 "on_read": [{"type": "set_flag", "flag": "read_carls_papers"}]}
A paper-colored panel over the dimmed game, titled THE CHIEF SCIENTIST’S PAPERS, with the first two numbered items of Carl’s project minutes.
Carl's papers on the note screen. The game is dimmed and paused behind it.

The note screen is a CanvasLayer with a 72% black ColorRect, and a 620 by 440 PanelContainer with a paper-colored StyleBoxFlat and a ScrollContainer for the text. While it’s up the party can’t move and the game clock is stopped.

A note opens by itself the first time the party has it. The screen listens to the inventory’s changed signal and only sets a flag. Then in _process, when no other screen has the input and no NPC is talking, it opens the first unread note in the inventory. So a note taken from a chest opens after the chest screen closes, and never in the middle of a conversation. The ids of read notes are saved in a read list, and on_read only runs when an id is added to it, so the flag gets set once. The note is kept and can be read again from the inventory.

Calling experience juice

In my description of the place, the party climbs out of a vat of juice, and juice is experience. Renaming it was a text change to 57 strings. The code still says xp. Then 22 tests failed because they expected the old words or the old counts, and each one had to be fixed.

An injector is a misc item with a juice number. A Juice Ampoule has 60 and a Juice Injector has 200. Using one puts all of it into the one character shown in the inventory, it isn’t shared, and it’s banked the same way experience always was. Eating Subject 29 is shared, so each of the four gets 30.

Stocking a chest from the party that opens it

A new game can start with nothing now. It’s one static switch (or --start-bare on the command line). The normal start runs exactly as before, and then the inventory is cleared, everything equipped that isn’t an organ comes off, and the spell binders are blanked.

The first chest then gives back what this party should have. A stock in the content can have a rule:

{"id": "breakout_start",
 "contents": [{"item": "clotting_draught", "count": 2}],
 "per_member": {"weapon_tier": 1, "spells": true}}

For each character, the rule finds their highest-ranked weapon skill (the order of the skill list settles a tie) and adds the first tier 1 weapon for that skill. Then it adds a sheet for every starting spell that isn’t in their binder. Nothing is rolled, so the same party always gets the same chest. The default party in the film gets 12 items, including a Carcass Splitter, an Offcut Cleaver and a Bone Saw.

The rule runs the first time the chest is opened, because the party doesn’t exist when the world is built. I couldn’t leave the chest empty until then, because an empty chest shows “empty” and goes dark. So it’s built with its fixed contents and the rule’s share is added on first open. The chest’s id goes into a saved stocked list so it’s never filled twice.

What isn’t done?

The two quests can’t be finished. They end in a town that has nothing in it yet. The content validator refuses a quest stage with no way forward, and I didn’t want a fake flag to get past it, so a stage can now say awaits with a note about what’s missing. The validator treats that stage as leading to the next one, and the quest sits open in the journal.

Every number here is a placeholder: 60 and 200 for the items, 120 for Subject 29, and quest rewards of 500 and 300. One of the lockers has a code that nothing in the game tells the party yet.

When this merged with the floors branch, both had added content, so the tests’ expected counts had to be added up by hand: 8 quests, 14 NPCs and 52 doors.

I read every line the player sees as one text file and I think it’s great. Carl’s first line is “THEY SAID I WAS MAD! It is in the minutes.” The ward key’s description is “On a lanyard, with a tag: SUBJECTS, DO NOT FEED. Somebody has added: MORE.” I do want an easier way to manage and edit those files, and that’s a follow-on.

Twelve captioned frames from the film in two columns, from the empty inventory through the locker, Carl, Subject 29, Helena, the papers and the injector.
The film's frames in order, each with its caption.