jQuery vs React I

A text-based adventure game for my 404 page

EXPERIMENTAL
NoRud [CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0)]

jQuery vs React: a JavaScript text-based videogame

A bit more than a quarter of a century ago, I have encountered a nice little text-based adventure like Colossal Cave Adventure of which I remember little if not the amazing impression it had on me. The terminal in front of me could understand what I typed into it and it could bring me through an incredible adventure. I was completely and utterly lost at all times! I loved it.

Now, I haven't played such games in a while, but I was writing the 404 for my website and because I looooove placeholders more than reality itself I started typing

You are lost and confused...

and baaam! A three decades image of myself as a kid raptured in front of my uncle computer hit me and so did the question: what would it take to remake it with modern tech?

It turns out that you need a couple of free hours, a bit of humour and a plan.

Note on my usage of the frameworks: React is huge and my website has jQuery already loaded so my 404 is using the latter, but the React version turned out super cool and by being more compact is part of my experimental bar.

Concept

I could have used pen and paper, photoshop or any device, but to the get into last century's mood I had to draw in ASCII.

|---------------|---------------| ASCII
|       x       |       x       | 
|               |               | 
|x    death    x|W     hall    x| MAP
|               |               | 
|       x       |       S       |
|---------------|---------------| OF
|       N       |       N       |
|               |               |
|x    aside    E|W    start    x| A
|               |               |
|       x       |       x       |
|---------------|---------------| MAZE

As you can probably see there is a start room and you could go North or West to two others rooms. From there you either come back or die.

Pretty useless, right? Remember that this is for a 404 page, you should not hang around there for too long. By the way, for the same reasons, the only commands from the original game we are going to replicate are the movements with N, E, S and W with a good old fashioned "press any key" to restart.

The jQuery approach

I know it might sound obvious but you will need jQuery to make this work, a <div> with id "text_adventure", and an <input> with id "hidden_input"

$( document ).ready(function() {
    /* 1 */
    var rooms = {
      'start': {
          text: 'You are entering a dark cave armed with a club, a sandwich and a towel.',
          death: false,
          directions: {
              east: null,
              north: 'hall',
              west: 'aside',
              south: null,
          }
      },
      'hall': {
          text: 'You are in a really warm room armed with a club, a sandwich and a towel. Light comes from your left.',
          death: false,
          directions: {
              east: null,
              north: null,
              west: 'death',
              south: 'start',
          }
      },
      'aside': {
          text: 'You are in a really warm room armed with a club, a sandwich and a towel. Light lies in front of you.',
          death: false,
          directions: {
              east: 'start',
              north: 'death',
              west: null,
              south: null,
          }
      },
      'death': {
          text: 'You shouldn\'t have entered the pit of fire. You died. ✞ R.I.P. Press any key to restart.',
          death: true,
          directions: {
              east: null,
              north: null,
              west: null,
              south: null,
          }
      },
    };

    /* 2 */
    var container = $('#text_adventure');
    var shouldMove = false;
    var isDead = false;
    var delay = 100;
    var currentRoom = {};
    var sentence = '';

    /* 3 */
    function delayLoop (i, target, splitted, delay) {
        setTimeout(function () {
            target.append(splitted[i] + ' ');

            i++;

            if (i < splitted.length) {
                delayLoop(i, target, splitted, delay);
            } else {
                if(isDead) {
                    showGameOver();
                } else {
                    shouldMove = true;
                }
            }
        }, delay);
    }

    /* 4 */
    function getDirection(input){
        switch(input.toLowerCase()) {
            case 'e': return 'east';
            case 'n': return 'north';
            case 'w': return 'west';
            case 's': return 'south';
            default: return null;
        }
    }

    /* 5a */
    function getAvailableDirections(){
        var directions = [];
        $.each(currentRoom.directions, function(direction, value){
            if(value) directions.push(direction);
        });

        return directions;
    }

    /* 5b */
    function printAvailableDirections(){
        return 'You can go ' + getAvailableDirections().join(' or ') + '.';
    }

    /* 6 */
    function appendDot(target){
        target.append('.');
    }

    /* 7 */   
    function showStart(){
        $('#hidden_input').val('');
        container.removeClass('red');
        var loadingContainer = $('<p>loading</p>');
        container.append(loadingContainer);

        for(var i=1; i<=3; i++){
            setTimeout(function(){appendDot(loadingContainer);}, i * delay * 3);
        }

        setTimeout(function(){
            $('#hidden_input').focus();
            currentRoom = rooms['start'];
            var sentenceContainer = $('<p></p>');
            container.append(sentenceContainer);
            sentence = currentRoom.text + ' ' + printAvailableDirections();
            delayLoop(0, sentenceContainer, sentence.split(' '), delay);
        }, 4 * delay * 3);
    }

    /* 8 */
    function showGameOver(){
        console.log('game over');
    }

    /* 9 */
    $(container).on('click', function(){
        $('#hidden_input').focus();
    });

    /* 10 */
    $('#hidden_input').on('keyup', function(e) {
        if(isDead){
            isDead = false;
            container.html('');
            showStart();
        }

        if(shouldMove){
            shouldMove = false;

            $('#hidden_input').val('');

            var direction = getDirection(e.key);

            sentence = 'Please type E for east, N for north, W for west or S for south.';

            if (direction) {
                container.append($('<p><code>' + e.key.toUpperCase() + '</code></p>'));

                sentence = 'You cannot go there. ' + printAvailableDirections();

                if(getAvailableDirections().includes(direction)){
                    currentRoom = rooms[currentRoom.directions[direction]];
                    sentence = currentRoom.text + ' ' + printAvailableDirections();

                    if(currentRoom.death){
                        isDead = true;
                        sentence = currentRoom.text;
                        container.addClass('red');
                    }
                }
            }

            var sentenceContainer = $('<p></p>');
            container.append(sentenceContainer);

            delayLoop(0, sentenceContainer, sentence.split(' '), delay);
        }
    });

    showStart();
});

