Let's Code: Mini-Cluedo

Over in the Code Wars thread, I proposed this challenge with Ink in mind. Assembling a set of choices based on multiple independent factors isn’t always straightforward, so I wanted to see how different choice-based platforms would handle it.

It would, of course, be easy in a parser-based system, since those tend to have elaborate world models built in.

So what if we used a parser system, without its world model?

Today we’re going to implement this challenge in Dialog (a parser system with good support for hyperlinks), without using its standard library or world model. Everything needs to be built entirely from scratch!

Our challenge is:

A mini Cluedo game with multiple rooms to move through and multiple people also moving around. The choices available on each “turn” are the choices from the current room, plus the choices from whichever person (or people, if you want) are in that room. The winning choice, “accuse [X person] of doing something in [Y room]”, requires a specific person and a specific place.

So for example, if you’re in the Study with Professor Plum, your choices might be “investigate the Study”, “investigate Professor Plum”, “ask Professor Plum to come with me”, “accuse Professor Plum in the Study”. The point is to have the choice list be assembled based on multiple independent factors.

So let’s get going!

10 Likes

First of all, we’re going to need our rooms and our suspects. These will be the only objects in the game. I’m taking these names from some of the various Cluedo spinoffs.

#hedgemaze
(name *) the hedge maze
(room *)
(descr *) What gruesome secrets could be hidden amidst these tangled paths?

#fountain
(name *) the fountain
(room *)
(descr *) The pleasant burble of the fountain could easily cover a victim's screams.

#rosegarden
(name *) the rose garden
(room *)
(descr *) The thorns are long and sharp, the petals as red as blood.

#peach
(name *) Miss Peach
(person *)
(dict *) Ms.
(verb *) flounces

#brown
(name *) Reverend Brown
(person *)
(dict *) Rev.
(verb *) shuffles

#gray
(name *) Sergeant Gray
(person *)
(dict *) Sgt. Grey
(verb *) strides

(dict (person $Who)) (name $Who)

So now we have three rooms, marked with (room $), and three people, marked with (person $). Each one has a name; rooms also have a description, and people have a verb indicating how they move. People also have a (dict $) predicate for parsing, because Dialog is a parser system at heart and you can’t truly escape that.

2 Likes

If you’re not familiar with Dialog syntax, a word starting with # is an object, and all you have to do to create an object is use its name anywhere in the program. If you put an object at the very start of a line, it becomes the “current topic”; you can then use * in place of its name, for legibility.

