Let's Code: Mini-Cluedo

My oh my and here I thought I was the only one thinking of creating a Dendry version of Mini-Cluedo :smiley: Sounds like :cake: :cake:!

1 Like

Mini-Cluedo in Dendry

Here’s my stab at the Mini-Cluedo challenge using Dendry!

I put up the game at itch, but it is a clone of Daniel’s version textwise, so nothing new there.

I started out creating the game using Borogove, but at some point the process broke down. Autumn Chen confirmed she could compile the game, so I fired up docker and used that instead to compile my game.

Creating a Dendry project

First, I created an info.dry file containing some basic information about the game. I used the IFID Generator to grab a unique IFID.

info.dry

title: mini-cluedo
author: Onno Brouwer
ifid: B7170209-E784-4AF6-AC6D-C5EAB1DD3B27
firstScene: utilities

A unit of content served by Dendry is called a scene and is stored in a file with naming convention scenename.scene.dry.

Creating utilities

The info.dry file tells us that the first scene of the game is called utilities and will therefore be stored in utilities.scene.dry. I use this scene to create the list of murder weapons used in Daniel’s game, along with some general-purpose functions I plan to be using later.

utilities.scene.dry

title: utilities
onArrival: {!
//  static data
    Q.weapons = [
        "waffle iron",
        "poisoned croquembouche",
        "spell of ancient power",
    ];
//  functions
    Q.all_keys   = function(o) { return Object.keys(o); }
    Q.pick_entry = function(a) { return a[Math.floor(Math.random() * a.length)]; }
    Q.list_text  = function(a) {
        let t = "";
        let n = a.length;
        a.forEach((v,i) => {
            if (i) {
                if (n > 2) { t += ","; }
                if (i == n-1) { t += " and"; }
                t += " ";
            }
            t += v;
        })
        return t;
    }
!}
goTo: rooms

Dendry does not have support for structured data (yet), all we can use are simple variables. However we can easily drop down in JavaScript by escaping the code using a {! ... !} construct. Any simple variables (numbers or strings) stored in the Q object in JavaScript are accessible by Dendry, so I am using this object to store any data I want to keep around during the game.

In the utilities scene the onArrival entry allows us to execute code when the game enters the scene. There is also an onDeparture entry, but I am not making use of that here.

variable purpose
Q.weapons holds the list of murder weapons used in the game
Q.all_keys given an object, returns the list of keys used by this object
Q.pick_entry given a list, returns a random entry in the list
Q.list_text given a list, joins the list entries together in a comma-separated text

Creating rooms

The goTo entry tells Dendry where to go after the scene has been completed. In this case, we go to the rooms scene, where I define a list of rooms used in the game, along with some helper functions to manipulate rooms.

rooms.scene.dry

title: rooms
onArrival: {!
//  static data
    Q.rooms = {
        hedgemaze:  { name: "the hedge maze"  },
        fountain:   { name: "the fountain"    },
        rosegarden: { name: "the rose garden" },
    }
//  functions
    Q.all_rooms = function()  { return Q.all_keys(Q.rooms); }
    Q.pick_room = function()  { return Q.pick_entry(Q.all_rooms()); }
    Q.room_name = function(r) { return Q.rooms[r].name; }
!}
goTo: persons
variable purpose
Q.rooms holds the list of rooms used in the game, and provides a name for each room
Q.all_rooms returns a list of all rooms
Q.pick_room returns a random room
Q.room_name given a room, returns its name

Creating people

Next up: persons! We need to define a list of persons used in the game, along with some helper functions to manipulate persons.

persons.scene.dry

title: persons
onArrival: {!
//  static data
    Q.persons = {
        peach: { name: "Miss Peach",     verb: "flounces" },
        brown: { name: "Reverend Brown", verb: "shuffles" },
        gray:  { name: "Sergeant Gray",  verb: "strides"  },
    }
//  functions
    Q.all_persons = function()    { return Q.all_keys(Q.persons); }
    Q.pick_person = function()    { return Q.pick_entry(Q.all_persons()); }
    Q.person_name = function(p)   { return Q.persons[p].name; }
    Q.person_verb = function(p)   { return Q.persons[p].verb; }
    Q.person_room = function(p)   { return Q.persons[p].room; }
    Q.move_person = function(p,r) { Q.persons[p].room = r; }
!}
goTo: intro
variable purpose
Q.persons holds the list of persons used in the game, and provides a name and verb
Q.all_persons returns a list of all persons
Q.pick_person returns a random person
Q.person_name given a person, returns his/her name
Q.person_verb given a person, returns his/her verb for moving around
Q.person_room given a person, returns his/her current room (will be set later)
Q.move_person given a person and a room, moves the person into the room

