// DOMTANKS - SERVER - COPYRIGHT © 2025 by SEBASTIAN KRAUS
import express from 'express';
import chalk from 'chalk';
import { WebSocketServer } from 'ws';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
const app = express();
app.use(express.static("./client"));
// Regular HTTP server to land on
const httpServer = app.listen(9876);
// Set up WS Server
const wsServer = new WebSocketServer({ port: 9877 });
// read obstacles file
const fileObstacles = initObstacles();
// WS Server Events
wsServer.on("connection", function(ws) {
ws.id = uuidv4();
ws.dir = [0,0];
ws.pos = [0,0];
ws.health = 100;
ws.isAlive = true;
ws.isNew = true;
ws.initOnClient = false;
ws.name = "Player #" + Date.now().toString(16).slice(-5);
ws.tankColor = "hsl(" + Math.random() * 360 + " 100% 50%)";
ws.barrelRotation = 0;
clientLog(ws, "Connected!");
// (GLOBAL) Start Game Loop if not already running
if(!gameRunning) {
gameData.clients = Array.from(wsServer.clients);
gameData.settings.obstacles = fileObstacles;
setInterval(wsGameLoop, 1000 / FPS);
serverLog("Someone connected, gameloop started!");
}
// Received message from Client
ws.on("message", function(msg) {
msg = JSON.parse(msg);
// Ping
if(msg.pong) wsHeartbeat(ws);
// game initialized
if(msg.init) {
ws.initOnClient = true;
}
// Player Movement
if(msg.moveDir && ws.isAlive) {
clientLog(ws, "New Move: " + msg.moveDir);
this.dir = [...msg.moveDir];
// normalize direction (for diagonal movement and against cheaters)
this.dir = normalize2D(this.dir);
}
// Player Shot
if(msg.shoot && ws.isAlive) {
clientLog(ws, "New Shot: " + msg.shootPos);
gameData.projectiles.push({isNew: true, clickPos: msg.shootPos, ownerId: ws.id});
}
// Player Name change
if(msg.name && ws.isAlive) {
clientLog(ws, "Player name change: " + msg.name);
ws.name = msg.name;
}
if(msg.barrelRotation) {
ws.barrelRotation = msg.barrelRotation + "deg";
}
// do for all connected clients
/*wsServer.clients.forEach(function each(client) {
// check if client is ready
if (client.readyState === WebSocket.OPEN) {
client.send(msg.toString());
serverLog(msg.toString());
}
})*/
});
ws.on("error", console.error);
});
function wsHeartbeat(ws) {
ws.connectionIsAlive = true;
}
// Disconnect timeouted Clients
const wsTimeoutInterval = setInterval(function ping() {
try {
wsServer.clients.forEach(function each(ws) {
if (ws.connectionIsAlive === false) {
ws.send(JSON.stringify({"timeout":"true"}));
clientLog(ws, "Terminated because of timeout!");
removeTank(gameData.sendClients.findIndex(client => client.id == ws.id));
return ws.terminate();
}
ws.connectionIsAlive = false;
ws.send(JSON.stringify({"ping":1}));
});
} catch(error) {
// do nothing really
}
}, 5000);
wsServer.on('close', function close() {
clearInterval(wsTimeoutInterval);
});
// Once WS Server connection is established, connection can be upgraded from HTTP to WS
httpServer.on('upgrade', async function upgrade(request, socket, head) {
// Emit connection when request accepted
wsServer.handleUpgrade(request, socket, head, function done(ws) {
wsServer.emit('connection', ws, request);
clientLog(ws, "Connection upgraded from HTTP to WS!");
});
});
function clientLog(ws, str) {
console.log(chalk.yellow(`[DomTanks] ${new Date().toLocaleTimeString()}: Client ${ws.id}: ${str}`));
}
function serverLog(str, carriageReturn = false) {
process.stdout.write(chalk.cyan(`[DomTanks] ${new Date().toLocaleTimeString()}: ${str}${carriageReturn?'\r':'\n'}`));
}
///////////////// GAME LOGIC ////////////////
const FPS = 30;
const DEFAULT_TANK_SPEED = 0.25;
const DEFAULT_PROJECTILE_SPEED = 0.5;
const GAMEAREA_WIDTH = 1500;
const GAMEAREA_HEIGHT = 1500;
const TANK_SIZE = 25;
const PROJECTILE_SIZE = 8;
let gameRunning = false;
let gameData = {};
let lastGameData = {};
gameData.settings = {};
gameData.settings.gameAreaSize = [GAMEAREA_WIDTH, GAMEAREA_HEIGHT];
gameData.settings.tankSize = TANK_SIZE;
gameData.settings.projectileSize = PROJECTILE_SIZE;
gameData.projectiles = [];
gameData.sendClients = [];
let lastTimestamp = Date.now();
function wsGameLoop() {
// No one connected? Clear the loop
if(Array.from(wsServer.clients).length === 0) {
clearInterval(this);
serverLog("Nobody connected, gameloop cleared!");
gameRunning = false;
return;
}
gameRunning = true;
// Set up next round
makeGameData(wsServer.clients);
const changeOccured = hasGameDataChanged();
// send next round to clients ONLY if something changed
if(changeOccured) {
wsServer.clients.forEach(client => {
let personalGameData = structuredClone(gameData);
personalGameData.myId = client.id;
if(client.initOnClient) delete personalGameData.settings;
client.send(JSON.stringify(personalGameData));
});
serverLog(`Sending gameData to ${Array.from(wsServer.clients).length} clients.`, true);
lastGameData = structuredClone(gameData);
}
updatelastTimestamp();
}
function makeGameData(clients) {
gameData.clients = Array.from(clients);
gameData.projectiles.forEach((proj,i) => {
if(proj.isNew) {
let ownerId = gameData.clients.findIndex(client => client.id === gameData.projectiles[i].ownerId);
gameData.projectiles[i].pos = [gameData.clients[ownerId].pos[0] + TANK_SIZE / 2 - PROJECTILE_SIZE / 2,
gameData.clients[ownerId].pos[1] + TANK_SIZE / 2 - PROJECTILE_SIZE / 2];
gameData.projectiles[i].dir = getNormalizedDirection([gameData.projectiles[i].pos[0] + PROJECTILE_SIZE / 2,
gameData.projectiles[i].pos[1] + PROJECTILE_SIZE / 2],
gameData.projectiles[i].clickPos);
gameData.projectiles[i].id = uuidv4();
gameData.projectiles[i].isNew = false;
}
// move projectile further
let newPos = getNewPos(gameData.projectiles[i].pos, gameData.projectiles[i].dir, DEFAULT_PROJECTILE_SPEED);
// check if projectile collided with tank
let tankCollisionIndex = getProjectileCollisonWithTank(proj.pos, i, gameData.clients);
if(tankCollisionIndex != -1 && gameData.clients[tankCollisionIndex].id != proj.ownerId) {
gameData.clients[tankCollisionIndex].health -= 10;
if(gameData.clients[tankCollisionIndex].health <= 0) {
gameData.clients[tankCollisionIndex].health = 0;
killTank(gameData.clients[tankCollisionIndex]);
}
// delete projectile that hit tank
gameData.projectiles.splice(i, 1);
// delete projectiles that are out of bounds
} else if(isProjectileCollidingWithObstacle(proj.pos, gameData.settings.obstacles)) gameData.projectiles.splice(i, 1);
else if(isPosInBounds(newPos, PROJECTILE_SIZE)) gameData.projectiles[i].pos = newPos;
else gameData.projectiles.splice(i, 1);
});
gameData.clients.forEach((client,i) => {
// initialize new Client
if(client.isNew) {
do {
gameData.clients[i].pos = getRandomSpawnPos();
// loop until valid spawn pos is found
} while(getCollisionWithOtherTank(gameData.clients[i].pos,i,gameData.clients) !== -1 || isTankCollidingWithObstacle(gameData.clients[i].pos, gameData.settings.obstacles));
gameData.clients[i].isNew = false;
// stuff for the connected clients
} else {
if(Math.abs(client.dir[0]) + Math.abs(client.dir[1]) !== 0) {
gameData.clients[i].pos = getNewPosWithCollisions(i);
}
}
});
gameData.deltaTime = getDeltaTime();
lastTimestamp = Date.now();
gameData.clients.forEach((client,i) => {
gameData.sendClients[i] = {};
gameData.sendClients[i].health = client.health;
gameData.sendClients[i].isAlive = client.isAlive;
gameData.sendClients[i].pos = client.pos;
gameData.sendClients[i].id = client.id;
gameData.sendClients[i].name = client.name;
gameData.sendClients[i].tankColor = client.tankColor;
gameData.sendClients[i].barrelRotation = client.barrelRotation;
});
gameData.sendClients.forEach((client,i) => {
if(gameData.clients.findIndex(c => c.id == client.id) == -1) delete gameData.sendClients[i];
});
delete gameData.clients;
return gameData;
}
function hasGameDataChanged() {
let gd = structuredClone(gameData);
let lgd = structuredClone(lastGameData);
gd.deltaTime = 0;
lgd.deltaTime = 0;
return JSON.stringify(gd) != JSON.stringify(lgd);
}
function initObstacles() {
try {
return JSON.parse(fs.readFileSync("obstacles.json")).obstacles;
} catch(err) {
console.log(err);
return;
}
}
function isProjectileCollidingWithObstacle(projPos, obstacles) {
return obstacles.some(obstacle => calcBoxCollision({"left": projPos[0], "top": projPos[1], "width": PROJECTILE_SIZE, "height": PROJECTILE_SIZE}, obstacle));
}
function isTankCollidingWithObstacle(tankPos, obstacles) {
return obstacles.some(obstacle => calcBoxCollision({"left": tankPos[0], "top": tankPos[1], "width": TANK_SIZE, "height": TANK_SIZE}, obstacle));
}
function getNewPos(oldPos, newDir, speed) {
if(oldPos == null) oldPos = [0,0];
let x = oldPos[0] + newDir[0] * speed * getDeltaTime();
let y = oldPos[1] + newDir[1] * speed * getDeltaTime();
return [x, y];
}
function getDeltaTime() {
return Date.now() - lastTimestamp;
}
function updatelastTimestamp() {
lastTimestamp = Date.now();
}
function normalize2D([x, y]) {
if(!(Math.abs(x)+Math.abs(y))) return [0, 0];
let dirLength = Math.sqrt(Math.abs(x)**2+Math.abs(y)**2);
return [x/dirLength,y/dirLength];
}
// returns index of tank it collides with or -1 if none found
function getCollisionWithOtherTank(tankPos, tankIndex, tanks) {
let collisionTankIndex = -1;
if(tanks.length === 1) return -1;
tanks.forEach((otherTank, otherIndex) => {
if(tankIndex !== otherIndex) {
if(calcDiskCollision(tankPos, otherTank.pos, TANK_SIZE, TANK_SIZE)) collisionTankIndex = otherIndex;
}
});
return collisionTankIndex;
}
function getProjectileCollisonWithTank(projPos, projIndex, tanks) {
let collisionTankIndex = -1;
tanks.forEach((tank, tankIndex) => {
if(calcDiskCollision(projPos, tank.pos, PROJECTILE_SIZE, TANK_SIZE)) collisionTankIndex = tankIndex;
});
return collisionTankIndex;
}
function calcDiskCollision(a, b, aSize, bSize) {
const ax = a[0] + aSize / 2;
const ay = a[1] + aSize / 2;
const bx = b[0] + bSize / 2;
const by = b[1] + bSize / 2;
return getDistanceSquared([ax,ay],[bx,by]) <= ((aSize+bSize)/2)**2;
}
function calcBoxCollision(a, b) {
return (
a.left < b.left + b.width &&
a.left + a.width > b.left &&
a.top < b.top + b.height &&
a.top + a.height > b.top
);
}
function correctPosForBounds(pos) {
// Game Area Bounds Restriction
let x = Math.max(0, pos[0]);
let y = Math.max(0, pos[1]);
x = Math.min(GAMEAREA_WIDTH - TANK_SIZE, x);
y = Math.min(GAMEAREA_HEIGHT - TANK_SIZE, y);
return [x,y];
}
function isPosInBounds(pos, size) {
if(pos[0] < 0) return false;
if(pos[1] < 0) return false;
if(GAMEAREA_WIDTH - size < pos[0]) return false;
if(GAMEAREA_HEIGHT - size < pos[1]) return false;
return true;
}
function getDistanceSquared(a, b) {
return (b[0] - a[0])**2 + (b[1] - a[1])**2;
}
function getDistance(a, b) {
return Math.sqrt(getDistanceSquared(a, b));
}
function getRandomSpawnPos() {
return [Math.round(Math.random() * (GAMEAREA_WIDTH - TANK_SIZE)), Math.round(Math.random() * (GAMEAREA_HEIGHT - TANK_SIZE))];
}
function calcNearestValidTankPos(tank, otherTank) {
let distance = getDistance(tank.pos, otherTank.pos) - TANK_SIZE;
return [tank.pos[0] + normalize2D(tank.dir)[0] * distance, tank.pos[1] + normalize2D(tank.dir)[1] * distance];
}
function getNormalizedDirection(a, b) {
return normalize2D([b[0]-a[0], b[1]-a[1]]);
}
function killTank(client) {
client.health = 0;
client.isAlive = false;
client.dir = [0,0];
}
function removeTank(clientIndex) {
delete gameData.sendClients[clientIndex];
}
function getNewPosWithCollisions(i) {
let newPos = getNewPos(gameData.clients[i].pos, gameData.clients[i].dir, DEFAULT_TANK_SPEED);
// collision with bounds
newPos = correctPosForBounds(newPos);
// collision with other tank
let collisionIndex = getCollisionWithOtherTank(newPos,i,gameData.clients);
if(collisionIndex !== -1)
return calcNearestValidTankPos(gameData.clients[i], gameData.clients[collisionIndex]);
// collision with obstacle
if(isTankCollidingWithObstacle(newPos, gameData.settings.obstacles)) {
if(!isTankCollidingWithObstacle([newPos[0],gameData.clients[i].pos[1]], gameData.settings.obstacles)) return [newPos[0],gameData.clients[i].pos[1]];
else if(!isTankCollidingWithObstacle([gameData.clients[i].pos[0],newPos[1]], gameData.settings.obstacles)) return [gameData.clients[i].pos[0],newPos[1]];
return gameData.clients[i].pos;
}
return newPos;
}