Skip to content

Tutorial Tic Tac Toe

Marc Staebler edited this page Mar 26, 2026 · 5 revisions

Tutorial: Multiplayer Tic-Tac-Toe mit Bestenliste

In diesem Tutorial baust du Schritt für Schritt ein vollständiges Multiplayer-Tic-Tac-Toe-Spiel mit Bestenliste. Am Ende hast du eine App, in der zwei Spieler im Channel gegeneinander antreten können und ihre Siege in einer Rangliste festgehalten werden.

Voraussetzungen: Du solltest die Seite Erste Schritte bereits durchgearbeitet haben und deine Entwicklungsumgebung eingerichtet haben (FTP-Zugang, Editor, Knuddels Desktop App).

Was du in diesem Tutorial lernst:

  • Spielzustand auf dem Server verwalten
  • Zwei Spieler über Events miteinander verbinden
  • Ein interaktives Spielfeld im Frontend bauen
  • Punkte speichern und eine Bestenliste (Toplist) einrichten
  • Chat-Befehle für deine App definieren

Schritt 1: Projektstruktur anlegen

Erstelle auf deinem Computer folgenden Ordner mit drei Dateien:

tic-tac-toe/
├── main.js          # Server-Logik (Spielregeln, Matchmaking, Punkte)
├── app.config       # App-Konfiguration
└── www/
    └── index.html   # Frontend (Spielfeld, Bestenliste)

Tipp: Du kannst den Ordner und die Dateien über deinen Dateimanager oder direkt im Terminal anlegen:

mkdir tic-tac-toe
cd tic-tac-toe
touch main.js app.config
mkdir www
touch www/index.html

Schritt 2: app.config einrichten

Öffne die Datei app.config und füge Folgendes ein:

appName = TicTacToe
appVersion = 1.0
autoUpdate = true
appDeveloper.knuddelsDEV = DeinNickname
appDeveloper.knuddelsDE = DeinNickname
mayBeInstalledBy.1 = DeinNickname.knuddelsDEV
mayBeInstalledBy.2 = DeinNickname.knuddelsDE

Wichtig: Ersetze DeinNickname überall durch deinen tatsächlichen Knuddels-Nicknamen.


Schritt 3: Server-Logik – Das Grundgerüst

Jetzt beginnen wir mit der Datei main.js. Hier läuft die gesamte Spiellogik. Wir bauen den Code Stück für Stück auf.

Jede Knuddels-App braucht ein App-Objekt, in dem du deine Hooks und Chat-Befehle als Eigenschaften definierst. Hilfsfunktionen stehen als normale Funktionen außerhalb des Objekts. Fang mit diesem Grundgerüst an:

// ===================================================
// Tic-Tac-Toe – Server-Logik (main.js)
// ===================================================

// Hier speichern wir das aktuelle Spiel und die Warteschlange
var warteschlange = null;   // User-Objekt des wartenden Spielers
var aktuellesSpiel = null;  // Objekt mit allen Infos zum laufenden Spiel

var App = {

// ----- App-Lifecycle -----

onAppStart: function() {
    var logger = KnuddelsServer.getDefaultLogger();
    logger.info('Tic-Tac-Toe App gestartet!');

    // Bestenliste (Toplist) anlegen
    var toplistAccess = KnuddelsServer.getToplistAccess();
    toplistAccess.createOrUpdateToplist('ttt_siege', 'Tic-Tac-Toe Siege', {
        ascending: false
    });
},

onShutdown: function() {
    // Aufräumen beim Beenden
    warteschlange = null;
    aktuellesSpiel = null;
},

Hinweis: Ab hier zeigen wir immer nur die einzelnen Abschnitte, die du der Reihe nach als Eigenschaften in das var App = { ... };-Objekt einfügst. Achte auf die Kommas zwischen den Eigenschaften. Das schließende }; kommt ganz am Ende – in Schritt 11.

Was passiert hier?

  • warteschlange merkt sich den Spieler, der gerade auf einen Gegner wartet.
  • aktuellesSpiel enthält alle Daten zum laufenden Spiel (Spieler, Spielfeld, wer dran ist).
  • In onAppStart erstellen wir gleich eine Toplist für die Siege. Sie basiert auf dem UserPersistence-Key ttt_siege und zeigt die Spieler mit den meisten Siegen ganz oben.
  • Hooks wie onAppStart werden als Eigenschaften des App-Objekts definiert.

Schritt 4: Chat-Befehle definieren

Spieler sollen über Chat-Befehle mit der App interagieren können. Füge diesen Code in main.js ein (weiterhin innerhalb des App-Blocks):

// ----- Chat-Befehle -----

