nengi Game pt 1: space game, graphics

Previous: nengi Basics 4: View Culling & Cameras

We've now covered the most essential parts of nengi: entities, messages/commands, and views. We've also laid the foundation for a multiplayer game. Before we move on to advanced network programming, let's really learn what we can do with these.

We'll be making a small spaceship game, but as we do, I'll be discussing how to make games with nengi in general. Specifically, we'll approach the following topics:

I'm sure you've got a game idea or twenty in mind. So while we are going to make a specific game, I'll try and generally convey the ideas behind this process.

Please checkout: git pull and git checkout game-1.

I've added some important files to the project -- so you'll want to make sure you've pulled and checked out the code instead of just continuing from the previous tutorial.

Start with npm start

Adding Graphics to Player

Open up Player.js and replace our player's circlce graphics with our new ship sprite:

import { Container, Sprite } from 'pixi.js'

class Player extends Container {
    constructor() {
        super()

        const sprite = Sprite.from('/images/ship.png')
        sprite.scale.set(3, 3)
        sprite.anchor.set(0.5, 0.5)
        this.addChild(sprite)
    }
}

export default Player

Pixi's scale property affects the size of the display object. The anchor affects how the object is aligned with 0,0 as top left corner, and 0.5, 0.5 as the center. Altogether our code says to render ship.png, (orignally 16x16 pixels) at triple size and centered. By default this will be blurry, but we can configure pixi for pixel art by changing the global scale mode (research "pixel art in pixi" if you want to know more about this topic).

Add the following code to the top of renderer.js to enable pixel art aka "nearest neighbor scaling":

import { autoDetectRenderer, Container, settings, SCALE_MODES } from 'pixi.js'
import BackgroundGrid from './BackgroundGrid.js'

settings.SCALE_MODE = SCALE_MODES.NEAREST

After adding the above code, refresh the page and you should see a cute little pixel art ship instead of a circle.

Adding and Networking Rotation

For this game we're going to have our ship always aiming at our mouse cursor. This isn't like the classic and often immitated Asteroids (1979) on Atari. This type of rotation is much more like the aiming you'll find in a lot of io games. Let's think for a moment about what this will entail:

So it would be correct to think that there may be more than one way to do this. But anytime we're thinking about user input and whether a piece of state should be calculated on the server or the client, we need to ask ourselves if we are piercing the authoritative server model. If we calculate the rotation that our ship faces on the client, and send it to the server, are we creating an exploitable vulnerability? The answer is it depends on our game rules. Are spaceships in our game allowed to face ANY direction? Are they allowed to turn around INSTANTENOUSLY? If the answer to these questions is yes, then any possible Number is a valid value at any time for our ship's rotation and thus the client choosing the value is not going to fundamentally subvert our game rules. However if our game was specifically about having a limited turning speed, then it would be inappropriate to allow our serverside code to simply accept a rotation sent to it from the client.

So what's the answer for *this* game? Well, as game designer of this here game, I've decided that the spaceships are allowed to rotate instantly. But if I were to change my mind later I'll send my relative cursor position to the server and let the server calculate the rotation to prevent cheating.

So that's enough discussion for now. Let's add rotation to the game.

In PlayerInput.js:

import nengi from 'nengi'

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

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

export default PlayerInput

That's rotation added to our command.

In PlayerCharacter.js:

class PlayerCharacter {
    constructor() {
        this.x = 50
        this.y = 50
        this.rotation = 0
    }
}

PlayerCharacter.protocol = {
    x: { type: nengi.Number, interp: true },
    y: { type: nengi.Number, interp: true },
    rotation: { type: nengi.RotationFloat32, interp: true }
}

The gives PlayerCharacter a networked rotation property. RotationFloat32 is floating point number that with special interpolation rules that makes for nice and smooth rotations (research: 'rotation interpolation' if you want to know why this needs to exist, or just let nengi handle it).

In gameClient.js:

import { frameState, releaseKeys, currentState } from './input.js'
const update = (delta, tick, now) => {
    client.readNetworkAndEmit()

    /* clientside logic can go here */
    if (state.myEntity) {
        const { up, down, left, right } = frameState
        const { mouseX, mouseY } = currentState
        const worldCoords = renderer.toWorldCoordinates(mouseX, mouseY)
        const dx = worldCoords.x - state.myEntity.x
        const dy = worldCoords.y - state.myEntity.y
        const rotation = Math.atan2(dy, dx)
        client.addCommand(new PlayerInput(up, down, left, right, rotation, delta))
        renderer.centerCamera(state.myEntity)
    }

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

Here we've done the math for the rotation, our good friend atan2 to the rescue! Please note that the highlighted lines are not all new, but they're a mixture of the new math and also moving some of the pre-existing code to be within the if-block. This logic has been moved here because we cannot calculate rotation without state.myEntity. A note here: currentState comes from the input system, so it is the current state of the mouse, and I feel this is a confusing variable name given that we also have another variable called state.

In gameServer.js:

instance.on('command::PlayerInput', ({ command, client }) => {
    const { up, down, left, right, rotation, 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
    }
    entity.rotation = rotation
})

Here we accept the rotation from the client's command and apply it to the PlayerCharacter entity controlled by this client. Normally we would think about validating this input, after all it very well could be fraudulent. This is totally something that we should do, but that will be saved for a later tutorial.

If we refresh our game we'll find that it nearly working with the exception being that our ship is always sideways. Specifically our ship is off by 90 degrees. What solves this? Well we could get our artist (me 😢) to draw our ship sprites facing sideways instead of up, but I just spoke with the artist and he refused, so we'll have to do it in code.

class Player extends Container {
    constructor() {
        super()

        const sprite = Sprite.from('/images/ship.png')
        sprite.scale.set(3, 3)
        sprite.anchor.set(0.5, 0.5)
        sprite.rotation = 0.5 * Math.PI
        this.addChild(sprite)
    }
}

Now our ship will aim at our cursor! Math.PI * 1/2 is 90 degrees btw; if you use radians a lot you just start to think things like "2 pi is a circle, 1 pi is half a circle, 1/2 pie is a quarter circle, etc".

You may be wondering how it is that offseting our sprite's rotation by a quarter circle could possibly work given that the rotation for the ship is frequently changing. Shouldn't the rotation value coming over the network just overwrite our 0.5 * Math.PI and put our ship back to being sideways? But this does not occur, and the reason is that we are not rotating the ship sprite -- we are rotating Player, which is its parent. So Player's rotation is a dynamic value as we play, and the ship sprite's local rotation will always be the same. This is a common feature of parent-child relationships in rendering frameworks. We could use it further by adding thrusters, damage decals, and modded weapons all onto the ship as children -- they would all use the position, rotation, and scale of their parent, which is the mathy way of saying they would look like they were attached to the ship.

In summary, we

Next we'll make some asteroids, giving us something to avoid or attack.

Next: nengi Game pt 2: asteroids