0
\$\begingroup\$

This time, I have prepared this page where a user may select what AI bots will be run in the game of Connect Four. The entire repository is here.

Code

index.html:

<!DOCTYPE html>
<html>
    <head>
        <title>AI vs. AI playing Connect 4</title>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <link rel='stylesheet' type="text/css" href='css/connect4.css' />
    </head>
    <body>
        <form>
            <div class="paramDivs">
            <h3>X engine parameters</h3>
            
            <table>
                <tr><td><h5>Search algorithm for X (beginning) AI:</h5></td><td></td></tr>
                <tr><td> <label for="xEngineId">Choose X engine:</label> </td> 
                    <td>
                        <select name="xEngine" id="xEngineId">
                            <option value="alpha-beta">Alpha-beta pruning</option>
                            <option value="negamax"   >Negamax</option>
                            <option value="pvs"       >PVS (Principal Variation Search)</option>
                        </select>
                    </td>
                </tr>
                
                <tr><td><h5>Search depth for X AI:</h5></td><td></td></tr>
                <tr><td> <label for="xEngineDepthId">Choose X engine depth  :</label> </td>
                    <td>
                        <select name="xEngineDepth" id="xEngineDepthId">
                            <option value="1"         >Depth 1 </option>
                            <option value="2"         >Depth 2 </option>
                            <option value="3"         >Depth 3 </option>
                            <option value="4"         >Depth 4 </option>
                            <option value="5"         >Depth 5 </option>
                            <option value="6"         >Depth 6 </option>
                            <option value="7"         >Depth 7 </option>
                            <option value="8" selected>Depth 8 </option>
                            <option value="9"         >Depth 9 </option>
                            <option value="10"        >Depth 10</option>
                        </select>
                    </td>
                </tr>
            </table>
            </div>
            
            <div class="paramDivs">
            <h3>O engine parameters</h3>
            
            <table>
                <tr><td><h5>Search algorithm for O (following) AI:</h5></td><td></td></tr>
                <tr><td> <label for="oEngineId">Choose O engine:</label> </td> 
                    <td>
                        <select name="oEngine" id="oEngineId">
                            <option value="alpha-beta">Alpha-beta pruning</option>
                            <option value="negamax"   >Negamax</option>
                            <option value="pvs"       >PVS (Principal Variation Search)</option>
                        </select>
                    </td>
                </tr>
                
                <tr><td><h5>Search depth for X AI:</h5></td><td></td></tr>
                <tr><td> <label for="oEngineDepthId">Choose O engine depth  :</label> </td>
                    <td>
                        <select name="oEngineDepth" id="oEngineDepthId">
                            <option value="1"         >Depth 1 </option>
                            <option value="2"         >Depth 2 </option>
                            <option value="3"         >Depth 3 </option>
                            <option value="4"         >Depth 4 </option>
                            <option value="5"         >Depth 5 </option>
                            <option value="6"         >Depth 6 </option>
                            <option value="7"         >Depth 7 </option>
                            <option value="8" selected>Depth 8 </option>
                            <option value="9"         >Depth 9 </option>
                            <option value="10"        >Depth 10</option>
                        </select>
                    </td>
                </tr>
            </table>
            </div>
        </form>

        <div class="paramDivs">
            <button type="button" onclick="runMatch();">RUN MATCH!</button>
        </div>
        
        <div class="paramDivs">
            <button type="button" onclick="clearOutput();">Clear output</button>
        </div>
        
        <pre id='output-text-div'></pre>
        
        <div class="paramDivs">
            <button type="button" onclick="haltMatch();">Halt match</button>
        </div>
        
        <div class="paramDivs">
            <button type="button" onclick="clearOutput();">Clear output</button>
        </div>
        
        <script src='js/connect4ai.js'></script>
        <script src="js/MatchRunner.js"></script>
        
        <script>
            
            function clearOutput() {
                document.getElementById("output-text-div").innerHTML = "";
            }
            
            let matchRunner;
            
            function haltMatch() {
                if (matchRunner) {
                    matchRunner.halt();
                    matchRunner = null;
                }
            }
            
            function runMatch() {
                matchRunner = 
                        new MatchRunner(
                                getXEngine(),
                                getOEngine(),
                                getXEngineDepth(),
                                getOEngineDepth(),
                                document.getElementById("output-text-div"));
                                
                document.getElementById("output-text-div").innerHTML = "";
                matchRunner.start();
            }
            
            const heuristicFunction            = new ConnectFourHeuristicFunction();
            const output = document.getElementById('output-text-div');
            
            function nameToEngine(name) {
                switch (name) {
                    case "alpha-beta":
                        return new ConnectFourAlphaBetaPruningSearchEngine(
                                        heuristicFunction);
                        
                    case "negamax":
                        return new ConnectFourNegamaxSearchEngine(
                                        heuristicFunction);
                        
                    case "pvs":
                        return new ConnectFourPrincipalVariationSearchEngine(
                                        heuristicFunction);
                        
                    default:
                        throw "Should not get here.";
                }
            }
            
            function getXEngine() {
                const selection = document.getElementById("xEngineId");
                return nameToEngine(selection.value);
            }
            
            function getOEngine() {
                const selection = document.getElementById("oEngineId");
                return nameToEngine(selection.value);
            }
            
            function getXEngineDepth() {
                const selection = document.getElementById("xEngineDepthId");
                return Number(selection.value);
            }
            
            function getOEngineDepth() {
                const selection = document.getElementById("oEngineDepthId");
                return Number(selection.value);
            }
        </script>
    </body>