chatCommands: {
    // /ttt – Öffnet die App
    ttt: function(user, params, command) {
        oeffneApp(user);
    },
    // /tttplay – Spiel beitreten oder Warteschlange betreten
    tttplay: function(user, params, command) {
        spielBeitreten(user);
    },
    // /ttttop – Bestenliste anzeigen
    ttttop: function(user, params, command) {
        oeffneApp(user);
    }
},

Beachte: Chat-Befehle werden als Objekt auf chatCommands definiert – als eine Eigenschaft des App-Objekts.

Die Funktionen oeffneApp und spielBeitreten schreiben wir in den nächsten Schritten.


Schritt 5: App-Fenster öffnen

Diese Funktion öffnet das HTML-Frontend als Popup für den Spieler. Füge sie in main.js ein:

// ----- App öffnen -----

function oeffneApp(user) {
    // Aktuelle Punkte des Spielers laden
    var siege = user.getPersistence().getNumber('ttt_siege', 0);

    var pageData = {
        username: user.getNick(),
        siege: siege
    };

    var htmlFile = new HTMLFile('index.html', pageData);
    var popup = AppContent.popupContent(htmlFile, 480, 650);
    popup.setResponsive(true);

    if (user.canShowAppViewMode(AppViewMode.Popup)) {
        user.sendAppContent(popup);
    } else {
        user.sendPrivateMessage('Die App kann auf deinem Gerät leider nicht angezeigt werden.');
    }
}

Was passiert hier?

  • Wir lesen die bisherigen Siege des Spielers aus seiner UserPersistence.
  • Diese Daten werden als pageData an das Frontend geschickt, damit sie dort sofort verfügbar sind.
  • Das Popup ist 480×650 Pixel groß und responsive.

Schritt 6: Matchmaking – Spieler zusammenbringen

Das Herzstück des Multiplayer-Spiels: Wenn ein Spieler beitreten will, kommt er entweder in die Warteschlange oder wird mit dem wartenden Spieler zu einem Spiel zusammengeführt.

// ----- Matchmaking -----

function spielBeitreten(user) {
    // Prüfen: Ist der Spieler bereits in einem Spiel?
    if (aktuellesSpiel !== null) {
        if (aktuellesSpiel.spielerX.getUserId() === user.getUserId() ||
            aktuellesSpiel.spielerO.getUserId() === user.getUserId()) {
            user.sendPrivateMessage('Du bist bereits in einem Spiel!');
            return;
        }
        user.sendPrivateMessage('Es läuft gerade ein Spiel. Bitte warte, bis es vorbei ist.');
        return;
    }

    // Prüfen: Wartet der Spieler bereits?
    if (warteschlange !== null && warteschlange.getUserId() === user.getUserId()) {
        user.sendPrivateMessage('Du wartest bereits auf einen Gegner.');
        return;
    }

    if (warteschlange === null) {
        // Erster Spieler: In die Warteschlange
        warteschlange = user;
        user.sendPrivateMessage('Du bist in der Warteschlange. Warte auf einen Gegner...');

        var bot = KnuddelsServer.getDefaultBotUser();
        bot.sendPublicMessage(
            user.getProfileLink() + ' sucht einen Gegner für Tic-Tac-Toe! ' +
            'Schreibe °>/tttplay|/tttplay<° um mitzuspielen.'
        );

        // App nur öffnen, wenn der Spieler noch kein Popup offen hat
        // (bei "Nochmal spielen" ist das Popup bereits offen)
        if (user.getAppContentSessions(AppViewMode.Popup).length === 0) {
            oeffneApp(user);
        }
    } else {
        // Zweiter Spieler: Spiel starten!
        var spielerX = warteschlange;
        var spielerO = user;
        warteschlange = null;

        spielStarten(spielerX, spielerO);
    }
}

Was passiert hier?

  • Erst wird geprüft, ob der Spieler schon in einem laufenden Spiel oder in der Warteschlange ist.
  • Der erste Spieler kommt in die warteschlange, und im Channel wird eine öffentliche Nachricht gepostet, damit andere mitspielen können.
  • Bevor die App geöffnet wird, prüfen wir mit getAppContentSessions(), ob der Spieler bereits ein Popup offen hat. Bei einem Rematch ("Nochmal spielen") ist das der Fall – dann wird kein neues Popup geöffnet, da das bestehende weiter genutzt wird.
  • Sobald ein zweiter Spieler /tttplay eingibt, wird das Spiel gestartet.

Schritt 7: Spiel starten und Spiellogik

Jetzt wird es spannend – die eigentliche Spiellogik. Füge diesen Code in main.js ein:

// ----- Spiel starten -----

