Original post at https://siderite.dev/blog/programming-simple-game-in-pure-html-javascript.
The code for this series of posts can be found at https://github.com/Siderite/Complementary
I was helping a friend with basic programming and I realized that I've been so caught up with the newest fads and development techniques that I've forgotten about simple programming, for fun, with just the base principles and tools provided "out of the box". This post will demonstrate me messing up writing a game using HTML and Javascript only.
Mise en place
This French phrase is used in professional cooking to represent the preparation of ingredients and utensils before starting the actual cooking. We will need this before starting developing our game:
- description: the game will show a color and the player must choose from a selection of other colors the one that is complementary
- two colors are complementary if when they are mixed, they cancel each other out, resulting in a grayscale "color" like white, black or some shade of gray. Wait! Was that the metaphor in Fifty Shades of Grey?
- technological stack: HTML, Javascript, CSS
- flavor of Javascript: ECMAScript 2015 (also known as ES6)
- using modules: no - this would be nice, but modules obey CORS, so you won't be able to run it with the browser from the local file system.
- unit testing: yes, but we have to do it as simply as possible (no external libraries)
- development IDE: Visual Studio Code
- it's free and if you don't like it, you can just use Notepad to the same result
- source control: Git (on GitHub)
Installing Visual Studio Code
Installing VS Code is just as simple as downloading the installer and running it.
Then, select the Open Folder option, create a project folder (let's call it Complementary), then click on Select Folder.
The vanilla installation will help you with syntax highlighting, code completion, code formatting.
Project structure
For starters we will need the following files:
- complementary.html - the actual page that will be open by the browser
- complementary.js - the Javascript code
- complementary.css - the CSS stylesheet
Other files will be added afterwards, but this is the most basic separation of concerns: code and data in the .js file, structure in .html and presentation in .css.
Starting to code
First, let's link the three files together by writing the simplest HTML structure:
<html>
<head>
<link rel="stylesheet" href="complementary.css"/>
<script src="complementary.js"></script>
</head>
<body>
</body>
</html>
This instructs the browser to load the CSS and JS files.
In the Javascript file we encapsulate out logic into a Game class:
"use strict";
class Game {
init(doc) {
this._document = doc;
this._document.addEventListener('DOMContentLoaded',this.onLoad.bind(this),false);
}
onLoad() {
}
}
const game=new Game();
game.init(document);
We declared a class (a new concept in Javascript ES6) and a method called init that receives a doc. The idea here is that when the script is loaded, a new Game will be created and the initialization function will receive the current document so it can interact with the user interface. We used the DOMContentLoaded event to call onLoad only when the page document object model (DOM) has been completely loaded, otherwise the script would run before the elements have been loaded.
Also, not the use of the bind method on a function. addEventListener expects a function as the event handler. If we only specify this.onLoad, it will run the function, but with the this context of the event, which would be window, not our game object. this.onLoad.bind(this), on the other hand, is a function that will be executed in the context of our game.
Now, let's consider how we want to game to play out:
- a guide color must be shown
- this means the color needs to be generated
- a list of colors to choose from must be displayed
- colors need to be generated
- one color needs to be complementary to the guide color
- color elements need to respond to mouse clicks
- a result must be computed from the chosen color
- the outcome of the user choice must be displayed
- the score will need to be calculated
This gives us the structure of the game user interface. Let's add:
- a guide element
- a choice list element
- a score element
<html>
<head>
<link rel="stylesheet" href="complementary.css"/>
<script type="module" src="complementary.js"></script>
</head>
<body>
<div id="guideColor"></div>
<div id="choiceColors"></div>
<div id="score"></div>
</body>
</html>
Note that we don't need to choose how they look (that's the CSS) or what they do (that's the JS).
This is a top-down approach, starting from user expectations and then filling in more and more details until it all works out.
Let's write the logic of the game. I won't discuss that too much, because it's pretty obvious and this post is about structure and development, not the game itself.
"use strict";
class Game {
constructor() {
// how many color choices to have
this._numberOfChoices = 5;
// the list of user scores
this._log = [];
}
init(doc) {
this._document = doc;
this._document.addEventListener('DOMContentLoaded', this.onLoad.bind(this), false);
}
onLoad() {
this._guide = this._document.getElementById('guideColor');
this._choices = this._document.getElementById('choiceColors');
// one click event on the parent, but event.target contains the exact element that was clicked
this._choices.addEventListener('click', this.onChoiceClick.bind(this), false);
this._score = this._document.getElementById('score');
this.startRound();
}
startRound() {
// all game logic works with numeric data
const guideColor = this.randomColor();
this._roundData = {
guideColor: guideColor,
choiceColors: this.generateChoices(guideColor),
tries: new Set()
};
// only this method transforms the data into visuals
this.refreshUI();
}
randomColor() {
return Math.round(Math.random() * 0xFFFFFF);
}
generateChoices(guideColor) {
const complementaryColor = 0xFFFFFF - guideColor;
const index = Math.floor(Math.random() * this._numberOfChoices);
const choices = [];
for (let i = 0; i < this._numberOfChoices; i++) {
choices.push(i == index
? complementaryColor
: this.randomColor());
}
return choices;
}
refreshUI() {
this._guide.style.backgroundColor = '#' + this._roundData.guideColor.toString(16).padStart(6, '0');
while (this._choices.firstChild) {
this._choices.removeChild(this._choices.firstChild);
}
for (let i = 0; i < this._roundData.choiceColors.length; i++) {
const color = this._roundData.choiceColors[i];
const elem = this._document.createElement('span');
elem.style.backgroundColor = '#' + color.toString(16).padStart(6, '0');
elem.setAttribute('data-index', i);
this._choices.appendChild(elem);
}
while (this._score.firstChild) {
this._score.removeChild(this._score.firstChild);
}
const threshold = 50;
for (let i = this._log.length - 1; i >= 0; i--) {
const value = this._log[i];
const elem = this._document.createElement('span');
elem.className = value >= threshold
? 'good'
: 'bad';
elem.innerText = value;
this._score.appendChild(elem);
}
}
onChoiceClick(ev) {
const elem = ev.target;
const index = elem.getAttribute('data-index');
// just a regular expression test that the attribute value is actually a number
if (!/^\d+$/.test(index)) {
return;
}
const result = this.score(+index);
elem.setAttribute('data-result', result);
}
score(index) {
const expectedColor = 0xFFFFFF - this._roundData.guideColor;
const isCorrect = this._roundData.choiceColors[index] == expectedColor;
if (!isCorrect) {
this._roundData.tries.add(index);
}
if (isCorrect || this._roundData.tries.size >= this._numberOfChoices - 1) {
const score = 1 / Math.pow(2, this._roundData.tries.size);
this._log.push(Math.round(100 * score));
this.startRound();
}
return isCorrect;
}
}
const game = new Game();
game.init(document);
This works, but it has several problems, including having too many responsibilities (display, logic, handling clicks, generating color strings from numbers, etc).
And while we have the logic and the structure, the display leaves a lot to be desired. Let's fix this first (I am terrible with design, so I will just dump the result here and it will be a homework for the reader to improve on the visuals).
First, I will add a new div to contain the three others. I could work directly with body, but it would be ugly:
<html>
<head>
<link rel="stylesheet" href="complementary.css" />
<script src="complementary.js"></script>
</head>
<body>
<div class="board">
<div id="guideColor"></div>
<div id="choiceColors"></div>
<div id="score"></div>
</div>
</body>
</html>
Then, let's fill in the CSS:
body {
width: 100vw;
height: 100vh;
margin: 0;
}
.board {
width:100%;
height:100%;
display: grid;
grid-template-columns: 50% 50%;
grid-template-rows: min-content auto;
}
#score {
grid-column-start: 1;
grid-column-end: 3;
grid-row: 1;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
#score span {
display: inline-block;
padding: 1rem;
border-radius: 0.5rem;
background-color: darkgray;
margin-left: 2px;
}
#score span.good {
background-color: darkgreen;
}
#score span.bad {
background-color: red;
}
#guideColor {
grid-column: 1;
grid-row: 2;
}
#choiceColors {
grid-column: 2;
grid-row: 2;
display: flex;
flex-direction: column;
}
#choiceColors span {
flex-grow: 1;
cursor: pointer;
}
#choiceColors span[data-result=false] {
opacity: 0.3;
}
I used a lot of flex and grid to display things.
The game should now do the following:
- displays a left side color
- displays five rows of different colors in the right side
- clicking on any of them modifies the score (each wrong choice halves the maximum score of 100)
- when there are no more moves left or the correct choice is clicked, the score is added to a list at the top of the board
- the score tiles are either green (score>=50) or red
However, I am dissatisfied with the Javascript code. If Game has too many responsibilities it is a sign that new classes need to be created.
Refactoring the code
First, I will encapsulate all color logic into a Color class.
class Color {
constructor(value = 0 /* black */) {
this._value = value;
}
toString() {
return '#' + this._value.toString(16).padStart(6, '0');
}
complement() {
return new Color(0xFFFFFF - this._value);
}
equals(anotherColor) {
return this._value === anotherColor._value;
}
static random() {
return new Color(Math.round(Math.random() * 0xFFFFFF));
}
}
This simplifies the Game class like this:
class Game {
constructor() {
// how many color choices to have
this._numberOfChoices = 5;
// the list of user scores
this._log = [];
}
init(doc) {
this._document = doc;
this._document.addEventListener('DOMContentLoaded', this.onLoad.bind(this), false);
}
onLoad() {
this._guide = this._document.getElementById('guideColor');
this._choices = this._document.getElementById('choiceColors');
// one click event on the parent, but event.target contains the exact element that was clicked
this._choices.addEventListener('click', this.onChoiceClick.bind(this), false);
this._score = this._document.getElementById('score');
this.startRound();
}
startRound() {
// all game logic works with numeric data
const guideColor = Color.random();
this._roundData = {
guideColor: guideColor,
choiceColors: this.generateChoices(guideColor),
tries: new Set()
};
// only this method transforms the data into visuals
this.refreshUI();
}
generateChoices(guideColor) {
const complementaryColor = guideColor.complement();
const index = Math.floor(Math.random() * this._numberOfChoices);
const choices = [];
for (let i = 0; i < this._numberOfChoices; i++) {
choices.push(i == index
? complementaryColor
: Color.random());
}
return choices;
}
refreshUI() {
this._guide.style.backgroundColor = this._roundData.guideColor.toString();
while (this._choices.firstChild) {
this._choices.removeChild(this._choices.firstChild);
}
for (let i = 0; i < this._roundData.choiceColors.length; i++) {
const color = this._roundData.choiceColors[i];
const elem = this._document.createElement('span');
elem.style.backgroundColor = color.toString();
elem.setAttribute('data-index', i);
this._choices.appendChild(elem);
}
while (this._score.firstChild) {
this._score.removeChild(this._score.firstChild);
}
const threshold = 50;
for (let i = this._log.length - 1; i >= 0; i--) {
const value = this._log[i];
const elem = this._document.createElement('span');
elem.className = value >= threshold
? 'good'
: 'bad';
elem.innerText = value;
this._score.appendChild(elem);
}
}
onChoiceClick(ev) {
const elem = ev.target;
const index = elem.getAttribute('data-index');
// just a regular expression test that the attribute value is actually a number
if (!/^\d+$/.test(index)) {
return;
}
const result = this.score(+index);
elem.setAttribute('data-result', result);
}
score(index) {
const expectedColor = this._roundData.guideColor.complement();
const isCorrect = this._roundData.choiceColors[index].equals(expectedColor);
if (!isCorrect) {
this._roundData.tries.add(index);
}
if (isCorrect || this._roundData.tries.size >= this._numberOfChoices - 1) {
const score = 1 / Math.pow(2, this._roundData.tries.size);
this._log.push(Math.round(100 * score));
this.startRound();
}
return isCorrect;
}
}
But it's still not enough. Game is still doing a lot of UI stuff. Can we fix that? Yes, with custom HTML elements!
Here is the code. It looks verbose, but what it does is completely encapsulate UI logic into UI elements:
class GuideColor extends HTMLElement {
set color(value) {
this.style.backgroundColor = value.toString();
}
}
class ChoiceColors extends HTMLElement {
connectedCallback() {
this._clickHandler = this.onChoiceClick.bind(this);
this.addEventListener('click', this._clickHandler, false);
}
disconnectedCallback() {
this.removeEventListener('click', this._clickHandler, false);
}
onChoiceClick(ev) {
const elem = ev.target;
if (!(elem instanceof ChoiceColor)) {
return;
}
const result = this._choiceHandler(elem.choiceIndex);
elem.choiceResult = result;
}
setChoiceHandler(handler) {
this._choiceHandler = handler;
}
set colors(value) {
while (this.firstChild) {
this.removeChild(this.firstChild);
}
for (let i = 0; i < value.length; i++) {
const color = value[i];
const elem = new ChoiceColor(color, i);
this.appendChild(elem);
}
}
}
class ChoiceColor extends HTMLElement {
constructor(color, index) {
super();
this.color = color;
this.choiceIndex = index;
}
get choiceIndex() {
return +this.getAttribute('data-index');
}
set choiceIndex(value) {
this.setAttribute('data-index', value);
}
set choiceResult(value) {
this.setAttribute('data-result', value);
}
set color(value) {
this.style.backgroundColor = value.toString();
}
}
class Scores extends HTMLElement {
set scores(log) {
while (this.firstChild) {
this.removeChild(this.firstChild);
}
for (let i = log.length - 1; i >= 0; i--) {
const value = log[i];
const elem = new Score(value);
this.appendChild(elem);
}
}
}
class Score extends HTMLElement {
constructor(value) {
super();
this.innerText = value;
this.className = value > 50
? 'good'
: 'bad';
}
}
class Board extends HTMLElement {
constructor() {
super();
this._guide = new GuideColor();
this._choices = new ChoiceColors();
this._score = new Scores();
}
connectedCallback() {
this.appendChild(this._guide);
this.appendChild(this._choices);
this.appendChild(this._score);
}
setChoiceHandler(handler) {
this._choices.setChoiceHandler(handler);
}
set guideColor(value) {
this._guide.color = value;
}
set choiceColors(value) {
this._choices.colors = value;
}
set scores(value) {
this._score.scores = value;
}
}
window.customElements.define('complementary-board', Board);
window.customElements.define('complementary-guide-color', GuideColor);
window.customElements.define('complementary-choice-colors', ChoiceColors);
window.customElements.define('complementary-choice-color', ChoiceColor);
window.customElements.define('complementary-scores', Scores);
window.customElements.define('complementary-score', Score);
With this, the HTML becomes:
<html>
<head>
<link rel="stylesheet" href="complementary.css" />
<script src="complementary.js"></script>
</head>
<body>
<complementary-board>
</complementary-board>
</html>
and the CSS:
body {
width: 100vw;
height: 100vh;
margin: 0;
}
complementary-board {
width:100%;
height:100%;
display: grid;
grid-template-columns: 50% 50%;
grid-template-rows: min-content auto;
}
complementary-scores {
grid-column-start: 1;
grid-column-end: 3;
grid-row: 1;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
complementary-score {
display: inline-block;
padding: 1rem;
border-radius: 0.5rem;
background-color: darkgray;
margin-left: 2px;
}
complementary-score.good {
background-color: darkgreen;
}
complementary-score.bad {
background-color: red;
}
complementary-guide-color {
grid-column: 1;
grid-row: 2;
}
complementary-choice-colors {
grid-column: 2;
grid-row: 2;
display: flex;
flex-direction: column;
}
complementary-choice-color {
flex-grow: 1;
cursor: pointer;
}
complementary-choice-color[data-result=false] {
opacity: 0.3;
}
Next
In the next blog posts we will see how we can test our code (we have to make it more testable first!) and how we can use Git as source control. Finally we should have a working game that can be easily modified independently: the visual design, the working code, the structural elements.
Top comments (0)