January 26, 2025

Remake of a Multiplayer WebSocket Game I Made 10 Years Ago.

I'm revisiting an old multiplayer WebSocket game I wrote in NodeJS on its 10th anniversary to rewrite it in PHP into a single-player game where you can challenge the computer.

In this blog, I’ll revisit a WebSocket multiplayer game I created 10 years ago. The original game was written in NodeJS with SocketIO but I’ll rewrite parts of it in PHP to turn it into a single-player game where you can play against the computer.

10th Anniversary

I’m turning 40 in a month and the older I get the more I look back and feel nostalgic about past events in my life and career. The other day I remembered a game I created when I was only 6 years deep into my career as a web developer. Back then, I was very intrigued to learn more about real-time data and data visualization in the browser. There’s nothing more productive than having fun while learning so I decided that I wanted to create a multiplayer game that uses WebSockets to transfer data which is displayed with SVG elements in real-time on the screen in the browser.

When I was in junior high school, everyone was raving about the Nokia 3310 cellphone and it was the one thing everyone wanted. There was this really simple game called Snake that I remember almost everyone was playing during recess. Not being financially fortunate I somehow managed to possess one of these cellphones and I remember loving this game, playing it all the time. This was the first time I ever played a game on a cellphone and it was mind-blowing.

The inspiration for the game I created came from this game, but of course, the purpose wasn’t to create an advanced game with all the features of Snake, it was more to learn how one could transfer data in real time to create something interactive in the browser. This was back in 2015 when I was about to make my first-ever trip with my wife to northern Norway, which changed my life. While reading some Norwegian I encountered the phrase “Vi Snakkes” (Talk to you later), which resembles the name of the original game, so I decided I’d call it Snakkes.

Meet Snakkes 2015

The original version of the game used the SocketIO library for WebSocket communication and d3 for interacting with the SVG elements in the browser. At this time, SocketIO version 1.0 was less than a year old! Also, ECMAScript 2015 wasn’t released yet so there was no such thing as classes in JavaScript. Instead, we used constructor functions to create something that worked like a class. If you haven’t seen or heard about them, they’re awesome, check out an example of some logic I wrote for this game back in the day, [here].

The original game was written in a super old version of NodeJS with dependencies older than the dinosaurs so it will most probably not run on modern environments. I therefore wanted to revisit this game after 10 years and give it some love.

Meet Snakkes 2025

The original version of Snakkes only had a multiplayer mode and while it’s fun to play against a human, you need someone to challenge to be able to play. So, in this new version, I’m remaking Snakkes to be a single-player game where you can challenge the computer instead of another human player.

The game engine is rewritten in PHP, but it only contains the minimum necessities to make the game work so it’s rather simple. Let’s take a closer look at it.

Game Engine

The game engine loops and refreshes the game state about 12 times a second, sending the updated game state to the browser. The engine calculates new coordinates for the worms, checks for collisions between worms and the board bounds and collisions between worms and the apple. It only contains a few logical steps, let’s make a quick run-through of them.

Game State
If any of the players have hit the max score limit, change the game state to finished and return early in the loop.

Worm And Apple Collision
Did the player or computer worm eat an apple? If so, increment their score by 1.

Computer Worm Direction
Calculate the best direction for the computer worm, the precision of this logic is determined by the game difficulty setting. In normal mode, there’s a 25% chance that the wrong direction will be chosen, in insane mode, the computer worm will never get the wrong direction.

Move Worms
Generate the next pair of coordinates for both worms depending on their direction. If they’re hitting the board bounds, then do not add new coordinates to the worm instance. After adding new coordinates to the worm, remove the last set of coordinates unless the worm has eaten an apple. If the worm has eaten an apple, we won’t remove the last coordinates to let the worm grow by one square.

Respawn Apple
If any of the worms have eaten the apple, then we’ll give the apple a pair of new coordinates and respawn it. When respawning the apple, we’ll check the coordinates of the worms to make sure that the apple is not put on top of any of the worms since it’d immediately be eaten.

//
// public function process(Game $game): void
//
$player1AteApple = false;
$player2AteApple = false;

// should game end?
if ($this->gameHandler->gameShouldEnd($game)) {
    $this->gameHandler->endGame($game);
}

// return if game has ended
if ($this->gameHandler->gameIsFinished($game)) {
    return;
}

// did player1's worm eat an apple?
if ($this->collision->wormCollisionWithApple($game->worm1, $game->apple)) {
    $player1AteApple = true;
    $game->player1Score = $game->player1Score + 1;
}

// did player2's worm eat an apple?
if ($this->collision->wormCollisionWithApple($game->worm2, $game->apple)) {
    $player2AteApple = true;
    $game->player2Score = $game->player2Score + 1;
}

// calculate best direction for computer worm
// 25% change of wrong direction in normal mode, 0% in insane mode
$precision = $game->difficulty === 'normal' ? 3 : 0;
$game->worm2->direction = $this->coordinates
    ->calculateBestDirectionForWorm($game->worm2, $game->apple, $precision);

// move player1's worm
$newCoordinates = $this->coordinates->getNextCoordinatesForWorm($game->worm1);
if (! is_null($newCoordinates)) {
    $wormCoordinates = [...$game->worm1->coordinates, $newCoordinates];
    // if worm didn't eat apple, remove last coordinate,
    // otherwise let it grow by 1 square
    if (! $player1AteApple) {
        array_shift($wormCoordinates);
    }
    $game->worm1->coordinates = $wormCoordinates;
}

// move player2's worm
$newCoordinates = $this->coordinates->getNextCoordinatesForWorm($game->worm2);
if (! is_null($newCoordinates)) {
    $wormCoordinates = [...$game->worm2->coordinates, $newCoordinates];
    // if worm didn't eat apple, remove last coordinate,
    // otherwise let it grow by 1 square
    if (! $player2AteApple) {
        array_shift($wormCoordinates);
    }
    $game->worm2->coordinates = $wormCoordinates;
}

// respawn apple if eaten
if ($player1AteApple || $player2AteApple) {
    $appleCoordinates = $this->coordinates->getNewCoordinatesForApple($game->worm1, $game->worm2);
    $game->apple->coordinates = $appleCoordinates;
}

WebSocket Server

In my previous blog, I wrote a WebSocket server from scratch using only PHP socket_* functions, if you haven’t checked it out, it’s available [here]. I created a chat app demo for that blog, but it would be a waste to not use it for something else, so I’ll use it with this new version of Snakkes as well.

The WebSocket server will be in charge of transfering the newly updated game state to the client which will then update the game in the browser. I’ve only added a few lines of code to make the WebSocket server transmit the game state, the full diff is available [here].

Inside the game repository, there’s a GameHandler class that has a method that takes all the coordinates for both worms and the apple and makes the data ready to send to the client over the WebSocket connection. The data is formatted like so, [x, y, color]. Let’s have a look at the logic that generates this data.

/**
 * @return array{
 *      state: GameStateString,
 *      board: array<int, array{
 *          0: int, 1: int, 2: WormColorString|AppleColorString
 *      }>,
 *      scores: array{ player1: int, player2: int }
 * }
 */
public function getGameState(Game $game): array
{
    $paintedCoordinates = [];

    foreach ($game->worm2->coordinates as $coordinates) {
        $paintedCoordinates[] = [
            $coordinates['x'],
            $coordinates['y'],
            $game->worm2->color
        ];
    }
    foreach ($game->worm1->coordinates as $coordinates) {
        $paintedCoordinates[] = [
            $coordinates['x'],
            $coordinates['y'],
            $game->worm1->color
        ];
    }
    $paintedCoordinates[] = [
        $game->apple->coordinates['x'],
        $game->apple->coordinates['y'],
        $game->apple->color
    ];

    return [
        'state'  => $game->gameState,
        'board'  => $paintedCoordinates,
        'scores' => [
            'player1' => $game->player1Score,
            'player2' => $game->player2Score
        ]
    ];
}

Front-end Logic

Finally, we have a fully functional WebSocket server that transfers the state generated by the GameHandler and updated by the game engine. Now all that we need to do is listen for player keyboard input to change the direction of the worm and repaint the squares in the browser every time we get new data from the WebSocket connection.

Snakkes Game Demo

The logic that first paints the board on page load and then repaints the board on every WebSocket message is rather straightforward and uses d3 to more easily interact with the SVG elements. The logic looks something like this:

(function paint() {
    const boardWidth      = {{ $board->width }};
    const boardHeight     = {{ $board->height }};
    const squareWidth     = {{ $board->squareWidth }};
    const squareHeight    = {{ $board->squareHeight }};
    const ticksX          = JSON.parse("{{ json_encode($board->ticksX) }}");
    const ticksY          = JSON.parse("{{ json_encode($board->ticksY) }}");
    const backgroundColor = "{{ $board->backgroundColor }}";

    d3.select("#game")
            .append("svg")
            .attr("width", boardWidth)
            .attr("height", boardHeight)
            .append("g")
            .attr("id", "board");

    ticksX.map(function (xTick) {
        ticksY.map(function (yTick) {
            d3.select("#board")
                .append("rect")
                .attr("x", xTick)
                .attr("y", yTick)
                .attr("width", squareWidth)
                .attr("height", squareHeight)
                .attr("class", "pixies")
                .attr("fill", backgroundColor);
        });
    });
})();
function repaint(paintedCoordinates) {
    const backgroundColor = "{{ $board->backgroundColor }}";

    d3.selectAll('#board .pixies').each(function () {
        var _this = d3.select(this);
        var x     = parseInt(_this.attr('x'), 10);
        var y     = parseInt(_this.attr('y'), 10);
        var fill  = backgroundColor;

        paintedCoordinates.forEach(function (coordinates) {
            if (coordinates[0] === x && coordinates[1] === y) {
                fill = coordinates[2];
            }
        });
        _this.attr('fill', fill);
    });
};

Final Words

I felt very nostalgic doing a remake of this old side project. I remember meeting many hurdles on the way while making the original version of the game. However, I’m a bit relieved that it was a much easier endeavor today, 10 years later, where the remake only took a day of coding.

I’ll post some links to the original repositories on GitHub so you can check them out if you’re interested.

Snakkes 2025
Snakkes 2025 (Server, PHP)
Snakkes 2025 (Client)

Snakkes 2015
Snakkes 2015 (Multiplayer)
Snakkes 2015 (Solo)

That’s it for this time, I hope that this will be useful for someone out there who might have a similar project looking to have fun while learning more about WebSocket communication and real-time SVG data rendering in the browser.

Until next time, have a good one!

Oliver Lundquist

Born in 🇸🇪, resident in 🇯🇵 for 15+ yrs, husband and father of a daughter and son, web developer since 2009.

Read more about me
• mail@oliverlundquist.com• Instagram (@olibalundo)