function spielStarten(spielerX, spielerO) {
    // Spielfeld: 9 Felder, null = leer, 'X' oder 'O' = belegt
    aktuellesSpiel = {
        spielerX: spielerX,
        spielerO: spielerO,
        feld: [null, null, null, null, null, null, null, null, null],
        amZug: 'X'   // X beginnt immer
    };

    var bot = KnuddelsServer.getDefaultBotUser();
    bot.sendPublicMessage(
        'Tic-Tac-Toe: ' + spielerX.getProfileLink() + ' (X) vs. ' +
        spielerO.getProfileLink() + ' (O) – Das Spiel beginnt!'
    );

    // App nur öffnen, wenn der Spieler noch kein Popup offen hat.
    // Bei "Nochmal spielen" ist das Popup bereits offen – dann nur Update senden.
    if (spielerX.getAppContentSessions(AppViewMode.Popup).length === 0) {
        oeffneApp(spielerX);
    }
    if (spielerO.getAppContentSessions(AppViewMode.Popup).length === 0) {
        oeffneApp(spielerO);
    }

    // Spielzustand an beide Spieler senden
    sendeSpielUpdate(spielerX);
    sendeSpielUpdate(spielerO);
}

Jetzt brauchen wir eine Funktion, die den aktuellen Spielzustand an die Clients schickt:

// ----- Spielzustand an einen Spieler senden -----

function sendeSpielUpdate(user) {
    if (aktuellesSpiel === null) return;

    var spielerSymbol = 'X';
    if (aktuellesSpiel.spielerO.getUserId() === user.getUserId()) {
        spielerSymbol = 'O';
    }

    var sessions = user.getAppContentSessions(AppViewMode.Popup);
    for (var i = 0; i < sessions.length; i++) {
        sessions[i].sendEvent('spielUpdate', {
            feld: aktuellesSpiel.feld,
            amZug: aktuellesSpiel.amZug,
            meinSymbol: spielerSymbol,
            gegnerName: spielerSymbol === 'X'
                ? aktuellesSpiel.spielerO.getNick()
                : aktuellesSpiel.spielerX.getNick()
        });
    }
}

Was passiert hier?

  • Das Spielfeld ist ein Array mit 9 Einträgen (für die 3×3-Felder). null bedeutet leer, 'X' und 'O' sind gesetzte Felder.
  • sendeSpielUpdate schickt den aktuellen Zustand an alle offenen Popup-Sessions eines Spielers.
  • Jeder Spieler erfährt sein eigenes Symbol (meinSymbol) und den Namen des Gegners.

Schritt 8: Züge verarbeiten und Gewinner prüfen

Wenn ein Spieler auf ein Feld klickt, schickt das Frontend ein Event an den Server. Der Server muss den Zug validieren, das Feld aktualisieren und prüfen, ob jemand gewonnen hat.

// ----- Events vom Client empfangen -----

onEventReceived: function(user, type, data, appContentSession) {
    if (type === 'zug') {
        zugVerarbeiten(user, data.position);
    } else if (type === 'getToplist') {
        toplistSenden(user, appContentSession);
    } else if (type === 'spielBeitreten') {
        spielBeitreten(user);
    }
},

// ----- Zug verarbeiten -----

function zugVerarbeiten(user, position) {
    if (aktuellesSpiel === null) {
        user.sendPrivateMessage('Es läuft gerade kein Spiel.');
        return;
    }

    // Welcher Spieler ist dran?
    var spielerAmZug = aktuellesSpiel.amZug === 'X'
        ? aktuellesSpiel.spielerX
        : aktuellesSpiel.spielerO;

    // Ist der richtige Spieler dran?
    if (spielerAmZug.getUserId() !== user.getUserId()) {
        user.sendPrivateMessage('Du bist gerade nicht am Zug!');
        return;
    }

    // Ist das Feld gültig und frei?
    if (position < 0 || position > 8 || aktuellesSpiel.feld[position] !== null) {
        user.sendPrivateMessage('Dieses Feld ist nicht verfügbar.');
        return;
    }

    // Zug setzen
    aktuellesSpiel.feld[position] = aktuellesSpiel.amZug;

    // Gewinner prüfen
    var gewinner = gewinnerpruefung(aktuellesSpiel.feld);

    if (gewinner !== null) {
        spielBeenden(gewinner);
        return;
    }

    // Unentschieden prüfen (alle Felder belegt)
    var allesBelegt = true;
    for (var i = 0; i < 9; i++) {
        if (aktuellesSpiel.feld[i] === null) {
            allesBelegt = false;
            break;
        }
    }

    if (allesBelegt) {
        spielBeenden('unentschieden');
        return;
    }

    // Nächster Spieler ist dran
    aktuellesSpiel.amZug = aktuellesSpiel.amZug === 'X' ? 'O' : 'X';

    // Beide Spieler informieren
    sendeSpielUpdate(aktuellesSpiel.spielerX);
    sendeSpielUpdate(aktuellesSpiel.spielerO);
}

