Double Debug
—
June 28, 2022
On today's episode of overengineering, I'm making an interactive dialogue mini game in React. If you're reading this article, chances are that you've already seen it — but here is a link anyway.
The setup: you're talking to an avatar with headphones and an uninspired facial expression. Whenever he says something, you can choose your reply. Different replies lead the conversation in different paths, mimicing a real life conversation. Although there are only 3 possible outcomes, this mini AI gives you quite a few possible paths to explore and hopefully catch your attention as soon as you visit the website.
To represent different stages of the conversation, I used a state machine
... or if you like math, a directed graph
. This is not only a great way to visualize the problem, but also the exact structure I used for the logic of the component. Behind the scenes, this avatar knows of all the dialogue stages and for each of them, he knows which reply leads to which stage.
Each node in the graph represents a dialogue stage, which holds information about the avatar's sentence, and the two replies — all in a POJO (plain old javascript object).
Each reply also holds a number, pointing to the next node in the graph. There are 10 nodes in total, so the full graph looks like this:
If we take a closer look at one of the nodes, for example Node 1, we can see that it holds the first sentence of the conversation. The avatar says:
I'm DoubleDebug, a full stack web developer.
and you can reply with either
Nice to meet you, or
I'm not interested.
The first reply has a property called nextStage
with a value of 2, which means that it leads to the second graph node.
1{ 2 "stage": 1, 3 "sentences": ["I'm Double Debug,", 4 "a full stack web developer."], 5 "replies": [ 6 { 7 "text": "Nice to meet you, Double Debug!", 8 "nextStage": 2 9 }, 10 { 11 "text": "I'm not interested...", 12 "nextStage": 7 13 } 14 ] 15}
Some nodes are a dead end and they point to themselves. For example, if you find yourself on Node 10, the avatar prompt is "Let's start over. What do you say?". If you choose to reply with "No, thank you", you will be scrolled down to the next section of the website and the conversation is over.
Other nodes, such as Node 6, are considered "successful" nodes because they've led you to the end of the sequence and of this Call To Action (CTA)
component. Node 6 offers you two replies, but both of these replies are actions. You can either explore my past projects or you can head to the "Hire me" section.
1{ 2 "stage": 1, 3 "sentences": ["Great!", 4 "Thanks for chatting with me."], 5 "replies": [ 6 { 7 "text": "Explore projects", 8 "nextStage": 6, 9 "action": ({ router }) => { 10 router.push('/projects/explore'); 11 } 12 }, 13 { 14 "text": "Hire me", 15 "nextStage": 6, 16 "action": () => { 17 window.scrollTo(0, 5000); 18 } 19 } 20 ] 21}
As you can see, Node 6 has a new field under "replies", called action
. It defines what will happen if you choose a certain reply. In this case, choosing "Explore projects" will send you to the "projects/explore" page and choosing "Hire me" will scroll the page to the contact section.
The HTML is very simple. The avatar is a rounded image sitting on the left side — this is the only static part of the component. On the right side is a relative div, which holds two children - the speech bubble and the reply box. The speech bubble is a transparent image with absolute positioning and a negative z-index. On top of it is an h2 heading tag with one or two pieces of text. The reply box is also absolutely positioned and displays a grid of two full width buttons.
The key element of the UI, however, are the CSS animations.
In order to get the user to focus on the conversation and to keep his eyes on the speech bubble, I decided to play a fade-in animation for all of the text. This is where my overengineering kicks in. In hindsight, it would've been a lot easier to generate static speech bubble images and just transition between them with a fade-in-out effect. This would've avoided storing all the data for each individual node, as well as the triviality of pinpointing the duration of each CSS animation. But no... we like to suffer around here. 🙂
1{ 2 "stage": 6, 3 "sentences": [ 4 { 5 "text": "Great!", 6 "props": { 7 "fontSize": "8xl", 8 "textAlign": "center", 9 ... 10 }, 11 "duration": 1000, 12 "delay": 1000, 13 }, 14 { 15 "text": "Thanks for chatting with me.", 16 "props": { 17 "fontSize": "4xl", 18 "textAlign": "right", 19 ... 20 }, 21 "duration": 1000, 22 "delay": 1500, 23 }, 24 ], 25 "replies": [ 26 ... 27 ], 28 "repliesDelay": 2000, 29 },
To be clear, the examples shown above are simplified for the sake of this blog. In reality, each node has a lot more properties and most of them hold information about the UI of this component. If we take a closer look at one of the nodes, we can see properties such as duration
and delay
, which are applied directly to the CSS of the h1 element. We also have react component props, such as fontSize
and textAlign
. These are props for Chakra components, which is the UI framework I chose for this website.
1const Dialogue: React.FC = () => { 2 const [stageNumber, setStageNumber] = useState<number>(1); 3 const currentStage = useMemo( 4 () => dialogueData.filter((s) => s.stage === stageNumber)[0], 5 [stageNumber] 6 ); 7 8 return ( 9 ... 10 ); 11}
On a higher level, we have a Dialogue react component which holds state regarding the current stage of the conversation with the avatar.
Whenever I choose a reply, I will update the current stage of the conversation and set it to nextStage
number of the chosen reply.
1const Dialogue: React.FC = () => { 2 ... 3 4 return ( 5 ... 6 {currentDialogueStage.sentences.map(element => ( 7 <Text>{element.text}</Text> 8 ))} 9 ... 10 {currentDialogueStage.replies.map(reply => ( 11 <Button 12 onClick={() => setStageNumber(reply.nextStage)} 13 > 14 {reply} 15 </Button> 16 ))} 17 ); 18}
All overengineering jokes aside, this was a really fun mini project. I've always liked playing video games and one of the reasons for that is because of the amount of choices you have as a player. The power to change the outcome of the game. And even though I didn't grow up to be a game developer (at least not yet — you never know), I always try to incorporate that interactivity aspect into my websites.