</html>

js/MatchRunner.js:

class MatchRunner {
    
    static #X = 1;
    static #O = 0;
    
    #engineX;
    #engineO;
    #engineDepthX;
    #engineDepthO;
    #outputDiv;
    #currentEngineDepth;
    #currentEngineTurn;
    
    #currentTurnNumber = MatchRunner.#X;
    #keepRunning       = false;
    #currentState      = new ConnectFourBoard();
    #totalDurationX    = 0;
    #totalDurationO    = 0;
    
    constructor() {
        this.#engineX            = arguments[0];
        this.#engineO            = arguments[1];
        this.#engineDepthX       = arguments[2];
        this.#engineDepthO       = arguments[3];
        this.#outputDiv          = arguments[4];
        this.#currentEngineTurn  = this.#engineX;
        this.#currentEngineDepth = this.#engineDepthX;
    }
    
    start() {
        this.#keepRunning = true;
        this.clearOutput();
        this.#gameLoop();
    }
    
    halt() {
        this.#keepRunning = false;
        this.#outputDiv.innerHTML += "Match halted! Stopping prematurely...";
    }
    
    clearOutput() {
        this.#outputDiv.innerHTML = "";
    }
    
    getTotalDurationX() {
        return this.#totalDurationX;
    }
    
    getTotalDurationO() {
        return this.#totalDurationO;
    }
    
    #gameLoop() {
        if (!this.#keepRunning) {
            window.scrollTo(0, document.body.scrollHeight);
            return;
        }
        
        const startTime = this.#millis();
        
        this.#currentState =
                this.#currentEngineTurn.search(
                    this.#currentState, 
                    this.#currentEngineDepth,
                    this.#getPlayerEnum());
        
        const endTime = this.#millis();
        const duration = endTime - startTime;
        
        if (this.#currentEngineTurn === this.#engineX) {
            this.#totalDurationX += duration;
        } else {
            this.#totalDurationO += duration;
        }
        
        this.#outputDiv.innerHTML += this.#currentState.toString();
        this.#outputDiv.innerHTML += "<br/>";
        
        const turnEngineName = this.#getCurrentEngineName();
        const turnNumeral = this.#getTurnNumberString();;
        
