Closed 3mcd closed 2 years ago
Leaving some more scratch work here. The general idea is that a system can be invoked within a context, like a predictive context. Most of the Harmony API would be updated to accept an optional context id, changing the final target of that call. Components of a different context could be stored in parallel with the components today. Contexts would just need to track this mapping from the base context schema id to the corresponding schema id within the context.
import * as Rapier from "@dimforge/rapier3d-compat"
import { Command, Context, Effect, Entity, Format, Schema, World } from "harmony-ecs"
const GRAVITY = { x: 0, y: -9.81, z: 0 }
const RigidBody = Schema.define()
const PlayerInput = Schema.defineBinary(Format.uint8)
const Players = [RigidBody] as const
enum PlayerInputIntent {
Jump = 1 << 0
}
enum MyCommand {
Spawn = Command.define({ entity: Format.entity }),
Input = Command.define({ entity: Format.entity, input: Format.uint8 })
}
enum MyEffect {
Physics = Effect.defineRef(() => new Rapier.World(GRAVITY))
}
enum MyContext {
Predicted
}
function simulate(ecs: World.World, ctx: Context.Id) {
const physics = Effect.call(ecs, MyEffect.Physics, ctx)
const players = Effect.call(ecs, Effect.Query, ctx)(Players)
for (const spawn of Command.drain(ecs, MyCommand.Spawn, ctx)) {
const actor = makePlayerActor()
Entity.set(ecs, input.entity, [RigidBody], [actor], ctx)
simulation.add(actor)
}
for (const input of Command.drain(ecs, MyCommand.Input, ctx)) {
Entity.set(ecs, input.entity, [PlayerInput], [input], ctx)
}
for (const [e, [b, i]] of players) {
for (let _ = 0; _ < e.length; _++) {
const body = b[_] as Rapier.RigidBody
const input = i[_]
if ((input | PlayerInputIntent.Jump) === PlayerInputIntent.Jump) {
body.applyForce(/* ... */)
}
}
}
}
const ecs = World.make(1e5)
const pipeline = Pipeline.make(
System.make(simulate),
System.make(simulate, MyContext.Predicted)
)
We could then target one system vs the other with a server snapshot etc.
Better yet, systems could be passed a World
wrapped with the present context id, i.e. a tuple of (World, Context.Id)
, removing the need for the API to accept the optional context parameter:
// `ecs` is actually (World.World, Context.Id)
function simulate(ecs: World.World) {
for (const spawn of Command.drain(ecs, Commands.Spawn)) {
// ...
}
}
Overview
I recently helped port the fantastic prediction/reconciliation algorithm CrystalOrb from Rust to JavaScript. The JS port works very well (see a very rough demo here) but CrystalOrb's current design (see this issue) works best when most game logic is located in the
World.step
function. Furthermore, the CrystalOrb client maintains two separate (but complete) simulations, without exposing one as the source of truth for client-only physics (e.g. particle physics).This design makes it a bit tricky to implement ECS alongside CrystalOrb, since ECS logic is traditionally implemented as small, pure-ish functions (in that they usually only modify a small subset of entities and components), and writing non-predictive code is a bit difficult without implementing an entire command/snapshot/interpolate workflow, or writing similar command logic around the CrystalOrb "protocol".
It would be awesome if Harmony (and eventually Javelin) had support for this kind of predictive algorithm out-of-the-box. I'm dubbing this "Predictive Systems" in the meantime. Of course, currently Harmony doesn't have a concept of systems at all (only entities, components, and queries), but new structures could be introduced with this proposal which cover other best practices for system design and organization outside of netcode.
Proposal
Out-of-scope
In-scope
CrystalOrb does a few things that we can learn from:
Things we'll want to add
New structures and functions to consider
API Sample