nengi Basics 4: View Culling & Cameras

Previous: nengi Basics 3: Commands & Movement

In this tutorial we will learn about the spatial nature of nengi.Instances and how network data is culled by sending clients only a small piece of a larger world. We will also make a camera that follows our entity like most top down 2D games.

Let's begin with fresh code: git pull and git checkout basics-4.

Start with npm start

When we invoke instance.addEntity the entity gets added to a spatial structure inside of nengi. Nengi uses the entity's x and y to determine if the entity is within the view of any particular client. Clients only see entities within their view. Due to this view culling it is possible to create vast worlds with tens of thousands of entities (up to 65,535 currently) without using additional bandwidth.

The game we left off with in the last tutorial section had a bug: when we move our player to the right or down our entity disappears. The reason for this is simply that we need to configure a client view area.

Open up gameServer.js and define a client.view in the 'connect' handler.

instance.on('connect', ({ client, callback }) => {
    /* client init logic & state can go here */
    callback({ accepted: true, text: 'Welcome!' })
    instance.message(new NetLog('hello world'), client)
    const entity = new PlayerCharacter()
    instance.addEntity(entity)
    entities.set(entity.nid, entity)
    client.entity = entity
    client.view = {
        x: entity.x,
        y: entity.y,
        halfWidth: 500,
        halfHeight: 500
    }
})

The view is centered at x, y, and extends outwards by halfWidth and halfHeight. If you're wondering why it is expressed in this manner instead of like a more conventional rectangle, the answer is that this is an AABB (axis-aligned bounding box) and that the halfWidth, halfHeight syntax is really supposed to emphasize that fact.

In the future, nengi will support other types of views other than a 2D AABB. The intention is to add a 2D circle, a 3D sphere, and a 3D AABB along with the addition of a z axis in the spatial data. So both 2D and 3D will be getting some love. Having multiple views for a single client is also under consideration. If you are currently (nengi ~1.9) using nengi in a 3D game, then you should set the halfWidth and halfHeight to very large numbers to essentially disable the culler which is not yet 3D compatible.

Okay, so back to programming!

We defined a view for the client on connect above, but this is insufficient because this view never moves. It has been set to be at the entity's position at the time of connect but that's that! If we want the view to follow the player through the world (which we usually do...) then we need to move it in the update loop as well.

const update = (delta, tick, now) => {
    instance.emitCommands()
    /* serverside logic can go here */
    instance.clients.forEach(client => {
        client.view.x = client.entity.x
        client.view.y = client.entity.y
    })
    instance.update()
}

This will keep the client's view following their entity. See how handy it was to define client.entity in the connect handler?

