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

2557
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
server/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "snatchgame-server",
"version": "1.0.0",
"description": "Competitive clicker game server",
"main": "dist/index.js",
"scripts": {
"dev": "npx ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@colyseus/monitor": "latest",
"@colyseus/schema": "latest",
"colyseus": "latest",
"cors": "latest",
"express": "latest"
},
"devDependencies": {
"@types/cors": "latest",
"@types/express": "latest",
"@types/node": "latest",
"ts-node-dev": "^2.0.0",
"typescript": "latest"
}
}

129
server/src/adminApi.ts Normal file
View File

@@ -0,0 +1,129 @@
import { Request, Response, Router } from "express";
import { matchMaker } from "colyseus";
import { GameRoom } from "./rooms/GameRoom";
const adminRouter = Router();
adminRouter.get("/rooms", async (req: Request, res: Response) => {
try {
const rooms = await matchMaker.query({});
const roomStats = rooms.map(room => ({
roomId: room.roomId,
name: room.name,
clients: room.clients,
maxClients: room.maxClients,
metadata: room.metadata,
locked: room.locked,
private: room.private,
createdAt: room.createdAt
}));
res.json(roomStats);
} catch (error) {
console.error("[AdminAPI] Error fetching rooms:", error);
res.status(500).json({ error: "Failed to fetch rooms" });
}
});
adminRouter.get("/rooms/:roomId/stats", async (req: Request, res: Response) => {
try {
const { roomId } = req.params;
const roomData = await matchMaker.remoteRoomCall(roomId, "getState");
res.json(roomData);
} catch (error) {
console.error(`[AdminAPI] Error fetching room ${req.params.roomId} stats:`, error);
res.status(500).json({ error: "Failed to fetch room stats" });
}
});
adminRouter.post("/rooms/:roomId/pause", async (req: Request, res: Response) => {
try {
const { roomId } = req.params;
const rooms = await matchMaker.query({ roomId });
if (rooms.length === 0) {
return res.status(404).json({ error: "Room not found" });
}
await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:pause"]);
res.json({ success: true, message: "Room paused" });
} catch (error) {
console.error(`[AdminAPI] Error pausing room ${req.params.roomId}:`, error);
res.status(500).json({ error: "Failed to pause room" });
}
});
adminRouter.post("/rooms/:roomId/resume", async (req: Request, res: Response) => {
try {
const { roomId } = req.params;
const rooms = await matchMaker.query({ roomId });
if (rooms.length === 0) {
return res.status(404).json({ error: "Room not found" });
}
await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:resume"]);
res.json({ success: true, message: "Room resumed" });
} catch (error) {
console.error(`[AdminAPI] Error resuming room ${req.params.roomId}:`, error);
res.status(500).json({ error: "Failed to resume room" });
}
});
adminRouter.post("/rooms/:roomId/restart", async (req: Request, res: Response) => {
try {
const { roomId } = req.params;
const rooms = await matchMaker.query({ roomId });
if (rooms.length === 0) {
return res.status(404).json({ error: "Room not found" });
}
await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:restart"]);
res.json({ success: true, message: "Room restarted" });
} catch (error) {
console.error(`[AdminAPI] Error restarting room ${req.params.roomId}:`, error);
res.status(500).json({ error: "Failed to restart room" });
}
});
adminRouter.post("/rooms/:roomId/kick/:playerId", async (req: Request, res: Response) => {
try {
const { roomId, playerId } = req.params;
const rooms = await matchMaker.query({ roomId });
if (rooms.length === 0) {
return res.status(404).json({ error: "Room not found" });
}
await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:kick", playerId]);
res.json({ success: true, message: `Player ${playerId} kicked` });
} catch (error) {
console.error(`[AdminAPI] Error kicking player from room ${req.params.roomId}:`, error);
res.status(500).json({ error: "Failed to kick player" });
}
});
adminRouter.get("/stats", async (req: Request, res: Response) => {
try {
const stats = await matchMaker.stats.fetchAll();
const globalCCU = await matchMaker.stats.getGlobalCCU();
res.json({
processes: stats,
globalCCU,
localCCU: matchMaker.stats.local.ccu,
localRoomCount: matchMaker.stats.local.roomCount
});
} catch (error) {
console.error("[AdminAPI] Error fetching stats:", error);
res.status(500).json({ error: "Failed to fetch stats" });
}
});
export { adminRouter };

42
server/src/index.ts Normal file
View File

@@ -0,0 +1,42 @@
import { Server } from "colyseus";
import { createServer } from "http";
import express from "express";
import cors from "cors";
import { monitor } from "@colyseus/monitor";
import { GameRoom } from "./rooms/GameRoom";
import { LobbyRoom } from "./rooms/LobbyRoom";
import { adminRouter } from "./adminApi";
const port = Number(process.env.PORT) || 3000;
const app = express();
app.use(cors());
app.use(express.json());
const server = createServer(app);
const gameServer = new Server({
server,
});
gameServer.define("lobby", LobbyRoom)
.filterBy(["maxClients"]);
gameServer.define("game", GameRoom)
.filterBy(["maxClients"])
.enableRealtimeListing();
app.use("/api", adminRouter);
app.use("/colyseus", monitor());
app.get("/health", (req, res) => {
res.json({ status: "healthy", uptime: process.uptime() });
});
gameServer.listen(port);
console.log(`🎮 Snatch Game Server is running on port ${port}`);
console.log(`📊 Monitor: http://localhost:${port}/colyseus`);
console.log(`🌐 WebSocket: ws://localhost:${port}`);
console.log(`🔧 Admin API: http://localhost:${port}/api`);

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;
}
}

View File

@@ -0,0 +1,46 @@
export class NameManager {
private static instance: NameManager;
private nameCounters: Map<string, number> = new Map();
private sessionToName: Map<string, string> = new Map();
private constructor() {}
static getInstance(): NameManager {
if (!NameManager.instance) {
NameManager.instance = new NameManager();
}
return NameManager.instance;
}
generateUniquePlayerName(baseName: string, sessionId: string): string {
const normalizedName = baseName.trim().toLowerCase();
if (!normalizedName) {
return this.generateUniquePlayerName('player', sessionId);
}
const currentCounter = this.nameCounters.get(normalizedName) || 0;
const newCounter = currentCounter + 1;
this.nameCounters.set(normalizedName, newCounter);
const uniqueName = newCounter === 1 ? normalizedName : `${normalizedName}-${newCounter}`;
this.sessionToName.set(sessionId, uniqueName);
return uniqueName;
}
releasePlayerName(sessionId: string): void {
const name = this.sessionToName.get(sessionId);
if (name) {
this.sessionToName.delete(sessionId);
}
}
getPlayerName(sessionId: string): string | undefined {
return this.sessionToName.get(sessionId);
}
getAllActivePlayers(): string[] {
return Array.from(this.sessionToName.values());
}
}

21
server/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "../",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*", "../shared/**/*"],
"exclude": ["node_modules", "dist"]
}