Click here to Skip to main content
Click here to Skip to main content

Persistent Client-Server Game of Life with Obelisk.js & Spike Engine

, 18 Jun 2014
Rate this:
Please Sign up or sign in to vote.
A game of life "MMO", with a persistent simulation running on the server.

Introduction

Game of Life is a cellular automation invented by John Horton Conway in 1970. It's a zero player game of evolution. What if we took this game and let it live on a server, continuosly thus allowing two people observe the same game board, even if they're on different continents. That's the idea behind this article: create a persistent, almost "MMO"-like simulation on the server and allow clients to observe and render this simulation remotely. 

 

[Live Demo]

Background

Let's start with a short summary explaining that this article accomplishes, and its main highlights:

  1. The game of life simulation runs continuously on the server. The grid then broadcasted to every client who is "observing" the game.
  2. The simulation introduces random mutations and board randomizations so it can run continusly without getting too boring.
  3. The rendering is built using javascript Obelisk.js library.
  4. It uses websockets internally, but abstracted by Spike Engine.
  5. The application server is a self-hosted executable and the client is just a plain html file.

 

Since the simulation runs on the server and rendered by the clients, we need to split the roles and what each node will do. In our case:

  1. Server is responsible for entire simulation execution, from one generation to another.
  2. Server is also rseponsible for managing a list of observers of the game world and for periodically senging the state of the game world to the observers.
  3. Clients (or observers) are responsible for joining/leaving the server and rendering the game world they receive.

The following figure illustrates the process:

Server-Side Implementation

Let's begin by examining the definition that represents the process of client-server communication. We have 3 operations:

  1. JoinGameOfLife: called by the observer to join the game. This tells the server to start sending updates to that particular client.
  2. LeaveGameOfLife: called by the observer to leave the game. This tells the server to stop sending updates.
  3. NewGeneration: initiated by the server, hence Direction="Push", simply send the grid of cells to the client. This grid is filled with zeros and/or ones (binary matrix) and represents live or empty cells of the map.
<?xml version="1.0" encoding="UTF-8"?>
<Protocol Name="MyGameOfLifeProtocol" xmlns="http://www.spike-engine.com/2011/spml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <Operations>

    <!-- Joins the game of life and allows clients to observe the board -->
    <Operation Name="JoinGameOfLife" Direction="Pull" SuppressSecurity="true"></Operation>

    <!-- Leaves the game of life -->
    <Operation Name="LeaveGameOfLife" Direction="Pull" SuppressSecurity="true"></Operation>

    <!-- Sends a new generation to the observers -->
    <Operation Name="NewGeneration" 
               Direction="Push"
               SuppressSecurity="true">
      <Outgoing>
        <Member Name="Grid" Type="ListOfInt16" />
      </Outgoing>
    </Operation>
    
  </Operations>
</Protocol>

We are not going to go through the actual implementation of the game of life, as it's just one of many and it's pretty straightforward. However, we added a couple of interesting modifications to spice up the simulation and a couple of nice performance tricks to speed up things. If you look at the snippet of code below, the function UpdateCell is the one responsible for updating a single cell of the field. If at least one cell is changed, we mark the entire generation as changed.

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void UpdateCell(int i, int j)
{
    var oldState  = GetAt(this.FieldOld, i, j);
    var neighbors = CountNeighbours(this.FieldOld, i, j);

    // Update the cell
    this.Field[i * FieldSize + j] =
        (short) (neighbors == 2 ? oldState : (neighbors == 3 ? 1 : 0));

    // Mark as dirty if new is not the same as old
    if (this.Field[i * FieldSize + j] != oldState)
        this.FieldChange = true;

}

During the update, we also do two additional things:

  1. If the field wasn't changed, which can happen quite often in the game of life, we randomize the board again and reinitialize it. This allows us to run the simulation forever and automatically restart the simulation if there's no more living cells, for example.
  2. Each generation got 5% of chance to create some random mutations. Once a single mutation have occured, we have 95% probability to have more mutation within the same generation. This allows us to "break" stable structures in the game of life, spicing things up.
private void Mutate()
{
    if(!this.FieldChange)
        this.Randomize();

    var probability = 0.05;
    while (Dice.NextDouble() < probability)
    {
        var x = Dice.Next(0, this.FieldSize);
        var y = Dice.Next(0, this.FieldSize);

        probability = 0.95;

        this.Field[x * FieldSize + y] =
            (short)(this.Field[x * FieldSize + y] == (short)1 ? 0 : 1);
    }
}