        this.#outputDiv.innerHTML += 
                this.#currentEngineTurn === this.#engineX ? 
                    turnEngineName 
                            + " made the "
                            + turnNumeral 
                            + " turn, duration: "
                            + (endTime - startTime) 
                            + " milliseconds.<br/>"
                    :
                    turnEngineName
                            + " made the "
                            + turnNumeral
                            + " turn, duration: "
                            + (endTime - startTime)
                            + " milliseconds.<br/>";
                    
        this.#currentTurnNumber++;
        
        if (this.#currentState.isTie()) {
            this.#keepRunning = false;
            this.#outputDiv.innerHTML += "RESULT: It's a tie.<br/>";
            this.#outputDiv.innerHTML += this.#getDurationReport();
            window.scrollTo(0, document.body.scrollHeight);
            return;
        }
        
        if (this.#currentEngineTurn === this.#engineX) {
            if (this.#currentState.isWinningFor(MatchRunner.#X)) {
                this.#keepRunning = false;
                
                this.#outputDiv.innerHTML += 
                        "RESULT: " 
                        + this.#currentEngineTurn.getName() 
                        + " (X) won.<br/>";
                
                this.#outputDiv.innerHTML += this.#getDurationReport();
                window.scrollTo(0, document.body.scrollHeight);
                return;
            }
        } else {
            if (this.#currentState.isWinningFor(MatchRunner.#O)) {
                this.#keepRunning = false;
                
                this.#outputDiv.innerHTML += 
                        "RESULT: " 
                        + this.#currentEngineTurn.getName() 
                        + " (O) won.<br/>";
                
                this.#outputDiv.innerHTML += this.#getDurationReport();
                window.scrollTo(0, document.body.scrollHeight);
                return;
            }
        }
        
        this.#flipCurrentEngineTurn();
        this.#flipDepth();
        
        window.scrollTo(0, document.body.scrollHeight);
        window.requestAnimationFrame(() => this.#gameLoop());
    }
    
    #getPlayerEnum() {
        if (this.#currentEngineTurn === this.#engineX) {
            return MatchRunner.#X;
        } else {
            return MatchRunner.#O;
        }
    }
    
    #getDurationReport() {
        return this.#engineX.getName() 
                + " (X) total duration: "
                + this.#totalDurationX 
                + " milliseconds.<br/>"
                + this.#engineO.getName()
                + " (O) total duration: " 
                + this.#totalDurationO 
                + " milliseconds.<br/>";
    }
    
    #flipCurrentEngineTurn() {
        if (this.#currentEngineTurn === this.#engineX) {
            this.#currentEngineTurn = this.#engineO;
        } else {
            this.#currentEngineTurn = this.#engineX;
        }
    }
    
    #flipDepth() {
        if (this.#currentEngineDepth === this.#engineDepthX) {
            this.#currentEngineDepth = this.#engineDepthO;
        } else {
            this.#currentEngineDepth = this.#engineDepthX;
        }
    }
    
    #getTurnNumberString() {
        const turn = this.#currentTurnNumber;
        
        if (turn % 10 === 1) {
            return turn + "st";
        }
        
        if (turn %10 === 2) {
            return turn + "nd";
        }
        
        if (turn % 10 === 3) {
            return turn + "rd";
        }
        
        return turn + "th";
    }
    
    #millis() {
        return new Date().valueOf();
    }
    
    #getCurrentEngineName() {
        if (this.#currentEngineTurn === this.#engineX) {
            return this.#engineX.getName() + " (X)";
        }
        
        return this.#engineO.getName() + " (O)";
    }
}

Critique request

As always, I would love to hear constructive critisism on my work.

\$\endgroup\$
0

1 Answer 1

3
\$\begingroup\$

On the design

Scrolling+resetting the whole page on every match is clunky for UX. It also invites some UI weirdness like the duplicate "Clear output" buttons (which I assume you added because the top one gets scrolled away).

I suggest a full-page flexbox layout where the game controls are always visible and the output is scrollable.

