-
Notifications
You must be signed in to change notification settings - Fork 1
Tutorial Tic Tac Toe
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
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
Ö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.knuddelsDEWichtig: Ersetze
DeinNicknameüberall durch deinen tatsächlichen Knuddels-Nicknamen.
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. Das wird mit einem speziellen Pattern angelegt: var App = (new function() { ... }());. Innerhalb dieses Blocks definierst du deine Hooks (mit this.) und Hilfsfunktionen. 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 = (new function() {
// ----- App-Lifecycle -----
this.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', {
displayType: ToplistDisplayType.Value,
ascending: false
});
};
this.onShutdownApp = function() {
// Aufräumen beim Beenden
warteschlange = null;
aktuellesSpiel = null;
};Hinweis: Ab hier zeigen wir immer nur die einzelnen Abschnitte, die du der Reihe nach innerhalb des
var App = (new function() { ... }());-Blocks ergänzt. Das schließende}());kommt ganz am Ende – in Schritt 11.
Was passiert hier?
-
warteschlangemerkt sich den Spieler, der gerade auf einen Gegner wartet. -
aktuellesSpielenthält alle Daten zum laufenden Spiel (Spieler, Spielfeld, wer dran ist). - In
onAppStarterstellen wir gleich eine Toplist für die Siege. Sie basiert auf dem UserPersistence-Keyttt_siegeund zeigt die Spieler mit den meisten Siegen ganz oben. - Hooks wie
onAppStartwerden mitthis.definiert – das ist die Knuddels-Konvention.
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 -----
this.chatCommands = {
// /ttt – Öffnet die App
ttt: function(user, params, func) {
oeffneApp(user);
},
// /tttplay – Spiel beitreten oder Warteschlange betreten
tttplay: function(user, params, func) {
spielBeitreten(user);
},
// /ttttop – Bestenliste anzeigen
ttttop: function(user, params, func) {
oeffneApp(user);
}
};Beachte: Chat-Befehle werden als Objekt auf
this.chatCommandsdefiniert – nicht einzeln zugewiesen.
Die Funktionen oeffneApp und spielBeitreten schreiben wir in den nächsten Schritten.
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
pageDataan das Frontend geschickt, damit sie dort sofort verfügbar sind. - Das Popup ist 480×650 Pixel groß und responsive.
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.'
);
} 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. - Sobald ein zweiter Spieler
/tttplayeingibt, wird das Spiel gestartet.
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!'
);
// Beide Spieler bekommen die App geöffnet
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).
nullbedeutet leer,'X'und'O'sind gesetzte Felder. -
sendeSpielUpdateschickt den aktuellen Zustand an alle offenen Popup-Sessions eines Spielers. - Jeder Spieler erfährt sein eigenes Symbol (
meinSymbol) und den Namen des Gegners.
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 -----
this.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?
-
zugVerarbeitenprü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.
-
gewinnerpruefunggeht alle 8 Gewinnkombinationen (3 Reihen, 3 Spalten, 2 Diagonalen) durch.
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 Keyttt_siegebasiert, wird die Bestenliste automatisch aktualisiert! - Im Channel wird das Ergebnis öffentlich verkündet.
- Beide Spieler bekommen ein Event mit dem Ergebnis.
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 });
}
}Wenn ein Spieler während eines Spiels den Channel verlässt, sollte das Spiel abgebrochen werden:
// ----- Nutzer verlässt den Channel -----
this.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 der var App = (new function() { ... }()); BlockDamit ist der Server-Code komplett! Die schließende }());-Klammer beendet den App-Block. Wenn du möchtest, kannst du einmal alles zusammen in die main.js kopieren und weitermachen mit dem Frontend.
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>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>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 });
}
}
// ===== 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">' + 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".
-
spielfeldZeichnenbaut 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.
Deine App ist fertig! Jetzt wird es Zeit, sie auszuprobieren.
-
Hochladen: Verbinde dich per FTP mit dem Dev-Server und lade den gesamten
tic-tac-toe-Ordner hoch. -
Installieren: Gehe in deinen MyChannel, gib
/appsein und installiere die App. -
Testen: Du brauchst zwei Spieler zum Testen. Am einfachsten:
- Öffne den Channel in zwei Browser-Tabs mit unterschiedlichen Accounts
- Gib in beiden
/tttplayein - Das Spiel sollte automatisch starten!
-
Bestenliste prüfen: Nach einem Sieg sollte der Gewinner in der Bestenliste auftauchen. Gib
/ttttopein 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 alleLogger.info()-Ausgaben und Fehlermeldungen.
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
-
UserPersistencefür Punkte nutzt, die automatisch in einer Toplist erscheinen - Grenzfälle behandelst (Spieler verlässt den Channel, ungültige Züge)
Dein Tic-Tac-Toe funktioniert – aber es gibt viele Möglichkeiten, es noch besser zu machen! Hier einige Ideen:
- 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
aktuellesSpielkannst 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.
- 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 perpageDataans Frontend schickst.
- 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.
- Client-Server-Kommunikation – Vertiefe dein Wissen über Events
- Datenspeicherung – Mehr über Persistence und UserPersistenceNumbers
- Toplisten – Labels, Rang-Events und mehr
- Best Practices – Tipps für performante und stabile Apps
- Erste Schritte – Voraussetzungen, Einrichtung, deine erste App
-
Projektstruktur – Dateien, Ordner und
app.config - Dev-Server – Entwicklungsumgebung und Testen
-
App-Lifecycle und Hooks –
onAppStart,onUserJoinedund Co. -
Server-API Grundlagen –
KnuddelsServer,Channel,Logger - Client-Server-Kommunikation – Events zwischen Frontend und Backend
-
Benutzer und Nachrichten –
User,BotUser, Nachrichten senden - HTML-UI und AppContent – Popups, Overlays, Globale Apps-UI
-
Datenspeicherung –
AppPersistence,UserPersistence -
Knuddel und Zahlungen – Transfers, Käufe,
KnuddelAccount - Toplisten – Ranglisten und Highscores
- Globale Apps – Apps im "Apps & Spiele"-Menü
- Quests – Aufgaben für Nutzer
- Externe Server-Kommunikation – HTTP-Requests an externe APIs
- Tutorials – Übersicht aller Tutorials
- Tic-Tac-Toe – Multiplayer-Spiel mit Bestenliste
- Best Practices – Tipps, Limits und häufige Fehler
- FAQ – Häufig gestellte Fragen
- KCode-Formatierung – Nachrichten-Formatierung mit KCode