Jetzt die Gewinner-Prüfung – hier werden alle möglichen Gewinnkombinationen geprüft:

// ----- Gewinnprüfung -----

function gewinnerpruefung(feld) {
    // Alle 8 möglichen Gewinnkombinationen
    var kombinationen = [
        [0, 1, 2],  // Obere Reihe
        [3, 4, 5],  // Mittlere Reihe
        [6, 7, 8],  // Untere Reihe
        [0, 3, 6],  // Linke Spalte
        [1, 4, 7],  // Mittlere Spalte
        [2, 5, 8],  // Rechte Spalte
        [0, 4, 8],  // Diagonale links-oben nach rechts-unten
        [2, 4, 6]   // Diagonale rechts-oben nach links-unten
    ];

    for (var i = 0; i < kombinationen.length; i++) {
        var a = kombinationen[i][0];
        var b = kombinationen[i][1];
        var c = kombinationen[i][2];

        if (feld[a] !== null && feld[a] === feld[b] && feld[b] === feld[c]) {
            return feld[a];  // Gibt 'X' oder 'O' zurück
        }
    }

    return null;  // Kein Gewinner
}

Was passiert hier?

  • zugVerarbeiten prüft zuerst, ob der richtige Spieler am Zug ist und ob das Feld frei ist.
  • Nach dem Setzen wird geprüft, ob jemand gewonnen hat oder ob es unentschieden ist.
  • gewinnerpruefung geht alle 8 Gewinnkombinationen (3 Reihen, 3 Spalten, 2 Diagonalen) durch.

Schritt 9: Spiel beenden und Punkte vergeben

Wenn das Spiel vorbei ist, werden Punkte vergeben und beide Spieler informiert:

// ----- Spiel beenden -----

function spielBeenden(ergebnis) {
    if (aktuellesSpiel === null) return;

    var bot = KnuddelsServer.getDefaultBotUser();
    var spielerX = aktuellesSpiel.spielerX;
    var spielerO = aktuellesSpiel.spielerO;

    if (ergebnis === 'unentschieden') {
        bot.sendPublicMessage('Tic-Tac-Toe: Unentschieden! Gut gespielt, ' +
            spielerX.getProfileLink() + ' und ' + spielerO.getProfileLink() + '!');

        sendeSpielErgebnis(spielerX, 'unentschieden', null);
        sendeSpielErgebnis(spielerO, 'unentschieden', null);
    } else {
        // ergebnis ist 'X' oder 'O'
        var gewinner = ergebnis === 'X' ? spielerX : spielerO;
        var verlierer = ergebnis === 'X' ? spielerO : spielerX;

        // Siege hochzählen (aktualisiert automatisch die Toplist!)
        gewinner.getPersistence().addNumber('ttt_siege', 1);

        bot.sendPublicMessage('Tic-Tac-Toe: ' + gewinner.getProfileLink() +
            ' gewinnt gegen ' + verlierer.getProfileLink() + '!');

        sendeSpielErgebnis(spielerX, 'gewonnen',
            ergebnis === 'X' ? spielerX.getNick() : spielerO.getNick());
        sendeSpielErgebnis(spielerO, 'gewonnen',
            ergebnis === 'X' ? spielerX.getNick() : spielerO.getNick());
    }

    // Spiel zurücksetzen
    aktuellesSpiel = null;
}

function sendeSpielErgebnis(user, ergebnis, gewinnerName) {
    var sessions = user.getAppContentSessions(AppViewMode.Popup);
    for (var i = 0; i < sessions.length; i++) {
        sessions[i].sendEvent('spielErgebnis', {
            ergebnis: ergebnis,
            gewinnerName: gewinnerName
        });
    }
}

Was passiert hier?

  • Bei einem Sieg wird addNumber('ttt_siege', 1) aufgerufen – das erhöht den Zähler um 1. Weil unsere Toplist auf demselben Key ttt_siege basiert, wird die Bestenliste automatisch aktualisiert!
  • Im Channel wird das Ergebnis öffentlich verkündet.
  • Beide Spieler bekommen ein Event mit dem Ergebnis.

Schritt 10: Bestenliste vom Server laden

Wenn ein Spieler die Bestenliste sehen will, holt der Server die Top-10:

// ----- Bestenliste senden -----

function toplistSenden(user, appContentSession) {
    var besteSpieler = UserPersistenceNumbers.getSortedEntries('ttt_siege', {
        ascending: false,
        count: 10
    });

    var liste = [];
    for (var i = 0; i < besteSpieler.length; i++) {
        var entry = besteSpieler[i];
        liste.push({
            position: entry.getPosition(),
            name: entry.getUser().getNick(),
            siege: entry.getValue()
        });
    }

    if (appContentSession) {
        appContentSession.sendEvent('toplist', { liste: liste });
    }
}

Schritt 11: Aufräumen wenn Spieler den Channel verlassen

Wenn ein Spieler während eines Spiels den Channel verlässt, sollte das Spiel abgebrochen werden:

// ----- Nutzer verlässt den Channel -----

onUserLeft: function(user) {
    // Aus der Warteschlange entfernen
    if (warteschlange !== null && warteschlange.getUserId() === user.getUserId()) {
        warteschlange = null;
        return;
    }

    // Laufendes Spiel abbrechen
    if (aktuellesSpiel !== null) {
        if (aktuellesSpiel.spielerX.getUserId() === user.getUserId() ||
            aktuellesSpiel.spielerO.getUserId() === user.getUserId()) {

            var andererSpieler = aktuellesSpiel.spielerX.getUserId() === user.getUserId()
                ? aktuellesSpiel.spielerO
                : aktuellesSpiel.spielerX;

            andererSpieler.sendPrivateMessage(
                'Dein Gegner hat den Channel verlassen. Das Spiel wurde abgebrochen.'
            );

            var sessions = andererSpieler.getAppContentSessions(AppViewMode.Popup);
            for (var i = 0; i < sessions.length; i++) {
                sessions[i].sendEvent('spielAbgebrochen', {});
            }

            aktuellesSpiel = null;
        }
    }
}

}; // <-- Hier schließt sich das var App = { ... }; Objekt

Damit ist der Server-Code komplett! Das schließende }; beendet das App-Objekt. Beachte, dass die Hilfsfunktionen (oeffneApp, spielBeitreten, spielStarten usw.) als normale Funktionen außerhalb des Objekts stehen und von den Hooks aufgerufen werden. Wenn du möchtest, kannst du einmal alles zusammen in die main.js kopieren und weitermachen mit dem Frontend.


Schritt 12: Das Frontend – HTML und CSS

Jetzt kommt der sichtbare Teil: das Spielfeld. Öffne www/index.html und füge diesen Code ein.

Wir beginnen mit dem HTML-Grundgerüst und dem Styling:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }

        body {
            font-family: Arial, sans-serif;
            background: #1a1a2e;
            color: #eee;
            display: flex;
            justify-content: center;
            align-items: flex-start;
            min-height: 100vh;
            padding: 15px;
        }

        .app {
            width: 100%;
            max-width: 400px;
            text-align: center;
        }

        h1 {
            font-size: 22px;
            margin-bottom: 5px;
            color: #e94560;
        }

        .info {
            font-size: 14px;
            color: #aaa;
            margin-bottom: 15px;
        }

        /* ---------- Navigations-Tabs ---------- */

        .tabs {
            display: flex;
            margin-bottom: 15px;
            border-radius: 8px;
            overflow: hidden;
        }

        .tab {
            flex: 1;
            padding: 10px;
            background: #16213e;
            color: #aaa;
            border: none;
            cursor: pointer;
            font-size: 14px;
            transition: background 0.2s;
        }

        .tab.active {
            background: #0f3460;
            color: #fff;
        }

        /* ---------- Seiten ---------- */

        .page {
            display: none;
        }

        .page.active {
            display: block;
        }

        /* ---------- Spielfeld ---------- */

        .spielfeld {
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 6px;
            max-width: 300px;
            margin: 15px auto;
        }

        .zelle {
            aspect-ratio: 1;
            background: #16213e;
            border: 2px solid #0f3460;
            border-radius: 8px;
            display: flex;
            justify-content: center;
            align-items: center;
            font-size: 40px;
            font-weight: bold;
            cursor: pointer;
            transition: background 0.2s;
        }

        .zelle:hover {
            background: #1a3a5c;
        }

        .zelle.x {
            color: #e94560;
        }

        .zelle.o {
            color: #00d2ff;
        }

        .zelle.gesperrt {
            cursor: default;
        }

        /* ---------- Status-Anzeige ---------- */

        .status {
            font-size: 16px;
            padding: 10px;
            margin: 10px 0;
            border-radius: 8px;
            background: #16213e;
        }

        .status.gewonnen {
            background: #1b5e20;
            color: #a5d6a7;
        }

        .status.verloren {
            background: #b71c1c;
            color: #ef9a9a;
        }

        .status.unentschieden {
            background: #e65100;
            color: #ffcc80;
        }

        /* ---------- Buttons ---------- */

        .btn {
            padding: 12px 24px;
            font-size: 15px;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            color: #fff;
            transition: background 0.2s;
            margin: 5px;
        }

        .btn-primary {
            background: #e94560;
        }

        .btn-primary:hover {
            background: #c73650;
        }

        .btn-secondary {
            background: #0f3460;
        }

        .btn-secondary:hover {
            background: #16213e;
        }

        /* ---------- Bestenliste ---------- */

        .topliste {
            text-align: left;
            width: 100%;
        }

        .topliste-eintrag {
            display: flex;
            align-items: center;
            padding: 10px 12px;
            margin-bottom: 4px;
            background: #16213e;
            border-radius: 8px;
        }

        .topliste-eintrag:nth-child(1) {
            background: #3a2e00;
            border: 1px solid #ffd700;
        }

        .topliste-eintrag:nth-child(2) {
            background: #2a2a2a;
            border: 1px solid #c0c0c0;
        }

        .topliste-eintrag:nth-child(3) {
            background: #2a1a0a;
            border: 1px solid #cd7f32;
        }

        .rang {
            font-size: 18px;
            font-weight: bold;
            width: 35px;
            color: #e94560;
        }

        .spieler-name {
            flex: 1;
            font-size: 14px;
        }

        .siege-zahl {
            font-size: 14px;
            font-weight: bold;
            color: #00d2ff;
        }

        .leer-text {
            color: #666;
            padding: 20px;
            font-size: 14px;
        }
    </style>