// mock output
document.getElementById("out").innerText = "scrollable output\n".repeat(100);
<html style="height: 100%;">
  <body style="display: flex; flex-direction: column; height: 100%; margin: 0;">

    <header>
      game controls always visible
    </header>

    <main id="out" style="flex-grow: 1; overflow-y: scroll; background-color: moccasin;">
    </main>

  </body>
</html>


On the code

js/MatchRunner.js

  • Appending (+=) to innerHTML is not recommended. It causes the DOM to be rebuilt each time, plus other side effects. There are 10 occurrences in the match runner loop. Replace them with insertAdjacentHTML, e.g.:

    - this.#outputDiv.innerHTML += "Match halted! Stopping prematurely...";
    + this.#outputDiv.insertAdjacentHTML("beforeend", "Match halted! Stopping prematurely...");
    
  • Unless I'm missing something, this ternary isn't needed since both branches are identical. Also string interpolation would be simpler than concatenation:

    - this.#currentEngineTurn === this.#engineX ? 
    -     turnEngineName
    -             + " made the "
    -             + turnNumeral
    -             + " turn, duration: "
    -             + (endTime - startTime)
    -             + " milliseconds.<br/>"
    -     :
    -     turnEngineName
    -             + " made the "
    -             + turnNumeral
    -             + " turn, duration: "
    -             + (endTime - startTime)
    -             + " milliseconds.<br/>"
    + `${turnEngineName} made the ${turnNumeral} turn, duration: ${endTime - startTime} milliseconds.<br/>`
    

index.html

  • The page's lang is missing. That prevents the language from being programmatically determined (screen readers, text translators).

    - <html>
    + <html lang="en">
    
  • Tables shouldn't be used for layout. Best practice is to separate content from presentation.

    If you don't have time/interest in learning CSS layouts, you could keep using table layouts while removing the table semantics via role="presentation" (but this is not recommended).

    Personally I use frontend libraries with layout utilities, but you can also implement the layout manually via flexbox or grid if you don't want to pull in extra dependencies.

    Example using Bootstrap grid:

    - <form>
    -     <table>
    -         <tr><td><h5>Search algorithm for X (beginning) AI:</h5></td><td></td></tr>
    -         <tr><td> <label for="xEngineId">Choose X engine:</label> </td>
    -             <td>
    -                 <select name="xEngine" id="xEngineId">
    -                     <option value="alpha-beta">Alpha-beta pruning</option>
    -                     ...
    -                 </select>
    -             </td>
    -         </tr>
    -         <tr><td><h5>Search depth for X AI:</h5></td><td></td></tr>
    -         <tr><td> <label for="xEngineDepthId">Choose X engine depth  :</label> </td>
    -             <td>
    -                 <select name="xEngineDepth" id="xEngineDepthId">
    -                     <option value="1">Depth 1</option>
    -                     ...
    -                 </select>
    -             </td>
    -         </tr>
    -     </table>
    -     <table>
    + <form class="row">
    +     <div class="col-md-6">
    +         <div class="row">
    +             <div class="col-8">
    +                 <div class="form-floating">
    +                     <select class="form-select" name="xEngine" id="xEngineId">
    +                         <option value="alpha-beta">Alpha-beta pruning</option>
    +                         ...
    +                     </select>
    +                     <label for="xEngineId">X engine</label>
    +                 </div>
    +             </div>
    +             <div class="col-4">
    +                 <div class="form-floating">
    +                     <select class="form-select" name="xEngineDepth" id="xEngineDepthId">
    +                         <option value="1">Depth 1</option>
    +                         ...
    +                     </select>
    +                     <label for="xEngineDepthId">X depth</label>
    +                 </div>
    +             </div>
    +         </div>
    +     </div>
    +     <div class="col-md-6">
    

js/connect4ai.js

  • Red on white has insufficient color contrast. If you want a named red that has sufficient contrast, crimson is an easy one to remember:

    - return "<span style='color: red;'>O</span>";
    + return "<span style='color: crimson;'>O</span>";
    
\$\endgroup\$

Not the answer you're looking for? Browse other questions tagged or ask your own question.