Software localization

Building and Localizing a Language Learning Mini-Game

Let's have some fun with language and code. We'll use Yandex's Translate API to translate the words for the player on the fly and React to build out the UI.
Software localization blog category featured image | Phrase

Tap the Pairs is simple: pairs of words are displayed, each being a word in English (or your native language), and its translation in another language the player wants to learn. For example, "Woman" and "Fille" are an English-French pair. Several of these pairs are shuffled and displayed in random order so that the original pairing is not immediately obvious. The player's goal is then to match the pairs correctly, learning and reinforcing her understanding of the words in the foreign language.

We'll build our version in the browser with React. We'll create canonical sets of English words that we'll translate on the fly using the Yandex translation API. This will allow us to present as many languages for the player to learn as the Yandex API supports.

Note » If you're interested in i18n in JavaScript in general, check out our comprehensive JavaScript localization tutorial, and if you want to dive a bit deeper into React i18n than we do here, have a look at Building an Awesome Magazine App with i18n in React.

Our Game Plan

Here's what our version will look like. You can find the game live on Heroku. You can also grab all the code from GitHub.

Game Rules

To help us stay focused as we build, let's go over the rules of the game.

  • Word pairs are shuffled and displayed to the player
  • The player selects two words
  • If the words are a correct pairing, e.g. "Apple" and "Pomme", the pair is marked as complete and neither word in the pair is selectable any longer
  • If the words are not a correct pairing, they are marked as incorrect and the player can try another selection

We'll also have selectable languages and a notion of categories, so that the player can choose a category of words she wants to learn in her target language e.g. Animals in Spanish or Food in French. When the player selects a new language or a new category, new words are shuffled and displayed for a new round of play.

Dependencies

We'll use React (v16.3) and bootstrap our project with Create React App. The axios HTTP client (v0.18) will come in handy for making network requests.

Note » I'll be assuming that you know the basics of React here. If you don't, you may want to check out the official React tutorial first. And if you know another web UI framework, like Vue or Angular, you should be able to follow most of the code here. In all cases, I encourage you to come along for the ride.

Scaffolding

Directory Structure

Our project directory will be as follows.

/

├── public/

|   └── categories/

└── src/

    ├── assets/

    ├── components/

    ├── config/

    ├── http/

    ├── models/

    └── index.js

We'll place our word files in public/categories/ so we can GET them by category in our app. Our app's view logic will exist as React Components inside src/components/. We'll need to make network requests, and the code for those will sit in src/http/. We'll also need a view model object for our word pairs, and we'll place that in src/models/. Some light configuration will be in src/config/. Let's get cooking.

React Component Breakup

Let's see how we can break up our game app into reasonable React components.

Our demo game | Phrase

The Game component will be our top-level container. It will also act as a controller that orchestrates the rest of the components. LanguageSelector and CategorySelector will be our option controls. When either of these change we'll update the TapThePairs component with new words. TapThePairs itself will house our Words and our matching logic. Header will just be a presentational component that contains our logo, lead copy, and sits atop our other components.

We'll build our components a bit later. Let's do some groundwork first.

HTTP

In order to load our category files and make calls to the Yandex translation API, we'll need an HTTP library. We can wrap some functions around the axios library to make our code more readable.

/src/http/index.js

import axios from 'axios';

import getURLSearchParams from './get-url-search-params';

const http = {

    /**

     * @param {string} url

     * @returns {Promise}

     */

    get(url) {

        return axios.get(url);

    },

    /**

     * @param {string} url

     * @param {Object<string>} params

     * @returns {Promise}

     */

    postWithFormUrlEncoded(url, params) {

        return axios({

            url,

            method: 'POST',

            data: getURLSearchParams(params),

            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },

        });

    }

}

export default http;

get() is just a trivial wrapper around axios.get(). postWithFormUrlEncoded() is more interesting. By default, axios will send POST request params as JSON. However, the Yandex Translate API only accepts the application/x-www-form-urlencoded content type for its POST requests. This is the HTTP content type that we would normally send if we POSTed from an HTML form. postWithFormUrlEncoded() is a helper function that abstracts the details of our axios content type configuration for better maintainability.

Note » You will have noticed that we're wrapping our params in a call to getURLSearchParams() above. This transforms our POST params to a native browser URLSearchParams object, which axios understands. The function is quite simple, and you can see its full code in the GitHub repo.

Note » If you're not familiar with axios, check out its documentation. It's a pretty straightforward cross-browser HTTP library.

Fetching from the Yandex API

Note » If you're coding along, you'll need to get a Yandex Translate API key. It's free, and once you get it, you can paste it in wherever you see translationApiKey.

We'll want to fetch a couple of things from the Yandex API: the list of supported languages it can translate, and the actual translations of our current word set. Let's start with the list of supported languages.

/src/http/fetch-languages.js

import startsWith from 'lodash/startsWith';

import http from './index';

import translationApiKey from '../config/translation-api-key';

/**

 * @param {string} [from = 'en']

 * @returns {Promise<Array>}

 */

export default function fetchLanguages(from = 'en') {

    return http.postWithFormUrlEncoded(

        'https://translate.yandex.net/api/v1.5/tr.json/getLangs',

        {

            key: translationApiKey,

            ui: from,

        },

    )

    .then(response => response.data)

    .then(({ dirs, langs }) => {

        // Translation languages that the API supports

        // translating _to_ given our _from_ language.

        const supportedToLanguages = [];

        // Supported directions e.g. from English to Spanish

        // represented as 'en-es'.

        dirs.filter(dir => startsWith(dir, `${from}-`))

            // 'en-es' becomes 'es'

            .map(supported => supported.split('-')[1])

            // 'es' becomes { code: 'es', name: 'Spanish' }

            .forEach(code => supportedToLanguages.push({ code, name: langs[code] }));

        return Promise.resolve(supportedToLanguages);

    });

}

The Yandex API wants to know our UI language when we ask for its supported translatable languages. It uses this ui param when it returns the names of its langs, giving them to us in our specified language.

It also returns a list of supported translation directions in dirs. These are dash-separated strings that signify the API's supporting translation from a language to another. So if the API supports translation from English to Spanish, the dirs list will contain the entry "en-es".

We take the langs and dirs lists and transform them to return our own list of supported languages: ones that can be translated from our specified language (English by default), and that have the shape { code: 'es', name: 'Spanish' }. fetchLanguages() will return an array of these for easy consumption by our UI components.

We also want the Yandex API to translate our game words for us.

/src/http/fetch-translations.js

import http from './index';

import translationApiKey from '../config/translation-api-key';

/**

 * @param {Array<string>} sources

 * @param {Object} options

 * @param {string} [options.from='en']

 * @param {string} options.to

 * @returns {Promise<Array<string>>}

 */

export default function fetchTranslations(sources, options) {

    const opt = { ...options };

    if (!opt.from) { opt.from = 'en' };

    const requests = sources.map(source => http.postWithFormUrlEncoded(

        'https://translate.yandex.net/api/v1.5/tr.json/translate',

        {

            key: translationApiKey,

            lang: `${opt.from}-${opt.to}`,

            text: source,

        },

    ));

    return Promise.all(requests)

        .then(responses => responses.map(r => r.data))

        .then(translationObjects => translationObjects.map(t => t.text[0]));

}

Our fetchTranslations() function will take an array of strings to translate, as well as (optional) from and (required) to options for the translation direction. It then calls the Yandex API to translate our strings one at a time in the given direction (lang). Finally, it transforms the returned translation objects to an array of translated strings and returns this array in a promise.

Our Word Categories

Our CategorySelector component will return the name of a category file when its value changes. Let's take a look at these files before we get to our components' code. My source words be will be in English here, but feel free to localize the game to any language of your choosing.

Each category will live in its own JSON file, and we can have as many of these files as we want. Here's an example.

/public/categories/basics-1.json

{

    "words": [

        "Man",

        "Water",

        "Girl",

        "Eating",

        "Bathroom",

        "Hello",

        "Goodbye"

    ]

}

We'll need to map our word category filenames to human-readable names for our CategorySelector. This can be configured in a simple file.

/src/config/categories.js

export default {

    'basics-1': 'Basics 1',

    'basics-2': 'Basics 2',

    plurals: 'Plurals',

    food: 'Food',

    animals: 'Animals',

};

The keys of the object are our category filenames (without extensions), and the values are the human-readable names. This will allow us to load a category file easily when the player switches the category.

We're now ready to build our UI. We'll get to our CategorySelector shortly, but let's start at the top.

The Game Component

Note » If you're coding along, be sure to rename the default App top-level component that Create React App makes for us to Game.

/src/components/Game.js

javascript import React, { Component } from 'react';