</head>

Schritt 13: Das Frontend – HTML-Struktur

Direkt unter dem </head>-Tag kommt der <body>:

<body>
    <div class="app">
        <h1>Tic-Tac-Toe</h1>
        <p class="info" id="begruessung">Willkommen!</p>

        <!-- Navigations-Tabs -->
        <div class="tabs">
            <button class="tab active" onclick="zeigeSeite('spiel')">Spiel</button>
            <button class="tab" onclick="zeigeSeite('bestenliste')">Bestenliste</button>
        </div>

        <!-- Seite: Spiel -->
        <div id="seite-spiel" class="page active">
            <div id="status" class="status">
                Klicke auf "Spiel starten", um einen Gegner zu suchen.
            </div>

            <div id="spielfeld-container" style="display: none;">
                <div class="spielfeld" id="spielfeld"></div>
            </div>

            <div id="aktionen">
                <button class="btn btn-primary" id="btn-start" onclick="spielStarten()">
                    Spiel starten
                </button>
                <button class="btn btn-secondary" id="btn-nochmal" onclick="spielStarten()" style="display: none;">
                    Nochmal spielen
                </button>
            </div>
        </div>

        <!-- Seite: Bestenliste -->
        <div id="seite-bestenliste" class="page">
            <div id="topliste-inhalt">
                <p class="leer-text">Lade Bestenliste...</p>
            </div>
        </div>
    </div>

Schritt 14: Das Frontend – JavaScript-Logik