Creating the intro

So far, we have not presented anything to the user. It is about time to change that, and present some eye candy and tell the user a bit of story. Enter the intro scene.

intro.scene.dry

title: intro
new-page: true
onArrival: {!
    Q.crimeScene = Q.pick_room();
    Q.murderer = Q.pick_person();
    Q.crimeSceneName = Q.room_name(Q.crimeScene);
    Q.murdererName = Q.person_name(Q.murderer);
    Q.all_persons().forEach((p) => { Q.move_person(p,"hedgemaze"); });
!}

= MINI-CLUEDO

{!<img src="sherlock.jpg"/>!}

Could it be? A murder most foul, at this pleasant garden party? Your crime senses are tingling—it must be!

You've dispersed all the guests except your three suspects. Now, can you prove your suspicions before it's too late? Or is this the end of your illustrious detecting career?

- @fountain: The game is afoot!
- @credits: Credits
variable purpose
Q.crimeScene the location where the murder took place
Q.crimeSceneName the name of the crime scene
Q.murderer the person who committed the murder
Q.murdererName the name of the murderer

First, we use the new-page entry to tell Dendry clear the screen and start a new page. Here I accidentally used an alternate syntax (I should have written it as newPage for consistency, but I forgot…). We then pick the crime scene and the murderer, and move all persons to the hedge maze.

The = MINI-CLUEDO content specifies an HTML heading. We embed a public domain Sherlock Holmes image (using {! ... !} within content allows us to embed arbitrary HTML) and give some backstory.

Finally, we offer the player two options: Start the game by moving to the fountain (The game is afoot!) or view the Credits. Dendry lists links at the end of the page using a - @sceneName syntax, optionally followed by a link title. If no link title is specified. the title of the target scene will be used.

Here’s what the intro looks like:

Creating the credits

First, let’s have a look at the Credits scene.

credits.scene.dry

title: credits
new-page: true

= MINI-CLUEDO

