nengi Basics 3: Commands & Movement

Previous: nengi Basics 2: Entities

In this tutorial we will send commands from the client and move our character on the server. In the most minimal sense we will have a multiplayer game by the end of this section (more *multiplayer* than *game* I suppose).

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

Start with npm start

There's not a whole lot to say about commands with respects to nengi. They're the same as messages -- just keys and values. What's important about commands is how we use them. Commands only go from the client to the server and that's pretty much the only special thing to note... that we bothered to use a whole different term just for this. Afterall, we're following an authoritative server model, so it is good to have a bit of formality regarding which pieces of state originate from a potentially fraudulent game client. All clients -- by the way -- should be considered potentially fraudulent. That means that all commands should be validated by our server logic before we let them affect the game world.

Defining a Command

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

import nengi from 'nengi'

class PlayerInput {
    constructor(up, down, left, right, delta) {
        this.up = up
        this.down = down
        this.left = left
        this.right = right
        this.delta = delta
    }
}

PlayerInput.protocol = {
    up: nengi.Boolean,
    down: nengi.Boolean,
    left: nengi.Boolean,
    right: nengi.Boolean,
    delta: nengi.Number
}

export default PlayerInput

And add it to nengiConfig.js as per usual:

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

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]
        ],
        commands: [
            ['PlayerInput', PlayerInput]
        ],
        basics: []
    }
}

export default config

Now I think we can all see where im going with up, down, left, and right but what is this delta? Apologies for the abbreviation, but this delta is none other than deltaTime, a common variable in game loops denoting the length of the frame, usually in seconds or milliseconds. Why do we need that? Let's explore.

deltaTime & Framerate Indepenence

What would happen if we removed delta and just used up, down, left, and right to move our character? Each frame we could see if up, down, left, and right were true and move in the corresponding direction by 5 pixels. That would produce movement, however the movement would be different depending on our framerate.

let x = 0
for (let i = 0; i < 30; i++) {
    x += 5 // 30x
}
console.log(x) // 150

/* versus */
let x = 0
for (let i = 0; i < 60; i++) {
    x += 5 // 60x
}
console.log(x) // 300

Now let's do 1 second of input at two different framerates with our pal deltaTime.

const deltaTime = 0.33333333
let x = 0
for (let i = 0; i < 30; i++) {
    x += 100 * deltaTime // 30x
}
console.log(x) // 100

/* versus */
const deltaTime = 0.016666667
let x = 0
for (let i = 0; i < 60; i++) {
    x += 100 * deltaTime // 60x
}
console.log(x) // 100

How magical! Now two computers with two different framerates can move an object at the same speed despite their computational differences. If that seems unintuitive try thinking of it this way: 50 full steps is the same distance as 100 half steps -- sure one scenario has more steps, but they're smaller steps. That's what deltaTime as a coefficient is expressing. Two other wonderful things are also worth mentioning about deltaTime. A) the player would still move the same speed even if the framerate were erratic (!!) and B) using a deltaTime in units of seconds produces human-expressable numbers, e.g. the code above describes an object that moves 100 units per second, which almost sounds like other human numbers such as "miles per hour."

There is a lot more that could be said about the benefits of expressing game input in terms of action + unit of time. DeltaTime isn't the only option, as saving an 'impulse duration' for any given action gets us to a very similar end result. We'll visit some of the more advanced benefits of thinking about time in this manner when we get into lag compensation and determinism in a later tutorial. For now, onwards with making our controllable entity!

Sending the Command

The game template itself already calculates deltaTime (hereforth known only as "delta") as part of the loop. The game template also includes a provisional input library which is ready to go. Take a skim of input.js in the client folder. This input system by default translates W A S D and the arrowkeys into up, down, left, right. Here's how we use it. Edit gameClient.js and add the following code:

import nengi from 'nengi'
import nengiConfig from '../common/nengiConfig.js'
import clientHookAPI from './clientHookAPI.js'
import createHooks from './hooks/createHooks.js'
import renderer from './graphics/renderer'
import { frameState, releaseKeys } from './input'
import PlayerInput from '../common/PlayerInput'

const client = new nengi.Client(nengiConfig, 100)

const state = {
    /* clientside state can go here */
}

/* 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.connect('ws://localhost:8079')

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))

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

export {
    update,
    state
}

Keep in mind there were a total of 5 lines of code added. We import the command/input related code, and then every frame we create a command that contains the player input. client.addCommand is what queues the command to be send to the server. When we invoke client.update() any commands in the queue get flushed to the server. This is a common pattern for nengi games -- add commands in the body of the update function, and then send then all off at the end of the update function. This game only has one command type at the moment.

Receiving the Command

In the previous tutorial we left off with some server code that just had all player characters slowly drifting towards the right. It has come time to get rid this, and move based on our command instead. Open up gameServer.js and add the following:

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'

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)
    entities.set(entity.nid, entity)
    client.entity = entity
})

instance.on('disconnect', client => {
    entities.delete(client.entity.nid)
    instance.removeEntity(client.entity)
})

/* on('command::AnyCommand', ({ command, client }) => { }) */
instance.on('command::PlayerInput', ({ command, client }) => {
    const { up, down, left, right, delta } = command
    const { entity } = client
    const speed = 200
    if (up) {
        entity.y -= speed * delta
    }
    if (down) {
        entity.y += speed * delta
    }
    if (left) {
        entity.x -= speed * delta
    }
    if (right) {
        entity.x += speed * delta
    }
})

const update = (delta, tick, now) => {
    instance.emitCommands()
    /* serverside logic can go here */
    //entities.forEach(entity => {
        // entity.x += 1
    //})
    instance.update()
}

export {
    update
}

We've added the handler for 'command::PlayerInput' and just written our movement logic directly into it. It uses some pretty destructuring syntax, but asides from that it is pretty plain. I also commented out the old code (lines 50-52) that used to make our entity move on its own -- feel free to delete these lines.

Open or refresh your browser http://localhost:8080 and you should be able to control the circle with W A S D or the arrow keys. One can even open two browsers and move two characters around.

One bug remains however, which is that if we move too far down or to the right our circle disappears. Why does this happen? It's the nengi view culler! We'll learn all about this powerful feature and even add a camera that follows our player around in the next tutorial.

Next: nengi Basics 4: View Culling & Cameras