Allow me to dissect it for you.

  1. The variable rooms represents my amazing ASCII art, with rooms, available directions (and where those lead), a text to show when entering them and if that kills you. It is nice to have it in this format as it could be fetched from a remote server as JSON without having to change any of the logic.
  2. A series of default values to start with: the container that will receive nodes, booleans for death and ability to move, the current room you are in and an empty sentence for typewriter-like effects.
  3. delayLoop() is a simple text effect: we pass an array of words (splitting a sentence on whitespaces .split(' ')), and append every word every 100ms. note that at the end of the sentence we either show the gameover if dead or allow the user to move.
  4. getDirection() kept me sane.
  5. getAvailableDirections() checks for non-null directions in the currentRoom returning them in an array and printAvailableDirections() render them nicely.
  6. appendDot() does exactly what it says.
  7. showStart() starts doing something:
    • it empties the input field,
    • removes the css class red (if there already),
    • appends the loading text and appends dots to it every 300ms (in effect we loop and start a setTimeout at 300ms [1 x 100 x 3], one at 600ms [2 x 100 x 3] and one at 900ms [3 x 100 x 3]),
    • after 1200ms we focus the input field,
    • set the starting room,
    • append an empty paragraph to the container,
    • prepare the sentence to be animated (adding the pretty printed available directions)
    • and we start the delayLoop() animation starting from zero (it will recurse(1) passing an incremented i).
  8. showGameOver() does nothing for the user.
  9. click anywhere in the container to focus again on the input
$(container).on('click', function(){
    $('#hidden_input').focus();
});
  1. the real logic of the game.

The binding on keyup allows us to catch the input after the press of the key.

You could be in two states in this game as in real life: alive or dead. If the latter, we are catching any kind of key and we are satisfied to simply empty the container, and restart the game.

If alive and allowed to move we block you from inputing anything else and empty the field, then we analise your command with getDirection(). Note that e.key in jQuery (as in vanilla JS) gives you exactly what you typed while keyCode has again something to do with ASCII. Once var direction is set we could move on, but give me a minute to show an old school variable initialisation and assignment used for var sentence that I personally call the inverted switch approach (I have seen this a lot in C#).

In a normal switch you will check against cases and then set a default value if none are satisfied. In this case we set a default sentence and if getDirection() returned null (when you typed something different from our allowed commands) the following IF statement is skipped entirely and this sentence get printed. It is much safer than initialising an empty sentence and filling it with a value using an else isn't it?

If you do enter the IF statement because you gave us an allowed command we got to check if that direction could bring you somewhere (again the inverted switch inits the sentence as you cannot go there).

We are almost there. You gave us a valid input, this also happens to be an available direction for the current room you are in and we could set the currentRoom to wherever you arrived at and override the sentence with the new room's text and its available directions!

But wait. What if you died entering the room?

Let us lock everything setting isDead to true add a scary red css class and print the final sentence of this deadly room omitting available directions. The next input will get caught in the first IF of this function and restart the game with any key.

Our sentence went through a lot of checks and overriding but it is finally ready to be appended and type-written in the container.

Don't forget to call showStart(); to start all this process the first time you load the page.

This version of the game is available at my 404, so simply try to get lost in my website to see it.

And React said "hold my beer"...

To use react in an existing HTML project you should follow this article including the optional babel script. A <div> with id "text_adventure_react" is required.

'use strict';

const rooms = {
    'start': {
        text: 'You are entering a dark cave armed with a club, a sandwich and a towel.',
        death: false,
        directions: {
            east: null,
            north: 'hall',
            west: 'aside',
            south: null,
        }
    },
    'hall': {
        text: 'You are in a really warm room armed with a club, a sandwich and a towel. Light comes from your left.',
        death: false,
        directions: {
            east: null,
            north: null,
            west: 'death',
            south: 'start',
        }
    },
    'aside': {
        text: 'You are in a really warm room armed with a club, a sandwich and a towel. Light lies in front of you.',
        death: false,
        directions: {
            east: 'start',
            north: 'death',
            west: null,
            south: null,
        }
    },
    'death': {
        text: 'You shouldn\'t have entered the pit of fire. You died. ✞ R.I.P. Press any key to restart.',
        death: true,
        directions: {
            east: null,
            north: null,
            west: null,
            south: null,
        }
    },
};

const delay = 100;

const Loading = (props) => {
  return (<p>{ props.text }</p>);
};

const initialState = {
    shouldMove: false,
    isDead: false,
    currentRoom: {},
    sentence: '',
    loading: true,
    loadingText: '',
    lastCommand: null,
};

class TextAdventure extends React.Component {
    textInput;
    state = initialState;

    componentDidMount(){
        this.showStart();
    }

    showStart = () => {
        this.delayLoop('loading . . .', 'loadingText', 3 * delay, false).then(()=>{
            this.setState(
                { loading: false, currentRoom: rooms['start'] },
                () => {
                    const sentence = `${this.state.currentRoom.text} ${this.printAvailableDirections(this.state.currentRoom)}`;
                    this.delayLoop(sentence).then(() => {
                        this.setState({ shouldMove: true });
                        this.textInput.focus();
                    });
                }
            );
        });
    };

    asyncForEach = async (array, callback) =>{
        for (let index = 0; index < array.length; index++) {
            await callback(array[index], index, array);
        }
    };

    timeout = async (ms) => {
        return new Promise(resolve => setTimeout(resolve, ms));
    };

    delayLoop = async (text, stateField = 'sentence', waitFor = delay, joinSpace = true) => {
        this.setState(() => {
            const outputObject = {};
            outputObject[stateField] = initialState[stateField];
            return outputObject;
        });

        await this.asyncForEach(text.split(' '), async (word) => {
            this.setState((prevState) => {
                const newSentence = `${prevState[stateField]}${(joinSpace ? ' ' : '')}${word}`;
                const outputObject = {};
                outputObject[stateField] = newSentence;

                return outputObject;
            });

            await this.timeout(waitFor);
        });
    };

    getAvailableDirections = (currentRoom) => {
        return Object.keys(currentRoom.directions).filter((direction) => (currentRoom.directions[direction]));
    };

    printAvailableDirections = (currentRoom) => {
        return `You can go ${this.getAvailableDirections(currentRoom).join(' or ')}.`;
    };

    getDirection = (input) =>{
        switch(input.toLowerCase()) {
            case 'e': return 'east';
            case 'n': return 'north';
            case 'w': return 'west';
            case 's': return 'south';
            default: return null;
        }
    };

    changeHandler = (e) => {
        if (!this.state.shouldMove) return null;
        const command = e.target.value;
        let sentence = `Please type E for east, N for north, W for west or S for south. ${this.state.sentence}`;
        let currentRoom = this.state.currentRoom;
        let isDead = this.state.isDead;
        let lastCommand = this.state.lastCommand;

        if(isDead){

            this.setState({...initialState}, () => {
                this.textInput.value = '';
                this.textInput.focus();
                this.showStart();
            });

            return null;
        }

        const direction = this.getDirection(command);

        if (direction) {
            lastCommand = command;
            sentence = `You cannot go there. ${this.printAvailableDirections(currentRoom)}`;

            if(this.getAvailableDirections(currentRoom).includes(direction)){
                currentRoom = rooms[currentRoom.directions[direction]];
                sentence = `${currentRoom.text} ${this.printAvailableDirections(currentRoom)}`;

                if(currentRoom.death){
                    isDead = true;
                    sentence = currentRoom.text;
                }
            }
        }

        this.setState({
            currentRoom,
            isDead,
            shouldMove: false,
            lastCommand,
        }, () => {
            this.delayLoop(sentence).then(() => {
                this.setState({ shouldMove: true });
                this.textInput.value = '';
                this.textInput.focus();
            });
        });
    };

    render() {
        const divClasses = [];
        if(this.state.isDead) divClasses.push('red');
        return (
            <div className={divClasses} onClick={() => this.textInput.focus()}>
                {this.state.loading ? <Loading text={this.state.loadingText} /> : (
                    <React.Fragment>
                        <p>{this.state.sentence}</p>
                        {this.state.lastCommand && <p><code>{this.state.lastCommand.toUpperCase()}</code></p>}
                    </React.Fragment>
                )}

                <input
                    type="text"
                    disabled={!this.state.shouldMove}
                    onChange={(e) => this.changeHandler(e)}
                    ref={(input) => { this.textInput = input; }}
                    style={{ width:'100%' }}
                />
            </div>
        );
    }
}

const domContainer = document.querySelector('#text_adventure_react');
ReactDOM.render(React.createElement(TextAdventure), domContainer);

The concepts and name of methods are similar to the jQuery version and they should not need an explanation if you know React. Also I am not going to teach you React here, I just wanted to show you the most notable differences between the old guy and the new kid on the same task.

May whatever you don't understand become a reason to learn (somewhere else, that is). - Elvis Salaris

The start of the code is almost identical but the first var room just became a const room. We are writing ES6 with classes, arrow functions and all that entails!

So that's a big initial difference, you could probably use old JS in React but you won't find tutorials to do so and it is a wonderful way to get up-to-date. By the way I am using JSX (thanks to the optional babel script) another little thing worth learning.

Point One:

No "setTimeout"s? Almost, we are using async functions to delay actions and fire callbacks once the Promises are fulfilled. There is one setTimeout (in method timeout), but we use it in a Promise...

timeout = async (ms) => {
    return new Promise(resolve => setTimeout(resolve, ms));
};

Async functions and a bit of added complexity allow us to use the same function (delayLoop) to typewrite every sentence and the initial loading. Much DRYer.

Point B:

ES6 is very powerful.

For instance look at filter and arrow functions to get to the available directions instead of the array/loop equivalent in jQuery...

getAvailableDirections = (currentRoom) => {
    return Object.keys(currentRoom.directions).filter((direction) => (currentRoom.directions[direction]));
};

Point Gamma:

React.

In React nothing gets re-rendered unless the state or props change and even this evaluation can be controlled. I know that it might look a bit confusing at the beginning, but the power of this tool makes this simple code behave and feel like a real web app, plus React components (check const Loading for the simplest example), JSX and classes give us reusable code and clean approaches to development.

To better enjoy the feeling and to become part of my experimental side bar (just pull the switch), this code does not append but replaces the rendered text and commands.

Game over

I leave to you the possibility to compare the two approaches and appreciate the differences, but I'd like you to note that without the typewriting animations you would have a much smaller code especially in React and due to the nature of tree-like decision logic behind the game I had to write a continuous series of concatenations of setStates/callbacks as I needed to chain events, wait for animations and be sure I wasn't overriding the state while doing so.

Both frameworks are not famous nor made for animations unless you look for third-party libraries or write a lot of complex stuff yourself, but they did do the job of remaking a classic game in around 200 lines of code and my 404 is looking better than ever or at least better than before.

This. Was. Great. Fun.