feat: implement competitive clicker MVP with Colyseus.js

- Add real-time multiplayer game server with Colyseus
- Implement unique player naming system with auto-increment
- Create lobby system with automatic matchmaking
- Build 10-minute competitive clicking game rooms (max 2 players)
- Add admin dashboard for game management (pause/resume/restart/kick)
- Implement Vue 3 client with professional UI
- Add WebSocket communication with state synchronization
- Include TypeScript throughout with proper typing
- Create REST API for admin operations
- Add reconnection support and error handling
This commit is contained in:
2025-08-06 02:32:18 -06:00
commit a28bc286a1
30 changed files with 7053 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
import { Room, Client } from "colyseus";
import { GameState } from "./schemas/GameState";
import { GameStatus } from "../../../shared/types";
import { NameManager } from "../utils/nameManager";
export class GameRoom extends Room<GameState> {
maxClients = 2;
private gameInterval?: NodeJS.Timeout;
private readonly TICK_RATE = 1000; // Update every second
onCreate(options: any) {
this.setState(new GameState());
this.state.roomId = this.roomId;
this.onMessage("click", (client) => {
this.handleClick(client);
});
this.onMessage("admin:pause", () => {
this.state.pauseGame();
});
this.onMessage("admin:resume", () => {
this.state.resumeGame();
});
this.onMessage("admin:restart", () => {
this.handleRestart();
});
this.onMessage("admin:kick", (client, playerId: string) => {
this.handleKick(playerId);
});
}
onJoin(client: Client, options: any) {
console.log(`[GameRoom] ${client.sessionId} joined room ${this.roomId}`);
const playerName = options.playerName || "player";
const uniqueName = NameManager.getInstance().generateUniquePlayerName(playerName, client.sessionId);
this.state.addPlayer(client.sessionId, uniqueName);
client.send("playerInfo", {
sessionId: client.sessionId,
name: uniqueName,
roomId: this.roomId
});
if (this.state.players.size === 2 && this.state.gameStatus === GameStatus.WAITING) {
this.startGame();
}
}
onLeave(client: Client, consented: boolean) {
console.log(`[GameRoom] ${client.sessionId} left room ${this.roomId}`);
const player = this.state.players.get(client.sessionId);
if (player) {
player.connected = false;
NameManager.getInstance().releasePlayerName(client.sessionId);
}
if (this.state.gameStatus === GameStatus.PLAYING) {
if (this.getConnectedPlayersCount() < 2) {
this.pauseGame();
}
}
this.allowReconnection(client, 30);
}
async onReconnect(client: Client) {
console.log(`[GameRoom] ${client.sessionId} reconnected to room ${this.roomId}`);
const player = this.state.players.get(client.sessionId);
if (player) {
player.connected = true;
}
if (this.state.gameStatus === GameStatus.PAUSED && this.getConnectedPlayersCount() === 2) {
this.state.resumeGame();
}
}
onDispose() {
console.log(`[GameRoom] Room ${this.roomId} disposing...`);
if (this.gameInterval) {
clearInterval(this.gameInterval);
}
this.state.players.forEach(player => {
NameManager.getInstance().releasePlayerName(player.sessionId);
});
}
private startGame() {
console.log(`[GameRoom] Starting game in room ${this.roomId}`);
this.state.startGame();
this.broadcast("gameStart");
this.gameInterval = setInterval(() => {
this.state.updateTimer(this.TICK_RATE / 1000);
if (this.state.gameStatus === GameStatus.FINISHED) {
this.endGame();
}
}, this.TICK_RATE);
}
private pauseGame() {
console.log(`[GameRoom] Pausing game in room ${this.roomId}`);
this.state.pauseGame();
this.broadcast("gamePaused");
}
private endGame() {
console.log(`[GameRoom] Game ended in room ${this.roomId}. Winner: ${this.state.winner}`);
if (this.gameInterval) {
clearInterval(this.gameInterval);
this.gameInterval = undefined;
}
this.broadcast("gameEnd", {
winner: this.state.winner,
players: Array.from(this.state.players.values()).map(p => ({
name: p.name,
clicks: p.clicks
}))
});
setTimeout(() => {
this.state.restartGame();
if (this.state.players.size === 2) {
this.startGame();
}
}, 5000);
}
private handleClick(client: Client) {
if (this.state.gameStatus !== GameStatus.PLAYING) {
return;
}
const player = this.state.players.get(client.sessionId);
if (player && player.connected) {
player.incrementClicks();
}
}
private handleRestart() {
console.log(`[GameRoom] Admin restart in room ${this.roomId}`);
if (this.gameInterval) {
clearInterval(this.gameInterval);
this.gameInterval = undefined;
}
this.state.restartGame();
this.broadcast("gameRestart");
if (this.state.players.size === 2) {
setTimeout(() => this.startGame(), 2000);
}
}
private handleKick(playerId: string) {
console.log(`[GameRoom] Admin kick player ${playerId} from room ${this.roomId}`);
const client = this.clients.find(c => c.sessionId === playerId);
if (client) {
client.leave(1000);
}
}
private getConnectedPlayersCount(): number {
let count = 0;
this.state.players.forEach(player => {
if (player.connected) count++;
});
return count;
}
getState() {
return {
roomId: this.roomId,
players: Array.from(this.state.players.values()).map(p => ({
sessionId: p.sessionId,
name: p.name,
clicks: p.clicks
})),
gameStatus: this.state.gameStatus,
timeRemaining: this.state.timeRemaining,
winner: this.state.winner
};
}
}