import './Game.css'; import http from '../http'; import Header from './Header'; import Pair from '../models/Pair'; import TapThePairs from './TapThePairs'; import CategorySelector from './CategorySelector'; import LanguageSelector from './LanguageSelector'; import fetchTranslationsFromApi from '../http/fetch-translations';

class Game extends Component { state = { pairs: [], words: [], selectedLanguage: 'fr', selectedCategory: 'basics-1', }

componentDidMount() {

    this.fetchWordsAndTranslations();

}

fetchWordsAndTranslations() {

    http.get(`/categories/${this.state.selectedCategory}.json`)

        .then(({ data: { words } }) =&gt; {

            this.setState({ words }, () =&gt; this.fetchTranslations());

        })

        .catch(error =&gt; console.log(error));

}

fetchTranslations() {

    fetchTranslationsFromApi(

        this.state.words,

        { from: 'en', to: this.state.selectedLanguage }

    )

    .then((translations) =&gt; {

        const pairs = this.state.words.map((word, i) =&gt;

            new Pair(word, translations[i]));

        this.setState({ pairs });

    })

    .catch(error =&gt; console.log(error));

}

/**

 * @param {string} langCode

 */

selectLanguage(langCode) {

    this.setState({ selectedLanguage: langCode }, () =&gt;

        this.fetchTranslations());

}

/**

 * @param {string} category

 */

selectCategory(category) {

    this.setState({ selectedCategory: category }, () =&gt;

        this.fetchWordsAndTranslations());

}

render() {

    return (

        &lt;div className="Game"&gt;

            &lt;Header /&gt;

            &lt;div className="Game__controls"&gt;

                &lt;h2 className="Game__controls__header"&gt;

                    What do you want to learn?

                &lt;/h2&gt;

                &lt;LanguageSelector

                    value={this.state.selectedLanguage}

                    onChange={langCode =&gt; this.selectLanguage(langCode)}

                /&gt;

                &lt;CategorySelector

                    value={this.state.selectedCategory}

                    onChange={category =&gt; this.selectCategory(category)}

                /&gt;

            &lt;/div&gt;

            &lt;TapThePairs pairs={this.state.pairs} /&gt;

        &lt;/div&gt;

    );

}

}

export default Game;

Our component has four pieces of state that it maintains:

  • pairs → an array of current word Pair objects that the player can match. We'll get to the Pair model a bit later on.
  • words → an array of the source words in the currently selected category. We keep this for efficiency. We don't want to load the same category file again if the player selects a different language but keeps the same category.
  • selectedLanguage → the code of the currently selected language. We default to French, "fr", here.
  • selectedCategory → the filename (minus extension) of the currently loaded category. We default to "basics-1".

When our component mounts, we fetch our default category's word file and translate its words using the Yandex API. Then, whenever the player selects a new category, we load the appropriate category file and translate its words. When she selects a new language, we run our category's words through the Yandex API again to get the new translations.

Note » If you localize your game to a source language other than English, be sure to update the from option in the call to fetchTranslationsFromApi().

Let's start building our sub-components.

The Category Selector

Category selector | Phrase

Now we can write our CategorySelector.

/src/components/CategorySelector.js

javascript import React, { Component } from 'react';

import categories from '../config/categories';

class CategorySelector extends Component { render() { return ( <div className="CategorySelector"> <label htmlFor="category">CATEGORY</label>

            {' '}

            &lt;select

                id="category"

                value={this.props.value}

                onChange={e =&gt; this.props.onChange(e.target.value)}

            &gt;

                {Object.keys(categories).map(filename =&gt; (

                    &lt;option value={filename} key={filename}&gt;

                        {categories[filename]}

                    &lt;/option&gt;

                ))}

            &lt;/select&gt;

        &lt;/div&gt;

    );

}

}

export default CategorySelector;

Nothing too crazy going on here. We're just using our configured categories map to render out a <select> dropdown and reporting the updated filename onChange(). We use the React controlled component pattern to establish uni-directional data flow for our <select>. This is just a fancy way of saying that our <select> never sets its own value. In our case, we grab its value onChange(), give it to our parent component, and let our parent component tell us its new value. This keeps our state in the Game component, which allows it to act as a controller.

Note » You may have noticed that our CategorySelector is a React.PureComponent. Pure components are like pure functions: given the same input (props), they return (render) the same output. Using pure components can sometimes yield performance benefits.

The Language Selector

Language selector | Phrase

Our language selector component is very similar to its category counterpart.

/src/components/LanguageSelector.js

javascript import orderBy from 'lodash/orderBy'; import React, { PureComponent } from 'react';

import fetchLanguages from '../http/fetch-languages';

class LanguageSelector extends PureComponent { state = { languages: [] }

componentDidMount() {

    fetchLanguages()

        .then((languages) =&gt; {

            const sorted = orderBy(languages, 'name');

            this.setState({ languages: sorted });

        })

        .catch(error =&gt; console.log(error));

}

render() {

    return (

        &lt;div className="LanguageSelector"&gt;

            &lt;label htmlFor="language"&gt;LANGUAGE&lt;/label&gt;

            {' '}

            &lt;select

                id="language"

                value={this.props.value}

                onChange={e =&gt; this.props.onChange(e.target.value)}

            &gt;

                {this.state.languages.map(({ code, name }) =&gt; (

                    &lt;option value={code} key={code}&gt;

                        {name}

                    &lt;/option&gt;

                ))}

            &lt;/select&gt;

        &lt;/div&gt;

    );

}

}

export default LanguageSelector;

The main difference here is that LanguageSelector maintains a bit of state, languages, which is an array of languages supported by the Yandex Translate API. When LanguageSelector mounts we fetch those languages from Yandex, sort them by name, and render them to the player.

Tap The Pairs

Ok, we're ready to build our main game logic. This logic's primary home will be a TapThePairs component. Before we get to that component, let's go over the game rules one more time and build a model to help us out.

Game Rules (Again)

  • Word pairs are shuffled and displayed to the player
  • The player selects two words
  • If the words are a correct pairing, e.g. "Apple" and "Pomme", the pair is marked as complete and neither word in the pair is selectable any longer
  • If the words are not a correct pairing, they are marked as incorrect and the player can try another selection

We'll have to maintain a non-trivial amount of state to realize these rules in our game. We can build a Pair class to help us out.

Note » If you're coming from a language other than JavaScript, do note that JavaScript doesn't really have classes. A JavaScript class is just syntactic sugar over a constructor function. JavaScript doesn't use the classical inheritance model found in other languages such as PHP, Java, or C#. It uses prototypal inhertiance. This is important because if you try to use JavaScript classes like Java classes, you will bring about the apocalypse and all the world's kittens will die.

/src/models/Pair.js

class Pair {

    /**

     * @param {string} firstWord

     * @param {string} secondWord

     */

    constructor(firstWord, secondWord) {

        this.first = { word: firstWord, selected: false, mismatched: false };

        this.second = { word: secondWord, selected: false, mismatched: false };

        this.completed = false;

    }

    /**

     * Retrieve a word

     *

     * @param {string} key 'first' | 'second'

     * @return {Object}

     */

    get(key) {

        return this[key];

    }

    /**

     * Toggle the selected state of a word

     *

     * @param {string} key 'first' | 'second'

     */

    toggleSelected(key) {

        this[key].selected = !this[key].selected;

    }

    /**

     * Get the other word in the pair

     *

     * @param {string} key 'first' | 'second'

     * @return {Object}

     */

    other(key) {

        if (key === 'first') { return this.second; }

        else if (key === 'second') { return this.first; }

    }

    /**

     * Mark this pair as successfully completed

     */

    markCompleted() {

        this.clearMismatch('first');

        this.clearMismatch('second');

        this.completed = true;

    }

    /**

     * Mark word as mistmatched to a word in a different pair

     *

     * @param {string} key 'first' | 'second'

     */

    setMismatch(key) {

        this[key].mismatched = true;

        this[key].selected = false;

    }

    /**

     * Clear the mistmatch state of this word

     *

     * @param {string} key 'first' | 'second'

     */

    clearMismatch(key) {

        this[key].mismatched = false;

    }

}

export default Pair;

A Pair represents two words like "Man" (English) and "L'homme" (French), although Pairs have no notion of language. Pair objects will vastly reduce the complexity of dealing with game state when we build our related components.

A Pair is comprised of two word objects, each containing its word in text, as well as flags that track whether the word is selected and whether it was matched incorrectly (mismatched). A Pair starts its life as incomplete until it is completed, meaning that its two words have been matched to each other. Helper methods on Pair allow us to mutate the state of its words flags, as well as set the pair as completed. When using its methods, we access the Pairʼs individual words using the string keys 'first' or 'second'.

