Skip to content

Instantly share code, notes, and snippets.

@redblobgames
Created May 19, 2025 22:45
Show Gist options
  • Save redblobgames/4ecb85002496af90b0eee73db5bcf833 to your computer and use it in GitHub Desktop.
Save redblobgames/4ecb85002496af90b0eee73db5bcf833 to your computer and use it in GitHub Desktop.
LLM-generated MMO

I gave gemini 2.5 the prompt

write a tank-based combat mmo in javascript with both the server and client portions

I saved the gemini output in LLM-OUTPUT.md

It told me some libraries to install/download, so I did.

There were only minor bugs (player.setVelocity and player.setAngularVelocity should be player.body.*)

Okay, creating a full tank-based combat MMO is a very big project! But I can help you get started with a simplified version in JavaScript, showing you the basic parts for both the server (which runs the game's logic) and the client (what players see and interact with in their web browser).

We'll use Node.js with the express and ws libraries for the server, and HTML, CSS, and JavaScript (with the Phaser 3 game framework) for the client. Phaser will make it easier to draw things on the screen and handle game actions.

Think of it like this:

  • The Server: It's like the main brain of the game. It knows where every player is, handles the rules (like when a tank gets hit), and tells all the players what's happening.
  • The Client: This is what runs in each player's web browser. It shows the game world, lets the player control their tank, and sends their actions to the server. It also receives updates from the server to show what other players are doing.

This will be a basic example to show how the pieces fit together.

Let's start with the Server:

1. Server Project Setup

First, you'll need Node.js installed on your computer. Create a new folder for your game, let's call it tank_mmo. Open a terminal or command prompt, go into this folder, and type:

npm init -y
npm install express ws

This creates a package.json file (which describes your project) and installs two important libraries:

  • express: Helps create the web server to send your game files to the player's browser.
  • ws: Allows real-time communication (using WebSockets) between the server and the clients.

2. Server Code (server.js)

Create a file named server.js in your tank_mmo folder and put the following code into it:

// server.js

// 1. Set up the basics
const express = require('express');
const http = require('http');
const WebSocket = require('ws');

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

// This will serve files from a 'public' folder (we'll create this later)
app.use(express.static('public'));

// 2. Store game information
let players = {}; // Store data about connected players (their tanks)
let nextPlayerId = 0; // To give each player a unique ID

// 3. Handle WebSocket connections (when a player connects)
wss.on('connection', (ws) => {
    const playerId = nextPlayerId++;
    console.log(`Player ${playerId} connected`);

    // Create a new tank for this player
    // Starting position and color are random for simplicity
    players[playerId] = {
        id: playerId,
        x: Math.random() * 700 + 50, // Random x position
        y: Math.random() * 500 + 50, // Random y position
        angle: 0, // Facing direction
        color: `0x${Math.floor(Math.random()*16777215).toString(16)}`, // Random color
        health: 100
    };

    // Send the new player their ID and the current state of all other players
    ws.send(JSON.stringify({ type: 'your_id', id: playerId }));
    ws.send(JSON.stringify({ type: 'current_players', players: players }));

    // Tell all OTHER players about this new player
    broadcast({
        type: 'player_joined',
        player: players[playerId]
    }, playerId); // Don't send to the new player themselves

    // Handle messages from this player
    ws.on('message', (message) => {
        try {
            const data = JSON.parse(message);

            // When a player moves their tank
            if (data.type === 'player_move' && players[playerId]) {
                players[playerId].x = data.x;
                players[playerId].y = data.y;
                players[playerId].angle = data.angle;

                // Tell all other players about this movement
                broadcast({
                    type: 'player_update',
                    id: playerId,
                    x: data.x,
                    y: data.y,
                    angle: data.angle
                }, playerId);
            }

            // When a player shoots
            if (data.type === 'shoot' && players[playerId]) {
                console.log(`Player ${playerId} shot`);
                // Tell all players (including the shooter) that a shot happened
                broadcast({
                    type: 'bullet_fired',
                    playerId: playerId,
                    x: data.x, // Starting position of the bullet
                    y: data.y,
                    angle: data.angle
                });
            }

            // When a player's tank is hit
            if (data.type === 'hit_player' && players[data.targetId]) {
                players[data.targetId].health -= data.damage;
                console.log(`Player ${data.targetId} hit, health: ${players[data.targetId].health}`);

                broadcast({
                    type: 'player_hit',
                    targetId: data.targetId,
                    attackerId: playerId, // Who did the shooting
                    health: players[data.targetId].health
                });

                if (players[data.targetId].health <= 0) {
                    console.log(`Player ${data.targetId} defeated`);
                    broadcast({
                        type: 'player_defeated',
                        playerId: data.targetId
                    });
                    // For simplicity, we remove the player. A real game might have respawns.
                    delete players[data.targetId];
                }
            }

        } catch (error) {
            console.error('Failed to parse message or process:', message, error);
        }
    });

    // Handle when a player disconnects
    ws.on('close', () => {
        console.log(`Player ${playerId} disconnected`);
        delete players[playerId]; // Remove player from our list

        // Tell all other players that this player left
        broadcast({
            type: 'player_left',
            id: playerId
        });
    });

    ws.on('error', (error) => {
        console.error(`WebSocket error for player ${playerId}:`, error);
    });
});

// Function to send a message to all connected players (except one, if specified)
function broadcast(data, excludePlayerId = null) {
    const message = JSON.stringify(data);
    wss.clients.forEach(client => {
        // Find the player ID associated with this client
        let clientId = -1;
        for (const id in players) {
            // This is a simplification; a robust solution would map ws clients to player IDs directly
            if (players[id].ws === client) { // This assumes you store 'ws' instance in player object, which we are not doing yet for simplicity
                clientId = parseInt(id);
                break;
            }
        }
        // A better way to check if the client is the one to exclude:
        // For this example, we're sending to all.
        // If we had a direct map of WebSocket clients to player IDs, we could exclude properly.
        // For now, the exclude logic in the 'player_joined' and 'player_update' relies on the sender not being in the broadcast loop.
        // Let's refine this broadcast to work with the `players` structure.
        // We need to iterate through players and send if their websocket is open.

        // Temporary simple broadcast to all clients:
        if (client.readyState === WebSocket.OPEN) {
            // If an excludePlayerId is provided, we'd need to check if this 'client' corresponds to that player.
            // This part is tricky without directly mapping ws instances to playerIds upon connection.
            // For now, specific exclusions are handled where broadcast is called.
            client.send(message);
        }
    });
}


// 4. Start the server
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
    console.log(`Server started on http://localhost:${PORT}`);
});

// A simple game loop on the server (optional, could be event-driven)
// For now, most logic is event-driven by client messages.
// setInterval(() => {
//     // Game logic that needs to run regularly, e.g., check for game over conditions, update AI, etc.
//     // For this example, we'll keep it simple and mostly rely on messages from clients.
// }, 1000 / 60); // Roughly 60 times per second

Explanation of server.js:

  1. Setup: Loads the express and ws libraries. express.static('public') tells the server to provide any files found in a folder named public.
  2. Game State:
    • players: An object to store information about each connected player's tank (like its ID, position x, y, direction angle, color, and health).
    • nextPlayerId: A simple counter to give each new player a unique ID.
  3. Handling Connections (wss.on('connection', ...)):
    • When a new player connects via WebSocket:
      • A unique playerId is assigned.
      • A new tank object is created for this player with some random starting values.
      • The server sends a message back to only this new player telling them their playerId ({ type: 'your_id', ... }).
      • It also sends the current list of all players already in the game ({ type: 'current_players', ... }).
      • Then, it tells all other players that a new player has joined ({ type: 'player_joined', ... }).
    • Handling Messages (ws.on('message', ...)):
      • When the server receives a message from a player:
        • It parses the message (which we'll send as JSON text).
        • If it's a 'player_move' message, it updates that player's tank position and angle in the players object on the server. Then, it tells all other players about this update.
        • If it's a 'shoot' message, it tells all players that this player fired a bullet.
        • If it's a 'hit_player' message (sent by a client that detected its bullet hit someone), the server updates the target's health and informs everyone. If health is zero, it announces defeat.
    • Handling Disconnections (ws.on('close', ...)):
      • When a player closes their game window or loses connection:
        • Their tank is removed from the players object.
        • All other players are told that this player has left.
  4. Broadcast Function:
    • The broadcast function is a helper to send a message to all connected players. We sometimes use excludePlayerId so a player doesn't get their own action sent back to them unnecessarily. (The broadcast function in the example is simplified. A more robust version would directly map WebSocket client objects to player IDs to manage exclusions better).
  5. Start Server: Makes the server start listening for connections on a specific port (usually 3000 by default).

Next, let's set up the Client (what the player sees).

3. Client Project Structure

Inside your tank_mmo folder, create a new folder named public. This is where all the files for the client-side will go.

Inside the public folder, create:

  • index.html (the main page)
  • style.css (for some basic looks)
  • game.js (where our game's JavaScript code will go)
  • A folder named js (optional, but good for organization). Inside js, download the phaser.min.js file from the Phaser website (https://phaser.io/download/stable) and place it there.

Your public folder should look like this:

public/
├── index.html
├── style.css
├── game.js
└── js/
    └── phaser.min.js

4. Client HTML (public/index.html)

<!DOCTYPE html>
<html>
<head>
    <title>Tank MMO Lite</title>
    <link rel="stylesheet" href="style.css">
    <script src="js/phaser.min.js"></script>
</head>
<body>
    <h1>Tank Combat!</h1>
    <div id="game-container">
        </div>
    <script src="game.js"></script>
</body>
</html>
  • This HTML file includes our style.css for looks, the Phaser library, and our game.js script.
  • The <div id="game-container"></div> is where Phaser will create the game screen.

5. Client CSS (public/style.css)

body {
    font-family: Arial, sans-serif;
    display: flex;
    flex-direction: column;
    align-items: center;
    margin: 0;
    background-color: #f0f0f0;
}

#game-container {
    border: 1px solid black;
    /* Dimensions will be set by Phaser */
}

h1 {
    color: #333;
}
  • Just some very basic styling.

6. Client JavaScript (public/game.js)

This is where the main client-side game logic will live. It will be quite long, so we'll build it up.

// public/game.js

// --- Game Configuration ---
const config = {
    type: Phaser.AUTO, // Use WebGL if available, otherwise Canvas
    parent: 'game-container', // ID of the div to contain the game
    width: 800,
    height: 600,
    backgroundColor: '#cccccc',
    physics: {
        default: 'arcade', // Simple arcade physics
        arcade: {
            gravity: { y: 0 }, // No gravity in a top-down tank game
            debug: false // Set to true to see physics bodies
        }
    },
    scene: {
        preload: preload,
        create: create,
        update: update
    }
};

// --- Global Variables for the Client ---
let game = new Phaser.Game(config); // The main Phaser game instance
let socket; // For WebSocket communication with the server
let selfClientId = null; // This client's player ID, given by the server
let playerTank; // This client's tank sprite
let otherPlayers = {}; // Sprites for other players' tanks
let bullets; // A Phaser group for bullets
let cursors; // For keyboard input
let lastMoveTime = 0; // To limit how often we send movement updates

// --- Phaser Scene Functions ---

function preload() {
    // Load assets here (images, sounds, etc.)
    // For simplicity, we'll draw tanks and bullets with Phaser graphics,
    // so no external images are strictly needed for this basic version.
    // If you have tank.png and bullet.png:
    // this.load.image('tank', 'assets/tank.png');
    // this.load.image('bullet', 'assets/bullet.png');
}

function create() {
    // --- Setup WebSocket Connection ---
    // Use 'ws://localhost:3000' for local development
    // For a deployed game, use wss://your-deployed-server-address
    socket = new WebSocket(`ws://${window.location.hostname}:3000`);

    socket.onopen = () => {
        console.log('Connected to server');
    };

    socket.onmessage = (event) => {
        const message = JSON.parse(event.data);
        // console.log('Received message:', message);

        switch (message.type) {
            case 'your_id':
                selfClientId = message.id;
                console.log('My player ID is:', selfClientId);
                break;

            case 'current_players':
                // Add all players already in the game
                for (const id in message.players) {
                    if (message.players.hasOwnProperty(id)) {
                        const playerData = message.players[id];
                        if (playerData.id === selfClientId) {
                            addPlayerTank(this, playerData); // 'this' refers to the Phaser Scene
                        } else {
                            addOtherPlayerTank(this, playerData);
                        }
                    }
                }
                break;

            case 'player_joined':
                if (message.player.id !== selfClientId) {
                    addOtherPlayerTank(this, message.player);
                }
                break;

            case 'player_update':
                if (otherPlayers[message.id]) {
                    otherPlayers[message.id].setPosition(message.x, message.y);
                    otherPlayers[message.id].setAngle(message.angle);
                }
                break;

            case 'player_left':
                if (otherPlayers[message.id]) {
                    otherPlayers[message.id].destroy(); // Remove tank sprite
                    delete otherPlayers[message.id]; // Remove from our list
                }
                break;

            case 'bullet_fired':
                fireBullet(this, message.x, message.y, message.angle, message.playerId);
                break;

            case 'player_hit':
                if (message.targetId === selfClientId && playerTank) {
                    playerTank.health = message.health;
                    // Add visual feedback for being hit (e.g., flash red)
                    playerTank.setTint(0xff0000);
                    this.time.delayedCall(100, () => {
                        if (playerTank) playerTank.clearTint();
                    });
                    console.log(`I was hit! My health: ${playerTank.health}`);
                } else if (otherPlayers[message.targetId]) {
                    otherPlayers[message.targetId].health = message.health;
                    otherPlayers[message.targetId].setTint(0xff0000); // Flash red
                    this.time.delayedCall(100, () => {
                         if(otherPlayers[message.targetId]) otherPlayers[message.targetId].clearTint();
                    });
                }
                // You could update a health bar UI here
                break;

            case 'player_defeated':
                if (message.playerId === selfClientId && playerTank) {
                    console.log("I've been defeated!");
                    playerTank.setActive(false).setVisible(false); // Hide tank
                    // You could show a "Game Over" message or respawn logic
                } else if (otherPlayers[message.playerId]) {
                    console.log(`Player ${message.playerId} defeated`);
                    otherPlayers[message.playerId].setActive(false).setVisible(false);
                    // Show an explosion or something
                    this.time.delayedCall(1000, () => { // Remove after a delay
                        if (otherPlayers[message.playerId]) {
                            otherPlayers[message.playerId].destroy();
                            delete otherPlayers[message.playerId];
                        }
                    });
                }
                break;
        }
    };

    socket.onerror = (error) => {
        console.error('WebSocket Error:', error);
    };

    socket.onclose = () => {
        console.log('Disconnected from server');
        // Maybe show a "disconnected" message to the player
    };

    // --- Setup Game Elements ---
    // Create a group for bullets to manage them easily
    bullets = this.physics.add.group({
        defaultKey: 'bullet_sprite', // If using a loaded image
        maxSize: 30, // Max bullets on screen at once
        runChildUpdate: true // Bullets will update themselves
    });

    // Input
    cursors = this.input.keyboard.createCursorKeys(); // For arrow key movement
    this.input.keyboard.on('keydown-SPACE', () => { // For shooting with Spacebar
        if (playerTank && playerTank.active && socket && socket.readyState === WebSocket.OPEN) {
            // Tell the server we are shooting
            socket.send(JSON.stringify({
                type: 'shoot',
                x: playerTank.x,
                y: playerTank.y,
                angle: playerTank.angle
            }));
        }
    });

    // Add collision detection between bullets and tanks
    // Note: For an MMO, authoritative hit detection should be on the server.
    // Here, the client that fired the bullet can detect a hit and tell the server.
    this.physics.add.overlap(bullets, null, handleBulletTankCollision, null, this);
    // The 'null' for colliders means bullets will collide with *all* physics bodies
    // in the scene unless specified. We'll filter in `handleBulletTankCollision`.

} // --- End of create() ---

function update(time, delta) {
    if (!playerTank || !playerTank.active) return; // Don't do anything if our tank isn't ready or active

    let inputChanged = false;
    const moveSpeed = 200; // Pixels per second
    const angularSpeed = 150; // Degrees per second

    // --- Handle Player Input and Movement ---
    // Stop movement by default
    playerTank.setVelocity(0, 0);
    playerTank.setAngularVelocity(0);

    if (cursors.left.isDown) {
        playerTank.setAngularVelocity(-angularSpeed);
        inputChanged = true;
    } else if (cursors.right.isDown) {
        playerTank.setAngularVelocity(angularSpeed);
        inputChanged = true;
    }

    if (cursors.up.isDown) {
        // Move in the direction the tank is facing
        this.physics.velocityFromAngle(playerTank.angle - 90, moveSpeed, playerTank.body.velocity);
        inputChanged = true;
    } else if (cursors.down.isDown) {
        this.physics.velocityFromAngle(playerTank.angle - 90, -moveSpeed / 2, playerTank.body.velocity); // Slower reverse
        inputChanged = true;
    }

    // Keep tank within game boundaries
    playerTank.x = Phaser.Math.Clamp(playerTank.x, 25, config.width - 25); // 25 is half tank size (approx)
    playerTank.y = Phaser.Math.Clamp(playerTank.y, 25, config.height - 25);

    // Send movement updates to the server periodically
    if (inputChanged && time > lastMoveTime + 50) { // Send update every 50ms if moving
        if (socket && socket.readyState === WebSocket.OPEN) {
            socket.send(JSON.stringify({
                type: 'player_move',
                x: playerTank.x,
                y: playerTank.y,
                angle: playerTank.angle
            }));
        }
        lastMoveTime = time;
    }

    // Update bullets (basic client-side movement for immediate feedback)
    // Server will be the authority for hit detection ultimately
    bullets.getChildren().forEach(bullet => {
        if (bullet.active) {
            // Check bounds and deactivate if out of screen
            if (bullet.x < 0 || bullet.x > config.width || bullet.y < 0 || bullet.y > config.height) {
                bullet.setActive(false).setVisible(false);
                bullet.destroy(); // Or return to a pool
            }
        }
    });

} // --- End of update() ---


// --- Helper Functions for the Client ---

function addPlayerTank(scene, playerData) {
    // Create the tank sprite using Phaser's graphics for simplicity
    // A real game would use playerTank = scene.physics.add.sprite(playerData.x, playerData.y, 'tank_image');
    playerTank = scene.add.graphics({ x: playerData.x, y: playerData.y });
    drawTank(playerTank, playerData.color); // Custom function to draw the tank

    // Add physics body to the graphics object
    scene.physics.world.enableBody(playerTank, Phaser.Physics.Arcade.DYNAMIC_BODY);
    playerTank.body.setSize(40, 50); // Approximate size of our drawn tank
    playerTank.body.setCollideWorldBounds(true); // Don't go off screen

    playerTank.id = playerData.id;
    playerTank.angle = playerData.angle; // Initial angle from server
    playerTank.health = playerData.health;
    playerTank.setDepth(1); // Ensure player's tank is usually above others if overlapping briefly

    // Store the graphics object itself if you need to reference it by its physics body later
    playerTank.setData('isPlayer', true);
    playerTank.setData('id', playerData.id);
}

function addOtherPlayerTank(scene, playerData) {
    // otherTank = scene.physics.add.sprite(playerData.x, playerData.y, 'tank_image');
    const otherTank = scene.add.graphics({ x: playerData.x, y: playerData.y });
    drawTank(otherTank, playerData.color);
    scene.physics.world.enableBody(otherTank, Phaser.Physics.Arcade.STATIC_BODY); // Other tanks are static from this client's physics perspective
    otherTank.body.setSize(40, 50);

    otherTank.id = playerData.id;
    otherTank.angle = playerData.angle;
    otherTank.health = playerData.health;
    otherPlayers[playerData.id] = otherTank; // Store it

    otherTank.setData('isPlayer', false);
    otherTank.setData('id', playerData.id);
}

function drawTank(graphics, colorHex) {
    const color = Phaser.Display.Color.HexStringToColor(colorHex).color;
    graphics.clear(); // Clear previous drawing if any (for updates, though we recreate on join)
    // Tank Body
    graphics.fillStyle(color, 1);
    graphics.fillRect(-20, -25, 40, 50); // Tank body (width 40, height 50)
    // Tank Turret
    graphics.fillStyle(0x666666, 1); // Darker grey for turret
    graphics.fillRect(-5, -15, 10, 30); // Turret base
    // Tank Barrel
    graphics.fillStyle(0x444444, 1); // Even darker for barrel
    graphics.fillRect(-2.5, -40, 5, 25); // Barrel (width 5, length 25, pointing "up" relative to tank body)

    // The graphics origin (0,0) is the center of the tank for rotation.
    // The fillRect positions are relative to this origin.
    // Angle will be applied to the graphics container.
}

function fireBullet(scene, x, y, angle, ownerId) {
    let bullet = bullets.get(); // Get a bullet from the pool/group

    if (bullet) {
        // If not using a sprite, create graphics for bullet
        if (!bullet.texture || bullet.texture.key === '__MISSING') {
             // Create a simple circular graphic for the bullet if no sprite is set
            const bulletGraphics = scene.add.graphics();
            bulletGraphics.fillStyle(0x333333, 1); // Dark grey bullet
            bulletGraphics.fillCircle(0, 0, 5); // 5px radius circle
            // Create a texture from the graphics
            const textureKey = `bullet_gfx_${Date.now()}_${Math.random()}`; // Unique key
            bulletGraphics.generateTexture(textureKey, 10, 10);
            bulletGraphics.destroy(); // Clean up graphics object, we only need the texture

            bullet.setTexture(textureKey); // Apply the new texture
        }

        bullet.setActive(true).setVisible(true);
        bullet.setPosition(x, y);
        bullet.setAngle(angle);
        bullet.ownerId = ownerId; // Whose bullet is this?
        bullet.body.setSize(10, 10); // Physics body for collision

        // Calculate velocity based on angle
        const speed = 600; // Bullet speed
        scene.physics.velocityFromAngle(angle - 90, speed, bullet.body.velocity); // Phaser angles start from right, -90 for "up"

        // Optional: Make bullets disappear after some time or distance
        bullet.lifespan = 2000; // Milliseconds
        scene.time.delayedCall(bullet.lifespan, () => {
            bullet.setActive(false).setVisible(false);
            bullet.destroy(); // Or return to pool if using one properly
        });
    }
}

function handleBulletTankCollision(bullet, tankGraphics) {
    // This function is called by Phaser when a bullet overlaps *any* physics body.
    // We need to check if 'tankGraphics' is actually one of our tanks.
    // And make sure the bullet doesn't hit its own owner immediately.

    if (!bullet.active || !tankGraphics.active) return;

    const tankId = tankGraphics.getData('id');
    const isPlayerTank = tankGraphics.getData('isPlayer'); // Custom data we set on tank graphics

    if (typeof tankId === 'undefined') return; // Not a tank we track

    // Prevent bullet from hitting its owner immediately after firing
    if (bullet.ownerId === tankId) {
        // Optional: Allow self-hit after a very short delay if desired,
        // but for now, just ignore if owner is the target.
        // This check is simple. A more robust way is to ignore collisions for a few frames.
        return;
    }

    // If this client is the one who fired the bullet, tell the server about the hit
    if (bullet.ownerId === selfClientId) {
        if (socket && socket.readyState === WebSocket.OPEN) {
            socket.send(JSON.stringify({
                type: 'hit_player',
                targetId: tankId,
                damage: 10 // Example damage
            }));
        }
    }

    // Deactivate bullet on hit (client-side prediction)
    bullet.setActive(false).setVisible(false);
    bullet.destroy(); // Or return to a pool

    // Client can show an impact effect immediately
    // The server will confirm the health change.
    // console.log(`Bullet from ${bullet.ownerId} hit tank ${tankId}`);
}

Explanation of public/game.js:

  1. Phaser Config: Sets up the game screen size, physics, and tells Phaser which functions to use for preload, create, and update.
  2. Global Variables:
    • socket: For the WebSocket connection.
    • selfClientId: Stores the ID this player gets from the server.
    • playerTank: The Phaser object for the player's own tank.
    • otherPlayers: An object to store Phaser objects for other players' tanks.
    • bullets: A Phaser "group" to manage all the bullets on screen.
    • cursors: For easy keyboard input.
  3. preload():
    • This is where you'd load images (like tank.png, bullet.png) if you had them. For now, we'll draw simple shapes.
  4. create(): This function runs once when the game starts.
    • WebSocket Connection: It connects to our server.js using new WebSocket(...).
      • socket.onopen: Logs when the connection is successful.
      • socket.onmessage: This is crucial! It handles all messages coming from the server.
        • 'your_id': Stores the player's unique ID.
        • 'current_players': When the player first joins, the server sends a list of everyone already playing. This code loops through them and creates tank sprites for each.
        • 'player_joined': When a new player joins after this client, this adds their tank.
        • 'player_update': When another player moves, this updates the position and angle of their tank sprite.
        • 'player_left': When a player disconnects, this removes their tank sprite.
        • 'bullet_fired': When anyone shoots, this calls fireBullet() to show the bullet on screen.
        • 'player_hit': When the server says someone was hit, it updates the health (client-side variable) and makes the tank flash red.
        • 'player_defeated': Hides the defeated tank.
      • socket.onerror: Logs any WebSocket errors.
      • socket.onclose: Logs when the connection to the server is closed.
    • Game Elements:
      • bullets = this.physics.add.group(...): Creates a pool for managing bullet objects efficiently.
      • cursors = this.input.keyboard.createCursorKeys(): Sets up arrow key input.
      • this.input.keyboard.on('keydown-SPACE', ...): Makes the spacebar fire a bullet by sending a 'shoot' message to the server.
      • this.physics.add.overlap(...): Tells Phaser to call handleBulletTankCollision when a bullet and another physics object (hopefully a tank) overlap.
  5. update(time, delta): This function runs continuously (many times per second, forming the game loop).
    • It checks if playerTank exists (the player's tank has been created).
    • Input Handling:
      • Checks if arrow keys are pressed.
      • If so, it sets the tank's velocity (for forward/backward movement) or angular velocity (for turning).
      • this.physics.velocityFromAngle(...) is a Phaser helper to move an object in the direction it's facing.
    • Boundary Check: Prevents the player's tank from going off-screen.
    • Send Movement: If the player moved their tank, it sends a 'player_move' message to the server with the new x, y, and angle. It only sends this update every 50 milliseconds to avoid flooding the server.
    • Bullet Update: Moves bullets and deactivates them if they go off-screen.
  6. Helper Functions:
    • addPlayerTank(): Creates the graphics for the player's own tank. We use scene.add.graphics() to draw simple shapes for the tank body and barrel. It's given a physics body.
    • addOtherPlayerTank(): Similar to addPlayerTank, but for other players' tanks.
    • drawTank(): A reusable function to draw the tank's appearance (body, turret, barrel) on a given Phaser Graphics object.
    • fireBullet(): Creates a bullet sprite (or graphic), sets its initial position, angle (from the server message), owner, and velocity.
    • handleBulletTankCollision():
      • This is called by Phaser's physics when a bullet overlaps any physics object.
      • It checks if the thing hit is a tank we know about and not the bullet's owner.
      • Crucially, if this client fired the bullet that hit another tank, it sends a 'hit_player' message to the server. The server is the authority for damage.
      • The bullet is then destroyed on the client for immediate feedback.

7. Running the Game

  1. Start the Server: Open your terminal, go to the tank_mmo folder, and run:

    node server.js

    You should see Server started on http://localhost:3000.

  2. Open the Client: Open a web browser (like Chrome or Firefox) and go to: http://localhost:3000

    You should see "Tank Combat!" and a grey area where the game will appear. If everything is connected, your tank should appear. Open a second browser window (or an incognito window) and go to the same address. You should see two tanks, and you can control each one from its respective window.

What this example does:

  • Allows multiple players to connect.
  • Players can drive their tanks around.
  • Players can shoot bullets (visualized for everyone).
  • Basic client-side hit detection informs the server.
  • Server manages player state (position, health) and broadcasts updates.
  • Tanks can be "defeated" (disappear).

Important Simplifications and Next Steps for a Real MMO:

  • Authoritative Server: In this example, the client that fires a bullet tells the server it hit something. For a real game, the server should ideally simulate the bullet's path and determine hits to prevent cheating. This is much more complex.
  • Server-Side Physics: Movement and collision should ideally be fully calculated on the server to ensure fairness and consistency.
  • Client-Side Prediction & Interpolation: To make movement look smooth despite network lag, clients often predict their own movement and smoothly interpolate the movement of other players.
  • Database: To save player progress, accounts, etc., you'd need a database (like MongoDB, PostgreSQL).
  • Better Graphics & Assets: Using actual images for tanks and bullets, and adding animations and sound effects.
  • More Gameplay Features: Health bars visible on screen, scores, different tank types, power-ups, game modes, maps, etc.
  • Scalability: The current server can only handle a small number of players in one process. Real MMOs use complex server architectures to support many players.
  • Security: Lots of checks are needed to prevent cheating and hacking.
  • Error Handling & Robustness: Making the code more resilient to unexpected situations.
  • User Interface: More UI elements for game information, chat, etc.
  • Code Organization: For a larger game, you'd split the client and server code into many more files and modules.

This is a very foundational example, but it should give you a good idea of how a client and server can work together in a real-time multiplayer game using JavaScript! Good luck!

// public/game.js
// --- Game Configuration ---
const config = {
type: Phaser.AUTO, // Use WebGL if available, otherwise Canvas
parent: 'game-container', // ID of the div to contain the game
width: 800,
height: 600,
backgroundColor: '#cccccc',
physics: {
default: 'arcade', // Simple arcade physics
arcade: {
gravity: { y: 0 }, // No gravity in a top-down tank game
debug: false // Set to true to see physics bodies
}
},
scene: {
preload: preload,
create: create,
update: update
}
};
// --- Global Variables for the Client ---
let game = new Phaser.Game(config); // The main Phaser game instance
let socket; // For WebSocket communication with the server
let selfClientId = null; // This client's player ID, given by the server
let playerTank; // This client's tank sprite
let otherPlayers = {}; // Sprites for other players' tanks
let bullets; // A Phaser group for bullets
let cursors; // For keyboard input
let lastMoveTime = 0; // To limit how often we send movement updates
// --- Phaser Scene Functions ---
function preload() {
// Load assets here (images, sounds, etc.)
// For simplicity, we'll draw tanks and bullets with Phaser graphics,
// so no external images are strictly needed for this basic version.
// If you have tank.png and bullet.png:
// this.load.image('tank', 'assets/tank.png');
// this.load.image('bullet', 'assets/bullet.png');
}
function create() {
// --- Setup WebSocket Connection ---
// Use 'ws://localhost:3000' for local development
// For a deployed game, use wss://your-deployed-server-address
socket = new WebSocket(`ws://${window.location.hostname}:3000`);
socket.onopen = () => {
console.log('Connected to server');
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
// console.log('Received message:', message);
switch (message.type) {
case 'your_id':
selfClientId = message.id;
console.log('My player ID is:', selfClientId);
break;
case 'current_players':
// Add all players already in the game
for (const id in message.players) {
if (message.players.hasOwnProperty(id)) {
const playerData = message.players[id];
if (playerData.id === selfClientId) {
addPlayerTank(this, playerData); // 'this' refers to the Phaser Scene
} else {
addOtherPlayerTank(this, playerData);
}
}
}
break;
case 'player_joined':
if (message.player.id !== selfClientId) {
addOtherPlayerTank(this, message.player);
}
break;
case 'player_update':
if (otherPlayers[message.id]) {
otherPlayers[message.id].setPosition(message.x, message.y);
otherPlayers[message.id].setAngle(message.angle);
}
break;
case 'player_left':
if (otherPlayers[message.id]) {
otherPlayers[message.id].destroy(); // Remove tank sprite
delete otherPlayers[message.id]; // Remove from our list
}
break;
case 'bullet_fired':
fireBullet(this, message.x, message.y, message.angle, message.playerId);
break;
case 'player_hit':
if (message.targetId === selfClientId && playerTank) {
playerTank.health = message.health;
// Add visual feedback for being hit (e.g., flash red)
playerTank.setTint(0xff0000);
this.time.delayedCall(100, () => {
if (playerTank) playerTank.clearTint();
});
console.log(`I was hit! My health: ${playerTank.health}`);
} else if (otherPlayers[message.targetId]) {
otherPlayers[message.targetId].health = message.health;
otherPlayers[message.targetId].setTint(0xff0000); // Flash red
this.time.delayedCall(100, () => {
if(otherPlayers[message.targetId]) otherPlayers[message.targetId].clearTint();
});
}
// You could update a health bar UI here
break;
case 'player_defeated':
if (message.playerId === selfClientId && playerTank) {
console.log("I've been defeated!");
playerTank.setActive(false).setVisible(false); // Hide tank
// You could show a "Game Over" message or respawn logic
} else if (otherPlayers[message.playerId]) {
console.log(`Player ${message.playerId} defeated`);
otherPlayers[message.playerId].setActive(false).setVisible(false);
// Show an explosion or something
this.time.delayedCall(1000, () => { // Remove after a delay
if (otherPlayers[message.playerId]) {
otherPlayers[message.playerId].destroy();
delete otherPlayers[message.playerId];
}
});
}
break;
}
};
socket.onerror = (error) => {
console.error('WebSocket Error:', error);
};
socket.onclose = () => {
console.log('Disconnected from server');
// Maybe show a "disconnected" message to the player
};
// --- Setup Game Elements ---
// Create a group for bullets to manage them easily
bullets = this.physics.add.group({
defaultKey: 'bullet_sprite', // If using a loaded image
maxSize: 30, // Max bullets on screen at once
runChildUpdate: true // Bullets will update themselves
});
// Input
cursors = this.input.keyboard.createCursorKeys(); // For arrow key movement
this.input.keyboard.on('keydown-SPACE', () => { // For shooting with Spacebar
if (playerTank && playerTank.active && socket && socket.readyState === WebSocket.OPEN) {
// Tell the server we are shooting
socket.send(JSON.stringify({
type: 'shoot',
x: playerTank.x,
y: playerTank.y,
angle: playerTank.angle
}));
}
});
// Add collision detection between bullets and tanks
// Note: For an MMO, authoritative hit detection should be on the server.
// Here, the client that fired the bullet can detect a hit and tell the server.
this.physics.add.overlap(bullets, null, handleBulletTankCollision, null, this);
// The 'null' for colliders means bullets will collide with *all* physics bodies
// in the scene unless specified. We'll filter in `handleBulletTankCollision`.
} // --- End of create() ---
function update(time, delta) {
if (!playerTank || !playerTank.active) return; // Don't do anything if our tank isn't ready or active
let inputChanged = false;
const moveSpeed = 200; // Pixels per second
const angularSpeed = 150; // Degrees per second
// --- Handle Player Input and Movement ---
// Stop movement by default
playerTank.body.setVelocity(0, 0); // FIXED: gemini missed ".body"
playerTank.body.setAngularVelocity(0); // FIXED: gemini missed ".body"
if (cursors.left.isDown) {
playerTank.body.setAngularVelocity(-angularSpeed); // FIXED: gemini missed ".body"
inputChanged = true;
} else if (cursors.right.isDown) {
playerTank.body.setAngularVelocity(angularSpeed); // FIXED: gemini missed ".body"
inputChanged = true;
}
if (cursors.up.isDown) {
// Move in the direction the tank is facing
this.physics.velocityFromAngle(playerTank.angle - 90, moveSpeed, playerTank.body.velocity);
inputChanged = true;
} else if (cursors.down.isDown) {
this.physics.velocityFromAngle(playerTank.angle - 90, -moveSpeed / 2, playerTank.body.velocity); // Slower reverse
inputChanged = true;
}
// Keep tank within game boundaries
playerTank.x = Phaser.Math.Clamp(playerTank.x, 25, config.width - 25); // 25 is half tank size (approx)
playerTank.y = Phaser.Math.Clamp(playerTank.y, 25, config.height - 25);
// Send movement updates to the server periodically
if (inputChanged && time > lastMoveTime + 50) { // Send update every 50ms if moving
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: 'player_move',
x: playerTank.x,
y: playerTank.y,
angle: playerTank.angle
}));
}
lastMoveTime = time;
}
// Update bullets (basic client-side movement for immediate feedback)
// Server will be the authority for hit detection ultimately
bullets.getChildren().forEach(bullet => {
if (bullet.active) {
// Check bounds and deactivate if out of screen
if (bullet.x < 0 || bullet.x > config.width || bullet.y < 0 || bullet.y > config.height) {
bullet.setActive(false).setVisible(false);
bullet.destroy(); // Or return to a pool
}
}
});
} // --- End of update() ---
// --- Helper Functions for the Client ---
function addPlayerTank(scene, playerData) {
// Create the tank sprite using Phaser's graphics for simplicity
// A real game would use playerTank = scene.physics.add.sprite(playerData.x, playerData.y, 'tank_image');
playerTank = scene.add.graphics({ x: playerData.x, y: playerData.y });
drawTank(playerTank, playerData.color); // Custom function to draw the tank
// Add physics body to the graphics object
scene.physics.world.enableBody(playerTank, Phaser.Physics.Arcade.DYNAMIC_BODY);
playerTank.body.setSize(40, 50); // Approximate size of our drawn tank
playerTank.body.setCollideWorldBounds(true); // Don't go off screen
playerTank.id = playerData.id;
playerTank.angle = playerData.angle; // Initial angle from server
playerTank.health = playerData.health;
playerTank.setDepth(1); // Ensure player's tank is usually above others if overlapping briefly
// Store the graphics object itself if you need to reference it by its physics body later
playerTank.setData('isPlayer', true);
playerTank.setData('id', playerData.id);
}
function addOtherPlayerTank(scene, playerData) {
// otherTank = scene.physics.add.sprite(playerData.x, playerData.y, 'tank_image');
const otherTank = scene.add.graphics({ x: playerData.x, y: playerData.y });
drawTank(otherTank, playerData.color);
scene.physics.world.enableBody(otherTank, Phaser.Physics.Arcade.STATIC_BODY); // Other tanks are static from this client's physics perspective
otherTank.body.setSize(40, 50);
otherTank.id = playerData.id;
otherTank.angle = playerData.angle;
otherTank.health = playerData.health;
otherPlayers[playerData.id] = otherTank; // Store it
otherTank.setData('isPlayer', false);
otherTank.setData('id', playerData.id);
}
function drawTank(graphics, colorHex) {
const color = Phaser.Display.Color.HexStringToColor(colorHex).color;
graphics.clear(); // Clear previous drawing if any (for updates, though we recreate on join)
// Tank Body
graphics.fillStyle(color, 1);
graphics.fillRect(-20, -25, 40, 50); // Tank body (width 40, height 50)
// Tank Turret
graphics.fillStyle(0x666666, 1); // Darker grey for turret
graphics.fillRect(-5, -15, 10, 30); // Turret base
// Tank Barrel
graphics.fillStyle(0x444444, 1); // Even darker for barrel
graphics.fillRect(-2.5, -40, 5, 25); // Barrel (width 5, length 25, pointing "up" relative to tank body)
// The graphics origin (0,0) is the center of the tank for rotation.
// The fillRect positions are relative to this origin.
// Angle will be applied to the graphics container.
}
function fireBullet(scene, x, y, angle, ownerId) {
let bullet = bullets.get(); // Get a bullet from the pool/group
if (bullet) {
// If not using a sprite, create graphics for bullet
if (!bullet.texture || bullet.texture.key === '__MISSING') {
// Create a simple circular graphic for the bullet if no sprite is set
const bulletGraphics = scene.add.graphics();
bulletGraphics.fillStyle(0x333333, 1); // Dark grey bullet
bulletGraphics.fillCircle(0, 0, 5); // 5px radius circle
// Create a texture from the graphics
const textureKey = `bullet_gfx_${Date.now()}_${Math.random()}`; // Unique key
bulletGraphics.generateTexture(textureKey, 10, 10);
bulletGraphics.destroy(); // Clean up graphics object, we only need the texture
bullet.setTexture(textureKey); // Apply the new texture
}
bullet.setActive(true).setVisible(true);
bullet.setPosition(x, y);
bullet.setAngle(angle);
bullet.ownerId = ownerId; // Whose bullet is this?
bullet.body.setSize(10, 10); // Physics body for collision
// Calculate velocity based on angle
const speed = 600; // Bullet speed
scene.physics.velocityFromAngle(angle - 90, speed, bullet.body.velocity); // Phaser angles start from right, -90 for "up"
// Optional: Make bullets disappear after some time or distance
bullet.lifespan = 2000; // Milliseconds
scene.time.delayedCall(bullet.lifespan, () => {
bullet.setActive(false).setVisible(false);
bullet.destroy(); // Or return to pool if using one properly
});
}
}
function handleBulletTankCollision(bullet, tankGraphics) {
// This function is called by Phaser when a bullet overlaps *any* physics body.
// We need to check if 'tankGraphics' is actually one of our tanks.
// And make sure the bullet doesn't hit its own owner immediately.
if (!bullet.active || !tankGraphics.active) return;
const tankId = tankGraphics.getData('id');
const isPlayerTank = tankGraphics.getData('isPlayer'); // Custom data we set on tank graphics
if (typeof tankId === 'undefined') return; // Not a tank we track
// Prevent bullet from hitting its owner immediately after firing
if (bullet.ownerId === tankId) {
// Optional: Allow self-hit after a very short delay if desired,
// but for now, just ignore if owner is the target.
// This check is simple. A more robust way is to ignore collisions for a few frames.
return;
}
// If this client is the one who fired the bullet, tell the server about the hit
if (bullet.ownerId === selfClientId) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: 'hit_player',
targetId: tankId,
damage: 10 // Example damage
}));
}
}
// Deactivate bullet on hit (client-side prediction)
bullet.setActive(false).setVisible(false);
bullet.destroy(); // Or return to a pool
// Client can show an impact effect immediately
// The server will confirm the health change.
// console.log(`Bullet from ${bullet.ownerId} hit tank ${tankId}`);
}
<!DOCTYPE html>
<html>
<head>
<title>Tank MMO Lite</title>
<link rel="stylesheet" href="style.css">
<script src="js/phaser.min.js"></script>
</head>
<body>
<h1>Tank Combat!</h1>
<div id="game-container">
</div>
<script src="game.js"></script>
</body>
</html>
body {
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
margin: 0;
background-color: #f0f0f0;
}
#game-container {
border: 1px solid black;
/* Dimensions will be set by Phaser */
}
h1 {
color: #333;
}
// server.js
// 1. Set up the basics
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// This will serve files from a 'public' folder (we'll create this later)
app.use(express.static('public'));
// 2. Store game information
let players = {}; // Store data about connected players (their tanks)
let nextPlayerId = 0; // To give each player a unique ID
// 3. Handle WebSocket connections (when a player connects)
wss.on('connection', (ws) => {
const playerId = nextPlayerId++;
console.log(`Player ${playerId} connected`);
// Create a new tank for this player
// Starting position and color are random for simplicity
players[playerId] = {
id: playerId,
x: Math.random() * 700 + 50, // Random x position
y: Math.random() * 500 + 50, // Random y position
angle: 0, // Facing direction
color: `0x${Math.floor(Math.random()*16777215).toString(16)}`, // Random color
health: 100
};
// Send the new player their ID and the current state of all other players
ws.send(JSON.stringify({ type: 'your_id', id: playerId }));
ws.send(JSON.stringify({ type: 'current_players', players: players }));
// Tell all OTHER players about this new player
broadcast({
type: 'player_joined',
player: players[playerId]
}, playerId); // Don't send to the new player themselves
// Handle messages from this player
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
// When a player moves their tank
if (data.type === 'player_move' && players[playerId]) {
players[playerId].x = data.x;
players[playerId].y = data.y;
players[playerId].angle = data.angle;
// Tell all other players about this movement
broadcast({
type: 'player_update',
id: playerId,
x: data.x,
y: data.y,
angle: data.angle
}, playerId);
}
// When a player shoots
if (data.type === 'shoot' && players[playerId]) {
console.log(`Player ${playerId} shot`);
// Tell all players (including the shooter) that a shot happened
broadcast({
type: 'bullet_fired',
playerId: playerId,
x: data.x, // Starting position of the bullet
y: data.y,
angle: data.angle
});
}
// When a player's tank is hit
if (data.type === 'hit_player' && players[data.targetId]) {
players[data.targetId].health -= data.damage;
console.log(`Player ${data.targetId} hit, health: ${players[data.targetId].health}`);
broadcast({
type: 'player_hit',
targetId: data.targetId,
attackerId: playerId, // Who did the shooting
health: players[data.targetId].health
});
if (players[data.targetId].health <= 0) {
console.log(`Player ${data.targetId} defeated`);
broadcast({
type: 'player_defeated',
playerId: data.targetId
});
// For simplicity, we remove the player. A real game might have respawns.
delete players[data.targetId];
}
}
} catch (error) {
console.error('Failed to parse message or process:', message, error);
}
});
// Handle when a player disconnects
ws.on('close', () => {
console.log(`Player ${playerId} disconnected`);
delete players[playerId]; // Remove player from our list
// Tell all other players that this player left
broadcast({
type: 'player_left',
id: playerId
});
});
ws.on('error', (error) => {
console.error(`WebSocket error for player ${playerId}:`, error);
});
});
// Function to send a message to all connected players (except one, if specified)
function broadcast(data, excludePlayerId = null) {
const message = JSON.stringify(data);
wss.clients.forEach(client => {
// Find the player ID associated with this client
let clientId = -1;
for (const id in players) {
// This is a simplification; a robust solution would map ws clients to player IDs directly
if (players[id].ws === client) { // This assumes you store 'ws' instance in player object, which we are not doing yet for simplicity
clientId = parseInt(id);
break;
}
}
// A better way to check if the client is the one to exclude:
// For this example, we're sending to all.
// If we had a direct map of WebSocket clients to player IDs, we could exclude properly.
// For now, the exclude logic in the 'player_joined' and 'player_update' relies on the sender not being in the broadcast loop.
// Let's refine this broadcast to work with the `players` structure.
// We need to iterate through players and send if their websocket is open.
// Temporary simple broadcast to all clients:
if (client.readyState === WebSocket.OPEN) {
// If an excludePlayerId is provided, we'd need to check if this 'client' corresponds to that player.
// This part is tricky without directly mapping ws instances to playerIds upon connection.
// For now, specific exclusions are handled where broadcast is called.
client.send(message);
}
});
}
// 4. Start the server
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server started on http://localhost:${PORT}`);
});
// A simple game loop on the server (optional, could be event-driven)
// For now, most logic is event-driven by client messages.
// setInterval(() => {
// // Game logic that needs to run regularly, e.g., check for game over conditions, update AI, etc.
// // For this example, we'll keep it simple and mostly rely on messages from clients.
// }, 1000 / 60); // Roughly 60 times per second
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment