A high-performance low-level state management system for games 🎮.
Generate monomorphic, proxy-like shapes. Keeps the inline cache hot and performant while preserving the ergonomics of property access, dirty tracking, and change notification callbacks.
- Change/dirty tracking
- Creating and applying diffs
- Full (de)serialization to/from JSON
- Fixed-size contiguous binary representation for efficient state transformations using
TypedArrayor even WASM.
| Type name | Width |
|---|---|
| boolean | 1 |
| float32 | 4 |
| float64 | 8 |
| int16 | 2 |
| int32 | 4 |
| int64 | 8 |
| int8 | 1 |
| string | 0☨ |
| uint16 | 2 |
| uint32 | 4 |
| uint64 | 8 |
| uint8 | 1 |
| varint | 0☨ |
| varuint | 0☨ |
☨: 0-width types have dynamic size and so can't be mapped to a contiguous fixed-size memory buffer.
Proxy types aggregate primitive and/or proxy types into higher-level structures.
All proxy type builders accept a configuration object as the first parameter:
Requires an element key to define the structure of the array elements. Encodes a 32-bit prefix followed by the contents of the array.
Accepts options for JSON.stringify:
and JSON.parse:
Requires a key and value key to define the structure of the map.
The configuration object consists of the Properteas of the object.
Given an object schema for a 2D position with x and y coordinates, we can create a pool of positions and allocate a couple:
import { float32, object, Pool } from 'propertea'
// create
const positionPool = new Pool(object({
x: float32(),
y: float32(),
}))
// allocated with defaults
const first = positionPool.allocate()
expect(first.x).to.equal(0)
expect(first.y).to.equal(0)
// allocated with explicit value
const second = positionPool.allocate({ x: 100, y: 200 })
expect(second.x).to.equal(100)
expect(second.y).to.equal(200)Since float32 is a fixed-width type, the state is mapped to contiguous memory:
const view = new Float32Array(positionPool.data.memory.buffer)
// equivalent!
expect(view[0]).to.equal(first.x)
expect(view[1]).to.equal(first.y)
expect(view[2]).to.equal(second.x)
expect(view[3]).to.equal(second.y)State synchronization is bidirectional; updating the proxy updates the buffer and vice-versa:
// proxy -> buffer
second.x = 123
expect(view[2]).to.equal(123)
// buffer -> proxy
view[3] = 234
expect(second.y).to.equal(234)Note: Setting the memory directly will bypass the automatic dirty tracking and change notification. However, this means it's really fast! You'll need to manage those yourself when you need them, though.
Not all types are fixed-size. A familiar example would be a string.
Dynamic-sized types aren't mapped to contiguous memory. Their data lives in JS properties.
import { object, Pool, string, uint32 } from 'propertea'
// create
const userPool = new Pool(object({
age: uint32(),
name: string(),
}))
const user = userPool.allocate()
expect(user.age).to.equal(0)
expect(user.name).to.equal('')
// same structure; empty buffer
expect(userPool.data.memory.buffer.byteLength).to.equal(0)Notice that even though age is a uint32 (a fixed-size type), userPool becomes dynamic-sized when any type contained within is dynamic-sized.
Changes are tracked. Let's continue with our position pool from above:
import { Diff } from 'propertea'
const another = positionPool.allocate()
expect(another[Diff]()).to.deep.equal({ x: 0, y: 0 })Why is there already a diff right after the position was allocated? Proxies are created dirty. Think about a game world with monsters. If a new monster spawns, all clients should automatically treat the new monster as a change. Make sense?
A proxy may be marked clean:
import { MarkClean } from 'propertea'
another[MarkClean]()
expect(another[Diff]()).to.equal(undefined)Changes may trigger a callback:
import { object, Pool, string } from 'propertea'
let bits: number[] = []
const reactivePool = new Pool(object({
foo: string(),
}), {
onDirty: (
// the dirty bit
bit,
) => {
// ... do something!
bits.push(bit)
}
})
reactivePool.allocate()
reactivePool.allocate()
expect(bits).to.deep.equal([0, 1])Note: Chrome has laughably bad WASM memory management and the default of using WASM memory has been disabled due to out-of-memory errors when using less than 1 MB of memory. This is due to Chrome allocating 4 GB per memory instance for "safety", instead of only allocating what's needed.
Pools are can be optionally structured to be operated on by WASM. See src/pool.test.wat for a minimal example of using WASM to transform data (and track changes).
Excerpted from src/pool.test.js:
import { float32, object, Pool } from 'propertea'
const pool = new Pool(object({
z: float32(),
}), {
useWasm: true,
})
// generate random samples and use them to initialize our pool
const samples = []
for (let i = 0; i < 10; ++i) {
const sample = Math.random()
samples.push(sample)
pool.allocate({ z: sample })
}
pool.markClean()
// compile the WAT to WASM and get the exports
const { default: buffer } = await import('./pool.test.wat?multi_memory')
// type the exports if you're using TypeScript
interface WasmTestExports extends Record<string, any> {
thisIsAWasmTest: (arg: number) => void
}
const exports = await WebAssembly.instantiate(buffer, { pool: pool.wasmImports() })
.then(({ instance: { exports } }) => exports as WasmTestExports)
// generate a random sample to pass to the WASM
const parameter = Math.random()
exports.thisIsAWasmTest(parameter)NOTE: Dirty/change callbacks can not be invoked by WASM.
The proxy can be serialized to JSON:
import { ToJSON } from 'propertea'
expect(JSON.stringify(another[ToJSON]())).to.equal('{"x":0,"y":0}')Every Propertea uses (or builds) a crunches codec to serialize to/from binary.
Networked real-time applications with arbitrarily-large mutable state (read: games 🎮) need to efficiently synchronize state from server to client(s). This generally involves tracking state changes and sending only the delta each update interval ("diffing").
It is greatly beneficial for performance when data is arranged contiguously so that e.g. SIMD may be leveraged for data transformations.
This library is fast. As you can see in benchmark/pool.bench.js (run with npm test -- --run --project bench), Propertea beats native JavaScript by 100-1000x transforming contiguous data. Pooled allocations actually beat native after warming the pool.
- Fixed-length arrays
- Write up creating custom
Properteatypes - Write up manual dirty tracking/notification when using memory access/WASM