With this model in place, we can get to our game logic.

The TapThePairs Component

/src/components/TapThePairs.js

javascript import shuffle from "lodash/shuffle"; import React, { Component } from "react";

import Word from "./Word"; import "./TapThePairs.css";

class TapThePairs extends Component { static getDerivedStateFromProps(nextProps, prevState) { if (nextProps.pairs !== prevState.pairs) { return { pairs: nextProps.pairs, wordOrder: [] }; }

    return null;

}

constructor(props) {

    super(props);

    this.state = {

        pairs: props.pairs,

        prevSelection: null,

        wordOrder: this.generateRandomWordOrder(props.pairs),

    };

}

componentDidUpdate(prevProps) {

    if (this.props.pairs !== prevProps.pairs) {

        const wordOrder = this.generateRandomWordOrder(this.props.pairs);

        this.setState({ wordOrder });

    }

}

/**

 * Generate the random order of words for the play round

 *

 * @param {Array&lt;Pair&gt;} pairs

 * @return {Array&lt;number&gt;} randomized array indexes

 */

generateRandomWordOrder(pairs) {

    const order = [];

    const wordCount = pairs.length * 2;

    for (let i = 0; i &lt;= wordCount; i += 1) {

        order.push(i);

    }

    return shuffle(order);

}

/**

 * Get ordering for word in the current play round

 *

 * @param {number} pairIndex

 * @param {string} wordKey 'first' | 'second'

 * @returns {number}

 */

getWordOrder(pairIndex, wordKey) {

    const wordIndex = pairIndex * 2 + (wordKey === "first" ? 0 : 1);

    return this.state.wordOrder[wordIndex];

}

/**

 * @param {number} pairIndex

 * @param {string} wordKey 'first' | 'second'

 */

attemptMatching(pairIndex, wordKey) {

    const { pairs } = this.state;

    let { prevSelection } = this.state;

    const pair = pairs[pairIndex];

    if (pair.completed) {

        return;

    }

    pair.toggleSelected(wordKey);

    // check if we're tapping the same word twice in a row

    if (

        prevSelection &&

        prevSelection.pair.get(prevSelection.wordKey) === pair.get(wordKey)

    ) {

        this.setState({ pairs, prevSelection: null });

        return;

    }

    if (prevSelection === null) {

        // fresh start, new pairing attempt

        pairs.forEach((pair) =&gt; {

            pair.clearMismatch("first");

            pair.clearMismatch("second");

        });

        prevSelection = { pair, wordKey };

    } else {

        // we've attempted a pairing

        if (pair.other(wordKey).selected) {

            // success

            pair.markCompleted();

        } else {

            // oops, incorrect pairing

            prevSelection.pair.setMismatch(prevSelection.wordKey);

            pair.setMismatch(wordKey);

        }

        // start fresh on the next word selection

        prevSelection = null;

    }

    this.setState({ pairs, prevSelection });

}

render() {

    return (

        &lt;div className="TapThePairs"&gt;

            &lt;h2 className="TapThePairs__header"&gt;

                Tap (Or Click) Matching Pairs

            &lt;/h2&gt;

            &lt;div className="TapThePairs__words"&gt;

                {this.state.pairs.map(({ first, second, completed }, i) =&gt; (

                    &lt;React.Fragment key={`${first.word}-${second.word}`}&gt;

                        &lt;Word

                            word={first.word}

                            completed={completed}

                            selected={first.selected}

                            mismatched={first.mismatched}

                            order={this.getWordOrder(i, "first")}

                            onClick={() =&gt; this.attemptMatching(i, "first")}

                        /&gt;

                        &lt;Word

                            word={second.word}

                            completed={completed}

                            selected={second.selected}

                            mismatched={second.mismatched}

                            order={this.getWordOrder(i, "second")}

                            onClick={() =&gt;

                                this.attemptMatching(i, "second")

                            }

                        /&gt;

                    &lt;/React.Fragment&gt;

                ))}

            &lt;/div&gt;

        &lt;/div&gt;

    );

}

}

export default TapThePairs;

Let's break this code down piece by piece.

Loading New Words

When our app first loads, and then whenever the player selects a new language or a new category, our TapThePairs component will receive a new array of Pair objects in its pairs prop. When this prop is updated, we need to randomize the order of all the words in the pairs and render them for the player to interact with. We maintain our own copy of the pairs prop as internal component state, so we can mutate each Pairʼs words and its completed state as the player attempts matches.

We set the pairs prop's value to our own state.pairs in our constructor to handle the initialization case. When we receive pairs after intialization, such as when a category changes, we use the static getDerivedStateFromProps() method to generate our new component state, including the new state.pairs, from the new pairs prop. And, since our randmization is a side effect that acts on state, we place that in componentDidUpdate(). This keeps our components pairs state in sync with its prop.

Note » If you're a React developer, you may be wondering why we're not just using componentWillReceiveProps() instead of getDerivedStateFromProps() and componentDidUpdate(). Since React 16.3 componentWillReceiveProps() and some other component lifecycle methods are being slowly and gradually deprecated. You can read more about that on the official React blog.

Randomzing Word Order

/src/components/TapThePairs.js (excerpt)

    /**

     * Generate the random order of words for the play round

     *

     * @param {Array<Pair>} pairs

     * @return {Array<number>} randomized array indexes

     */

    generateRandomWordOrder(pairs) {

        const order = [];

        const wordCount = pairs.length * 2;

        for (let i = 0; i <= wordCount; i += 1) {

            order.push(i);

        }

        return shuffle(order);

    }

We randomize the order of the words we present to the player according to our game rules. It wouldn't make much sense to play a word matching game where each word appears next to its correct match. generateRandomWordOrder() is a little utility method that does this randomization for us. The method assumes that the initial ordering of words in their pairs defines their indexes. So, in the Array<Pair<first, second>>: [Pair("Man", "L'homme"), Pair("Apple, "Pomme")], the index of "Man" is 0, the index of "L'homme" is 1, the index of "Apple" is 2, and the index of "Pomme" is 3. The method simply adds all these indexes to an array, shuffles this array, and returns it.

/src/components/TapThePairs.js (excerpt)

    /**

     * Get ordering for word in the current play round

     *

     * @param {number} pairIndex

     * @param {string} wordKey 'first' | 'second'

     * @returns {number}

     */

    getWordOrder(pairIndex, wordKey) {

        const wordIndex = (pairIndex * 2) + (wordKey === 'first' ? 0 : 1);

        return this.state.wordOrder[wordIndex];

    }

