Giter Club home page Giter Club logo

snowglobe's Introduction

Snowglobe ☃️

An experimental TypeScript port of the Rust multiplayer game networking library CrystalOrb by Ernest Wong.

Snowglobe is under active development and will break often. The API/implementation will eventually diverge from CrystalOrb due to particular characteristics of JavaScript, but for now it remains virtually 1:1.

Snowglobe demo

Install

Install snowglobe using NPM:

npm i @hastearcade/snowglobe

About

Snowglobe is an orchestrator designed to solve state syncronization between a game server and a set of game clients. In a perfect network, maintaining the state of your game world between a set of parties would simply be a matter of applying updates to the game world to all parties simultaneously, but we do not live in a perfect world.

The networking and physics libraries required to build a game or simulation are outside the scope of Snowglobe. Snowglobe will orchestrate the game sync algorithm, but to build a full game the developer will need to utilize networking libraries like Geckos.io and physics libraries like Rapier.

The problem becomes non-trivial when layering in latency, bad actors, and disconnects. Glenn Fiedler describes the problems and solutions here.

Snowglobe assists in solving the difficulty of networking physics for Typescript based games or simulations by implementing Client-side prediction, Server reconciliation, and Display State interpolation. Ernest Wong and the Crystal Orb team did an excellent job describing the solution as:

  • Client-side prediction. Clients immediately apply their local input to their simulation before waiting for the server, so that the player's inputs feel responsive.
  • Server reconciliation. The Server runs a delayed, authoritative version of the simulation, and periodically sends authoritative snapshots to each client. Since the server's snapshots represent an earlier simulation frame, each client fast-forwards the snapshot they receive until it matches the same timestamp as what's being shown on screen. Once the timestamps match, clients smoothly blend their states into the snapshot states.
  • Display state interpolation. The simulation can run at a different time-step from the render framerate, and the client will automatically interpolate between the two simulation frames to get the render display state.

Quick Start

Below is a (non-comprehensive) sample of the ported CrystalOrb API. This example is missing critical features like a game loop, networking, serialization, etc.

See the Code Architecture section below for a more detailed description of the Snowglobe types and integration points.

import * as Snowglobe from 'snowglobe'

type MyCommand = Snowglobe.Command & { kind: 'jump' }

type MySnapshot = Snowglobe.Snapshot & {
  position: number
  velocity: number
}

type MyDisplayState = Snowglobe.DisplayState & {
  position: number
  velocity: number
}

function interpolate(state1: MyDisplayState, state2: MyDisplayState, t: number) {
  return {
    // simple lerp function
    position: (1 - t) * state1.position + t * state2.position,
    velocity: (1 - t) * state1.velocity + t * state2.velocity
  }
}

class Net implements Snowglobe.NetworkResource {
  // implement methods like send(), connections(), etc.
}

class World implements Snowglobe.World {
  // implement step(), applyCommand(), applySnapshot(), etc.
}

const config: Snowglobe.Config = Snowglobe.makeConfig()
const makeWorld = () => new World()
const client = new Client(makeWorld, config, interpolate)
const server = new Server(makeWorld(), config, 0)

Examples

npm run example:standalone # starter example
npm run example:demo # mock client/server demo

Code Architecture

The following diagram shows a visual representation of the code architecture. The diagram is meant to be understood by reading alongside examples/standalone.ts.

Snowglobe Diagram

The typical game or simulation will have a game loop that runs at 60 fps. Snowglobe is responsible for facilitating client side prediction, server reconciliation, and display state interpolation. This helps solve some of the challenges with networked games and physics. Snowglobe provides the following types to be inherited from in order to utilize Snowglobe's toolset.

World: All games start with a representation of a World. To utilize Snowglobe you will need to inherit from Snowglobe.World and implement the step, commandIsValid, applyCommand, applySnapshot, snapshot, and displayState functionality. Please send the standalone implementation of MyWorld for more details.

DisplayState: While every game has a World, not every game needs to display the entire world to the player at one time. You might have many objects that need to be culled from the scene. Thus, Snowglobe assumes there is an abstraction of the rendered state called DisplayState. As a developer, you must create a custom MyDisplayState class that inherits from Snowglobe.DisplayState. This display state should represent the minimal amount of state necessary to render to the screen.