Jetzt kommt der interaktive Teil. Füge diesen <script>-Block vor dem schließenden </body>-Tag ein:

    <script>
        // ===== Initialisierung =====

        var pageData = {};
        if (typeof Client !== 'undefined') {
            pageData = Client.pageData;
        } else {
            // Testdaten für lokales Testen im Browser
            pageData = { username: 'TestUser', siege: 5 };
        }

        document.getElementById('begruessung').textContent =
            'Hallo, ' + pageData.username + '! (Siege: ' + pageData.siege + ')';

        // ===== Seiten-Navigation =====

        function zeigeSeite(seite) {
            // Alle Seiten ausblenden
            var seiten = document.querySelectorAll('.page');
            for (var i = 0; i < seiten.length; i++) {
                seiten[i].classList.remove('active');
            }
            // Alle Tabs deaktivieren
            var tabs = document.querySelectorAll('.tab');
            for (var i = 0; i < tabs.length; i++) {
                tabs[i].classList.remove('active');
            }

            // Gewählte Seite anzeigen
            document.getElementById('seite-' + seite).classList.add('active');

            // Tab markieren
            var index = seite === 'spiel' ? 0 : 1;
            tabs[index].classList.add('active');

            // Bei Bestenliste: Daten vom Server laden
            if (seite === 'bestenliste') {
                if (typeof Client !== 'undefined' && Client.sendEvent) {
                    Client.sendEvent('getToplist', {});
                }
            }
        }

        // ===== Spiel starten =====

        function spielStarten() {
            document.getElementById('status').textContent = 'Suche Gegner...';
            document.getElementById('status').className = 'status';
            document.getElementById('btn-start').style.display = 'none';
            document.getElementById('btn-nochmal').style.display = 'none';
            document.getElementById('spielfeld-container').style.display = 'none';

            if (typeof Client !== 'undefined' && Client.sendEvent) {
                Client.sendEvent('spielBeitreten', {});
            }
        }

        // ===== Spielfeld zeichnen =====

        function spielfeldZeichnen(feld, amZug, meinSymbol) {
            var container = document.getElementById('spielfeld');
            container.innerHTML = '';

            var istMeinZug = amZug === meinSymbol;

            for (var i = 0; i < 9; i++) {
                var zelle = document.createElement('div');
                zelle.className = 'zelle';

                if (feld[i] === 'X') {
                    zelle.textContent = 'X';
                    zelle.classList.add('x', 'gesperrt');
                } else if (feld[i] === 'O') {
                    zelle.textContent = 'O';
                    zelle.classList.add('o', 'gesperrt');
                } else if (istMeinZug) {
                    // Leeres Feld und ich bin dran → klickbar
                    zelle.setAttribute('data-pos', i);
                    zelle.addEventListener('click', function() {
                        var pos = parseInt(this.getAttribute('data-pos'));
                        zugSenden(pos);
                    });
                } else {
                    zelle.classList.add('gesperrt');
                }

                container.appendChild(zelle);
            }
        }

        // ===== Zug an den Server senden =====

        function zugSenden(position) {
            if (typeof Client !== 'undefined' && Client.sendEvent) {
                Client.sendEvent('zug', { position: position });
            }
        }

        // ===== Hilfsfunktion: HTML-Sonderzeichen escapen (XSS-Schutz) =====

        function escapeHtml(text) {
            var div = document.createElement('div');
            div.appendChild(document.createTextNode(text));
            return div.innerHTML;
        }

        // ===== Bestenliste anzeigen =====

        function toplisteAnzeigen(liste) {
            var container = document.getElementById('topliste-inhalt');

            if (liste.length === 0) {
                container.innerHTML = '<p class="leer-text">Noch keine Einträge vorhanden. Spiele ein Spiel!</p>';
                return;
            }

            var html = '<div class="topliste">';
            for (var i = 0; i < liste.length; i++) {
                var eintrag = liste[i];
                html += '<div class="topliste-eintrag">';
                html += '<span class="rang">#' + eintrag.position + '</span>';
                html += '<span class="spieler-name">' + escapeHtml(eintrag.name) + '</span>';
                html += '<span class="siege-zahl">' + eintrag.siege + ' Siege</span>';
                html += '</div>';
            }
            html += '</div>';

            container.innerHTML = html;
        }

        // ===== Events vom Server empfangen =====

        if (typeof Client !== 'undefined') {

            // Spielfeld-Update
            Client.addEventListener('spielUpdate', function(event) {
                var d = event.data;
                document.getElementById('spielfeld-container').style.display = 'block';
                document.getElementById('btn-start').style.display = 'none';
                document.getElementById('btn-nochmal').style.display = 'none';

                var istDran = d.amZug === d.meinSymbol;
                if (istDran) {
                    document.getElementById('status').textContent =
                        'Du bist dran! (Du spielst ' + d.meinSymbol + ')';
                } else {
                    document.getElementById('status').textContent =
                        d.gegnerName + ' ist am Zug... (Du spielst ' + d.meinSymbol + ')';
                }
                document.getElementById('status').className = 'status';

                spielfeldZeichnen(d.feld, d.amZug, d.meinSymbol);
            });

            // Spielergebnis
            Client.addEventListener('spielErgebnis', function(event) {
                var d = event.data;
                var statusEl = document.getElementById('status');

                if (d.ergebnis === 'unentschieden') {
                    statusEl.textContent = 'Unentschieden!';
                    statusEl.className = 'status unentschieden';
                } else if (d.gewinnerName === pageData.username) {
                    statusEl.textContent = 'Du hast gewonnen!';
                    statusEl.className = 'status gewonnen';
                    pageData.siege++;
                    document.getElementById('begruessung').textContent =
                        'Hallo, ' + pageData.username + '! (Siege: ' + pageData.siege + ')';
                } else {
                    statusEl.textContent = d.gewinnerName + ' hat gewonnen!';
                    statusEl.className = 'status verloren';
                }

                document.getElementById('btn-nochmal').style.display = 'inline-block';
            });

            // Spiel abgebrochen
            Client.addEventListener('spielAbgebrochen', function(event) {
                document.getElementById('status').textContent = 'Der Gegner hat das Spiel verlassen.';
                document.getElementById('status').className = 'status';
                document.getElementById('spielfeld-container').style.display = 'none';
                document.getElementById('btn-start').style.display = 'inline-block';
            });

            // Bestenliste empfangen
            Client.addEventListener('toplist', function(event) {
                toplisteAnzeigen(event.data.liste);
            });
        }
    </script>
</body>
</html>

Was passiert hier?

  • Die Seite hat zwei Tabs: "Spiel" und "Bestenliste".
  • spielfeldZeichnen baut das 3×3-Gitter auf. Leere Felder sind klickbar, wenn der Spieler am Zug ist.
  • Vier Event-Listener empfangen Updates vom Server: Spielzustand, Ergebnis, Abbruch und die Topliste.

Schritt 15: Hochladen und Testen

Deine App ist fertig! Jetzt wird es Zeit, sie auszuprobieren.

  1. Hochladen: Verbinde dich per FTP mit dem Dev-Server und lade den gesamten tic-tac-toe-Ordner hoch.

  2. Installieren: Gehe in deinen MyChannel, gib /apps ein und installiere die App.

  3. Testen: Du brauchst zwei Spieler zum Testen. Am einfachsten:

    • Öffne den Channel in zwei Browser-Tabs mit unterschiedlichen Accounts
    • Gib in beiden /tttplay ein
    • Das Spiel sollte automatisch starten!
  4. Bestenliste prüfen: Nach einem Sieg sollte der Gewinner in der Bestenliste auftauchen. Gib /ttttop ein oder wechsle in der App auf den Tab "Bestenliste".

Fehlerbehebung: Falls etwas nicht funktioniert, schaue in die App-Logs. Die findest du in der /apps-Verwaltung unter "show logs". Dort siehst du alle Logger.info()-Ausgaben und Fehlermeldungen.


Zusammenfassung

Du hast in diesem Tutorial gelernt, wie du:

  • Ein Matchmaking-System baust, das zwei Spieler zusammenführt
  • Spielzustand serverseitig verwaltest und über Events an Clients sendest
  • Ein interaktives Frontend mit Klick-Events baust
  • UserPersistence für Punkte nutzt, die automatisch in einer Toplist erscheinen
  • Grenzfälle behandelst (Spieler verlässt den Channel, ungültige Züge)

Ideen zum Erweitern und Experimentieren

Dein Tic-Tac-Toe funktioniert – aber es gibt viele Möglichkeiten, es noch besser zu machen! Hier einige Ideen:

Mehr Spiellogik

  • KI-Gegner: Füge einen Singleplayer-Modus hinzu, in dem der Server automatisch Züge für einen Computer-Gegner berechnet. Ein einfacher Ansatz: Der Computer setzt zufällig auf ein freies Feld. Fortgeschritten: Implementiere den Minimax-Algorithmus für einen unschlagbaren Gegner.
  • Mehrere Spiele gleichzeitig: Statt nur einem aktuellesSpiel kannst du ein Array oder Objekt mit mehreren Spielen verwenden, damit mehrere Paare gleichzeitig spielen können.
  • Zeitlimit: Gib jedem Spieler z.B. 30 Sekunden pro Zug. Wenn die Zeit abläuft, gewinnt der andere Spieler automatisch.
  • Best-of-3: Lass zwei Spieler eine Serie von drei Runden spielen. Wer zuerst zwei gewinnt, bekommt den Punkt.

Besseres Spielerlebnis

  • Animationen: Füge CSS-Animationen hinzu, wenn ein Stein gesetzt wird oder wenn jemand gewinnt (z.B. die Gewinnlinie aufleuchten lassen).
  • Sound-Effekte: Spiele einen Ton ab, wenn ein Zug gemacht wird oder das Spiel endet.
  • Spielerverlauf: Speichere nicht nur Siege, sondern auch Niederlagen und Unentschieden, und zeige eine Win-Rate in der Bestenliste an.
  • Profilbilder: Zeige die Profilbilder der Spieler im UI an, indem du user.getProfilePhoto() nutzt und die URL per pageData ans Frontend schickst.

Erweiterte Features

  • Elo-Rating: Statt nur Siege zu zählen, implementiere ein Elo-Bewertungssystem. Siege gegen stärkere Gegner bringen mehr Punkte!
  • Spieleinladungen: Lass Spieler gezielt andere Spieler herausfordern, statt über eine Warteschlange zu gehen (z.B. /ttt NickDesGegners).
  • Chat im Spiel: Füge ein kleines Chat-Feld in die App ein, über das die beiden Spieler während des Spiels miteinander schreiben können.
  • Größeres Spielfeld: Erweitere das Spiel auf ein 4×4- oder 5×5-Feld, bei dem man 4 in einer Reihe braucht.
  • Turniere: Baue ein Turniersystem, bei dem mehrere Spieler in K.O.-Runden gegeneinander antreten.

Weiterführende Wiki-Seiten


← Zurück zu den Tutorials | ← Zurück zur Übersicht

Navigation

Einstieg

Kernkonzepte

Features

Tutorials

Referenz & Hilfe

Clone this wiki locally