If we play our game for a sec (http://localhost:8080) we'll be able to move around and we should never disappear from having been culled from our client's view. HOWEVER the clientside of this game does not have a moving camera, so we can still totally just move off the screen. This is no longer a nengi culling issue, this has more to do with our renderer.

A 2D Camera

We're going to set up a camera in PIXI.js. But before that it is worth mentioning that this notion of a camera applies to most games and most engines and it'll be a bit different in each. In my limited experience in 3D engines (Babylon.js, Three.js) a camera is actually fairly intuitive -- there's just some sort of camera object with a position and an orientation and it is easy to imagine. Cameras in 2D engines can be a bit different. Sometimes a 2D engine has a legitimate camera implementation. In PIXI however, a camera is just a container that moves in the opposite direction than the player.

We also have a whole new issue which is that our game client has no idea which entity is ours. If we connect multiple browsers to the game we can move multiple entities around.. but each client is rendering the same thing with no affinity for one entity or the other. In fact the only relationship between the clients and the PlayerCharacters is in the serverside connect handler.

To solve this issue we must send what I call an Identity message from the server to the client, telling it the id of which entity it controls. From then on the client can follow that entity with a camera. This is a very common need, and I repeat this pattern in almost every game.

Create a new file in the common directory called Identity.js:

import nengi from 'nengi'

class Identity {
    constructor(entityId) {
        this.entityId = entityId
    }
}
Identity.protocol = {
    entityId: nengi.UInt16
}

export default Identity

And add it to nengiConfig.js as per usual:

import nengi from 'nengi'
import NetLog from './NetLog.js'
import PlayerCharacter from './PlayerCharacter.js'
import PlayerInput from './PlayerInput.js'
import Identity from './Identity.js'

const config = {
    UPDATE_RATE: 20, 

    ID_BINARY_TYPE: nengi.UInt16,
    TYPE_BINARY_TYPE: nengi.UInt8, 

    ID_PROPERTY_NAME: 'nid',
    TYPE_PROPERTY_NAME: 'ntype', 

    USE_HISTORIAN: true,
    HISTORIAN_TICKS: 40,

    protocols: {
        entities: [
            ['PlayerCharacter', PlayerCharacter]
        ],
        localMessages: [],
        messages: [
            ['NetLog', NetLog],
            ['Identity', Identity]
        ],
        commands: [
            ['PlayerInput', PlayerInput]
        ],
        basics: []
    }
}

export default config

So you know how in the server side 'connect' handler we create a PlayerCharacter for that client to control? Well we're going to send it the nid of that entity. It is important to remember that a nid is created for an entity AFTER it is added to an instance. If we tried to access the nid prior to adding it to the instance it would be undefined. Add this to gameServer.js:

import nengi from 'nengi'
import nengiConfig from '../common/nengiConfig.js'
import instanceHookAPI from './instanceHookAPI.js'
import NetLog from '../common/NetLog.js'
import PlayerCharacter from '../common/PlayerCharacter.js'
import Identity from '../common/Identity.js'

const instance = new nengi.Instance(nengiConfig, { port: 8079 })
instanceHookAPI(instance)

/* serverside state here */
const entities = new Map()

instance.on('connect', ({ client, callback }) => {
    /* client init logic & state can go here */
    callback({ accepted: true, text: 'Welcome!' })
    instance.message(new NetLog('hello world'), client)
    const entity = new PlayerCharacter()
    instance.addEntity(entity)
    instance.message(new Identity(entity.nid), client)
    entities.set(entity.nid, entity)
    client.entity = entity
    client.view = {
        x: entity.x,
        y: entity.y,
        halfWidth: 500,
        halfHeight: 500
    }
})

Next in gameClient.js we are going to want to listen for this message.

const state = {
    /* clientside state can go here */
    myId: null,
    myEntity: null
}

/* create hooks for any entity create, delete, and watch properties */
clientHookAPI(client, createHooks(state))

client.on('connected', res => { console.log('connection?:', res) })
client.on('disconnected', () => { console.log('connection closed') })

/* on('message::AnyMessage', msg => { }) */
client.on('message::NetLog', message => {
    console.log(`NetLog: ${ message.text }`)
})

client.on('message::Identity', message => {
    state.myId = message.entityId
})

This establishes myId and myEntity as a null, and sets myId when we receiving the 'Identity' message. The last thing to do is set myEntity in playerHooks.js:

import Player from '../graphics/Player.js'
import renderer from '../graphics/renderer.js'

export default (state) => {
    return {
        create({ data, entity }) {
            const graphics = new Player()
            renderer.middleground.addChild(graphics)
            if (state.myId === entity.nid) {
                state.myEntity = entity
            }
            return graphics
        },
        delete({ nid, graphics }) {
            renderer.middleground.removeChild(graphics)
        },
        watch: {
        }
    }
}

The state object accessible inside of the playerHook.js is the same state object from gameClient.js (you can follow the code if you'd like). So what have we done in total? "We" (nengi instance) created a nid for our entity when it was created, we sent this to the client via an Identity message, upon receiving this message we noted myId, and then finally when the hook fired for creating a PlayerCharacter whose id matched, we stored state.myEntity. That means state.myEnity if not null, is the entity that we are controlling. Is it ever null you may wonder? And the answer is yes -- right as our game client starts up myEntity is null and it'll be a few ticks of the game loop before it is defined, so we can't pretend like it is always around.

And now finally we can follow that entity with our camera! Add the following (gameClient.js):

const update = (delta, tick, now) => {
    client.readNetworkAndEmit()

    /* clientside logic can go here */
    const { up, down, left, right } = frameState
    client.addCommand(new PlayerInput(up, down, left, right, delta))

    if (state.myEntity) {
        renderer.centerCamera(state.myEntity)
    }

    renderer.update(delta)
    client.update()
    releaseKeys()
}

That's it! If you want to see the actual centerCamera code, take a read of the renderer but you'll find there's not much to it.

Open or refresh your browser http://localhost:8080 and the camera will follow you around. Connect multiple browsers and you'll see that each one knows which entity to follow.

Next: ???