Snapshot: Snapshots are points in time pictures of a world. All data is frozen and utilized by the Snowglobe system to interpolate state changes between the Client and Server. A snapshot is the minimal data object representing the entire physics simulation. The goal should be to keep the size of your snapshots as small as possible to reduce the load on the network. MySnapshot could have the same properties as MyWorld, but it is unlikely in a real world example.

Command: A Snowglobe command represents a player or server issuing an instruction to the game world. This could be moving left or spawning new NPCS. The game world will need to have the command processed which will result in a change of world state. This world state change will then need to be syncronized amongst the other clients. The developer will need to implement a MyCommand type that inherits from Command. This type should have all available commands that can be used by the system. In addition, the commandIsValid function of MyWorld will need to ensure that any command sent to the world is valid. For example, there may be certain commands that can only be processed by the server but not the Client

Once you have implemented MyWorld, MyDisplayState, MySnapshot, and MyCommand then Snowglobe will need to be incorporated into your game loop.

The game loop for standalone.ts has more code to facilitate the mock network needed than the actual Snowglobe integration. The primary integrations for Snowglobe are as follows:

  1. Create a Snowglobe Server or Client: const client1 = new Snowglobe.Client(makeWorld, config, interpolate)
  2. Issue commands:
client1Stage.ready.issueCommand(
  {
    kind: 'accelerate',
    clone() {
      return { ...this }
    }
  },
  client1Net
)
  1. Retrieve display state: client2DisplayState = client2Stage.ready.displayState()
  2. Update Client/Server: client1.update(deltaSeconds, secondsSinceStartup, client1Net)

TODO

  1. Make lag compensation optional
  2. Improve ownership management of entities and make it easier on the consumer of the library
  3. Document each setting in makeconfig so consumers know what impact it has
  4. Refactor Timestamps back to use Symbols, but make sure they work over real network/there is documentation
  5. Move geckos network into snowglobe as a plugin
  6. Update README with new lag compensation changes
  7. Fix build process to work with next/auto deploy with semantic versioning

snowglobe's People

Contributors

3mcd avatar foundrium avatar rallieon avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

Forkers

typeofalex

snowglobe's Issues

`Stage` and `ActiveClient` ergonomics

Getting the ActiveClient instance from a client is a little hairy and error-prone. You have to do stuff like:

if (client.state() === StageState.Ready) {
  client.stage().ready!...
}

Where we have to manually assert the presence of the ready property. We could take advantage of a union type/type narrowing to make this more intuitive and "safer".

Furthermore, getting deeply nested properties off of ActiveClient and company can be really nasty, e.g.

mockClientServer.client1.stage().ready!.timekeepingSimulations.stepper
          .lastReceivedSnapshotTimestamp!

By the looks of it, we didn't port over a few methods that would make this a bit cleaner, like this one from ready.rs:

    pub fn last_received_snapshot_timestamp(&self) -> &Option<Timestamp> {
        &self
            .0
            .borrow()
            .timekeeping_simulations
            .last_received_snapshot_timestamp
    }

The equivalent in snowglobe would let us rewrite the above TypeScript snippet as:

mockClientServer.client1.stage().ready!.lastReceivedSnapshotTimestamp()

Evaluate `Cloneable` trait

  • Can we remove the concept of Cloneable all-together by enforcing snapshots and commands be JSON-serializable?
  • Can we eliminate any existing clones to reduce memory usage/GC events.

Evaluate `Timestamped` decisions

I made a impulse decision to make Timestamped entities not "wrapped" like in CrystalOrb. Instead, snowglobe mutates the original object by adding a property with a Symbol key ($timestamp`). At the time, this seemed like it'd be more memory-efficient but may not be ideal.

`comparableRangeWithMidpoint` performance

This function originally tanked performance when we were still using objects for timestamps. I think it's still pretty slow. We should take a look at how it's used to see if we can replace it with a simple comparison, or consider memoizing the result to prevent excess memory allocations and CPU usage.

Performance profling

Do some surface-level performance profiling to get a sense for how many concurrent clients snowglobe can support in a medium-sized simulation.

OOP best practices

Do a pass over our modules to ensure we're using access modifiers where necessary, using member initialization patterns consistently, naming variables consistently, etc.

Evaluate message types

CrystalOrb is able to generate unique type identifiers for network messages (clock-sync events, snapshots, commands) using some kind of reflection/metaprogramming feature that we don't have in TypeScript. I quickly decided to categorize message types using integer ids using a function called makeTypeId.

  • We should make sure this is the right way to go about categorizing message, and if so
  • We should expose these type ids to consumers, maybe as an enum.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.