Guessing Game - with AJAX

This section puts together what we started to learn in the previous chapter, along with AJAX, to create an application that blends traditional server side rendering and page logic with AJAX / interactive features.

The last full guessing game implementation we implemented used server-side rendering in Chapter 16. We had dedicate pages for login, signup, history, game details, and of course guessing. We organized the application into routes on the server side, and we had an integrated database.

In Chapter 18, we diverged a lot and when all-in on client-side. We sacrificed the server side of things entirely. Whether you like server-side development or not, doing things without a database means you don't have user accounts or game history - and that's pretty limiting.

In this application, we will blend the strategies:

  • Everything other than the guessing game itself will use regular server-side rendering. Meaning:
    • There will be separate server-side routes and pages for sign ups, logins
    • There will be separate server-side routes and pages for historical data - game lists and details.
  • The guess page will be rendered by an express route - GET /, and that particular page will use AJAX:
    • A secret number will be rendered when the page is loaded, server-side, and stored in the session.
    • It will make web API calls when the user guesses, and update the UI based on whether the guess was right or not.
    • Clicking to play again will simply reset the UI by reloading the entire / page.

This hybrid approach uses AJAX where it has some value - it makes the guessing game feel a little more like an app - without page reloads between guesses.

Since everything but the guess page is the same as it was the last time, we won't repeat everything here - but of course you should check out the complete application here

The Guess Template

Now that we are using a more traditional server-side design, we can generate the guess page with pug instead of tedious HTML. Here's the standard express route - GET / that renders the guessing game itself.

const express = require('express')
const router = express.Router();
const Game = require('wf-guess-game').Game;

router.get('/', async (req, res) => {
    const game = new Game();
    req.session.game = game;
    res.render('guess', { game });
});

Here's the pug template, which contains HTML for displaying all three states of the game - start, guess, and success. It also links to the guess.js client-side script, which will do all the HTTP AJAX work and DOM manipulation.

extends layout
include mixins
block scripts 
    script(src="/guess.js")
    

block content
    section.main-content#guess
        
        //- The CSS hides guess-feedback on page load.  Later, when we start processing guesses, 
        //- we'll hide guess-instructions and show guess-feedback if the guess is incorrect.
        //- This simplifies our design of even handlers.
        p.guess-instructions I'm thinking of a number from 1-10!
        p.guess-feedback  Sorry, your guess was #{response}, try again! 

        .guess-grid        
            .rounded-section.guess-form 
                label(for="guess") Enter your guess: 
                .guess-input 
                    input(name="guess", required, placeholder="1-10", type="number", min="1", max="10")
                    button(type="button") Submit
                        
            ul.guess-list 
                //- We'll add the guess elements here.
    
    section.main-content#complete
        h1 Great job!
        .rounded-section.correct   
            h1
                //- We'll update the element(s) with .secret class to contain the secret number
                //- in JavaScript, when it's time to show the secret number.
                span.secret 
                span was the number!
            p 
                span It took you 
                // We'll update all the elements with class .number-of-guesses to contain the number of guesses
                // in JavaScript, when it's time to show the number of guesses.
                span.number-of-guesses 
                span  guesses!
    
    nav.area
        p: a(href="/history") Game History
    nav.play-new
        p: a(href="/") Start over

The script it's including is specified in it's script block. This block has been added to layout.pug, as a way for templates to include extra scripts if they want to. The other templates don't specify the script block, because they don't use any client-side scripts.

doctype html
html 
    head 
        title Guessing Game 
        meta(name="viewport", content="width=device-width,initial-scale=1")
        link(rel="stylesheet", href="/guess.css")
        //- This is just like the block content below - it's 
        //- a placeholder, and individual templates that extend 
        //- this layout.pug template can specify a scripts block
        //- if relevant.
        block scripts
    body 
        .grid
            block content
            nav.login
                if username 
                    p 
                        span Logged in as <b>#{username}</b>
                        br
                        a(href='/logout') Logout
                else 
                    p: a(href='/login') Login

As the comments in the pug template explain, we have some CSS to hide elements we don't want appearing on first page load. These were added to guess.css

/** Initial states for UI **/
#complete {
    display: none;
}

.guess-feedback {
    display: none;
}

Guessing Endpoint

The server must respond to guesses, which are still HTTP posts to the / route. Unlike in the past implementations, responses are not HTML - they are just JSON. The JSON response provides the necessary feedback to the caller - the guess is either too low, too high, or correct. The caller (our client-side JavaScript code) will update HTML.

router.post('/', async (req, res) => {
    if (req.session.game === undefined) {
        res.status(404).end();
        return;
    }

    const game = Game.fromRecord(req.session.game);
    const response = game.make_guess(req.body.guess);
    game.guesses.push(req.body.guess);

    if (response) {
        // This means the guess was incorrect.
        // Just respond with the response, which is a JSON object
        // of the form {{}}
        res.json({ correct: false, message: response });
    } else {
        if (req.session.account_id) {
            game.account = req.session.account_id;
            req.GameDb.record_game(game);
        }
        res.json({ correct: true, num_guesses: game.guesses.length });
    }
});

In order to use JSON data in the request body, we do need to ask Express to handle parsing JSON data in requests. This is easy, and is built into express. We can configure it in our main script, right when we create the express application object:


// We will be accepting JSON as a request body
// so we need to use the express.json() middleware
// to parse the request body into a JSON object
app.use(express.json());


Issuing the AJAX requests and updating the DOM

Now we put it all together with our client side script. It attaches event handlers to the guess buttons. When the guess button is pressed, it issues AJAX HTTP POST messages and updates the DOM. Note the similarities between this script and what we saw in the previous chapter. The DOM manipulation is basically the same, the difference is that the application state is back on the server (where personally, I think it belongs!).

const mask = (showStart, showGuess, showComplete) => {
    document.querySelector(".guess-instructions").style.display = showStart ? "block" : "none";
    document.querySelector(".guess-feedback").style.display = showGuess ? "block" : "none";
    document.querySelector("#guess").style.display = (showStart || showGuess) ? "block" : "none";
    document.querySelector("#complete").style.display = showComplete
        ? "block"
        : "none";
};

const init = () => {
    mask(true, false, false);
    const buttons = document.querySelectorAll("button");
    buttons.forEach(button => button.addEventListener("click", make_guess));
    const guessList = document.querySelector("ul.guess-list");
    while (guessList.firstChild) {
        guessList.removeChild(guessList.firstChild);
    }
};

const make_guess = (event) => {
    const inputElement = event.target.previousElementSibling;
    if (inputElement && inputElement.tagName === "INPUT") {
        fetch("/", {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({ guess: inputElement.value }),
        })
            .then((response) => response.json())
            .then((data) => {
                inputElement.value = "";

                if (data.correct) {
                    document.querySelector("span.secret").innerText = `${inputElement.value} `;
                    document.querySelector("span.number-of-guesses").innerText = data.num_guesses;
                    mask(false, false, true);
                }
                else {
                    document.querySelector("span.response").innerText = data.message;
                    const guessList = document.querySelector("ul.guess-list");
                    const newListItem = document.createElement("li");
                    if (data.message.includes("high")) {
                        newListItem.className = "rounded-section high";
                        newListItem.innerText = `${inputElement.value} too high`;
                    } else {
                        newListItem.className = "rounded-section low";
                        newListItem.innerText = `${inputElement.value} too low`;
                    }
                    guessList.appendChild(newListItem);
                    mask(false, true, false);
                }
            })
            .catch((error) => {
                console.error("Error:", error);
            });
        return;
    }
};
document.addEventListener("DOMContentLoaded", init);

Adding some transitions

One of the nice things about implementing the game portion of this with AJAX is that we can use (CSS) transitions for guesses entered. It's a nice little UX enhancement that doesn't really work as nicely with page reloads. To do it, we just add the following CSS rule to the li elements:

li {
    opacity: 0;
    animation: fadeIn 1s forwards;
}

@keyframes fadeIn {
    to {
        opacity: 1;
    }
}

Now each time the user makes a guess, the result (too hight or too low) eases in.

Is AJAX the right call here?

Maybe. At the time of this writing, there is little disagreement in the field that AJAX should be used to enhance UX. How it is used is a bit up for grabs though. There are some proponents of the philosophy that it should be used only in very view circumstances - and that keeping architecture as simple as possible is the way to go. On the other end of the spectrum, developers all in on React, Vue, and the SPA architecture (see next chapter) use AJAX for everything. There's also a middle ground, where applications are blended like above. Finally, there's a more structured middle ground supported by frameworks like HTMX that allow developers to create applications that are more traditional, while reaping most of the benefits of AJAX and SPA.

Important!

This example is really worth studying. It's blending a bunch of concepts. See if you can build on it, add more features!

Download the complete application here