@playertwo/core
v0.2.0
Published
Multiplayer without networking. Engine-agnostic multiplayer SDK with host-authoritative state sync.
Maintainers
Readme
@playertwo/core
Engine-agnostic multiplayer SDK with host-authoritative state synchronization.
Simple, clean, works with any game engine.
Features
- ✅ Declarative API - Define state and actions, not networking code
- ✅ Host-authoritative - Host runs the game, clients mirror state
- ✅ Automatic sync - Efficient diff/patch algorithm for bandwidth optimization
- ✅ Engine-agnostic - Works with Phaser, Unity, Godot, Three.js, etc.
- ✅ Transport-agnostic - P2P, WebSocket, UDP - your choice
- ✅ TypeScript - Full type safety
Installation
pnpm add @playertwo/coreQuick Start
1. Define Your Game
import { defineGame } from '@playertwo/core';
const game = defineGame({
setup: ({ playerIds }) => ({
players: Object.fromEntries(
playerIds.map(id => [id, { x: 100, y: 100, score: 0 }])
)
}),
actions: {
move: {
apply(state, playerId, input) {
state.players[playerId].x = input.x;
state.players[playerId].y = input.y;
}
}
},
onPlayerJoin(state, playerId) {
state.players[playerId] = { x: 100, y: 100, score: 0 };
},
onPlayerLeave(state, playerId) {
delete state.players[playerId];
}
});2. Create Runtime
import { GameRuntime } from '@playertwo/core';
import { TrysteroTransport } from '@playertwo/transport-trystero';
const transport = new TrysteroTransport({
roomId: 'game-room-123',
isHost: true
});
const runtime = new GameRuntime(game, transport, {
isHost: true,
playerIds: ['p1']
});3. Use in Your Game
// Submit actions
runtime.submitAction('move', { x: 150, y: 200 });
// Listen for state changes
runtime.onChange((state) => {
console.log('Players:', state.players);
});
// Broadcast custom events
runtime.broadcastEvent('explosion', { x: 100, y: 200 });
// Listen for events
runtime.onEvent('explosion', (senderId, eventName, payload) => {
console.log(`Explosion at ${payload.x}, ${payload.y}`);
});How It Works
Host-Authoritative Architecture
┌─────────────────────────────────────┐
│ HOST │
│ • Runs game logic │
│ • Applies actions │
│ • Syncs state to clients (20 FPS) │
└─────────────────┬───────────────────┘
│
state patches (diff)
│
┌────────┴────────┐
↓ ↓
┌─────────────────┐ ┌─────────────────┐
│ CLIENT 1 │ │ CLIENT 2 │
│ • Sends actions│ │ • Sends actions│
│ • Mirrors state│ │ • Mirrors state│
└─────────────────┘ └─────────────────┘Key Points:
- Host is authoritative (runs real physics/logic)
- Clients send inputs, receive state updates
- Efficient diff/patch algorithm minimizes bandwidth
- Default 20 FPS state sync (configurable)
Core Concepts
State
Plain JavaScript objects describing your game:
{
players: {
p1: { x: 100, y: 100, health: 100 },
p2: { x: 200, y: 200, health: 100 }
},
bullets: [],
gameState: 'playing'
}Rules:
- Must be JSON-serializable
- No functions or class instances
- Mutated directly (no immutability required)
Actions
The only way to modify state:
actions: {
shoot: {
apply(state, playerId, input) {
state.bullets.push({
x: input.x,
y: input.y,
ownerId: playerId
});
}
}
}Flow:
- Player calls
runtime.submitAction('shoot', { x: 100, y: 200 }) - Host applies action immediately
- Host broadcasts state patch to clients
- Clients receive and apply patch
Lifecycle Hooks
Handle player join/leave:
onPlayerJoin(state, playerId) {
state.players[playerId] = { x: 100, y: 100 };
},
onPlayerLeave(state, playerId) {
delete state.players[playerId];
}Integration with Game Engines
Phaser
Use @playertwo/phaser for automatic sprite syncing:
import { PhaserAdapter } from '@playertwo/phaser';
class GameScene extends Phaser.Scene {
create() {
const adapter = new PhaserAdapter(runtime, this);
const player = this.physics.add.sprite(100, 100, 'player');
adapter.trackSprite(player, `player-${adapter.myId}`);
// That's it! Sprite automatically syncs across network
}
}See Phaser Adapter docs for details.
Other Engines
For Unity, Godot, Three.js, etc.:
runtime.onChange((state) => {
// Update your game objects based on state
for (const [id, player] of Object.entries(state.players)) {
updateGameObject(id, player.x, player.y);
}
});Transports
@playertwo/core is transport-agnostic. Choose your backend:
P2P (Serverless)
import { TrysteroTransport } from '@playertwo/transport-trystero';
const transport = new TrysteroTransport({
roomId: 'game-123',
isHost: true // URL-based host selection
});Pros: Zero server costs, simple setup Cons: NAT traversal issues (5-10% of users)
WebSocket (Coming Soon)
import { WebSocketTransport } from '@playertwo/transport-ws';
const transport = new WebSocketTransport({
url: 'wss://your-server.com'
});Pros: Reliable, works for everyone Cons: Requires server hosting
Custom Transport
Implement the Transport interface:
interface Transport {
send(message: WireMessage, targetId?: string): void;
onMessage(handler: (msg: WireMessage, senderId: string) => void): () => void;
onPeerJoin(handler: (peerId: string) => void): () => void;
onPeerLeave(handler: (peerId: string) => void): () => void;
getPlayerId(): string;
getPeerIds(): string[];
isHost(): boolean;
}API Reference
- defineGame - Define game logic
- GameRuntime - Runtime instance
- Diff/Patch Utilities - State sync internals
Full documentation: API Reference
Examples
Examples
See interactive demos:
- Examples overview - Playable demos built with playertwo
- Live preview - Try transports in the browser
Run them locally:
cd @playertwo/demos
pnpm devTesting
# Run tests
pnpm test
# Watch mode
pnpm test:watch
# Coverage
pnpm test:coverageCurrent coverage: 96%+ on core algorithms ✅
Development
# Build
pnpm build
# Watch mode
pnpm dev
# Clean
pnpm cleanArchitecture
@playertwo/core (this package)
↓
├─ defineGame() - Declarative game definition
├─ GameRuntime - State management, action execution
├─ sync.ts - Diff/patch algorithm
└─ transport.ts - Transport interface
Used by:
├─ @playertwo/phaser - Phaser 3 adapter
├─ @playertwo/transport-* - Transport implementations
└─ Your game - Direct usageDesign Philosophy
Host-Authoritative
Host runs the real game, clients mirror state. Simple, works with any physics engine.
Why not deterministic?
- Most games don't need it
- Works with existing Phaser/Unity code
- AI can generate code easily
- Faster development
Declarative
Define state and actions once, not networking code.
// ❌ Imperative networking
socket.on('player-moved', (data) => {
players[data.id].x = data.x;
});
// ✅ Declarative actions
actions: {
move: {
apply(state, playerId, input) {
state.players[playerId].x = input.x;
}
}
}Transport-Agnostic
Swap networking backends without changing game code:
// Development: P2P
const transport = new TrysteroTransport({ roomId: 'dev-123' });
// Production: WebSocket
const transport = new WebSocketTransport({ url: 'wss://game.com' });Roadmap
- [x] Host-authoritative mode
- [x] P2P transport (Trystero)
- [x] Phaser adapter
- [x] Comprehensive tests (96%+ coverage)
- [ ] WebSocket transport
- [ ] Unity C# bindings
- [ ] Godot GDScript bindings
- [ ] Client prediction (optional advanced mode)
License
MIT - See LICENSE
Contributing
See CONTRIBUTING.md
Areas needing help:
- WebSocket transport implementation
- Unity/Godot adapters
- Example games
- Documentation improvements
Support
- Documentation: playertwo docs
- Issues: GitHub Issues
- Demo: Live preview