View File

@@ -0,0 +1,157 @@
import { Room, Client, matchMaker } from "colyseus";
import { LobbyState, AvailableRoom } from "./schemas/LobbyState";
import { NameManager } from "../utils/nameManager";
export class LobbyRoom extends Room<LobbyState> {
private updateInterval?: NodeJS.Timeout;
onCreate(options: any) {
this.setState(new LobbyState());
this.setPrivate(false);
this.onMessage("setName", (client, playerName: string) => {
this.handleSetName(client, playerName);
});
this.onMessage("quickPlay", (client) => {
this.handleQuickPlay(client);
});
this.onMessage("joinRoom", (client, roomId: string) => {
this.handleJoinRoom(client, roomId);
});
this.updateInterval = setInterval(() => {
this.updateAvailableRooms();
}, 2000);
}
onJoin(client: Client, options: any) {
console.log(`[LobbyRoom] ${client.sessionId} joined lobby`);
const defaultName = `guest`;
const uniqueName = NameManager.getInstance().generateUniquePlayerName(defaultName, client.sessionId);
this.state.addPlayer(client.sessionId, uniqueName);
client.send("welcome", {
sessionId: client.sessionId,
assignedName: uniqueName
});
this.updateAvailableRooms();
}
onLeave(client: Client, consented: boolean) {
console.log(`[LobbyRoom] ${client.sessionId} left lobby`);
const player = this.state.players.get(client.sessionId);
if (player) {
NameManager.getInstance().releasePlayerName(client.sessionId);
}
this.state.removePlayer(client.sessionId);
}
onDispose() {
console.log("[LobbyRoom] Disposing lobby room");
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
this.state.players.forEach(player => {
NameManager.getInstance().releasePlayerName(player.sessionId);
});
}
private handleSetName(client: Client, playerName: string) {
const currentPlayer = this.state.players.get(client.sessionId);
if (!currentPlayer) return;
NameManager.getInstance().releasePlayerName(client.sessionId);
const uniqueName = NameManager.getInstance().generateUniquePlayerName(playerName, client.sessionId);
currentPlayer.name = uniqueName;
client.send("nameUpdated", {
name: uniqueName
});
}
private async handleQuickPlay(client: Client) {
const player = this.state.players.get(client.sessionId);
if (!player || player.inGame) return;
try {
const reservation = await matchMaker.joinOrCreate("game", {
playerName: player.name
});
this.state.setPlayerInGame(client.sessionId, true);
client.send("roomReservation", {
sessionId: reservation.sessionId,
room: reservation.room
});
setTimeout(() => {
client.leave();
}, 1000);
} catch (error) {
console.error("[LobbyRoom] Error in quick play:", error);
client.send("error", {
message: "Could not find or create a game room"
});
}
}
private async handleJoinRoom(client: Client, roomId: string) {
const player = this.state.players.get(client.sessionId);
if (!player || player.inGame) return;
try {
const reservation = await matchMaker.joinById(roomId, {
playerName: player.name
});
this.state.setPlayerInGame(client.sessionId, true);
client.send("roomReservation", {
sessionId: reservation.sessionId,
room: reservation.room
});
setTimeout(() => {
client.leave();
}, 1000);
} catch (error) {
console.error("[LobbyRoom] Error joining room:", error);
client.send("error", {
message: "Could not join the selected room"
});
}
}
private async updateAvailableRooms() {
try {
const rooms = await matchMaker.query({ name: "game" });
const availableRooms = rooms
.filter(room => !room.locked && room.clients < 2)
.map(room => new AvailableRoom(
room.roomId,
room.clients,
room.metadata?.gameStatus || "waiting"
));
this.state.updateAvailableRooms(availableRooms);
} catch (error) {
console.error("[LobbyRoom] Error updating available rooms:", error);
}
}
}

View File

@@ -0,0 +1,86 @@
import { Schema, type, MapSchema } from "@colyseus/schema";
import { Player } from "./Player";
import { GameStatus } from "../../../../shared/types";
export class GameState extends Schema {
@type({ map: Player }) players = new MapSchema<Player>();
@type("string") gameStatus: GameStatus = GameStatus.WAITING;
@type("number") timeRemaining: number = 600; // 10 minutes in seconds
@type("string") winner: string = "";
@type("number") startTime: number = 0;
@type("string") roomId: string = "";
constructor() {
super();
}
addPlayer(sessionId: string, name: string): Player {
const player = new Player(sessionId, name);
this.players.set(sessionId, player);
return player;
}
removePlayer(sessionId: string): void {
this.players.delete(sessionId);
}
startGame(): void {
this.gameStatus = GameStatus.PLAYING;
this.startTime = Date.now();
this.timeRemaining = 600;
this.resetAllPlayers();
}
pauseGame(): void {
if (this.gameStatus === GameStatus.PLAYING) {
this.gameStatus = GameStatus.PAUSED;
}
}
resumeGame(): void {
if (this.gameStatus === GameStatus.PAUSED) {
this.gameStatus = GameStatus.PLAYING;
}
}
finishGame(): void {
this.gameStatus = GameStatus.FINISHED;
this.determineWinner();
}
restartGame(): void {
this.gameStatus = GameStatus.WAITING;
this.timeRemaining = 600;
this.winner = "";
this.startTime = 0;
this.resetAllPlayers();
}
private resetAllPlayers(): void {
this.players.forEach(player => player.reset());
}
private determineWinner(): void {
let maxClicks = -1;
let winner = "";
this.players.forEach(player => {
if (player.clicks > maxClicks) {
maxClicks = player.clicks;
winner = player.name;
}
});
this.winner = winner;
}
updateTimer(deltaTime: number): void {
if (this.gameStatus === GameStatus.PLAYING && this.timeRemaining > 0) {
this.timeRemaining -= deltaTime;
if (this.timeRemaining <= 0) {
this.timeRemaining = 0;
this.finishGame();
}
}
}
}

View File

@@ -0,0 +1,61 @@
import { Schema, type, MapSchema, ArraySchema } from "@colyseus/schema";
export class LobbyPlayer extends Schema {
@type("string") sessionId: string = "";
@type("string") name: string = "";
@type("boolean") inGame: boolean = false;
constructor(sessionId: string, name: string) {
super();
this.sessionId = sessionId;
this.name = name;
this.inGame = false;
}
}
export class AvailableRoom extends Schema {
@type("string") roomId: string = "";
@type("number") playerCount: number = 0;
@type("string") status: string = "";
constructor(roomId: string, playerCount: number, status: string) {
super();
this.roomId = roomId;
this.playerCount = playerCount;
this.status = status;
}
}
export class LobbyState extends Schema {
@type({ map: LobbyPlayer }) players = new MapSchema<LobbyPlayer>();
@type([AvailableRoom]) availableRooms = new ArraySchema<AvailableRoom>();
@type("number") totalPlayers: number = 0;
constructor() {
super();
}
addPlayer(sessionId: string, name: string): LobbyPlayer {
const player = new LobbyPlayer(sessionId, name);
this.players.set(sessionId, player);
this.totalPlayers = this.players.size;
return player;
}
removePlayer(sessionId: string): void {
this.players.delete(sessionId);
this.totalPlayers = this.players.size;
}
updateAvailableRooms(rooms: AvailableRoom[]): void {
this.availableRooms.clear();
rooms.forEach(room => this.availableRooms.push(room));
}
setPlayerInGame(sessionId: string, inGame: boolean): void {
const player = this.players.get(sessionId);
if (player) {
player.inGame = inGame;
}
}
}

View File

@@ -0,0 +1,24 @@
import { Schema, type } from "@colyseus/schema";
export class Player extends Schema {
@type("string") sessionId: string = "";
@type("string") name: string = "";
@type("number") clicks: number = 0;
@type("boolean") connected: boolean = true;
constructor(sessionId: string, name: string) {
super();
this.sessionId = sessionId;
this.name = name;
this.clicks = 0;
this.connected = true;
}
incrementClicks(): void {
this.clicks++;
}
reset(): void {
this.clicks = 0;
}
}