When we render our words, we pass them their order via getWordOrder(). This method assumes the same initial index ordering as generateRandomWorderOrder() did, and indexes into the generated random order array based on the given pair and word key. So if we have [Pair("Man, "L'homme"), Pair("Apple, "Pomme")], and we receive the second pair and the word key 'first', we know we're dealing with "Apple", and we index into our random order array with 2, returning 2's current random order.

We maintain this random order fixed for a play round by keeping it a bit of state called wordOrder, otherwise we would shuffle the order on every component render, which wouldn't be good.

Matching Logic

When the player taps or clicks on a word, we trigger the attemptMatch() method. Let's took a look at that method's code again.

/src/components/TapThePairs.js (excerpt)

    /**

     * @param {number} pairIndex

     * @param {string} wordKey 'first' | 'second'

     */

    attemptMatching(pairIndex, wordKey) {

        const { pairs } = this.state;

        let { prevSelection } = this.state;

        const pair = pairs[pairIndex];

        if (pair.completed) { return; }

        pair.toggleSelected(wordKey);

        // check if we're tapping the same word twice in a row

        if (prevSelection &&

            prevSelection.pair.get(prevSelection.wordKey) === pair.get(wordKey)) {

            this.setState({ pairs, prevSelection: null });

            return;

        }

        if (prevSelection === null) {

            // fresh start, new pairing attempt

            pairs.forEach((pair) => {

                pair.clearMismatch('first');

                pair.clearMismatch('second');

            });

            prevSelection = { pair, wordKey };

        } else {

            // we've attempted a pairing

            if (pair.other(wordKey).selected) {

                // success

                pair.markCompleted();

            } else {

                // oops, incorrect pairing

                prevSelection.pair.setMismatch(prevSelection.wordKey);

                pair.setMismatch(wordKey);

            }

            // start fresh on the next word selection

            prevSelection = null;

        }

        this.setState({ pairs, prevSelection });

    }

We maintain a bit of state called prevSelection that points to the pair and word key of the last word the player selected. When a round first starts, prevSelection will be null.

We first check if we're tapping a word belonging to a completed pair. If we are, we change nothing and return immediately, since a completed pair is no longer in play.

We then toggle the tapped word's selected state. A player may wish to unselect a word and attempt a new matching. We check for this case next: if the same word is being tapped twice in a row, we simply null the previous selection so we can start a new matching attempt on the next tap, and we return early.

If there was no previous selection, then, of course, this is a new matching attempt. In this case, we clear out any preexisting error or mismatching state, set the previous selection to the current selection, and we're done.

If, however, there was a previous selection, then we're picking the second word in a matching attempt. So we either have a correct match or a mismatch. If we have a correct match, we mark our pair completed. If we have a mismatch, we mark the previous selection and the current one as mismatched. In either case, we null the previous selection after we're done to start a new matching attempt on the next tap.

That's it for our game logic. We simply render all that state into an array of presentation Word components that add and remove CSS classes based on our words' state.

/src/components/TapThePairs.js (excerpt)

javascript render() { return ( <div className="TapThePairs"> <h2 className="TapThePairs__header">Tap (Or Click) Matching Pairs</h2>

            &lt;div className="TapThePairs__words"&gt;

                {this.state.pairs.map(({ first, second, completed }, i) =&gt; (

                    &lt;React.Fragment key={`${first.word}-${second.word}`}&gt;

                        &lt;Word

                            word={first.word}

                            completed={completed}

                            selected={first.selected}

                            mismatched={first.mismatched}

                            order={this.getWordOrder(i, 'first')}

                            onClick={() =&gt; this.attemptMatching(i, 'first')}

                        /&gt;

                        &lt;Word

                            word={second.word}

                            completed={completed}

                            selected={second.selected}

                            mismatched={second.mismatched}

                            order={this.getWordOrder(i, 'second')}

                            onClick={() =&gt; this.attemptMatching(i, 'second')}

                        /&gt;

                    &lt;/React.Fragment&gt;

                ))}

            &lt;/div&gt;

        &lt;/div&gt;

    );

}

Note » We're wrapping our Words in React.Fragment to avoid having to place them in a DOM element or array, which React requires if we otherwise return adjacent React elements from a function. Fragments were introduced in React 16.2 and they just mean "no DOM container".

The Word component is very simple, but here's its implementation for good measure.

The Word Component

/src/components/Word.js

import React, { PureComponent } from 'react';

import './Word.css';

class Word extends PureComponent {

    static defaultProps = {

        order: undefined,

    }

    getStateClasses() {

        const classes = [];

        if (this.props.selected) { classes.push('Word--selected'); }

        if (this.props.completed) {classes.push('Word--completed'); }

        if (this.props.mismatched) {classes.push('Word--mismatched'); }

        return classes.join(' ');

    }

    render() {

        return (

            <span

                style={{ order: this.props.order }}

                onClick={() => this.props.onClick()}

                className={`Word ${this.getStateClasses()}`}

            >

                {this.props.word}

            </span>

        );

    }

}

export default Word;

We're just toggling classes based on the given props and setting the CSS Flexbox order rule based on our currently determined, random-per-round ordering.

Note » Since we're using CSS to order our words and not HTML source, a savvy web ninja (do we still say ninja?) could inspect the words using her or his browser's developer tools, and determine correct matches based on the source order of the presented words. If this were a production app, this would definitely be a concern when considering scoring, leaderboards, cheating, etc. For the purposes of this demo, however, I went with the quickest solution. I'll post my lawyer's email address at the bottom of the article if you want to sue me. (Psyche. Not doing it).

In Conclusion

Writing code to localize your app is one task, but working with translations is a completely different story. Many translations for multiple languages may quickly overwhelm you which will lead to the user’s confusion.

Fortunately, Phrase can make your life as a developer easier. With its CLI and Bitbucket, GitHub, and GitLab syncs, your i18n can be on autopilot. The fully-featured Phrase web console, with machine learning and smart suggestions, is a joy for translators to use. Once translations are ready, they can sync back to your project automatically. You set it and forget it, leaving you to focus on the code you love. Check out all Phrase features for developers and see for yourself how they can streamline your software localization workflows.

I certainly hope you enjoyed reading this article as much as I did writing it and building the demo app. And I hope you learned a couple of things along the way as well. I encourage you to play the game live on Heroku and check out its code on GitHub. Try making your own version. Add levels, a progress bar, scoring. If you don't React (like, why not, brah?) port it into your favourite front-end framework. Above all, have fun! 😊