nengi Game pt 3: asteroid motion, feature architecture

Previous: nengi Game pt 2: asteroids

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

Start with npm start

In the previous tutorial we setup a barebone asteroid entity. In this tutorial we're going to bring it to life so that it can move around and collide with other objects.

In an authoritative server modeled game, the vast majority of our game logic is serverside. Entities, objects, etc all live primarily on the server, and the client merely sees them as if looking through a window. So when we write logic that moves and collides our asteroid, we do so on the server.

At a high level a game server's functionality is defined by a few major events:

Our asteroid feature consists of populating the game world with randomly moving asteroids that can collide with the player. How does this tie in with the above?

All features can be thought of in this manner; what should your system do in the following situations: start/stop, connect/disconnect, command received, and over time? Most features only interact with a few of these events.

Asteroid System

We'll be creating an chunk of code hereforth known as the asteroidSystem. The asteroid system will fill the game world with asteroids, keep the asteroids in motion, and make collision checks. It'll also serve as our first example of having a bit of architecture/engineering in place other than writing our features plainly into gameServer.js as we've been doing so far.

In the server folder create asteroidSystem.js:

import Asteroid from '../common/Asteroid.js'

const asteroids = new Map()

const spawnAsteroid = (instance) => {
    const asteroid = new Asteroid()
    asteroid.x = Math.random() * 1000
    asteroid.y = Math.random() * 1000
    instance.addEntity(asteroid)
    asteroids.set(asteroid.nid, asteroid)
}

const populate = (instance, howManyAsteroids) => {
    for (let i = 0; i < howManyAsteroids; i++) {
        spawnAsteroid(instance)
    }
}

const update = (delta) => {}

export default {
    populate,
    update
}

Our asteroidSystem maintains its own collection of asteroids in a Map on line 3. It exposes populate which will spawn a variable number of asteroids. It also exposes an update function that doesn't do anything yet. The only interesting logic is inside of spawnAsteroid. This creates instances of our Asteroid entity, randomizes their position, adds them to the nengi instance, and then stores a reference to them in asteroids using the asteroid.nid as the key. Is it appropriate to use a nengi id (nid) in this manner?

The rules for nid are as follows. When an entity is added to an instance, that instance assigns it a nid. This happens to be an integer between 1 and 65535. When the entity is removed from the instance, the nid is no longer valid. It is very common for me to use code that lets nengi assign an nid to entities, and then to use those nids as a unique identifier, so long as I'm working with objects that are always networked.

Now let's get our game server to populate the game with asteroids.

Add our new entity type to nengiConfig.js:

import Identity from '../common/Identity.js'
import asteroidSystem from './asteroidSystem.js'

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

/* serverside state here */
const entities = new Map()
asteroidSystem.populate(instance, 10)

instance.on('connect', ({ client, callback }) => {

Look in your browser and you should see something like this:

The results will vary between my screenshot and your browser, and these results will vary every time the server restarts because the asteroids are being given random positions.

Moving Asteroids

Getting these asteroids to move involves three steps

  1. assigning an initial direction of travel to each asteroid
  2. writing the math that moves an asteroid each frame
  3. tying our logic to the game loop

Add a velocity vector to Asteroid.js:

class Asteroid {
    constructor() {
        this.x = 150
        this.y = 150
        this.rotation = 0
        this.velocity = {
            x: 0,
            y: 0
        }
    }
}

And then set some values in asteroidSystem.js:

const randomWithinRange = (min, max) => {
    return Math.random() * (max - min) + min
}

const asteroids = new Map()

const spawnAsteroid = (instance) => {
    const asteroid = new Asteroid()
    asteroid.x = Math.random() * 1000
    asteroid.y = Math.random() * 1000
    asteroid.velocity.x = randomWithinRange(-5, 5)
    asteroid.velocity.y = randomWithinRange(-5, 5)
    instance.addEntity(asteroid)
    asteroids.set(asteroid.nid, asteroid)
}

And then the movement code, also in asteroidSystem.js:

const update = (delta) => {
    asteroids.forEach(asteroid => {
        asteroid.x += asteroid.velocity.x * delta
        asteroid.y += asteroid.velocity.y * delta
    })
}

Finally, we invoke asteroidSystem's update function from our game loop in gameServer.js:

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
    })
    asteroidSystem.update(delta)
    instance.update()
}

The game should now have asteroids in motion.

We've done some interesting things here both with respects to begining to add a real feature and keeping it relatively tidy. Our asteroid-related implementation is mostly encapsulated. Interally it expects to be given a nengi instance so that the asteroids can be networked, a number of asteroids to spawn initially, and it also relies on having its update invoked by the game loop. This is a fairly standard amount of coupling between game systems and rest of the game - something initializes the system, and then something invokes the system's logic each frame. Could it be better? Maybe a little. Could it be worse? Yes, much worse.

As we add new features or work on our game's architecture we should aim to encapsulate our logic at least as much as we've done here. That means while we prototype a feature, sure -- we're allowed to just invent a new type of entity or message and hack in some logic right into gameServer.js. But that's the prototype of our feature, and we should not consider it mature. The mature version has had some thought put into as to the lifecycle of its state (when does it start? when does it end?) and which of its functionality will be invoked by other parts of the game (which functions or objects does other code use?).

In the next tutorial we're going to make these asteroids collidable. We'll be faced with a real threat to separation and organization of our code as we consider a feature that by its very nature entails the interaction between separate components. At some point this game will have projectiles and a collectable resource and the potential for anything to collide with anything.

(coming in the future: pt 4, colisions)