## Vier Gewinnt mit Server Mit unserem Wissen über Web-APIs und JSON wollen wir nun die Vier-Gewinnt Web-App weiterentwickeln, so dass zwei Spieler:innen übers Web gegeneinander spielen können. Dazu muss Folgendes geschehen: * Die Spiel-Logik muss vom Browser auf den Server wandern. * Dazu muss ein Server-Prozess gestartet werden, wir verwenden dafür [[https://nodejs.org/|nodejs]] und [[https://expressjs.com/|express]]. * Der Server muss die folgenden zwei Web-APIs zur Verfügung stellen: * `//game`: gibt den Zustand des Games mit Id `` im JSON-Format zurück. * `//set/`: Setzt im Game mit Id `` die Spalte `` (muss im Bereich von 0-6 sein) an. Gibt anschliessend den Game-Zustand zurück. * Weiterhin muss der Browser die Client-Ressourcen (HTML, CSS, JS) bereitstellen. * `//` (inklusive `/` am Ende) soll das HTML zurückgeben. * `//connect4.js` soll das client-side JS zurückgeben. * `//connect4.css` soll das CSS zurückgeben. * Wird der leere Pfad aufgerufen, soll folgendes passieren: - der Spieler wird einem wartendenden Spiel zugeteilt, oder ein neues Spiel wird gestartet. - der Browser wird auf den Pfad `//` umgeleitet (_redirect_). * Das Javascript im Browser muss sich nur noch darum kümmern, die richtigen Web-APIs auf dem Server aufzurufen, und den Zustand des Spiels im HTML darzustellen. ### Installation von node.js und express * Node.js installieren: * https://nodejs.org/en/download/prebuilt-installer * Projekt initialisieren: * Eine Eingabeaufforderung **im Ordner der Webapp** öffnen. * `npm init` * `npm install express cookie-parser` * Öffne die Datei `package.json`: * füge einen Eintrag hinzu: ` "type": "module"` * Falls du git verwendest: * füge `node-modules` zu `.gitignore` hinzu. ### App definieren Zuerst benötigen wir eine Datei `app.js`, die definiert, wie der Server funktioniert, und welche APIs wir bereitstellen: import express from 'express' const app = express() const port = 3001 // Hello World. app.get('/hello', (req, res) => { res.send('Hello World!') }) // Listen on the given port app.listen(port, () => { console.log(`Example app listening on port ${port}`) }) Mit dem Kommandozeilenaufruf `node app.js` wird der Node-Server gestartet. Auf der Adresse http://localhost:3001/hello können wir auf die oben definierte API zugreifen. Der Code bedeutet: Wann immer eine _Request_ (_res_) für den Pfad `/hello` ankommt, so sende als _Response_ (_res_) den String `Hello World` zurück. Es ist auch möglich, Teile des im Request verlangten Pfades als Parameter zu erhalten: app.get('/hello/:name', (req, res) => { let name = req.params['name'] res.send(`Hello ${name}!``) }) ### Statische Dateien Die bisherigen HTML und CSS Dateien bleiben unverändert - der Server soll diese bitte direkt zum Browser übertragen. Das erreichen wir, indem wir einen Ordner `static` anlegen, und die client-side Dateien dorthin verschieben. Node wird instruiert, diese Dateien unverändert auszuliefern: // Serve all files from static/ as is. // For example, a request for '/42/connect4.html' will be served from // 'static/connect4.html' app.use('/:gameid/', express.static('static')) Ausprobieren: Node neu starten und den URL http://localhost:3001/0/connect4.html laden - unser HTML + CSS sollte korrekt angezeigt werden! Allenfalls ist der Port 3001 bereits besetzt, dann muss ein anderer Port gewählt werden. ### Server-Side Vier-Gewinnt Erstelle eine Kopie deiner `connect4.js` Datei als `connect4_server.js`, um auf dem Server ein Spiel laufen zu lassen. Was muss sich ändern? * Der Spielzustand muss als JSON vom Server zu den Clients gesendet werden. Glücklicherweise ist unser Spielzustand bereits JSON-kompatibel. * Wir möchten mehr als ein Spiel betreuen können - statt eine globale `game` Variable erstellen wir eine Funktion in `connect4.js`, die ein neues Spiel erzeugt. In `app.js` erstellen wir ein Spiel, z.B. mit `let game = newGame()`. Damit das funktioniert, müssen wir alle Funktionen oder Klassen in `connect4_server.js` mit `export` markieren und in `app.js` importieren: /* Returns a fresh game. */ export function newGame() { return { "state": "waiting", // or "waiting" or "won" or "tie" "board": [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ], "next": 1, // 1 or 2, the player whose turn it is, the winner if state is "won" "player1": undefined, "player2": undefined, } } /* Drops a piece into given column. */ export function dropPiece(game, column) { ... } import {newGame, dropPiece} from './connect4.js' let the_game = newGame(); app.get('/:gameid/game', (req, res) => { return the_game; }) app.get('/:gameid/set/:column', (req, res) => { let column = parseInt(req.params['cell']); dropPiece(the_game, column); res.json(the_game) }) Nun sollte unser Server ein einziges Game betreiben. Der Zustand sollte unter http://localhost:3001/0/game zurückgeliefert werden. Ein Spielzug wird über das `set` API gemacht, z.B. sollte die mittlere Spalte mit http://localhost:3001/0/set/4 gesetzt werden können. Kannst du (ganz ohne Grafik, nur mit den obigen URLs) ein Spiel spielen? Was gibt es noch zu lösen? * Der Browser benötigt auch Javascript. Der Click-Handler jedes Buttons soll das `set` API aufrufen (mit `fetch`) und anschliessend den Inhalt des HTMLs an den Spielzustand anpassen. * Wie weiss der Browser, ob er gelb oder rot spielt? * Wie können mehrere Spiele betreut werden (Hinweis: schau dir die `gameid` oben an...!) ### Client-Side Javascript Der Browser benötigt weiterhin Javascript für den Informationsaustausch mit dem Server. Der Click-Handler jedes Buttons soll das `set` API aufrufen und anschliessend den Inhalt des HTMLs an den Spielzustand anpassen. Weil alle JSON Schnittstellen so konzipiert sind, dass sie im Pfad unterhalb des Seiten-URLs stehen, muss der Client gar nicht wissen, was seine Game-Id ist. Stattdessen ruft er eine **relative** URL auf: * Spiel-URL: `http://localhost:3001/42/` * Schnittstelle für Game-Zustand: `http://localhost:3001/42/game` * Schnittstelle für Spielzug: `http://localhost:3001/42/set/4` Wir können also aus dem client-side Javascript einfach die relativen URLs `game` oder `set/4` aufrufen, der Browser setzt automatisch den Seitenpfad vorne dran ([[https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references|MDM: Resolving relative references]]). /** Updates the HTML user interface to match the given game state. */ function updateHtml(grid, game) { … } function updateStatus(status, game) { … } /** Drops a piece in the given column and updates the game state accordingly. */ async function dropPiece(grid, status, column) { // TODO: Check if move is valid. handleFetch(grid, status, `set/${column}`); } async function handleFetch(grid, status, url) { let response = await fetch(url); let game = await response.json(); // Update the HTML view. updateHtml(grid, game); // Update game status area to reflect winner / tie updateStatus(status, game); // TODO: fetch game state until it's our turn again. } /* Connects the game state to the HTML user interface. */ async function init() { // Find the HTML game grid. let grid = document.getElementsByTagName('c4-grid')[0]; let status = document.getElementsByTagName('c4-status')[0]; // Join a new game. await handleFetch(grid, status, `game`); // Install button click handlers for each button. let index = 0 for (let button of grid.getElementsByTagName("button")) { const column = index % 7; button.addEventListener("click", () => dropPiece(grid, status, column)); index++; } } init(); \\ ### User Ids & Cookies Um sicherzustellen, dass jede:r Benutzer:in nur in einem Spiel ist, und kein böswilliger Akteur einen falschen Spielzug ausführen kann, wollen wir eine Benutzer-Id für jede Verbindung erstellen. Dafür nützen wir Cookies. Das Cookie wird gesetzt wenn zum ersten Mal einem Spiel beigetreten wird: import express from 'express' import cookieParser from 'cookie-parser' import { newGame, dropPiece, toJson, isWaiting, joinGame } from './connect4.js' const app = express() app.use(cookieParser()) const port = 3001 /** Retrieve the user's identifier from the cookies, or set a new one. */ function getUserId(req, res) { let userid = req.cookies.userid if (userid == undefined) { userid = crypto.randomUUID() res.cookie('userid', userid) } return userid } /* The empty path will join a waiting game or create a fresh one. * Afterwards, the request is redirected to /:gameid/. */ app.get('', (req, res) => { const userid = getUserId(req, res) // First attempt to find a waiting game and join that. for (let game of Object.values(games)) { if (isWaiting(game, userid)) { joinGame(game, userid) console.log(`Game ${game.id} randomly joined by ${userid}`) res.redirect(`${game.id}/`) return } } // No waiting game found - create a new one let game = newGame(nextGameId) games[nextGameId] = game nextGameId += 1 joinGame(game, userid) console.log(`Game ${game.id} started by ${userid}`) res.redirect(`${game.id}/`) }) Falls ein Spieler direkt auf einen Spiel-URL geht (z.B. weil der erste Spieler den URL mit ihm geteilt hat), so muss das Spiel noch gejoint werden. Die Zustandsabfrage ändert sich also: /** Serve the game state of any valid game id. */ app.get('/:gameid/game', (req, res) => { const userid = getUserId(req, res); const game = games[parseInt(req.params['gameid'])] if (game == undefined) { res.status(404).json("no such game"); } else { if (isWaiting(game, userid)) { // Game is still waiting for a second player - join it! console.log(`Game ${game.id} directly joined by ${userid}`) joinGame(game, userid) } res.json(toJson(game, userid)) } }) Damit obiger Code funktioniert, benötigt die Game-Logik auf der server-side die Funktionen `isWaiting(game, userid)` und `joinGame(game, userid)` und `toJson(game, userid)`: /* Returns true if the game is waiting and userid * has not joined, false otherwise */ export function isWaiting(game, userid) { return game.state == "waiting" && game.player1 != userid; } /* Lets userid join the given game, throws an error if * the game cannot be joined. */ export function joinGame(game, userid) { if (!isWaiting(game, userid)) { throw Error("Not waiting!"); } if (game.player1 == undefined) { game.player1 = userid; } else { game.player2 = userid; game.state = "playing"; } } /* Returns the game state ready to be sent to the client. */ export function toJson(game, userid) { // TODO: create a copy of game using structuredClone and // set game.myturn=true if the current player is userid. return game; } \\ ### Sicherheit Nun können wir auch sicherstellen, dass nur ein berechtigter Spieler einen Stein setzen kann. In der `/:gameid/set/` Schnittstelle wird die Benutzer-Id ausgelesen und überprüft, ob der Spieler überhaupt im Spiel mitspielt, und ob er an der Reihe ist. Andernfalls wird ein HTTP Status-Code aus dem [[wp>List_of_HTTP_status_codes#4xx_client_errors|400er-Bereich]] zurückgegeben, z.B. `403` (_Forbidden_). /** Make a play. Only allows the joined players to */ app.get('/:gameid/set/:column', (req, res) => { const userid = getUserId(req, res); const game = games[parseInt(req.params['gameid'])] if (game == undefined) { res.status(404).json("no such game"); } else if (game.state != "playing") { res.status(403).json("game is not playing"); } else if (getCurrentPlayer(game) == userid) { let column = parseInt(req.params['column']); dropPiece(game, column); res.json(toJson(game, userid)); } else { res.status(403); res.json("Not your turn, my friend"); } }) /* Returns the playerid whose turn it is. */ export function getCurrentPlayer(game) { return game.next == 1 ? game.player1 : game.player2; } \\ ### Wer ist dran? Damit der Client weiss, ob er an der Reihe ist, müsste er seine eigene User-Id kennen. Wir könnten aber die Information auch einfach in das zurückgegebene JSON einbauen. Eine Möglichkeit wäre, dass wir für jede Zustandsabfrage überprüfen, ob der UserId des Requests gerade am Zug ist. Damit wir die Eigenschaft nicht auf der Server-Seite speichern, erstellen wir eine Kopie des JSON mit `structuredClone` ([[https://developer.mozilla.org/de/docs/Web/API/Window/structuredClone|MDM]]). export function toJson(game, userid) { let copy = structuredClone(game); // make a copy if (game.state == "playing") { if (copy.player1 == userid && copy.next == 1 || copy.player2 == userid && copy.next == 2) { copy["myturn"] = true; } } else if (game.state == "won") { if (copy.player1 == userid && copy.next == 1 || copy.player2 == userid && copy.next == 2) { copy["winner"] = true; } } return copy; } ### Polling Wie erfährt ein Client davon, dass der Gegenspieler ein Zug gemacht hat? Mit HTTP ist es nicht so einfach, den Client über eine Änderung des Serverzustands zu informieren. Am einfachsten ist es, wenn der Client _polling_ verwendet. Polling bedeutet, dass der Client periodisch den Server-Zustand abfrägt. Damit wir alle 500ms oder jede Sekunde den Serverzustand abfragen, bietet es sich an, wieder mit `await` zu arbeiten. Mit Promises können wir eine `async` Funktion bauen, die nebenläufig eine bestimme Zeitdauer wartet. Am Ende der `fetchUrl` Funktion rufen wir diese auf, bevor wir den Gamezustand mit einem Selbstaufruf pollen: async function wait(ms) { // Schläft für eine Anzahl Millsekunden, bevor die Promise settlet. return new Promise(resolve => setTimeout(resolve, ms)); } async function handleFetch(grid, status, url) { let response = await fetch(url); if (response.ok) { game = await response.json(); // Update the HTML view. updateHtml(grid, game); // Update game status area to reflect winner / tie updateStatus(status, game); } // POLLING: Falls wir nicht am Zug sind, holen wir uns nach 500ms // den neuen Gamezustand. Die Rekursion (Selbstaufruf) führt dazu, dass // jeder Aufruf von handleFetch wieder einen nächsten Aufruf erzeugt. if (game.state == "waiting" || game.state == "playing" && !game.myturn) { await wait(500); await handleFetch(grid, status, "game"); } } ### Long-Polling Mit der obigen Lösung fragt jeder Client zweimal pro Sekunde nach einem Update. Polling hat zwei Nachteile: * es wird relative viel Traffic erzeugt, insbesondere, wenn ausser der zwei Spieler noch zahlreiche Beobachter auf Änderungen warten. * trotzdem ist die Responsivität tief - im Mittel wartet jeder Client die Hälfte des eingestellten Delays, also 250 Millisekunden. Das ist für Vier-Gewinnt zwar ausreichend tief, aber nicht genug für Spiele mit Interaktivität. Um das zu beheben gibt es zwei Lösungsmöglichkeiten: - **Long-Polling**: Die Requests für den Spielzustand werden vom Server nicht sofort beantwortet, sondern erst, wenn sich der Zustand geändert hat. - **Web-Sockets**: Das Websockets-Protokoll erlaubt bidirektionale Verbindungen zwischen Client und Server. Über eine solche Verbindung werden Nachrichten über den Spielzustand ausgetauscht. Hier werden wir Long-Polling anschauen. Als erstes benötigen wir eine Datenstruktur, um die Requests für später zu speichern. /** All long-poll requests by gameid. For each gameid, a list of * [response, userid] entries is stored. */ let longpolls = {}; Dazu definieren wir eine neue Route `//longpoll`, die ganz ähnlich wie `//game` funktioniert: /** Serve the game state of any valid game id, but block until * the client is no longer blocked (long-polling). */ app.get('/:gameid/longpoll', (req, res) => { const userid = getUserId(req, res); const game = games[parseInt(req.params['gameid'])] if (game == undefined) { res.status(404).json("no such game"); } else { if (isWaiting(game, userid)) { console.log(`Game ${game.id} directly joined by ${userid}`) join(game, userid) res.json(toJson(game, userid)) res.end() return } // Check if userid needs to wait, then either: // - stash the [response, userid] pair for later // - instantly return actionable state if (shouldBlockRequest(game, userid)) { console.log(`Stash long-poll request for ${game.id} by ${userid}`) longpolls[game.id] ??= [] longpolls[game.id].push([res, userid]) // do not end here but keep request hanging. } else { // User can act on the state, no point in waiting. res.json(toJson(game, userid)) res.end() } } }) Im der server-side Game-Logik implementieren wir eine neue Funktion, um zu wissen, ob ein Request blockiert werden soll oder nicht - genau dann, wenn der Zustand `"waiting"` ist oder wenn der User gerade nicht am Zug ist (User hat als letztes gespielt, oder User ist ein Beobachter): export function shouldBlockRequest(game, userid) { return game.state == "waiting" || game.state == "playing" && getCurrentPlayer(game) != userid; } Nun benötigen wir noch die Logik, um die Long-Poll-Requests zu beantworten, sobald sich der Spielzustand geändert hat. Die Funktion wird aufgerufen, wenn ein Spiel gejoint wird, oder wenn ein Spielzug gemacht wird. /** Sends any pending long-polling responses after a state change. */ function sendLongPollResponses(game) { let polls = longpolls[game.id] delete longpolls[game.id] // remove if (polls) { for (let response of polls) { // a response object is a list with the actual HTTP response and the userid. let res = response[0] let userid = response[1] console.log(`End long-poll request for ${game.id} by ${userid}`) res.json(toJson(game, userid)) res.end() } } } Was bleibt zu tun? Der Client soll nicht mehr warten, sondern bei Bedarf das `longpoll` API benützen: async function handleFetch(grid, status, url) { let response = await fetch(url); if (response.ok) { game = await response.json(); // Update the HTML view. updateHtml(grid, game); // Update game status area to reflect winner / tie updateStatus(status, game); } if (!response.ok) { await wait(500) // wait a little if there is connection trouble } if (!response.ok || game.state == "waiting" || game.state == "playing" && !game.myturn) { await handleFetch(grid, status, `longpoll`); // longpoll to wait for state change } } Nun haben wir viel weniger Datenverkehr, der Server wird entlastet - dafür muss er sich mehr Informationen speichern. Ein Vorteil ist auch, dass Änderungen schneller beim long-polling Client ankommen. ### Hinweise * Die ganze Web-App mit JS & Node: https://github.com/tkilla77/ksr_talit_connect4/