nengi.Client prediction

On the clientside, one can take same command that was destined for the server and execute it locally in the game client. Via the nengi prediction api, this local state change can be registered as a prediction:

// in addition to sending `command` to the server
const entity = myLocalEntity
processInput(command, entity)
const prediction = {
    nid: entity.nid,
    x: entity.x,
    y: entity.y
}
client.addCustomPrediction(client.tick, prediction, ['x', 'y'])

Here `processInput` represents your game logic that moves or otherwise changes the state of an entity. Because we are running it on the client, it takes effect immediately as if we are playing with zero latency.

The prediction system now knows we've predicted that on `client.tick` our entity `x` and `y` are certain values. Because nengi deterministically tracks state per-frame on both the client and server it can tell us if the server ended up moving our entity to the same position that we had predicted.

If our locally predicted state resulting from our command(s) ends up being the same as what occurred on the server, then we are good to go and the prediction api will stay silent.

However it turns out that predicted state does not match what actually occurred on the server, then a `predictionError` will be added to the network snapshot:

const network = client.readNetwork()
/* ... entities, message, etc ... */
network.predictionErrors.forEach(predictionErrorFrame => {
    // uh oh, now we have to reconcile this prediction error
})

The `predictionErrorFrame` object is created to help reconcile the state of your client entities with their authoritative state. It contains all entities that had a prediction error and all properties on those entities that need to be corrected. It also contains the exact values that would reconcile any discrepency. In practice it is rare to predict the states of multiple entities, but that is supported if needed.

The correct way to reconcile a prediction error is to

  1. move the entity to the authoritative position (aka actualValue)
  2. re-apply any commands that have yet to be reflected in the server state
  3. overwrite any pending predictions

predictionErrorFrame.entities.forEach(predictionErrorEntity => {
    // get our clientside entity
    const entity = localEntity.get(predictionErrorEntity.id)

    // correct any prediction errors with server values...
    predictionErrorEntity.errors.forEach(predictionError => {
        entity[predictionError.prop] = predictionError.actualValue
    })

    // and then re-apply any commands issued since the frame that had the prediction error
    const commandSets = client.getUnconfirmedCommands() // client knows which commands need redone
    commandSets.forEach((commandSet, clientTick) => {
        commandSet.forEach(command => {
            // example assumes 'PlayerInput' is the command we are predicting
            if (command.protocol.name === 'PlayerInput') {
                processInput(command, entity)
                const prediction = {
                    nid: entity.nid,
                    x: entity.x,
                    y: entity.y
                }
                client.addCustomPrediction(clientTick, prediction, ['x', 'y'])) // overwrite
            }
        })
    })
})

The above code with very minor changes can perform prediction error reconcilation for a variety of commands and entity types.

You may be wondering why nengi doesn't just automagically reconcile prediction errors. The nengi philsophy is to keep as much control as possible in the hands of the game developer. Mutating the state of game objects from within nengi's core is a big no-no in my book. Nengi will say how to synchronize state, but the actual changing of state is the game's purview.

Also, on a more practical level, prediction reconciliation results in some form of rubberbanding. In order to smoothly conceal prediction errors the game client often needs to create two local copies of the player's entity (or at least the player's position)-- one of which moves smoothly and the other of which is immediately reconciled after any error. I consider this job to be game-related behavior, so I'm comfortable writing this code into a nengi template game, but not into nengi's core.

Advanced Usage

Additional data can be attached to prediction frames, and that data becomes accessible again later when performing reconciliation. This is a deterministic physics topic which I unfortunately cannot explain in a reasonably brief manner. Suffice to say, if your game uses velocity and acceleration, but only networks position -- then there is not technically enough data to deterministically rewind or fast-forward the state of an entity from x,y alone. Velocity (and possibly other state) is also needed to keep the simulation deterministic.

// in addition to sending `command` to the server
const entity = myLocalEntity
processInput(command, entity)
const prediction = {
    nid: entity.nid,
    x: entity.x,
    y: entity.y
    velocity: {
        x: entity.velocity.x,
        y: entity.velocity.y
    }
}
client.addCustomPrediction(client.tick, prediction, ['x', 'y'])

The `prediction` object above contains the actual state that the prediction api will store as a record of our entity as of this tick. In this case I've added the entity's velocity to this record as additional state even though the predicted properties are only ['x', 'y']. If we encounter a prediction error of x or y, we will have access to the velocity that the object was traveling even though the velocity is not a networked property.

// a prediction was incorrect, time to reconcile it
predictionErrorFrame.entities.forEach(predictionErrorEntity => {
    // get our clientside entity
    const entity = localEntities.get(predictionErrorEntity.id)

    // revert its relevant state to the state from the prediction frame
    entity.x = predictionErrorEntity.proxy.x
    entity.y = predictionErrorEntity.proxy.y
    entity.velocity.x = predictionErrorEntity.proxy.velocity.x
    entity.velocity.y = predictionErrorEntity.proxy.velocity.y

    // correct any prediction errors with server values...
    predictionErrorEntity.errors.forEach(predictionError => {
        entity[predictionError.prop] = predictionError.actualValue
    })

    // and then re-apply any commands issued since the frame that had the prediction error
    const commandSets = client.getUnconfirmedCommands()
    commandSets.forEach((commandSet, clientTick) => {
        commandSet.forEach(command => {
            if (command.protocol.name === 'PlayerInput') {
                processInput(command, entity)
                const prediction = {
                    nid: entity.nid,
                    x: entity.x,
                    y: entity.y
                    velocity: {
                        x: entity.velocity.x,
                        y: entity.velocity.y
                    }
                }
                client.addCustomPrediction(client.tick, prediction, ['x', 'y'])
            }
        })
    })
})

In the above code `proxy` contains the state we recorded at the time of prediction.