Now that we have the simulation implemented, how do we actually connect  the simulation to our networking backend? Join and leave operations are pretty straightforward and they simply put/remove an IClient instance to/from an IList<IClient>. We also start a game loop using Spike.Timer which handles all the threading for us. It is important to notice that if you have several timers, they will all share the same thread, avoiding performance problems such as oversubscription. The speed of game loop itself can be adjusted here, on the piece of code below we call it every 50 milliseconds and in our live demo it's set to 200 milliseconds.

[InvokeAt(InvokeAtType.Initialize)]
public static void Initialize()
{

    // Hook the events
    MyGameOfLifeProtocol.JoinGameOfLife += OnJoinGame;
    MyGameOfLifeProtocol.LeaveGameOfLife += OnLeaveGame;

    // Start the game loop,
    Timer.PeriodicCall(TimeSpan.FromMilliseconds(50), OnTick);

}

The game loop does pretty much that you would expect. It updates the game of life, performing the simulation and then sends the grid (32 by 32 binary matrix) to every client. We defined in both, our protocol and our Game class the same matrix to be a IList<Int16>. So we simply pass that list to the send method, without doing any conversion at all.

private static void OnTick()
{
    World.Update();

    // Make sure we don't add new observers while preparing to send
    lock (Observers)
    {
        // Send the grid to every observer
        foreach (var observer in Observers)
            observer.SendNewGenerationInform(World.World);
    }
}

Client-Side Implementation

Let's examine now the client side. The client needs to connect to the server and join the game, we also need to hook newGenerationInform event which will be invoked every time we receive a new grid from the server. Once we receive a grid, we copy it from an Array to an Int8Array and draw it.

// When the document is ready, we connect
$(document).ready(function () {
    var server = new ServerChannel("127.0.0.1:8002");

    // When the browser is connected to the server
    server.onConnect(function () {

        // Join the game
        server.joinGameOfLife();

        // Receive the updates
        server.newGenerationInform = function (p) {
            var field = new Int8Array(gridSize * gridSize);
            for (var i = 0; i < gridSize * gridSize; ++i)
                field[i] = p.grid[i];

            render(field);
        };
    });

});

We have used a rendering engine called Obelisk.js to render our isometric blocks and inspired by the work of @Safx, implementing the game of life in javascript. However, we do not have any game of life-related logic in our client. We simply have a render function that draws a Int8Array grid we receive from the server. Since the server pushes the data, we do not even have to have a render loop and simply redraw all the elements of our canvas on every corresponding receive.

function render(field) {
    // Clear the screen
    pixelView.clear();

    // Draw the board
    var boardColor = new obelisk.CubeColor().getByHorizontalColor(obelisk.ColorPattern.GRAY);
    var p = new obelisk.Point3D(cubeSide / 2, cubeSide / 2, 0);
    var cube = new obelisk.Cube(boardDimension, boardColor, false);
    pixelView.renderObject(cube, p);

    // Draw cells
    for (var i = 0; i < gridSize; ++i) {
        for (var j = 0; j < gridSize; ++j) {
            var z = field[i * gridSize + j];
            if (z == 0) continue;

            var color = new obelisk.CubeColor().getByHorizontalColor((i * 8) << 16 | (j * 8) << 8 | 0x80);
            var p = new obelisk.Point3D(cubeSide * i, cubeSide * j, 0);
            var cube = new obelisk.Cube(dimension, color, false);
            pixelView.renderObject(cube, p);
        }
    }
}

I hope you liked this article, please check out other Spike Engine articles we've written and feel free to contribute!

History

 

  • 19-06-14: Initial Version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

About the Author

Kel_
Chief Technology Officer Misakai Ltd.
Ireland Ireland
Roman Atachiants is the guy behind www.spike-engine.com project, a real-time, client-server networking layer (SOA, RPC) for .NET developers. Also the founder of Misakai Ltd..
 
He is a software engineer and scientist with extensive experience in different computer science domains, programming languages/principles/patterns & frameworks.
 
His main expertise consists of C# and .NET platform, game technologies, cloud, human-computer interaction, big data and artificial intelligence. He has an extensive programming knowledge and R&D expertise.

 
Follow on   Twitter

Comments and Discussions

 
-- There are no messages in this forum --
| Advertise | Privacy | Mobile
Web02 | 2.8.140721.1 | Last Updated 18 Jun 2014
Article Copyright 2014 by Kel_
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid