Note: you don't need the McDonald's toy to build this game and play with it, but having the toy will add an extra fun factor to the project :)
The Toy
The other day, my wife got Happy Meals at McDonald's for our kids, and I hate to admit it, but it was me, the one who enjoyed the toy the most.
It was a simple toy. A silly one: a robot looking thing with a smiley face (I don't even know what movie/game the promotion was about), a rotating handle on one side, and a hole at the bottom:
There was more to the toy: it became "interactive" with the McDonald's app. So, I downloaded the app and tested it. The functionality was simple:
- Place the toy on top of the phone (in a specific position)
- Dim the room lights
- Select among the options that popped up
- And the robot "came to life" so you could interact with it.
Of course, the robot didn't come to life. In reality, the toy is translucent with a hole at the bottom and some mirrors(?) inside, so by using the lights correctly and placing the toy in a specific location on the phone, the app could reflect images into the toy's screen/face.
I liked it. It had some Tamagotchi mixed with Big Hero 6's Baymax vibes. It was cute, ingenious, and simple... So simple, it was a pity that it was limited to just a few ad-peppered options from the restaurant's app. And the basic idea seemed reasonably easy to develop. So, what if...?
First version
I opened a browser and went to Codepen. I quickly typed four HTML elements on the editor:
<div class="face">
<div class="eye"></div>
<div class="eye"></div>
<div class="mouth"></div>
</div>
And then added some basic styles. Nothing fancy:
html, body {
background: #000;
}
.face {
position: relative;
width: 1.25in;
height: 1.25in;
overflow: hidden;
margin: 5vh auto 0 auto;
background: #fff;
border-radius: 100% / 30% 30% 60% 60%;
}
.eye {
position: absolute;
top: 40%;
left: 25%;
width: 15%;
height: 15%;
background: black;
border-radius: 50%;
}
.eye + .eye {
left: 60%;
}
.mouth {
position: absolute;
top: 60%;
left: 40%;
width: 20%;
height: 12%;
background: black;
border-radius: 0 0 1in 1in;
}
Note: I picked the
in
unit (inches), so it was absolute to all devices. I had to play a little with the actual value: I started with 1 inch, but it seemed a bit little; I moved up to 1.5 inches, but that was too big. In the end, I settled for 1.25 inches, but probably it could be a little bit smaller and be a better fit for this particular toy.
It took 5-10 minutes in total. It was not interactive, and it was not animated, but the results looked (on the toy) similar to those on the app:
First glitches and corrections
Who would have said that something so simple could already have some issues? But it did! A few things caught my attention from the beginning:
- The picture was flipped
- The drawing scaled poorly on mobile
- The browser bar was too bright
I assumed the first one was due to the use of mirrors inside the toy, which would make the left side on the screen be the right side on the toy, and vice versa. While this was not going to be a big issue while displaying a face, it could be problematic later if I wanted to show text or a picture.
The solution was to flip the face by using a scaleX
transform with value -1:
.face {
...
transform: scaleX(-1)
}
Specifying a viewport width in the head solves the poor escalation on mobile. It was easy with the viewport
meta-tag:
<meta name="viewport"
content="width=device-width, initial-scale=1" />
Finally, the browser top bar was too bright. This wouldn't usually be a problem, but considering that the toy requires dimming the lights to see it better, it is an issue because it can become a distraction.
Luckily, the color of that bar can be specified with the theme-color
meta-tag:
<meta name="theme-color" content="#000" />
The browser top bar was now black (the same color as the body background), making it more fluid with the page and removing the annoying difference.
First animations
At that point, the robot was too basic. Animations would make it likable and expressive, and CSS was the language for the job!
I did two animations at first: eyes blinking and mouth talking.
There are many ways to make the eyes open and close (blink or wink). An easy one is changing the opacity to 0 and then putting it back to 1. That way, the eyes will disappear for a short time and then come back again, which gives the blinking impression.
@keyframes blink {
0%, 5%, 100% { opacity: 1; }
2% { opacity: 0; }
}
It is a basic animation that could also be done by changing the height of the yes to zero and then back to the original size (but I'm not a big fan of that method because it looks fake to me). A better one could be animating clip-path. Browsers allow for transitions and animations of the clip-path as long as the number of points matches.
@keyframes blink {
0%, 10%, 100% {
clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
}
5% {
clip-path: polygon(0% 50%, 100% 50%, 100% 50%, 0% 50%);
}
}
I didn't go with the clip-path option because it would look weird if I wanted to animate the eyes later to show different expressions.
Yet another option would be to change the height of the eyes to 0 then back to their regular size. However, that would give the impression of a blink (and that's the option I finally went with, although it may not be the best one.)
Then, I also simulated the toy talking by animating the mouth opening and closing. I did it by changing the mouth size to 0 and reverting it to its original size:
@keyframes talk {
0%, 100% { height: 12%; }
50% { height: 0%; }
}
.mouth {
...
animation: talk 0.5s infinite;
}
Making the toy talk
So far, everything has been HTML and CSS. But using JavaScript and the Speech Synthesis API, the toy will be able to talk. I had already done something similar creating a teaching assistant or a speech-enabled searchbox, so I had some experience with it.
I added this talk
function that would take a string and the browser would read it:
function talk(sentence, language = "en") {
let speech = new SpeechSynthesisUtterance();
speech.text = sentence;
speech.lang = language;
window.speechSynthesis.speak(speech);
}
I added an optional language
parameter if I wanted to use the toy to speak in Spanish or another language in the future (multilingual toys and games for the win!).
One important thing to consider is that the speech synthesis speak()
requires a user activation to work (at least it does in Chrome). This is a security feature because sites and developers were abusing it, becoming a usability issue.
This means that the user/player will have to interact with the game to make the robot talk. That could be a problem if I wanted to add a greeting (there are ways of going around it), but it shouldn't be an issue for the rest of the game as it will require user interaction.
There's one more detail: there is an animation to make the robot's mouth move. Wouldn't it be great to apply it only when it's talking? That is actually pretty simple too! I added the animation to the .talking
class and add/remove the class when speech starts/ends respectively. These are the changes to the talk
function:
function talk(sentence, language = "en-US") {
let speech = new SpeechSynthesisUtterance();
speech.text = sentence;
speech.lang = language;
// make the mouth move when speech starts
document.querySelector(".mouth").classList.add("talking");
// stop the mouth then speech is over
speech.onend = function() {
document.querySelector(".mouth").classList.remove("talking");
}
window.speechSynthesis.speak(speech);
}
Basic game
The robot is at the top of the page, but it doesn't do much. So it was time to add some options! The first thing was including a menu for the player to interact. The menu will be at the bottom of the page, leaving enough space for the toy and the menu to not mess with each other.
<div id="menu" class="to-bottom">
<button>Jokes</button>
</div>
.to-bottom {
position: fixed;
left: 0;
bottom: 5vh;
width: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
}
button {
margin: 0.5rem;
min-width: 7rem;
height: 3.5rem;
border: 0;
border-radius: 0.2rem 0.2rem 0.4rem 0.4rem;
background: linear-gradient(#dde, #bbd);
border-bottom: 0.25rem solid #aab;
box-shadow: inset 0 0 2px #ddf, inset 0 -1px 2px #ddf;
color: #247;
font-size: 1rem;
text-shadow: 1px 1px 1px #fff;
box-sizing: content-box;
transition: border-bottom 0.25s;
font-family: Helvetica, Arial, sans-serif;
text-transform: uppercase;
font-weight: bold;
}
button:active {
border-bottom: 0;
}
The result looks a bit dated (sorry, I'm not much of a designer), but it works for what I want:
As for the jokes, I put them in an array of arrays (sorry, Data Structures professors) for simplicity. Then created a function that randomly picks an element within the parent array and reads the elements adding a short pause in between (using setTimeout()
for the delayed response. Otherwise, I would need an additional user action to continue reading).
The code looks like this:
const jokes = [
["Knock, knock", "Art", "R2-D2"],
["Knock, knock", "Shy", "Cyborg"],
["Knock, knock", "Anne", "Anne droid"],
["Why did the robot go to the bank?", "He'd spent all his cache"],
["Why did the robot go on holiday?", "To recharge her batteries"],
["What music do robots like?", "Heavy metal"],
["What do you call an invisible droid?", "C-through-PO"],
["What do you call a pirate robot?", "Argh-2D2"],
["Why was the robot late for the meeting?", "He took an R2 detour"],
["Why did R2D2 walk out of the pop concert?", "He only likes electronic music"],
["Why are robots never lonely?", "Because there R2 of them"],
["What do you call a frozen droid?", "An ice borg"]
];
function tellJoke() {
// hide the menu
hide("menu");
// pick a random joke
const jokeIndex = Math.floor(Math.random() * jokes.length);
const joke = jokes[jokeIndex];
// read the joke with pauses in between
joke.map(function(sentence, index) {
setTimeout(function() { talk(sentence); }, index * 3000);
});
// show the menu back again
setTimeout("show('menu')", (joke.length - 1) * 3000 + 1000);
}
As you may have noticed, I added a couple of extra functions: show()
and hide()
that add and remove the class "hidden," so I can animate them with CSS later and remove them from the view frame (I wanted to prevent users from clicking twice on the button.) Their code is not essential for this tutorial, but you can review it at the demo on CodePen.
Making the game more accessible
So far, the game is basic and usable. The user clicks on an option, and the robot replies using voice. But what happens when the user is deaf? They will miss the whole point of the game because it is all spoken!
A solution for that would be to add subtitles every time the robot talks. That way, the game will be accessible to more people.
In order to do this, I added a new element for subtitles, and expanded the talk
function a little bit more: display subtitles when speech start and hide them on speech end (similar to how the mouth movement happens):
function talk(sentence, language = "en-US") {
let speech = new SpeechSynthesisUtterance();
speech.text = sentence;
speech.lang = language;
// show subtitles on speech start
document.querySelector("#subtitles").textContent = sentence;
document.querySelector(".mouth").classList.add("talking");
speech.onend = function() {
// hide subtitles on speech end
document.querySelector("#subtitles").textContent = "";
document.querySelector(".mouth").classList.remove("talking");
}
window.speechSynthesis.speak(speech);
}
More options
Extending the game is easy: add more options to the menu and a function to handle them. I did add two more options: one with trivia questions (spoken) and another with flag questions (also trivia, but this time with images).
Both work more or less in the same way:
- Display a question in text form
- Display four buttons with potential answers
- Show the results after picking an option
The main difference is that the flag question will always have the same text, and the flag will be displayed on the robot's face (as something different.) But in general, the functionality of both options is similar, and they shared the same HTML elements, just interacting slightly differently in JavaScript.
The first part was adding the HTML elements:
<div id="trivia" class="to-bottom hidden">
<section>
<h2></h2>
<div class="options">
<button onclick="answerTrivia(0)"></button>
<button onclick="answerTrivia(1)"></button>
<button onclick="answerTrivia(2)"></button>
<button onclick="answerTrivia(3)"></button>
</div>
</section>
</div>
Most of the styling is already in place, but some additional rules need to be added (see the full demo for the complete example). All the HTML elements are empty because they are populated with the values of the questions.
And for that, I used the following JS code:
let correct = -1;
const trivia = [
{
question: "Who wrote the Three Laws of Robotics",
correct: "Isaac Asimov",
incorrect: ["Charles Darwin", "Albert Einstein", "Jules Verne"]
},
{
question: "What actor starred in the movie I, Robot?",
correct: "Will Smith",
incorrect: ["Keanu Reeves", "Johnny Depp", "Jude Law"]
},
{
question: "What actor starred the movie AI?",
correct: "Jude Law",
incorrect: ["Will Smith", "Keanu Reeves", "Johnny Depp"]
},
{
question: "What does AI mean?",
correct: "Artificial Intelligence",
incorrect: ["Augmented Intelligence", "Australia Island", "Almond Ice-cream"]
},
];
// ...
function askTrivia() {
hide("menu");
document.querySelector("#subtitles").textContent = "";
const questionIndex = Math.floor(Math.random() * trivia.length);
const question = trivia[questionIndex];
// fill in the data
correct = Math.floor(Math.random() * 4);
document.querySelector("#trivia h2").textContent = question.question;
document.querySelector(`#trivia button:nth-child(${correct + 1})`).textContent = question.correct;
for (let x = 0; x < 3; x++) {
document.querySelector(`#trivia button:nth-child(${(correct + x + 1) % 4 + 1})`).textContent = question.incorrect[x];
}
talk(question.question, false);
show('trivia');
}
function answerTrivia(num) {
if (num === correct) {
talk("Yes! You got it right!")
} else {
talk("Oh, no! That wasn't the correct answer")
}
document.querySelector("#trivia h2").innerHTML = "";
document.querySelector(".face").style.background = "";
hide("trivia");
show("menu");
}
The way the incorrect answers are placed on the buttons is far from ideal. They are always in the same order! This means that if the user pays a little bit of attention, they can find out which one is correct just by looking at the answers. Luckily for me, it's a game for kids, so they probably won't realize the pattern... hopefully.
The flag version presents some accessibility challenges. What if the players are blind? Then they cannot see the flag, and the game won't make sense to them. The solution was adding some visually hidden (but accessible for a screen reader) text describing the flags and placed right after the question.
What's next?
I built a clone of the McDonald's game using their toy, and it took around a couple of hours. (McDonald's, hire me! :P) It is basic (not that the original is far more complex), but it can be expanded easily.
There's an initial problem: not everyone will have the toy to play with it. You can still play the game without it (I'll need to add an option to undo the character's flip), but it loses some of the fun factor. One option would be to create my toys. I'll need to explore it (what good is having a 3D printer if you cannot use it :P)
Another thing that would be cool to improve the game would be to add better transitions to the actions. For example, when it tells a knock-knock joke, add longer pauses in which the eyes move side to side with a large smile, like waiting in anticipation for the person's "Who's there?" Or a glitch animation when changing from the face to a different image like the flags. Those micro-interactions and animations go a long way.
Apart from that, the game is easily expandable. It would be easy to add new options to the menu and extend the game with more mini-games and fun if I made it more modular. The only limit is our imagination.
If you have kids (or students), this is an excellent project to develop with them: it is simple, it can be great if they are learning web development, it has a wow-factor that will impress them. At least, it worked with my kids.
Here is the entire demo with the complete code (which includes a bit more than the one explained here):
Top comments (11)
What a great project idea!
Makes me want to buy one of those hologram prisms and have a play!
Testing link amazon.co.uk/dp/B01CO0EW34/ref=cm_...
Highly creative and innovative project.
This is amazing.
Thanks! :)
Such a wonderful idea and execution! Wonderful work 👏🔥
Thanks! 😊
Wow this was pretty damn cool actually.
Thanks!
thanks for sharing
Nice to see an original vanilla JS project!
The default synthesized voice is kind of sinister, suitable for ages 8+ I think! 😀
Thank you! :)