Lines starting with ( create rules, which are the lifeblood of the game. Now if we query (name #hedgemaze) it’ll print “the hedge maze”, while (name #fountain) will print “the fountain”, and so on.

If you query something that doesn’t have a rule at all, it’ll fail. So (person #peach) will succeed, while (person #rosegarden) will fail.

I’m not going to go too in-depth on Dialog syntax here, but those are the basics.

2 Likes

Now, the challenge says our suspects need to move around. Which means we need to give them a way to be in a room in the first place!

@($Obj is in $Parent)
	($Obj has parent $Parent)

(*(person $) is in #hedgemaze) %% They all start in one place

This defines a new ($ is in $) predicate using the built-in ($ has parent $) structure, and puts everyone in the #hedgemaze to start out.

Now, we want to be able to move people to random rooms. Something like this:

(move everyone to new locations)
	(exhaust) {
		*(person $Person)
		(move $Person to random location)
	}

Run through every (person $), and call (move $ to random location) on them.

How will we move someone to a random location?

(move $Person to random location)
	($Person is in $Old)
	(random room $New)
	(if) ($Old = $New) (current location $Old) (then)
		(name $Person) mills about.
	(elseif) (current location $Old) (then)
		(name $Person) (verb $Person) away to (name $New).
	(elseif) (current location $New) (then)
		(name $Person) (verb $Person) in from (name $Old).
	(endif)
	(now) ($Person is in $New)

Pick a random room, move them to it, then if they moved from or to the (current location $), narrate what happened. We haven’t defined what (current location $) means yet, but we don’t have to worry about that now.

How do we get a random room? Or a random person, for that matter?

(random room $Room)
	(collect $R)
		*(room $R)
	(into $Rooms)
	(random element of $Rooms is $Room)

(random person $Person)
	(collect $P)
		*(person $P)
	(into $People)
	(random element of $People is $Person)

Simple: we make a list of all the rooms (or people), then pick a random element.

But how do we pick a random element?

(random element of $List is $Element)
	(length of $List is $Length)
	(random from 1 to $Length into $N)
	(element $N from $List is $Element)

We’re getting closer now! But without the standard library, we’re going to need to write all these list-manipulation predicates ourselves. Time for a bit of recursion!

(element 1 from [$Head | $] is $Head)
(element $N from [$ | $Rest] is $Element)
	($N minus 1 into $Nm1)
	(element $Nm1 from $Rest is $Element)

(length of [] is 0)
(length of [$|$Rest] is $Np1)
	(length of $Rest is $N)
	($N plus 1 into $Np1)

[] is the syntax for an empty list, and [ $Head | $Tail ] is the syntax for a list whose first element is $Head; $Tail is a list containing all the other elements. (If you’ve done Haskell or LISP, this should look familiar.)

For this example, we could also have written simpler code that hardcodes the list of three rooms and three people. But this way, it’s easy to add more rooms or suspects in the future.

1 Like

As for the player, we don’t actually need an object for them. After all, it’s not like they’ll be accusing themself! So we can just store the player’s location in a global variable.

(global variable (current location #fountain))

And we’ll want to describe that location in classic parser-IF style:

(describe new location)
	(current location $Loc)
	(span @bold) { (uppercase) (name $Loc) } (line)
	(descr $Loc) (par)
	(collect $Person)
		*($Person is in $Loc)
	(into $People)
	(if) (nonempty $People) (then)
		You see (listing $People) here. (par)
	(endif)

Which means we need a way to list things.

(listing [])
(listing [$Single]) (name $Single)
(listing [$One $Two]) (name $One) and (name $Two)
(listing $Longer) (sub-listing $Longer)

(sub-listing [$Single]) and (name $Single)
(sub-listing [$Head | $Rest]) $Head , (sub-listing $Rest)

This will put all the commas and "and"s in the right place, no matter how many suspects end up being in the game. The standard library has a much more elaborate listing system, but I’m trying to code all of this without referencing the standard library’s implementation—this should be entirely from scratch. And really, anything more elaborate would be overkill here.

(Even this is somewhat overkill, since we’ll never need to list more than three things. But the goal is to make this code easily extensible.)

While we’re at it, let’s make two more global variables.

(global variable (murderer $))
(global variable (crime scene $))

It seems a mystery is afoot!

1 Like

Now, Dialog is at heart a parser system, so it’s hard to actually avoid the parser entirely. Links just insert words into the parser, rather than bypassing it completely, and the player can always type in their own commands if they want to. So we’re going to build an extremely basic parser.

(understand [go to | $Words] as [go $Loc])
	*(match $Words as room $Loc)

(understand [search | $Words] as [search $Loc])
	*(match $Words as room $Loc)

(understand [question | $Words] as [question $Who])
	*(match $Words as person $Who)

(understand [accuse | $Words] as [accuse $Who])
	*(match $Words as person $Who)

(understand [wait] as [wait])

Five actions should be plenty. We’re going to take whatever words the player types and parse them into a list: the first element is the action, the second element is the object it’s acting on.

To get those objects:

(match $Words as room $Loc)
	(determine object $Loc)
		*(room $Loc)
	(from words)
		(name $Loc)
	(matching all of $Words)

(match $Words as person $Who)
	(determine object $Who)
		*(person $Who)
	(from words)
		*(dict $Who)
	(matching all of $Words)

Notably, this doesn’t do any scope checking: you can search any room you want, and question any person you want, no matter where you are. That’s not ideal. So we’ll have to handle that in the implementation of the actions.

(perform [go (current location $Loc)])
	But you're already at (name $Loc).

(perform [go $New])
	(current location $Old)
	You make your way from (name $Old) to (name $New). (par)
	(now) (current location $New)
	(describe new location)

Rules are tried in the same order they appear in the source. So if we put our more specific rule first, that one will be tried first; the second rule will only be tried if the first one fails.

We’ll take plenty of advantage of this for our action rules.

(perform [search ~(current location $Loc)])
	But you're not at (name $Loc).

(perform [search (crime scene $Loc)])
	Your search uncovers some conclusive evidence: a murder took place in (name $Loc)!

(perform [search $])
	No crimes seem to have happened here.

First, check if it’s not the current location; then, check if it’s the crime scene; finally, give the default response.

(perform [question $Suspect])
	(current location $Loc)
	~($Suspect is in $Loc)
	But (name $Suspect) isn't here.

(perform [question (murderer $Suspect)])
	As you question (name $Suspect) you notice a slight nervous twitch. No doubt about it, you've found your murderer!

(perform [question #peach])
	"I do declare! Bless your heart, you really thought I could do such a wicked thing?"

(perform [question #brown])
	She shakes her head. "May the Lord forgive you for such an insulting question."

(perform [question #gray])
	"Harrumph! An officer of the law commit murder? Unthinkable! Absolutely absurd."

Same thing here, but with a different default response for each person.

(perform [accuse $Suspect])
	(current location $Loc)
	~($Suspect is in $Loc)
	But (name $Suspect) isn't here.

(perform [accuse $Suspect])
	(current location $Loc)
	(murderer $Suspect)
	(crime scene $Loc)
	"I knew it! It was (name $Suspect), in (name $Loc), with the (random weapon)!" No one can fault your logic, and soon the murderer is carted away for trial. (par)
	(span @bold) { \*\*\* Victory! \*\*\* }
	(quit)

(perform [accuse $Suspect])
	(current location $Loc)
	"I knew it! It was (name $Suspect), in (name $Loc), with the (random weapon)!" But your logic was unsound. The murderer goes free. (par)
	(span @bold) { \*\*\* Defeat... \*\*\* }
	(quit)

Weapons don’t really feature at all in our little scenario, but let’s add them to the text for a bit of variety.

(random weapon)
	(select)
		waffle iron
	(or)
		poisoned croquembouche
	(or)
		spell of ancient power
	(at random)

And last and certainly least:

(perform [wait])
	Time passes.
1 Like

Now for the heart of this challenge: putting together the list of choices!

(show choices)
	(current location $Loc)
	(link) { Search (name $Loc) } (line)
	(exhaust) {
		*(room $Room)
		~(current location $Room)
		(link) { Go to (name $Room) } (line)
	}
	(exhaust) {
		*($Person has parent $Loc)
		(link) { Question (name $Person) } (line)
		(link) { Accuse (name $Person) } (line)
	}
	(link) Wait

And our actual gameplay loop:

(program entry point)
	(intro)
	*(repeat forever)
	(if) (interpreter supports inline status bar) (then)
		(inline status bar @inline) (show choices)
	(else)
		(div @inline) (show choices)
	(endif)
	(par) > (get input $Words)
	(collect $Option)
		*(understand $Words as $Option)
	(into $Options)
	(if) ($Options = [$Single]) (then)
		(perform $Single)
		(par)
		(move everyone to new locations)
	(elseif) (empty $Options) (then)
		That didn't make sense!
	(else)
		Please be more specific.
	(endif)
	(fail) %% Go back to the (repeat forever)

We’re going to use an “inline status bar” to show the choices, a special feature of the Å-machine that lets you pop up a choice list each turn, then erase it the next turn. This doesn’t work on the Z-machine, so we explicitly check if it’s supported; if it’s not, we just display the choices as normal text.

This doesn’t really make for a great Z-machine game, since the Z-machine can’t do links, but it’s good to be compatible when possible. That’s also why we have the (dict $) rules: in case someone types in the commands manually, it’s reasonable to contract “Miss” to “Ms”, or spell “Gray” as “Grey”.

And, that’s why we collect every possible parse, instead of just assuming there will always be exactly one. Our links will never produce a broken or ambiguous command, but someone typing by hand might, and it’s polite to give some sort of error message instead of just crashing.

(How can there be ambiguity in such a simple parser? It’s unlikely but possible that someone would type GO TO THE, which could match any of the rooms.)

1 Like

The last thing we have to do is throw together a little intro explaining the situation:

(intro)
	Could it be? A murder most foul, at this pleasant garden party? Your crime senses are tingling—it must be! (par)
	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? (par)
	(span @bold) MINI-CLUEDO : An Interactive Demonstration by Daniel M. Stelzer (line)
	(compiler version) (par)
	
	(random room $Scene)
	(now) (crime scene $Scene)
	(random person $Killer)
	(now) (murderer $Killer)
	
	(describe new location)

And add a little boilerplate. I cheated slightly here: I deliberately avoided looking at the standard library while writing this, but I did go to the Miss Gosling source to grab this CSS, because I’m very bad at writing it by hand. Might as well take advantage of the work I already did last year!

(style class @inline)
	text-align: left;
	border: 1px solid rgb\(128, 128, 100\);
	background-color: rgba\(128, 128, 100, 0.33\);
	padding: 0.125em;
	padding-left: 0.67em;
	border-radius: 12px;
	margin-top: 1em;

I’m deliberately not doing any fancy styling for this demo, but testing shows that some bold headings would help a lot.

(style class @bold)
	font-weight: bold;

And finally, Dialog wants a bit of metadata to be provided, even for small example games. It’s good practice to do so, so let’s do it.

(story ifid) 7FA50285-1CB5-47B4-9E29-C542C54C65C7
(story title) Mini-Cluedo
(story author) Daniel M. Stelzer
(story release 1)

And there we have it! A small demo game written entirely from scratch, without relying on Dialog’s standard library or world model. It’s not a great game by any means, but it’s a complete one, with a simple puzzle to solve and NPCs that act on their own. Not bad for under 300 lines of code!

2 Likes

And that’s the end! If you want to try out the results, I’ve made an IFDB page here, which should have both the Å-machine web version and the Z-machine version available shortly. But you can also compile it yourself:

cluedo.dg (6.9 KB)

I hereby release all of this into the public domain, so you’re free to use it for anything you like. If you’ve got any questions, please feel free to ask! I threw this all together in a couple hours, so I’m sure there are bugs, weirdnesses, and badly-explained parts somewhere along the line.

3 Likes

Oh, shoot. And it’s not going to work on Parchment when the IFArchive upload goes through because I compiled it with the main version of Dialog, so it has the ZVM incompatibility.

Oh well. I’ll upload a new one once the current one finishes.

1 Like

I had a go at making the basic movement code in Strand. The game is barely playable, and there is no accuse feature yet. But you can talk to people!
Also not sure how the accuse is meant to work. Can you only accuse people int he room where they currently are. Because how does that work with choices?

Anyway. So i start with a really small map:

And i add my four characters:

  • Miss Scarlett
  • Colonel Mustard
  • Professor Plum
  • Mrs White

So that’s standard. Then this is currently the game code:

// Mini Cluedo Game file

TITLEMUSIC

GAMECOVER
[Strand Games](https://strandgames.com)
TITLEMUSIC

STORY
The intro.

BEGIN
\
The game begins.
UPDATEMAP
GOHALL
MOVELOOP^
MAIN

//////////////////////// move characters

// each one randomly picks an exit.
MOVEHALL
* LOUNGE
* DININGROOM

MOVEDININGROOM
* HALL
* LIBRARY

MOVELIBRARY
* DININGROOM
* LOUNGE

MOVELOUNGE
* HALL
* LIBRARY

// to move a character key on where they currently are.
MOVEANY > what is it in
* HALL
MOVEHALL
* DININGROOM
MOVEDININGROOM
* LIBRARY
MOVELIBRARY
* LOUNGE
MOVELOUNGE

// code to move a character
MOVECHR
MOVEITOUT
> put it in MOVEANY
MOVEITIN

//  we're leaving!
MOVEITOUT > is it in here
* yes
\n IT leaves.

// We're arriving!
MOVEITIN > is it in here
* yes
\n IT arrives.

// This makes a loop which moves characters every other turn
MOVELOOP<<
*
* MOVEEM

MOVEEM
> moveit MSCARLETT
> moveit CMUSTARD
> moveit PPLUM
> moveit MWHITE

In my version, you have to be in the appropriate room, with the appropriate person, to make your accusation. So if you think it’s Sergeant Gray in the Rose Garden, you need to go to the Rose Garden and wait for the Sergeant to wander in.

So the characters actuall navigate around the map with this, rather than randomly teleport.
Here’s a random transcript. Ignore the embedded markdown. what you get in debug mode!

[Strand Games](https://strandgames.com) The intro.

**Mini Cludo** by _A. Hacker_, v1.0.
Strand 1.2.21 (Gcc 14.3.0) Jul  6 2025, Core 1.22.
The game begins. You are in the hall.
(1) Go to the lounge
(2) Go to the dining room
> 1

> Go to the lounge.
You are in the lounge. [Mrs white](_MWHITE) is here, looking busy.
Professor plum arrives.
Mrs white leaves.
(1) Talk to Professor plum
(2) Go to the hall
(3) Go to the library
> 1

> Talk to Professor plum.

(1) What’s the answer?
(2) Where are you from?
(3) Ask Professor plum about the player
(4) Done
? 2

> Where are you from?
“I’m from Kerovnia.”
(1) What’s the answer?
(2) Ask Professor plum about the player
(3) Done
? 3

> Done.

(1) Talk to Professor plum
(2) Go to the hall
(3) Go to the library
> 3

> Go to the library.
You are in the library. [Miss Scarlett](_MSCARLETT) is here, staring into space.
Miss Scarlett leaves.
Professor plum arrives.
(1) Go to the lounge
(2) Go to the dining room
> z

> Z.
pom-de pom…
(1) Talk to Professor plum
(2) Go to the lounge
(3) Go to the dining room
> z

> Z.
pom-de pom…
Professor plum leaves.
Mrs white arrives.
(1) Go to the lounge
(2) Go to the dining room
> z

> Z.
Zzz…
(1) Talk to Mrs white
(2) Go to the lounge
(3) Go to the dining room
> 1

> Talk to Mrs white.

(1) What’s the answer?
(2) Where are you from?
(3) Ask Mrs white about the player
(4) Done
? 1

> What’s the answer?
“I’ve no idea.”
(1) Where are you from?
(2) Ask Mrs white about the player
(3) Done
? 2

> Ask Mrs white about the player.
“I’ve never seen you before.”
(1) Where are you from?
(2) Done
? 2

> Done.

Mrs white leaves.
(1) Go to the lounge
(2) Go to the dining room
> 2

> Go to the dining room.
You are in the dining room. [Mrs white](_MWHITE) is here, staring into space. [Miss Scarlett](_MSCARLETT) is here, staring into space.
(1) Talk to Mrs white
(2) Talk to Miss Scarlett
(3) Go to the library
(4) Go to the hall
> ask miss scarlett about mrs white

> Ask miss scarlett about mrs white.

“Sorry, I don’t know her.”
Miss Scarlett leaves.
Colonel mustard arrives.
Mrs white leaves.
(1) Talk to Colonel mustard
(2) Go to the library
(3) Go to the hall
>

That’s what i thought from your code. Might do the same here and see what comes out.

Parchment uses Bocfel now so it works. I played until I made a wrong accusation - is there no restart option after defeat?

1 Like

Oh excellent!

And nope, I probably should have implemented one. Make it so you have a choice to restart or quit at the end, or something like that. Maybe I’ll put that in a version 2 tomorrow, along with the ZVM compatibility.

For now, just gotta reload the page!

If we want to share examples of Mini-Cluedo in other systems, would you welcome that in this thread, or do you think it would make more sense to start a new one?

(I decided this was a good pretext to have a proper first go at Gruescript.)

2 Likes

I say whichever seems more readable to you! You’re welcome to post them here or make your own thread.

Let’s keep it in one place then!

I’ve not done any Gruescript before, but the docs are pretty readable, and the most structurally unusual thing (the division of logic into blocks that may either fail or succeed, with blocks run in order until one succeeds) is pretty reminiscent of Dialog. So I’m not certain that all of this counts as precisely idiomatic, but I don’t think I’ve done anything too ridiculous.

We start with a block of generic metadata:

game Mini-Cluedo
id minicluedo
author Adam Biltcliffe
version 1.0.0
person 2
examine off
conversation off
wait on
say This should have been the perfect society gathering ... until MURDER reared its ugly head! Three suspects remain at large, but can you pinpoint which of them is the guilty party before time runs out?

examine off means that we can’t examine things by clicking their names, which saves us writing some descriptions for this simple example. conversation off disables the built-in ASK/TELL topic system, which means we can focus on just the limited verb set specified in the description of the problem.

We need to create some rooms; Gruescript (following its parser heritage) has built-in syntax for creating a map based on compass directions, but we’re going to do as @Draconis did and just provide a verb for moving directly to each of the rooms in our rather simple map.

room foyer You're in the grand foyer.

room billiards_room You're in the billiards room.

room library You're in the library.

verb go_to_the_foyer
goto foyer

setverb go_to_the_foyer intransitive
!at foyer

verb go_to_the_billiards_room
goto billiards_room

setverb go_to_the_billiards_room intransitive
!at billiards_room

verb go_to_the_library
goto library

setverb go_to_the_library intransitive
!at library

The verb blocks tell us how to carry out the verb (which uses a single Gruescript command to move the player to the room in question). The setverb blocks tell us when the verb is available (in this case, when the player is not already in the desired room). The choice that is actually displayed to the player is the internal name of the verb with underscores converted to spaces, e.g. go to the foyer, which is fine for our purposes (I’m not sure if there’s a way to change this).

Now we can wander between our three rooms, and also wait, since that’s built into Gruescript and we opted into it by including wait on in our game block. Next: the NPCs.

2 Likes

We can create three NPCs by writing a thing block for each of them:

thing professor_boysenberry Professor Boysenberry
name Professor Boysenberry
loc foyer
tags alive male proper_name

thing miss_cherry Miss Cherry
name Miss Cherry
loc foyer
tags alive female proper_name

thing doctor_lime Doctor Lime
name Doctor Lime
loc foyer
tags alive female proper_name

We have to write the name out twice: the one on the first line is the “short description”, which is what appears when the game tells us the contents of a room, and the one which appears after name is the “screen name”, which is what will appear when the NPC is referred to in a text substitution. If we don’t do both, either the room descriptions or the text substitutions will fail to capitalise the name properly.

We want our NPCs to move every turn, which we do using the all iterator; this turns the remainder of the block into a loop over each of the NPCs iterated over, so to avoid thinking too hard about where this might succeed or fail, we turn this into a call to a named procedure wander which receives the NPC as a parameter:

rule
all these professor_boysenberry miss_cherry doctor_lime
run wander $this

Then we write a rule for wander like this:

proc wander person
pick new_room these foyer billiards_room library
eq $person.loc $new_room
sayat $new_room {the $Person} paces back and forth.

This sets the variable new_room to one of the three rooms and then checks whether the destination is the same as the point of origin. If it is, we get the message about pacing back and forth.

If the destination isn’t the same as the point of origin, the rule fails and is aborted. In this case the game looks for another rule which tells it how to carry out wandering, and finds this one instead:

proc wander person
sayat $person.loc {the $Person} decides to go to {$new_room.dest_name}.
assign old_room $person.loc
sayat $new_room {the $Person} arrives from {$old_room.dest_name}.
put $person $new_room

Rooms in Gruescript don’t have any kind of printed name by default, so since we want to be able to say “decides to go to the foyer”, we add a dest_name property to the rooms we defined earlier:

room foyer You're in the grand foyer.
prop dest_name the foyer

room billiards_room You're in the billiards room.
prop dest_name the billiards room

room library You're in the library.
prop dest_name the library

My second proc wander person block originally had the assertion !eq $person.loc $new_room at the start, but that’s redundant; if the location is equal to new_room, the first block will succeed and this one will never be reached at all. Note that even if the first block fails, its modification to the global state (assigning a random value to new_room) persists.

1 Like