> Written by Onno Brouwer for the {!<a href="https://intfiction.org/t/lets-code-mini-cluedo/75653" target="_blank">Let's Code: Mini-Cluedo</a>!} challenge.

> Coded using {!<a href="https://github.com/aucchen/dendry" target="_blank">Dendry</a>!}, originally by Ian Millington, with heavy modifications by Autumn Chen.

= Acknowledgments

> Original text taken from the Dialog game {!<a href="https://ifdb.org/viewgame?id=zwnijqzwab1d14vh" target="_blank">Mini-Cluedo</a>!} by {!<a href="https://ifdb.org/search?searchfor=author%3ADaniel+M.+Stelzer" target="_blank">Daniel M. Stelzer</a>!}.

> The cover art is in the public domain and can be found on {!<a href="https://commons.wikimedia.org/wiki/File:Sherlock_Holmes_-_The_Man_with_the_Twisted_Lip.jpg" target="_blank">Wikimedia</a>!}.

> This is a work of fiction. Any names or characters, businesses or places, events or incidents, are fictitious. Any resemblance to actual entities or actual events is purely coincidental.

- @intro: Continue...

As with the intro scene, we start on a new page, present a heading and serve some content. I use the quotation markup (>) to avoid every paragraph starting with an indented first line, as is the default mode of displaying text in Dendry.

At the end I link back to the intro scene with Continue... as its title.

1 Like

Describing locations

At the end of the intro scene we enter the game proper by moving to the fountain.

fountain.scene.dry

title: fountain
newPage: true
onArrival: location = "fountain"; here = "the fountain"
goTo: persons.present

**The Fountain**

The pleasant burble of the fountain could easily cover a victim's screams.

I use onArrival to set the location and here variables to the location and its name, respectively. We use a strong emphasis (**) to mention the location name and then provide a brief description. We start a location at a new screen and therefore set the newPage variable to true (any scenes NOT marked as such, will not create a new page but will be added to the current page.) After showing the content, we will move to the persons.present subscene, to report on the persons currently present in our location. We will get back to this in a moment.

While we’re at it, let’s also define the other locations.

hedgemaze.scene.dry

title: hedgemaze
newPage: true
onArrival: location = "hedgemaze"; here = "the hedge maze"
goTo: persons.present

**The Hedge Maze**

What gruesome secrets could be hidden amidst these tangled paths?

rosegarden.scene.dry

title: rosegarden
newPage: true
onArrival: location = "rosegarden"; here = "the rose garden"
goTo: persons.present

**The Rose Garden**

The thorns are long and sharp, the petals as red as blood.

Describing people

After describing the current location, we want to report on the persons present in this location. We revisit the persons.scene.dry file and add the following:

persons.scene.dry

@present
title: persons.present
onArrival: {!
    let list = Q.all_persons()
        .filter((p) => Q.person_room(p) == Q.location)
        .map((p) => Q.person_name(p));
    Q.text = (list.length == 0) ? " " : "You see " + Q.list_text(list) + " here.";
!}
goTo: moving

[+ text +]

A scene starting as @sceneName is what Dendry calls a subscene. Its full scene name is mainScene.sceneName where mainScene is the first scene in the scene file, derived from its filename (here persons.scene.dry), so specifying @present will end up with the full scene name of persons.present.

Basically what we do here, is to grab the list of all persons, filter on persons actually present in the current location, and then map them to their names, so we end up with a list of names of persons present in the location, which may or may not be empty.

We then create a Q.text variable and set it to a single space (" ") when nobody’s here; otherwise, we take the list and change it to a comma-separated text with some additional tekst added to it. In the content of the scene, I use [+ text +] to display the contents of the Q.text variable.

One small pitfall with Dendry is an empty string which would be printed as the number zero (“0”). To avoid this I use a single space (" ") instead.

Moving people

At the end of the persons.present scene, we move to the persons.moving scene. This is also a subscene, but we can omit the main part when navigating to a subscene within the same scene file. Let’s have a look at how we move people around:

persons.scene.dry

@moving
title: persons.moving
onArrival: {!
    let list = [];
    Q.all_persons().forEach((p) => {
        let oldRoom = Q.person_room(p);
        Q.move_person(p,Q.pick_room());
        let newRoom = Q.person_room(p);
        let person = Q.person_name(p);
        let verb = Q.person_verb(p);
        if (oldRoom == Q.location) { // starting at location
            if (newRoom == Q.location) { // milling about
                list.push(person + " mills about");
            } else { // leaving
                list.push(person + " " + verb + " away to " + Q.room_name(newRoom));
            }
        } else if (newRoom == Q.location) { // arriving at location
            list.push(person + " " + verb + " in from " + Q.room_name(oldRoom));
        }
    });
    Q.text = (list.length == 0) ? " " : list.join(". ") + ".";
!}
goTo: choices

[+ text +]

First we create an empty list. Then, we iterate over the list of all persons, remember the original room, pick a new one, and decide how we are going to report this. We also grab the person’s name and verb so we can add a bit of flavor text. The patterns we have are the following:

old room new room report
location location NAME mills about
location elsewhere NAME VERB away to NEWROOMNAME
elsewhere location NAME VERB in from OLDROOMNAME
elsewhere elsewhere

We push each person’s report on the list (unless they did not start or end up in the current location). Finally, we use our space trick in case the list is empty, and otherwise, we add proper punctuation and turn the list in a number of sentences, which we can then print out.

Offering choices

Finally, we move to the interesting bit: player interaction! We enter the choices scene.

choices.scene.dry

title: choices

- #look
- #walk
- #talk
- @wait

Now this looks a bit different. We refer to a number of scenes but we do not always directly specify them here. IMHO this is one of the major strengths of Dendry: the tag system. We can add one or more tags to a scene, and when setting up a link referring to a tag, all matching scenes will be populated in the link list. We can also use a condition for a scene to decide whether it is active, so the list of scenes thus presented can be dynamic.

Marker Name Result Purpose
# look any scene(s) with a LOOK tag search the current location
# walk any scene(s) with a WALK tag walk to another location
# talk any scene(s) with a TALK tag question or accuse persons
@ wait the WAIT subscene following wait without doing anything

Searching the current location

First, let’s deal with searching the current location. We add two subscenes to the current scene file.

choices.scene.dry

@look-right
title: Search [+ here +]
tags: look
view-if: crimeScene = location
goTo: persons.moving

{!<hr>!}

Your search uncovers some conclusive evidence: a murder took place in [+ here +]!

@look-wrong
title: Search [+ here +]
tags: look
view-if: crimeScene != location
goTo: persons.moving

---

No crimes seem to have happened here.

The tags entry specifies one or more tags attached to the scene. Here we use the look tag; these scenes will be considered when populating the - #look link. The view-if (or viewIf) entry specifies a condition; if the condition evaluates to true, then the scene will be added to the link list. In this case, the conditions are complementary, and thus we will end up with exacptly one match (we either search the crime scene, or we search another scene):

Subscene Condition Purpose
look-right crimeScene = location We search the crime scene
look-wrong crimeScene != location We search another scene

We refer to the current location name with [+ here +] (referring back to the Javascript variable Q.here set when arriving at the current location) when we are at the crime scene. We also use the location name in the title, so the link will show up as Search the fountain if we are doing a search while at the fountain, for example.

One small note: When doing a test for equality, dendry uses = instead of ==.

Recall that if we do not set the new-page or newPage entry, the content of this scene is added to the current scene. I add a horizontal rule by using --- in the Dendry markup. For reasons unknown to me at this time, it sometimes does not work; I used for the scenes where it stopped working a bit of HTML instead: {!<hr>!}.

Finally, after reporting on the current location, we move to the persons.moving subscene to give our persons a chance to move around.

Waiting

Let’s get that WAIT scene out of the way.

choices.scene.dry

@wait
title: Wait
goTo: persons.moving

---

Time passes...

All it does is to tell us that time is passing, and move to the persons.moving scene to let our persons do their thing.

1 Like

Walking to another location

We use the walk tag to refer to scenes where the player moves to another location. Let’s revisit each location scene and add a subscene to handle it.

fountain.scene.dry

@walk
title: Go to the fountain
tags: walk
viewIf: location != "fountain"
goTo: fountain

hedgemaze.scene.dry

@walk
title: Go to the hedge maze
tags: walk
viewIf: location != "hedgemaze"
goTo: hedgemaze

rosegarden.scene.dry

@walk
title: Go to the rose garden
tags: walk
viewIf: location != "rosegarden"
goTo: rosegarden

We use the walk tag, provide a title for the link, set a condition to only include it when it is NOT the current location, amd move to the location scene so we can create a new page and display its contents. Since we currently have three locations, this implies that there will always be two out of these three links available to move around.

Talking to people

Let’s move on to talking to people. First, let’s deal with Reverend Brown.

brown.scene.dry

@question-right
title: Question Reverend Brown
tags: talk
viewIf: {! return Q.person_room("brown") == Q.location && Q.murderer == "brown"; !}
goTo: persons.moving

---

You question Reverend Brown.

As you question Reverend Brown you notice a slight nervous twitch. No doubt about it, you've found your murderer!

@question-wrong
title: Question Reverend Brown
tags: talk
viewIf: {! return Q.person_room("brown") == Q.location && Q.murderer != "brown"; !}
goTo: persons.moving

---

You question Reverend Brown.

He shakes his head. "May the Lord forgive you for such an insulting question."

@accuse
title: Accuse Reverend Brown
tags: talk
onArrival: accused = "brown"
viewIf: {! return Q.person_room("brown") == Q.location; !}
goTo: choices.accuse

We have three subscenes for our Reverend, all using the talk tag.

Subscene Condition Purpose
question-right present and the murderer Show the Reverend to be nervous
question-wrong present and not the murderer Let the Reverend demonstrate his innocence
accuse present Accuse the Reverend

If the Reverend is not in the current location, all tests fail and none of these links will be populated in the choices scene. If the Reverend is in the current location, either one of the question links will be populated, as well as the accuse link. In the case of the accuse link, we merely set the accused variable to the Reverend, and defer handling to the choices.accuse subscene.

Here is a similar setup for Sergeant Gray.

gray.scene.dry

@question-right
title: Question Sergeant Gray
tags: talk
viewIf: {! return Q.person_room("gray") == Q.location && Q.murderer == "gray"; !}
goTo: persons.moving

---

You question Sergeant Gray.

As you question Sergeant Gray you notice a slight nervous twitch. No doubt about it, you've found your murderer!

@question-wrong
title: Question Sergeant Gray
tags: talk
viewIf: {! return Q.person_room("gray") == Q.location && Q.murderer != "gray"; !}
goTo: persons.moving

---

You question Sergeant Gray.

"Harrumph! An officer of the law commit murder? Unthinkable! Absolutely absurd."

@accuse
title: Accuse Sergeant Gray
tags: talk
onArrival: accused = "gray"
viewIf: {! return Q.person_room("gray") == Q.location; !}
goTo: choices.accuse

And here’s the setup for Miss Peach.

peach.scene.dry

@question-right
title: Question Miss Peach
tags: talk
viewIf: {! return Q.person_room('peach') == Q.location && Q.murderer == "peach"; !}
goTo: persons.moving

---

You question Miss Peach.

As you question Miss Peach you notice a slight nervous twitch. No doubt about it, you've found your murderer!

@question-wrong
title: Question Miss Peach
tags: talk
viewIf: {! return Q.person_room('peach') == Q.location && Q.murderer != "peach"; !}
goTo: persons.moving

---

You question Miss Peach.

"I do declare! Bless your heart, you really thought I could do such a wicked thing?"

@accuse
title: Accuse Miss Peach
tags: talk
onArrival: accused = "peach"
viewIf: {! return Q.person_room("peach") == Q.location; !}
goTo: choices.accuse

Accusing people

Now there’s only one thing left to do: handle the accusations. We return to the choices scene and add the final subscenes.

choices.scene.dry

@accuse
title: accuse
onArrival: {!
    Q.name = Q.person_name(Q.accused);
    Q.weapon = Q.pick_entry(Q.weapons);
!}
goTo: win if accused = murderer and location = crimeScene; lose if accused != murderer or location != crimeScene

{!<hr>!}

You accuse [+ name +].

"I knew it! It was [+ name +], in [+ here +], with the [+ weapon +]!"

@win
title: win

No one can fault your logic, and soon the murderer is carted away for trial.

- @intro: Victory!

@lose
title: lose

But your logic was unsound. The murderer goes free.

- @intro: Defeat…

First we set the name of the accused and choose a random weapon. We already have the current location represented by here.
Then we goTo either the win subscene (when we got both the murderer and the crime scene right) or the lose subscene (when we have either the murderer or the crime scene (or both) wrong). A goTo with multiple destinations will normally pick a random destination, but in this case, both destinations are guarded by mutially excluding conditions, so only one will be active at any time.

Finally, in the win and lose scenes we get told about our result and we return to the intro scene for another try at the game if we feel so inclined.

3 Likes

Post Mortem

Looking back, I find I relied rather heavily on JavaScript functionality, probably because it was very easy to add to scenes, and I could keep JavaScript and Dendry code together. I only used basic Dendry functionality to implement my game, Dendry has a lot more functionality to offer!

Entry Purpose
title Provide a title for links referring to the scene
newPage new-page Start a new page when set to true
tags Provide a list of tags to match against
viewIf view-if Provide a condition to decide whether the scene is active or not
onArrival on-arrival Code to execute when entering the scene
goTo go-to Provide a list of scenes to pick from for the next scene

One thing I could have done differently was to make use of qdisplay functionality to map qualities (the Dendry variables) to some descriptive text. Then I could refer to a person using an integer and map that to the person’s name. Likewise, for location names.

personname.qdisplay.dry


(0) : Miss Peach
(1) : Reverend Brown
(2) : Sergeant Gray

And then I could use in scene content a construct like [+ murderer : personname +] to print out the person’s name without using any fancy JavaScript.

Building the game

As mentioned in my first post, at some point I had to abandon Borogove because it gave me errors while there was nothing wrong with my game. I eventually started using Docker containers to build my game. It increases the test cycle, but at least it worked for me. I would drop into a Linux shell and use the following commands to build my game:

git clone https://github.com/OnnoBrouwer/mini-cluedo.git
cd mini-cluedo
IMAGE="node:latest"
docker run --rm --name compiler --entrypoint bash -v .:/mini-cluedo $IMAGE /mini-cluedo/build-html.sh
zip -j mini-cluedo.zip out/html/*

build.html

#!/bin/bash

cd mini-cluedo
npm add aucchen/dendry
npm run dendry make-html

package.json

{
  "dependencies": {
    "dendry": "github:aucchen/dendry"
  },
  "scripts": {
    "dendry": "dendry"
  }
}

The image I added to my repo as out/html/sherlock.jpg so it would be included in the final mini-cluedo.zip file.

3 Likes

Fantastic! It looks like this challenge (showing a list of choices based on independent pieces of state) is actually exactly what Dendry is best at, with the tags system. Am I getting that right?

1 Like

Exactly! This tag system is what makes Dendry very powerful IMHO. And it is relatively easy to influence what goes in the list, and even in what order etc.

One thing I forgot to mention is that by default Dendry does not scroll when the text flows below the bottom of your window. I understand I can configure this in the project but it somehow got lost in my notes exactly how to do this. I work around it by hitting the options menu at the top right corner and manually set it to animate. Then the window will scroll along as content gets added:

I could wait until Source Code Day 2026 but since most of it is already posted here I made my GitHub repository public.

OnnoBrouwer/mini-cluedo

1 Like