Multiplayer game Example in Javascript & Python

A simple example. An game that has a backend in Python and frontend in Javascript, using pixi.js.

View demo online
View full source on GitHub
Run the example

Running the example

You can run this self-contained example using Docker.

git clone https://github.com/scalesocket/scalesocket
cd scalesocket/examples/
docker compose up --build multiplayer

Then open http://localhost:5000/ in your browser.

Frontend code

The frontend consists of three files index.html, client.js and bunny.png. Pixi.js is used to simplify drawing and managing sprites.

The index.html file loads the game and connects to the server using websockets.

<!doctype html>
<html lang="en">

<head>
    <title>Multiplayer Game Example using ScaleSocket</title>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="https://pixijs.download/v7.0.0/pixi.min.js"></script>
    <script type="module" src="./client.js"></script>
    <style>
        body { background-color: #e2e8f0; font-family: Arial, Helvetica, sans-serif; margin: 0; text-align: center; }
        canvas { max-width: 100vw; height: auto; }
    </style>
</head>

<body>
    <div>
        <h1>Multiplayer Game Example</h1>
        <p><select
                onchange="let value = this.options[this.selectedIndex].value || (Math.random() + 1).toString(36).substring(7); window.location.search = `?room=${value}`;">
                <option disabled selected>Change Room</option>
                <option value="default">Room 0 (default)</option>
                <option value="room1">Room 1</option>
                <option value="room2">Room 2</option>
                <option value="room3">Room 3</option>
                <option value="">Create new room</option>
            </select></p>
    </div>
    <p>Multiplayer game, try tapping around and opening multiple instances. Built with <a
            href="https://www.scalesocket.org/man/examples/multiplayer" target="_blank">ScaleSocket</a>.</p>
    <canvas id="canvas"></canvas>
    <script type="module">
        import { Game } from "./client.js";
        const $ = document.querySelector.bind(document);
        const protocol = window.location.protocol === "https:" ? "wss" : "ws";
        const params = new URLSearchParams(document.location.search);
        const room = params.get("room") ?? 'default';

        // create a new PIXI application with resources
        const app = new PIXI.Application({
            width: 480, height: 480, autoDensity: true,
            view: $('#canvas'),
            backgroundColor: 0xffffff
        });

        PIXI.Assets.addBundle('default', { 'bunny': 'bunny.png' });

        let load = PIXI.Assets.loadBundle('default');

        // connect to websocket server and room based on URL
        const ws = new WebSocket(`${protocol}:${window.location.host}/${room}`);

        let connect = new Promise((resolve, reject) => {
            ws.addEventListener('open', (event) => resolve(), { once: true });
            ws.addEventListener('error', (event) => reject(), { once: true });
        });

        // start the game
        void Promise.all([load, connect]).then(async ([resources, _]) => {
            const game = new Game(app, ws, resources);
            await game.init();
        });
    </script>
</body>

</html>

The actual frontend logic is in client.js. We receive message from the websocket and update sprites (players) based on it. When the player clicks on the screen, we send the input to the server.

// client.js
export class Game {
    constructor(app, ws, resources) {
        this.app = app;
        this.ws = ws;
        this.resources = resources;
        this.state = new GameState(this.app.stage, resources);
    }

    sendMessage(t, data) {
        // send outgoing message
        this.ws.send(JSON.stringify({ t, data }));
    }

    recvMessage(t, data) {
        // handle incoming message
        if (t == "State") {
            this.state.updatePlayers(data.players);
        } else if (t == "Leave") {
            this.state.removePlayer(data.id);
        }
    }

    async init() {
        // set websocket listener
        this.ws.addEventListener('message', e => {
            const { t, data } = JSON.parse(e.data);
            this.recvMessage(t, data);
        });

        // set click listener
        this.app.stage.interactive = true;
        this.app.stage.hitArea = this.app.screen;
        this.app.stage.on('pointerdown', (e) => {
            this.sendMessage('Input', { x: e.global.x, y: e.global.y });
        });

        // set interpolation to run on every tick
        PIXI.Ticker.shared.add(dt => this.state.interpolate(dt));
    }
}

class GameState {
    /** @type {Record<number, {sprite: any, pos: any}>} */
    players = {}

    constructor(stage, resources) {
        this.stage = stage;
        this.resources = resources;
    }

    updatePlayers(players) {
        for (const [id, pos] of Object.entries(players)) {
            this.updatePlayer(id, pos);
        }
    }

    updatePlayer(id, pos) {
        if (id in this.players) {
            this.players[id].pos = pos;
        } else {
            this.addPlayer(id, pos);
        }
    }

    addPlayer(id, pos) {
        const [x, y] = pos;
        const sprite = new PIXI.Sprite(new PIXI.Texture(this.resources.bunny.baseTexture));
        sprite.anchor.set(0.5);
        sprite.position.set(x, y);

        this.players[id] = { pos, sprite };
        this.stage.addChild(sprite);
    }

    removePlayer(id) {
        if (id in this.players) {
            console.log('removing player');
            const sprite = this.players[id].sprite;
            this.stage.removeChild(sprite);
            sprite.destroy();
            delete this.players[id];
        }
    }

    interpolate(_dt) {
        for (const { pos, sprite } of Object.values(this.players)) {
            const [x, y] = pos;
            const dx = (x - sprite.x) / 3;
            const dy = (y - sprite.y) / 3;
            sprite.x += dx;
            sprite.y += dy;
        }
    }
}

Backend code

The backend is a single file, server.py. It reads stdin in a loop, and updates state based on incoming messages.

#!/usr/bin/python3
from contextlib import suppress
from json import JSONDecodeError, loads, dumps
from sys import stdin, stdout, stderr


def main():
    print("game server started", file=stderr)
    players = {}

    # receiving data is as easy as reading stdin
    stdin_events = map(parse_json, stdin)

    for event in stdin_events:
        t, id, data = parse_event(event)

        if t == "Join":     # default join event for new clients
            players[id] = (150, 150)
            send_event("State", {"players": players}, to_id=id)
        elif t == "Leave":  # default leave event for disconnected clients
            del players[id]
            send_event("Leave", {"id": id})
        elif t == "Input":  # our custom event for player input
            players[id] = (data.get("x", 0), data.get("y", 0))
            send_event("State", {"players": players})


def send_event(t: str, data: dict, to_id: int = None):
    # sending data is as easy as printing
    print(dumps({"t": t, "data": data, "_to": to_id}))


def parse_json(data: str):
    with suppress(JSONDecodeError):
        return loads(data)
    return None


def parse_event(event: dict):
    with suppress(KeyError):
        return event["t"], int(event["_from"]), event.get("data")
    return None, None, None


if __name__ == "__main__":
    # ensure python output is not buffered
    stdout.reconfigure(line_buffering=True)
    main()

Backend server

The backend is the ScaleSocket server.

We want to:

  • let players join rooms based on URL
  • start a new server.py process when a new user connects
  • send client connect and disconnect events to the server
  • host the static files

To do this, start ScaleSocket using:

scalesocket --addr 0.0.0.0:5000\
    --staticdir /var/www/public/ \
    --frame=json \
    --joinmsg '{"t":"Join","_from":#ID}'\
    --leavemsg '{"t":"Leave","_from":#ID}'\
    server.py

How does it work?

The frontend connects to the server using websockets. The server spins up a new server.py process.

When the frontend sends a input, ScaleSocket passes it directly to the stdin of server.py.

The input is read in a loop by mapping json parser over the stdin which is a generator in Python.

# excerpt from server.py
from sys import stdin

def parse_json(data: str):
    with suppress(JSONDecodeError):
        return loads(data)
    return None

stdin_events = map(parse_json, stdin)

for event in stdin_events:
    # do something with the event
    # in our case the frontend sends
    # {"t": "Input", "data": {"x": 123, "y": 456}}
    # ...

The events update the local state on the backend, and the backend sends the new state to stdout, by printing the state.

# excerpt from server.py
from json import dumps

# ...

# sending is as easy as printing
print(dumps({"t": t, "data": data}))

# optionally, we may specify a receiver by using "_to"
print(dumps({"t": t, "data": data, "_to": to_id}))

Was this page helpful?