add Box3D-powered Bad Movers game
All checks were successful
build / image (push) Successful in 1m39s

This commit is contained in:
Syncer Deploy
2026-08-31 12:32:39 -04:00
parent 147baf6b13
commit 38ac419204
18 changed files with 3320 additions and 10 deletions

View File

@@ -55,6 +55,11 @@ export type {
SnapshotBatch,
} from "./networked-types.js";
export { NetworkClock, type NetworkStats } from "./network-clock.js";
export {
definePhysicsBackend,
type PhysicsBackend,
type PhysicsBackendMetadata,
} from "./physics-backend.js";
export {
createBinaryProtocol,
type BinaryProtocol,

View File

@@ -0,0 +1,32 @@
/** Runtime information a game may expose without coupling itself to an SDK. */
export interface PhysicsBackendMetadata {
readonly name: string;
readonly runtime: string;
readonly version?: string;
}
/**
* A pluggable deterministic physics runtime for a serializable game state.
*
* Native/WASM handles stay inside the backend. The engine only checkpoints the
* plain State value, so a backend must be able to initialize from that value
* and reconcile an existing runtime to a fresh snapshot.
*/
export interface PhysicsBackend<State, StepResult = void> {
readonly metadata: PhysicsBackendMetadata;
initialize(state: State): void;
step(state: State, deltaSeconds: number): StepResult;
reconcile(predicted: State, snapshot: State): void;
reset(state: State): void;
dispose?(state: State): void;
}
/**
* Developer-facing HOF used to define a typed physics plug-in once and retain
* any game-specific methods added by that plug-in.
*/
export function definePhysicsBackend<State, StepResult = void>() {
return <Backend extends PhysicsBackend<State, StepResult>>(
backend: Backend,
): Readonly<Backend> => Object.freeze(backend);
}

View File

@@ -15,7 +15,8 @@
"dist"
],
"dependencies": {
"@syncer/engine": "0.0.0"
"@syncer/engine": "0.0.0",
"box3d-wasm": "^0.2.0"
},
"scripts": {
"dev": "tsc --watch --preserveWatchOutput",

124
packages/shared/src/box3d-wasm.d.ts vendored Normal file
View File

@@ -0,0 +1,124 @@
declare module "box3d-wasm/standard" {
export interface Vec3 {
x: number;
y: number;
z: number;
}
export interface Quat extends Vec3 {
w: number;
}
export interface Transform {
position: Vec3;
rotation: Quat;
}
export type BodyType = "static" | "kinematic" | "dynamic";
export interface MotionLocks {
linearX?: boolean;
linearY?: boolean;
linearZ?: boolean;
angularX?: boolean;
angularY?: boolean;
angularZ?: boolean;
}
export interface BodyDefinition {
type: BodyType;
position?: Vec3;
rotation?: Quat;
linearVelocity?: Vec3;
angularVelocity?: Vec3;
linearDamping?: number;
angularDamping?: number;
gravityScale?: number;
motionLocks?: MotionLocks;
isBullet?: boolean;
userData?: number;
}
export interface ShapeDefinition {
density?: number;
friction?: number;
restitution?: number;
isSensor?: boolean;
enableContactEvents?: boolean;
enableHitEvents?: boolean;
enableSensorEvents?: boolean;
userData?: number;
}
export interface Shape {
destroy(): void;
delete(): void;
}
export interface Body {
createBox(options: ShapeDefinition & { halfExtents: Vec3 }): Shape;
createCapsule(options: ShapeDefinition & { height: number; radius: number }): Shape;
getPosition(): Vec3;
getRotation(): Quat;
getLinearVelocity(): Vec3;
getAngularVelocity(): Vec3;
getType(): BodyType;
setType(type: BodyType): void;
setTransform(position: Vec3, rotation: Quat): void;
setTargetTransform(transform: Transform, deltaSeconds: number, wake: boolean): void;
setLinearVelocity(velocity: Vec3): void;
setAngularVelocity(velocity: Vec3): void;
setAwake(awake: boolean): void;
setBullet(bullet: boolean): void;
applyLinearImpulseToCenter(impulse: Vec3, wake: boolean): void;
destroy(): void;
delete(): void;
}
export interface ContactHitEvent {
shapeUserDataA: number;
shapeUserDataB: number;
point: Vec3;
normal: Vec3;
approachSpeed: number;
}
export interface ContactEvents {
begin: unknown[];
end: unknown[];
hit: ContactHitEvent[];
}
export interface WorldProfile {
[name: string]: number;
}
export class World {
constructor(options: {
gravity: Vec3;
enableSleep?: boolean;
enableContinuous?: boolean;
workerCount?: number;
});
createBody(definition: BodyDefinition): Body;
step(deltaSeconds: number, subStepCount: number): void;
explode(options: {
position: Vec3;
radius: number;
falloff: number;
impulsePerArea: number;
}): void;
getContactEvents(): ContactEvents;
getProfile(): WorldProfile;
destroy(): void;
delete(): void;
}
export interface Box3DModule {
World: typeof World;
readonly threaded: boolean;
readonly maxWorkers: number;
}
export default function Box3D(options?: unknown): Promise<Box3DModule>;
}

View File

@@ -9,6 +9,8 @@ export interface ApiMessage {
export * from "./arena.js";
export * from "./flux-types.js";
export * from "./flux-game.js";
export * from "./movers-types.js";
export * from "./movers-game.js";
export * from "./royale-types.js";
export * from "./royale-map.js";
export * from "./royale-game.js";

View File

@@ -0,0 +1,448 @@
import { definePhysicsBackend } from "@syncer/engine";
import Box3D, {
type Body,
type BodyType,
type Quat,
type Vec3,
type World,
type WorldProfile,
} from "box3d-wasm/standard";
import {
FURNITURE,
MOVERS_ARENA_HALF_DEPTH,
MOVERS_ARENA_HALF_WIDTH,
MOVERS_WALLS,
} from "./movers-config.js";
import type {
FurnitureKind,
MoversAuthorityState,
MoversClientState,
} from "./movers-types.js";
type MoversPhysicsState = MoversAuthorityState | MoversClientState;
export interface MoversPhysicsImpact {
itemId: number;
approachSpeed: number;
}
interface FurnitureBody {
body: Body;
kind: FurnitureKind;
mode: BodyType;
}
interface PhysicsRuntime {
world: World;
players: Map<number, Body>;
furniture: Map<number, FurnitureBody>;
pendingExplosions: Array<{
position: Vec3;
radius: number;
falloff: number;
impulsePerArea: number;
}>;
round: number;
}
const box3d = await Box3D();
const runtimes = new WeakMap<MoversPhysicsState, PhysicsRuntime>();
const furnitureShapeTagBase = 10_000;
const playerShapeTagBase = 100_000;
const identityRotation: Quat = { x: 0, y: 0, z: 0, w: 1 };
export const MOVERS_PHYSICS_BACKEND = {
name: "Box3D",
version: "0.1.0",
bindingVersion: "0.2.0",
runtime: "WebAssembly SIMD",
solver: "single-threaded deterministic",
subSteps: 4,
} as const;
export const moversPhysics = definePhysicsBackend<
MoversPhysicsState,
MoversPhysicsImpact[]
>()({
metadata: MOVERS_PHYSICS_BACKEND,
initialize(state) {
ensureRuntime(state);
},
step(state, deltaSeconds) {
const runtime = ensureRuntime(state);
syncStructure(runtime, state);
if (runtime.round !== state.round) {
forceState(runtime, state);
runtime.round = state.round;
}
preparePlayers(runtime, state);
prepareFurniture(runtime, state, deltaSeconds);
for (const explosion of runtime.pendingExplosions.splice(0)) {
runtime.world.explode(explosion);
}
runtime.world.step(deltaSeconds, MOVERS_PHYSICS_BACKEND.subSteps);
syncStateFromWorld(runtime, state);
return collectImpacts(runtime, state);
},
reconcile(predicted, snapshot) {
const runtime = runtimes.get(predicted);
if (!runtime) {
ensureRuntime(snapshot);
return;
}
if (predicted !== snapshot) {
runtimes.delete(predicted);
runtimes.set(snapshot, runtime);
}
syncStructure(runtime, snapshot);
forceState(runtime, snapshot);
runtime.round = snapshot.round;
},
reset(state) {
const runtime = ensureRuntime(state);
syncStructure(runtime, state);
forceState(runtime, state);
runtime.round = state.round;
},
dispose(state) {
const runtime = runtimes.get(state);
if (!runtime) return;
for (const body of runtime.players.values()) body.delete();
for (const record of runtime.furniture.values()) record.body.delete();
runtime.world.destroy();
runtime.world.delete();
runtimes.delete(state);
},
explode(
state: MoversPhysicsState,
options: { position: Vec3; radius: number; falloff: number; impulsePerArea: number },
) {
ensureRuntime(state).pendingExplosions.push(options);
},
profile(state: MoversPhysicsState): WorldProfile {
return ensureRuntime(state).world.getProfile();
},
});
function ensureRuntime(state: MoversPhysicsState): PhysicsRuntime {
const existing = runtimes.get(state);
if (existing) return existing;
const world = new box3d.World({
gravity: { x: 0, y: -18, z: 0 },
enableSleep: true,
enableContinuous: true,
workerCount: 1,
});
createStaticWorld(world);
const runtime: PhysicsRuntime = {
world,
players: new Map(),
furniture: new Map(),
pendingExplosions: [],
round: state.round,
};
runtimes.set(state, runtime);
syncStructure(runtime, state);
forceState(runtime, state);
return runtime;
}
function createStaticWorld(world: World): void {
createStaticBox(world, { x: 0, y: -0.5, z: 0 }, {
x: MOVERS_ARENA_HALF_WIDTH + 4,
y: 0.5,
z: MOVERS_ARENA_HALF_DEPTH + 4,
}, 1);
const wallHeight = 3;
for (const [index, wall] of MOVERS_WALLS.entries()) {
createStaticBox(
world,
{ x: wall.x, y: wallHeight / 2, z: wall.z },
{ x: wall.width / 2, y: wallHeight / 2, z: wall.depth / 2 },
100 + index,
);
}
const edgeThickness = 0.8;
createStaticBox(world, {
x: -MOVERS_ARENA_HALF_WIDTH - edgeThickness / 2,
y: wallHeight / 2,
z: 0,
}, { x: edgeThickness / 2, y: wallHeight / 2, z: MOVERS_ARENA_HALF_DEPTH + 1 }, 201);
createStaticBox(world, {
x: MOVERS_ARENA_HALF_WIDTH + edgeThickness / 2,
y: wallHeight / 2,
z: 0,
}, { x: edgeThickness / 2, y: wallHeight / 2, z: MOVERS_ARENA_HALF_DEPTH + 1 }, 202);
createStaticBox(world, {
x: 0,
y: wallHeight / 2,
z: -MOVERS_ARENA_HALF_DEPTH - edgeThickness / 2,
}, { x: MOVERS_ARENA_HALF_WIDTH + 1, y: wallHeight / 2, z: edgeThickness / 2 }, 203);
createStaticBox(world, {
x: 0,
y: wallHeight / 2,
z: MOVERS_ARENA_HALF_DEPTH + edgeThickness / 2,
}, { x: MOVERS_ARENA_HALF_WIDTH + 1, y: wallHeight / 2, z: edgeThickness / 2 }, 204);
}
function createStaticBox(world: World, position: Vec3, halfExtents: Vec3, tag: number): void {
const body = world.createBody({ type: "static", position, userData: tag });
const shape = body.createBox({
halfExtents,
friction: 0.78,
restitution: 0.04,
enableHitEvents: true,
userData: tag,
});
shape.delete();
body.delete();
}
function syncStructure(runtime: PhysicsRuntime, state: MoversPhysicsState): void {
const playerIds = new Set(state.players.map((player) => player.id));
for (const [id, body] of runtime.players) {
if (playerIds.has(id)) continue;
body.destroy();
body.delete();
runtime.players.delete(id);
}
for (const player of state.players) {
if (runtime.players.has(player.id)) continue;
runtime.players.set(player.id, createPlayerBody(runtime.world, player));
}
const itemIds = new Set(state.furniture.map((item) => item.id));
for (const [id, record] of runtime.furniture) {
const item = state.furniture.find((candidate) => candidate.id === id);
if (itemIds.has(id) && item?.kind === record.kind) continue;
record.body.destroy();
record.body.delete();
runtime.furniture.delete(id);
}
for (const item of state.furniture) {
if (runtime.furniture.has(item.id)) continue;
runtime.furniture.set(item.id, createFurnitureBody(runtime.world, item));
}
}
function createPlayerBody(
world: World,
player: MoversPhysicsState["players"][number],
): Body {
const body = world.createBody({
type: "dynamic",
position: { x: player.x, y: 1.05, z: player.z },
linearVelocity: { x: player.velocityX, y: 0, z: player.velocityZ },
gravityScale: 0,
linearDamping: 0.25,
motionLocks: {
linearY: true,
angularX: true,
angularY: true,
angularZ: true,
},
userData: playerShapeTagBase + player.id,
});
const shape = body.createCapsule({
height: 1.15,
radius: 0.47,
density: 1.2,
friction: 0.12,
restitution: 0.02,
enableHitEvents: true,
userData: playerShapeTagBase + player.id,
});
shape.delete();
return body;
}
function createFurnitureBody(
world: World,
item: MoversPhysicsState["furniture"][number],
): FurnitureBody {
const definition = FURNITURE[item.kind];
const mode = bodyMode(item);
const body = world.createBody({
type: mode,
position: positionOf(item),
rotation: rotationOf(item),
linearVelocity: velocityOf(item),
angularVelocity: angularVelocityOf(item),
linearDamping: 0.5 + definition.weight * 0.14,
angularDamping: 0.72,
isBullet: Math.hypot(item.velocityX, item.velocityY, item.velocityZ) > 9,
userData: furnitureShapeTagBase + item.id,
});
const shape = body.createBox({
halfExtents: definition.halfExtents,
density: definition.density,
friction: 0.62,
restitution: item.kind === "mattress" ? 0.18 : 0.06,
enableHitEvents: true,
userData: furnitureShapeTagBase + item.id,
});
shape.delete();
return { body, kind: item.kind, mode };
}
function preparePlayers(runtime: PhysicsRuntime, state: MoversPhysicsState): void {
for (const player of state.players) {
const body = runtime.players.get(player.id);
if (!body) continue;
body.setLinearVelocity({ x: player.velocityX, y: 0, z: player.velocityZ });
body.setAwake(true);
}
}
function prepareFurniture(
runtime: PhysicsRuntime,
state: MoversPhysicsState,
deltaSeconds: number,
): void {
for (const item of state.furniture) {
const record = runtime.furniture.get(item.id);
if (!record) continue;
const nextMode = bodyMode(item);
const changedMode = record.mode !== nextMode;
if (changedMode) {
record.body.setType(nextMode);
record.body.setTransform(positionOf(item), rotationOf(item));
record.mode = nextMode;
}
if (nextMode === "kinematic") {
record.body.setTargetTransform({
position: positionOf(item),
rotation: rotationOf(item),
}, deltaSeconds, true);
continue;
}
if (nextMode === "static") {
if (changedMode) record.body.setTransform(positionOf(item), rotationOf(item));
continue;
}
record.body.setBullet(Math.hypot(item.velocityX, item.velocityY, item.velocityZ) > 9);
record.body.setLinearVelocity(velocityOf(item));
record.body.setAngularVelocity(angularVelocityOf(item));
}
}
function forceState(runtime: PhysicsRuntime, state: MoversPhysicsState): void {
for (const player of state.players) {
const body = runtime.players.get(player.id);
if (!body) continue;
body.setTransform({ x: player.x, y: 1.05, z: player.z }, identityRotation);
body.setLinearVelocity({ x: player.velocityX, y: 0, z: player.velocityZ });
}
for (const item of state.furniture) {
const record = runtime.furniture.get(item.id);
if (!record) continue;
const mode = bodyMode(item);
if (record.body.getType() !== mode) record.body.setType(mode);
record.mode = mode;
record.body.setTransform(positionOf(item), rotationOf(item));
if (mode === "dynamic") {
record.body.setLinearVelocity(velocityOf(item));
record.body.setAngularVelocity(angularVelocityOf(item));
}
}
}
function syncStateFromWorld(runtime: PhysicsRuntime, state: MoversPhysicsState): void {
for (const player of state.players) {
const body = runtime.players.get(player.id);
if (!body) continue;
const position = body.getPosition();
const velocity = body.getLinearVelocity();
player.x = position.x;
player.z = position.z;
player.velocityX = velocity.x;
player.velocityZ = velocity.z;
}
for (const item of state.furniture) {
const body = runtime.furniture.get(item.id)?.body;
if (!body) continue;
const position = body.getPosition();
const rotation = body.getRotation();
const velocity = body.getLinearVelocity();
const angularVelocity = body.getAngularVelocity();
item.x = position.x;
item.y = position.y;
item.z = position.z;
item.velocityX = velocity.x;
item.velocityY = velocity.y;
item.velocityZ = velocity.z;
item.rotationX = rotation.x;
item.rotationY = rotation.y;
item.rotationZ = rotation.z;
item.rotationW = rotation.w;
item.angularVelocityX = angularVelocity.x;
item.angularVelocityY = angularVelocity.y;
item.angularVelocityZ = angularVelocity.z;
item.yaw = yawFromQuaternion(rotation);
}
}
function collectImpacts(
runtime: PhysicsRuntime,
state: MoversPhysicsState,
): MoversPhysicsImpact[] {
const maximumByItem = new Map<number, number>();
for (const hit of runtime.world.getContactEvents().hit) {
for (const tag of [hit.shapeUserDataA, hit.shapeUserDataB]) {
const itemId = tag - furnitureShapeTagBase;
const item = state.furniture.find((candidate) => candidate.id === itemId);
if (!item || item.carriedBy !== null || item.securedBy !== null) continue;
maximumByItem.set(itemId, Math.max(maximumByItem.get(itemId) ?? 0, hit.approachSpeed));
}
}
return [...maximumByItem].map(([itemId, approachSpeed]) => ({ itemId, approachSpeed }));
}
function bodyMode(item: MoversPhysicsState["furniture"][number]): BodyType {
if (item.securedBy !== null) return "static";
if (item.carriedBy !== null) return "kinematic";
return "dynamic";
}
function positionOf(item: MoversPhysicsState["furniture"][number]): Vec3 {
return { x: item.x, y: item.y, z: item.z };
}
function rotationOf(item: MoversPhysicsState["furniture"][number]): Quat {
return {
x: item.rotationX,
y: item.rotationY,
z: item.rotationZ,
w: item.rotationW,
};
}
function velocityOf(item: MoversPhysicsState["furniture"][number]): Vec3 {
return { x: item.velocityX, y: item.velocityY, z: item.velocityZ };
}
function angularVelocityOf(item: MoversPhysicsState["furniture"][number]): Vec3 {
return {
x: item.angularVelocityX,
y: item.angularVelocityY,
z: item.angularVelocityZ,
};
}
function yawFromQuaternion(rotation: Quat): number {
return Math.atan2(
2 * (rotation.w * rotation.y + rotation.x * rotation.z),
1 - 2 * (rotation.y * rotation.y + rotation.z * rotation.z),
);
}

View File

@@ -0,0 +1,121 @@
import type { FurnitureKind } from "./movers-types.js";
export interface FurnitureDefinition {
name: string;
value: number;
weight: number;
radius: number;
fragile: number;
halfExtents: { x: number; y: number; z: number };
density: number;
}
export const FURNITURE: Record<FurnitureKind, FurnitureDefinition> = {
piano: {
name: "Grand Piano",
value: 1_400,
weight: 3.2,
radius: 1.65,
fragile: 0.72,
halfExtents: { x: 1.55, y: 0.72, z: 0.78 },
density: 1.8,
},
aquarium: {
name: "Live Aquarium",
value: 1_100,
weight: 2.1,
radius: 1.15,
fragile: 1.45,
halfExtents: { x: 1.15, y: 0.78, z: 0.6 },
density: 1.45,
},
safe: {
name: "Suspicious Safe",
value: 1_800,
weight: 3.8,
radius: 0.9,
fragile: 0.28,
halfExtents: { x: 0.82, y: 0.85, z: 0.78 },
density: 5.2,
},
sofa: {
name: "Designer Sofa",
value: 850,
weight: 2.2,
radius: 1.55,
fragile: 0.58,
halfExtents: { x: 1.6, y: 0.62, z: 0.72 },
density: 0.72,
},
television: {
name: "Giant Television",
value: 1_250,
weight: 1.35,
radius: 1.05,
fragile: 1.7,
halfExtents: { x: 1.1, y: 0.78, z: 0.22 },
density: 1.05,
},
mattress: {
name: "King Mattress",
value: 420,
weight: 1.1,
radius: 1.55,
fragile: 0.35,
halfExtents: { x: 1.6, y: 0.32, z: 1.08 },
density: 0.28,
},
urn: {
name: "Grandma's Urn",
value: 2_200,
weight: 0.65,
radius: 0.48,
fragile: 2.2,
halfExtents: { x: 0.43, y: 0.6, z: 0.43 },
density: 1.4,
},
refrigerator: {
name: "Full Refrigerator",
value: 720,
weight: 2.65,
radius: 1.05,
fragile: 0.62,
halfExtents: { x: 0.82, y: 1.15, z: 0.75 },
density: 2.4,
},
plant: {
name: "Rare Houseplant",
value: 560,
weight: 0.7,
radius: 0.62,
fragile: 1.25,
halfExtents: { x: 0.52, y: 0.7, z: 0.52 },
density: 0.55,
},
"mystery-box": {
name: "Box (Probably Cat)",
value: 900,
weight: 0.8,
radius: 0.72,
fragile: 0.82,
halfExtents: { x: 0.7, y: 0.65, z: 0.7 },
density: 0.68,
},
};
export const MOVERS_WALLS = [
{ x: -19, z: -10, width: 9, depth: 0.7 },
{ x: -7, z: -10, width: 8, depth: 0.7 },
{ x: 7, z: -10, width: 8, depth: 0.7 },
{ x: 19, z: -10, width: 9, depth: 0.7 },
{ x: -17, z: 10, width: 15, depth: 0.7 },
{ x: 0, z: 10, width: 13, depth: 0.7 },
{ x: 17, z: 10, width: 15, depth: 0.7 },
{ x: -12, z: 0, width: 0.7, depth: 11 },
{ x: 12, z: 1, width: 0.7, depth: 10 },
{ x: 0, z: -6, width: 0.7, depth: 8 },
] as const;
export const MOVERS_ARENA_HALF_WIDTH = 42;
export const MOVERS_ARENA_HALF_DEPTH = 23;

View File

@@ -0,0 +1,827 @@
import {
createJsonCodec,
defineMultiplayerGame,
withInputStream,
} from "@syncer/engine";
import { FURNITURE } from "./movers-config.js";
import { moversPhysics } from "./movers-box3d.js";
import type {
FurnitureKind,
MoversAuthorityEvent,
MoversAuthorityFurniture,
MoversAuthorityPlayer,
MoversAuthorityState,
MoversClientState,
MoversFurnitureView,
MoversGameContract,
MoversInput,
MoversPerception,
MoversPlayerView,
MoversTeam,
} from "./movers-types.js";
export { FURNITURE, MOVERS_WALLS } from "./movers-config.js";
export { MOVERS_PHYSICS_BACKEND } from "./movers-box3d.js";
export const MOVERS_SOCKET_PATH = "/ws/movers";
export const MOVERS_TICK_RATE = 30;
export const MOVERS_SNAPSHOT_RATE = 15;
export const MOVERS_MATCH_TICKS = MOVERS_TICK_RATE * 150;
export const MOVERS_DEMOLITION_TICKS = MOVERS_TICK_RATE * 30;
export const MOVERS_BOT_IDS = [40_001, 40_002, 40_003, 40_004] as const;
const inputCodec = createJsonCodec<MoversInput>();
const stateCodec = createJsonCodec<MoversClientState>();
const eventCodec = createJsonCodec<MoversPerception>();
const eventLifetimeTicks = MOVERS_TICK_RATE * 6;
const maximumStamina = 100;
const baseMoversGame = defineMultiplayerGame<MoversGameContract>({
clock: {
ticksPerSecond: MOVERS_TICK_RATE,
snapshotsPerSecond: MOVERS_SNAPSHOT_RATE,
},
authority: {
createInitialState: createAuthorityState,
cloneState: cloneAuthorityState,
addPlayer(state, { playerId }) {
const team = leastPopulatedTeam(state);
state.players.push(createPlayer(playerId, team, false, state.players.length));
state.players.sort((left, right) => left.id - right.id);
},
removePlayer(state, { playerId }) {
const player = findPlayer(state, playerId);
if (player?.carryingId !== null && player?.carryingId !== undefined) {
const item = findFurniture(state, player.carryingId);
if (item) item.carriedBy = null;
}
state.players = state.players.filter((candidate) => candidate.id !== playerId);
},
applyInput(state, input, { playerId }) {
const player = findPlayer(state, playerId);
if (!player || player.bot) return;
applyCommand(player, input);
},
step(state, { tick, deltaSeconds, emit }) {
if (state.resetTicks > 0) {
state.resetTicks -= 1;
if (state.resetTicks === 0) resetMatch(state);
return;
}
state.elapsedTicks += 1;
state.yellowDoorTicks = Math.max(0, state.yellowDoorTicks - 1);
state.blueDoorTicks = Math.max(0, state.blueDoorTicks - 1);
if (!state.demolitionStarted && state.elapsedTicks >= MOVERS_MATCH_TICKS - MOVERS_DEMOLITION_TICKS) {
state.demolitionStarted = true;
emit({ id: state.nextEventId++, type: "demolition" });
}
for (const player of state.players) {
if (player.bot) updateBot(state, player, tick);
const carrying = player.carryingId === null ? null : findFurniture(state, player.carryingId) ?? null;
updateAuthorityPlayerIntent(player, carrying, deltaSeconds);
}
updateCarrying(state, emit);
for (const player of state.players) {
if (player.closeDoorsRequested) tryCloseTruck(state, player, emit);
player.throwRequested = false;
player.closeDoorsRequested = false;
}
if (state.demolitionStarted && tick % 75 === 0) demolitionPulse(state, tick, emit);
const impacts = moversPhysics.step(state, deltaSeconds);
for (const impact of impacts) {
if (impact.approachSpeed <= 4.8) continue;
const item = findFurniture(state, impact.itemId);
if (item) damageFurniture(state, item, (impact.approachSpeed - 4.8) * 1.35, tick, emit);
}
resolveCarrierTackles(state, tick, emit);
if (
state.elapsedTicks >= MOVERS_MATCH_TICKS ||
state.furniture.every((item) => item.securedBy !== null)
) {
finishMatch(state, emit);
}
},
validateState(state) {
return (
Number.isFinite(state.yellowScore + state.blueScore) &&
state.players.every(
(player) =>
Number.isFinite(player.x + player.z + player.velocityX + player.velocityZ) &&
player.stamina >= 0 &&
player.stamina <= maximumStamina,
) &&
state.furniture.every(
(item) =>
Number.isFinite(
item.x + item.y + item.z +
item.velocityX + item.velocityY + item.velocityZ +
item.rotationX + item.rotationY + item.rotationZ + item.rotationW +
item.angularVelocityX + item.angularVelocityY + item.angularVelocityZ +
item.damage,
) &&
item.damage >= 0 &&
item.damage <= 100,
)
);
},
},
prediction: {
createInitialState: createClientState,
cloneState: cloneClientState,
applyInput(state, input, { playerId }) {
const player = state.players.find((candidate) => candidate.id === playerId);
if (!player) return;
player.inputForward = input.forward;
player.inputStrafe = input.strafe;
player.sprinting = input.sprint;
player.grabbing = input.grab;
},
step(state, { tick, deltaSeconds }) {
if (state.resetTicks > 0) state.resetTicks -= 1;
else state.elapsedTicks += 1;
state.yellowDoorTicks = Math.max(0, state.yellowDoorTicks - 1);
state.blueDoorTicks = Math.max(0, state.blueDoorTicks - 1);
for (const player of state.players) {
const carrying = player.carryingId === null
? null
: state.furniture.find((item) => item.id === player.carryingId) ?? null;
updateVisiblePlayerIntent(player, carrying, deltaSeconds);
}
updateVisibleCarrying(state);
moversPhysics.step(state, deltaSeconds);
state.events = state.events.filter(
(entry) => entry.receivedTick >= tick - eventLifetimeTicks,
);
},
mergeSnapshot(predicted, snapshot, { tick }) {
const merged = cloneClientState(snapshot);
merged.events = predicted.events
.filter((entry) => entry.receivedTick >= tick - eventLifetimeTicks)
.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } }));
moversPhysics.reconcile(predicted, merged);
return merged;
},
applyEvent(state, event, { tick }) {
if (state.events.some((entry) => entry.event.id === event.id)) return;
state.events.push({ receivedTick: tick, event: { ...event } });
if (state.events.length > 64) state.events.shift();
},
validateState(state) {
return (
state.players.every(
(player) =>
Number.isFinite(player.x + player.z) &&
(player.stamina === null || (player.stamina >= 0 && player.stamina <= maximumStamina)),
) &&
state.furniture.every((item) => Number.isFinite(
item.x + item.y + item.z + item.rotationX + item.rotationY + item.rotationZ + item.rotationW,
))
);
},
},
visibility: {
createSnapshot(authority, { playerId }) {
return {
players: authority.players.map((player) => playerView(player, player.id === playerId)),
furniture: authority.furniture.map(furnitureView),
yellowScore: authority.yellowScore,
blueScore: authority.blueScore,
yellowDoorTicks: authority.yellowDoorTicks,
blueDoorTicks: authority.blueDoorTicks,
elapsedTicks: authority.elapsedTicks,
round: authority.round,
resetTicks: authority.resetTicks,
winner: authority.winner,
demolitionStarted: authority.demolitionStarted,
events: [],
};
},
validateClientState(authority, candidate, { playerId }) {
const expected = findPlayer(authority, playerId);
const reported = candidate.players.find((player) => player.id === playerId);
return Boolean(
expected &&
reported &&
reported.stamina !== null &&
Math.hypot(reported.x - expected.x, reported.z - expected.z) <= 4.5 &&
Math.abs(reported.stamina - expected.stamina) <= 18,
);
},
perceive(_authority, event) {
return { ...event };
},
},
input: {
validate(input) {
return (
typeof input === "object" && input !== null &&
Number.isFinite(input.forward) && Number.isFinite(input.strafe) &&
Math.abs(input.forward) <= 1 && Math.abs(input.strafe) <= 1 &&
typeof input.sprint === "boolean" && typeof input.grab === "boolean" &&
typeof input.throwItem === "boolean" && typeof input.closeDoors === "boolean"
);
},
},
encoding: {
input: inputCodec,
clientState: stateCodec,
perception: eventCodec,
},
});
export const moversGame = withInputStream(baseMoversGame, {
heartbeatRateHz: 20,
timeoutMs: 400,
inputsEqual(left, right) {
return (
left.forward === right.forward && left.strafe === right.strafe &&
left.sprint === right.sprint && left.grab === right.grab &&
left.throwItem === right.throwItem && left.closeDoors === right.closeDoors
);
},
neutralize: neutralInput,
resume(lastInput) {
return { ...lastInput, throwItem: false, closeDoors: false };
},
});
function neutralInput(): MoversInput {
return {
forward: 0,
strafe: 0,
sprint: false,
grab: false,
throwItem: false,
closeDoors: false,
};
}
function createAuthorityState(): MoversAuthorityState {
return {
players: [
createPlayer(MOVERS_BOT_IDS[0], "yellow", true, 0),
createPlayer(MOVERS_BOT_IDS[1], "yellow", true, 1),
createPlayer(MOVERS_BOT_IDS[2], "blue", true, 2),
createPlayer(MOVERS_BOT_IDS[3], "blue", true, 3),
],
furniture: createFurniture(),
yellowScore: 0,
blueScore: 0,
yellowDoorTicks: 0,
blueDoorTicks: 0,
elapsedTicks: 0,
round: 1,
resetTicks: 0,
winner: null,
demolitionStarted: false,
nextEventId: 1,
};
}
function createClientState(): MoversClientState {
return {
players: [],
furniture: [],
yellowScore: 0,
blueScore: 0,
yellowDoorTicks: 0,
blueDoorTicks: 0,
elapsedTicks: 0,
round: 1,
resetTicks: 0,
winner: null,
demolitionStarted: false,
events: [],
};
}
function createPlayer(id: number, team: MoversTeam, bot: boolean, slot: number): MoversAuthorityPlayer {
const spawnX = team === "yellow" ? -34 : 34;
return {
id,
team,
bot,
x: spawnX,
z: ((slot % 3) - 1) * 3,
velocityX: 0,
velocityZ: 0,
yaw: team === "yellow" ? Math.PI / 2 : -Math.PI / 2,
stamina: maximumStamina,
carryingId: null,
inputForward: 0,
inputStrafe: 0,
sprinting: false,
grabbing: false,
throwing: false,
closingDoors: false,
throwRequested: false,
closeDoorsRequested: false,
botTargetId: null,
};
}
function createFurniture(): MoversAuthorityFurniture[] {
const layout: Array<[FurnitureKind, number, number, number]> = [
["piano", -7, -5, 0.12],
["aquarium", 7, -7, -0.24],
["safe", 1, 5, 0],
["sofa", -10, 7, Math.PI / 2],
["television", 10, 4, 0.15],
["mattress", -2, -9, -0.3],
["urn", 0, -1, 0],
["refrigerator", 13, -2, 0.1],
["plant", -13, 0, -0.2],
["mystery-box", 4, 9, 0.35],
];
return layout.map(([kind, x, z, yaw], index) => {
const rotation = quaternionFromYaw(yaw);
return {
id: index + 1,
kind,
x,
y: FURNITURE[kind].halfExtents.y + 0.08,
z,
velocityX: 0,
velocityY: 0,
velocityZ: 0,
yaw,
rotationX: rotation.x,
rotationY: rotation.y,
rotationZ: rotation.z,
rotationW: rotation.w,
angularVelocityX: 0,
angularVelocityY: 0,
angularVelocityZ: 0,
damage: 0,
carriedBy: null,
securedBy: null,
lastDamageTick: -1_000,
};
});
}
function applyCommand(player: MoversAuthorityPlayer, input: MoversInput): void {
player.inputForward = input.forward;
player.inputStrafe = input.strafe;
player.sprinting = input.sprint;
player.grabbing = input.grab;
if (input.throwItem && !player.throwing) player.throwRequested = true;
if (input.closeDoors && !player.closingDoors) player.closeDoorsRequested = true;
player.throwing = input.throwItem;
player.closingDoors = input.closeDoors;
}
function updateBot(state: MoversAuthorityState, player: MoversAuthorityPlayer, tick: number): void {
let targetX = 0;
let targetZ = 0;
let grab = false;
let closeDoors = false;
if (player.carryingId !== null) {
targetX = player.team === "yellow" ? -34 : 34;
targetZ = ((player.id % 3) - 1) * 3;
const reached = inTruckZone(player.team, player.x, player.z);
grab = !reached;
closeDoors = reached;
} else {
const target = chooseBotTarget(state, player);
player.botTargetId = target?.id ?? null;
if (target) {
targetX = target.x;
targetZ = target.z;
grab = Math.hypot(targetX - player.x, targetZ - player.z) < 2.6;
} else {
targetX = Math.sin(tick * 0.014 + player.id) * 8;
targetZ = Math.cos(tick * 0.011 + player.id) * 6;
}
}
const dx = targetX - player.x;
const dz = targetZ - player.z;
const distance = Math.hypot(dx, dz);
player.inputStrafe = distance > 0.3 ? clamp(dx / distance, -1, 1) : 0;
player.inputForward = distance > 0.3 ? clamp(-dz / distance, -1, 1) : 0;
player.sprinting = distance > 8 && player.stamina > 18;
player.grabbing = grab;
if (closeDoors && !player.closingDoors) player.closeDoorsRequested = true;
player.closingDoors = closeDoors;
}
function chooseBotTarget(state: MoversAuthorityState, player: MoversAuthorityPlayer): MoversAuthorityFurniture | null {
let nearest: MoversAuthorityFurniture | null = null;
let best = Number.POSITIVE_INFINITY;
for (const item of state.furniture) {
if (item.securedBy !== null || item.carriedBy !== null) continue;
const distance = Math.hypot(item.x - player.x, item.z - player.z);
const score = distance - FURNITURE[item.kind].value / 4_000;
if (score >= best) continue;
nearest = item;
best = score;
}
return nearest;
}
function updateAuthorityPlayerIntent(
player: MoversAuthorityPlayer,
carrying: MoversAuthorityFurniture | null,
deltaSeconds: number,
): void {
const definition = carrying ? FURNITURE[carrying.kind] : null;
const carryingScale = definition ? 1 / (0.72 + definition.weight * 0.19) : 1;
const moving = Math.hypot(player.inputStrafe, player.inputForward) > 0.05;
const canSprint = player.sprinting && moving && player.stamina > 0 && !carrying;
updateMovementIntent(player, (canSprint ? 10.8 : 7.1) * carryingScale, deltaSeconds);
player.stamina = clamp(player.stamina + (canSprint ? -34 : 23) * deltaSeconds, 0, maximumStamina);
}
function updateVisiblePlayerIntent(
player: MoversPlayerView,
carrying: MoversFurnitureView | null,
deltaSeconds: number,
): void {
const definition = carrying ? FURNITURE[carrying.kind] : null;
const carryingScale = definition ? 1 / (0.72 + definition.weight * 0.19) : 1;
const moving = Math.hypot(player.inputStrafe, player.inputForward) > 0.05;
const stamina = player.stamina ?? maximumStamina;
const canSprint = player.sprinting && moving && stamina > 0 && !carrying;
updateMovementIntent(player, (canSprint ? 10.8 : 7.1) * carryingScale, deltaSeconds);
if (player.stamina !== null) {
player.stamina = clamp(player.stamina + (canSprint ? -34 : 23) * deltaSeconds, 0, maximumStamina);
}
}
function updateMovementIntent(
player: Pick<MoversAuthorityPlayer, "velocityX" | "velocityZ" | "yaw" | "inputForward" | "inputStrafe">,
speed: number,
deltaSeconds: number,
): void {
let directionX = player.inputStrafe;
let directionZ = -player.inputForward;
const magnitude = Math.hypot(directionX, directionZ);
if (magnitude > 1) {
directionX /= magnitude;
directionZ /= magnitude;
}
const acceleration = 42 * deltaSeconds;
player.velocityX = approach(player.velocityX, directionX * speed, acceleration);
player.velocityZ = approach(player.velocityZ, directionZ * speed, acceleration);
if (magnitude < 0.05) {
const damping = Math.exp(-11 * deltaSeconds);
player.velocityX *= damping;
player.velocityZ *= damping;
} else {
player.yaw = rotateToward(player.yaw, Math.atan2(directionX, directionZ), 9 * deltaSeconds);
}
}
function updateCarrying(state: MoversAuthorityState, emit: (event: MoversAuthorityEvent) => void): void {
for (const player of state.players) {
if (player.carryingId === null) continue;
const item = findFurniture(state, player.carryingId);
if (!item || item.securedBy !== null) {
player.carryingId = null;
continue;
}
if (player.throwRequested) {
releaseFurniture(item, player, true);
player.carryingId = null;
emit({ id: state.nextEventId++, type: "thrown", playerId: player.id, itemId: item.id, team: player.team });
} else if (!player.grabbing) {
releaseFurniture(item, player, false);
player.carryingId = null;
} else {
anchorFurniture(item, player);
}
}
for (const player of state.players) {
if (player.carryingId !== null || !player.grabbing) continue;
let nearest: MoversAuthorityFurniture | null = null;
let nearestDistance = 2.65;
for (const item of state.furniture) {
if (item.carriedBy !== null || item.securedBy !== null) continue;
const distance = Math.hypot(item.x - player.x, item.z - player.z);
if (distance >= nearestDistance) continue;
nearest = item;
nearestDistance = distance;
}
if (!nearest) continue;
nearest.carriedBy = player.id;
player.carryingId = nearest.id;
anchorFurniture(nearest, player);
emit({ id: state.nextEventId++, type: "grabbed", playerId: player.id, itemId: nearest.id, team: player.team });
}
}
function updateVisibleCarrying(state: MoversClientState): void {
for (const player of state.players) {
if (player.carryingId === null) continue;
const item = state.furniture.find((candidate) => candidate.id === player.carryingId);
if (item) anchorFurniture(item, player);
}
}
function anchorFurniture(
item: MoversAuthorityFurniture | MoversFurnitureView,
player: Pick<MoversAuthorityPlayer, "x" | "z" | "velocityX" | "velocityZ" | "yaw">,
): void {
const definition = FURNITURE[item.kind];
const distance = 1.05 + definition.radius * 0.62;
item.x = player.x + Math.sin(player.yaw) * distance;
item.y = Math.max(definition.halfExtents.y + 0.1, 1.45);
item.z = player.z + Math.cos(player.yaw) * distance;
item.velocityX = player.velocityX;
item.velocityY = 0;
item.velocityZ = player.velocityZ;
item.yaw = player.yaw;
const rotation = quaternionFromYaw(player.yaw);
item.rotationX = rotation.x;
item.rotationY = rotation.y;
item.rotationZ = rotation.z;
item.rotationW = rotation.w;
item.angularVelocityX = 0;
item.angularVelocityY = 0;
item.angularVelocityZ = 0;
}
function releaseFurniture(item: MoversAuthorityFurniture, player: MoversAuthorityPlayer, thrown: boolean): void {
const forwardX = Math.sin(player.yaw);
const forwardZ = Math.cos(player.yaw);
item.carriedBy = null;
item.velocityX = player.velocityX + forwardX * (thrown ? 12 : 0);
item.velocityY = thrown ? 4.2 : 0;
item.velocityZ = player.velocityZ + forwardZ * (thrown ? 12 : 0);
item.angularVelocityX = thrown ? (player.id % 2 === 0 ? 5.2 : -5.2) : 0;
item.angularVelocityY = thrown ? 2.4 : 0;
item.angularVelocityZ = thrown ? (player.id % 2 === 0 ? -3.6 : 3.6) : 0;
}
function resolveCarrierTackles(
state: MoversAuthorityState,
tick: number,
emit: (event: MoversAuthorityEvent) => void,
): void {
for (let leftIndex = 0; leftIndex < state.players.length; leftIndex += 1) {
const left = state.players[leftIndex]!;
for (let rightIndex = leftIndex + 1; rightIndex < state.players.length; rightIndex += 1) {
const right = state.players[rightIndex]!;
if (left.team === right.team || Math.hypot(right.x - left.x, right.z - left.z) > 1.3) continue;
const speed = Math.hypot(left.velocityX - right.velocityX, left.velocityZ - right.velocityZ);
if (speed < 8.2) continue;
const carrier = left.carryingId !== null ? left : right.carryingId !== null ? right : null;
if (!carrier || tick % 2 !== carrier.id % 2) continue;
const item = findFurniture(state, carrier.carryingId!);
if (!item) continue;
carrier.carryingId = null;
carrier.grabbing = false;
releaseFurniture(item, carrier, true);
emit({ id: state.nextEventId++, type: "thrown", playerId: carrier.id, itemId: item.id, team: carrier.team });
}
}
}
function tryCloseTruck(
state: MoversAuthorityState,
player: MoversAuthorityPlayer,
emit: (event: MoversAuthorityEvent) => void,
): void {
if (!nearOwnTruck(player) || doorTicks(state, player.team) > 0) return;
if (player.carryingId !== null) {
const held = findFurniture(state, player.carryingId);
if (held && inTruckZone(player.team, held.x, held.z)) {
held.carriedBy = null;
player.carryingId = null;
}
}
const cargo = state.furniture.filter(
(item) => item.securedBy === null && item.carriedBy === null && inTruckZone(player.team, item.x, item.z),
);
if (cargo.length === 0) return;
setDoorTicks(state, player.team, MOVERS_TICK_RATE * 2);
emit({ id: state.nextEventId++, type: "doors", team: player.team });
cargo.forEach((item, index) => {
item.securedBy = player.team;
item.velocityX = 0;
item.velocityY = 0;
item.velocityZ = 0;
item.angularVelocityX = 0;
item.angularVelocityY = 0;
item.angularVelocityZ = 0;
item.x = player.team === "yellow" ? -36 - (index % 2) * 2.2 : 36 + (index % 2) * 2.2;
item.y = FURNITURE[item.kind].halfExtents.y + 0.08;
item.z = -3 + Math.floor(index / 2) * 2.5;
item.yaw = 0;
item.rotationX = 0;
item.rotationY = 0;
item.rotationZ = 0;
item.rotationW = 1;
const value = remainingValue(item);
if (player.team === "yellow") state.yellowScore += value;
else state.blueScore += value;
emit({
id: state.nextEventId++,
type: "secured",
playerId: player.id,
itemId: item.id,
kind: item.kind,
team: player.team,
value,
});
});
}
function demolitionPulse(
state: MoversAuthorityState,
tick: number,
emit: (event: MoversAuthorityEvent) => void,
): void {
const angle = tick * 0.031;
moversPhysics.explode(state, {
position: { x: Math.cos(angle) * 6, y: 0.4, z: Math.sin(angle * 1.3) * 5 },
radius: 22,
falloff: 3,
impulsePerArea: 5.5,
});
for (const item of state.furniture) {
if (item.securedBy !== null || item.carriedBy !== null) continue;
damageFurniture(state, item, 3.5, tick, emit);
}
}
function damageFurniture(
state: MoversAuthorityState,
item: MoversAuthorityFurniture,
rawAmount: number,
tick: number,
emit: (event: MoversAuthorityEvent) => void,
): void {
const amount = Math.min(100 - item.damage, rawAmount * FURNITURE[item.kind].fragile);
if (amount <= 0.1) return;
item.damage = clamp(item.damage + amount, 0, 100);
if (tick - item.lastDamageTick < 5) return;
item.lastDamageTick = tick;
emit({
id: state.nextEventId++,
type: "damaged",
itemId: item.id,
amount: Math.round(amount),
remainingValue: remainingValue(item),
});
}
function finishMatch(state: MoversAuthorityState, emit: (event: MoversAuthorityEvent) => void): void {
if (state.resetTicks > 0) return;
state.winner = state.yellowScore === state.blueScore
? "draw"
: state.yellowScore > state.blueScore ? "yellow" : "blue";
state.resetTicks = MOVERS_TICK_RATE * 7;
emit({ id: state.nextEventId++, type: "winner", team: state.winner, round: state.round });
}
function resetMatch(state: MoversAuthorityState): void {
state.round += 1;
state.yellowScore = 0;
state.blueScore = 0;
state.yellowDoorTicks = 0;
state.blueDoorTicks = 0;
state.elapsedTicks = 0;
state.winner = null;
state.demolitionStarted = false;
state.furniture = createFurniture();
state.players.forEach((player, index) => {
Object.assign(player, createPlayer(player.id, player.team, player.bot, index));
});
moversPhysics.reset(state);
}
function playerView(player: MoversAuthorityPlayer, owner: boolean): MoversPlayerView {
return {
id: player.id,
team: player.team,
bot: player.bot,
x: player.x,
z: player.z,
velocityX: player.velocityX,
velocityZ: player.velocityZ,
yaw: player.yaw,
stamina: owner ? player.stamina : null,
carryingId: player.carryingId,
inputForward: player.inputForward,
inputStrafe: player.inputStrafe,
sprinting: player.sprinting,
grabbing: player.grabbing,
};
}
function furnitureView(item: MoversAuthorityFurniture): MoversFurnitureView {
return {
id: item.id,
kind: item.kind,
x: item.x,
y: item.y,
z: item.z,
velocityX: item.velocityX,
velocityY: item.velocityY,
velocityZ: item.velocityZ,
yaw: item.yaw,
rotationX: item.rotationX,
rotationY: item.rotationY,
rotationZ: item.rotationZ,
rotationW: item.rotationW,
angularVelocityX: item.angularVelocityX,
angularVelocityY: item.angularVelocityY,
angularVelocityZ: item.angularVelocityZ,
damage: item.damage,
carriedBy: item.carriedBy,
securedBy: item.securedBy,
};
}
function cloneAuthorityState(state: MoversAuthorityState): MoversAuthorityState {
return {
...state,
players: state.players.map((player) => ({ ...player })),
furniture: state.furniture.map((item) => ({ ...item })),
};
}
function cloneClientState(state: MoversClientState): MoversClientState {
return {
...state,
players: state.players.map((player) => ({ ...player })),
furniture: state.furniture.map((item) => ({ ...item })),
events: state.events.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } })),
};
}
function findPlayer(state: MoversAuthorityState, id: number): MoversAuthorityPlayer | undefined {
return state.players.find((player) => player.id === id);
}
function findFurniture(state: MoversAuthorityState, id: number): MoversAuthorityFurniture | undefined {
return state.furniture.find((item) => item.id === id);
}
function leastPopulatedTeam(state: MoversAuthorityState): MoversTeam {
const yellow = state.players.filter((player) => player.team === "yellow").length;
return yellow <= state.players.length - yellow ? "yellow" : "blue";
}
function nearOwnTruck(player: Pick<MoversAuthorityPlayer, "team" | "x" | "z">): boolean {
return player.team === "yellow"
? player.x < -27 && Math.abs(player.z) < 10
: player.x > 27 && Math.abs(player.z) < 10;
}
function inTruckZone(team: MoversTeam, x: number, z: number): boolean {
return team === "yellow"
? x < -29 && x > -41 && Math.abs(z) < 8
: x > 29 && x < 41 && Math.abs(z) < 8;
}
function doorTicks(state: MoversAuthorityState, team: MoversTeam): number {
return team === "yellow" ? state.yellowDoorTicks : state.blueDoorTicks;
}
function setDoorTicks(state: MoversAuthorityState, team: MoversTeam, ticks: number): void {
if (team === "yellow") state.yellowDoorTicks = ticks;
else state.blueDoorTicks = ticks;
}
function remainingValue(item: Pick<MoversAuthorityFurniture, "kind" | "damage">): number {
return Math.max(25, Math.round(FURNITURE[item.kind].value * (1 - item.damage / 100)));
}
function quaternionFromYaw(yaw: number): { x: number; y: number; z: number; w: number } {
return { x: 0, y: Math.sin(yaw / 2), z: 0, w: Math.cos(yaw / 2) };
}
function rotateToward(current: number, target: number, maximum: number): number {
const difference = normalizeAngle(target - current);
return normalizeAngle(current + clamp(difference, -maximum, maximum));
}
function normalizeAngle(value: number): number {
return Math.atan2(Math.sin(value), Math.cos(value));
}
function approach(value: number, target: number, maximumDelta: number): number {
return value < target
? Math.min(target, value + maximumDelta)
: Math.max(target, value - maximumDelta);
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}

View File

@@ -0,0 +1,161 @@
export type MoversTeam = "yellow" | "blue";
export type FurnitureKind =
| "piano"
| "aquarium"
| "safe"
| "sofa"
| "television"
| "mattress"
| "urn"
| "refrigerator"
| "plant"
| "mystery-box";
export interface MoversInput {
forward: number;
strafe: number;
sprint: boolean;
grab: boolean;
throwItem: boolean;
closeDoors: boolean;
}
export interface MoversAuthorityPlayer {
id: number;
team: MoversTeam;
bot: boolean;
x: number;
z: number;
velocityX: number;
velocityZ: number;
yaw: number;
stamina: number;
carryingId: number | null;
inputForward: number;
inputStrafe: number;
sprinting: boolean;
grabbing: boolean;
throwing: boolean;
closingDoors: boolean;
throwRequested: boolean;
closeDoorsRequested: boolean;
botTargetId: number | null;
}
export interface MoversAuthorityFurniture {
id: number;
kind: FurnitureKind;
x: number;
y: number;
z: number;
velocityX: number;
velocityY: number;
velocityZ: number;
yaw: number;
rotationX: number;
rotationY: number;
rotationZ: number;
rotationW: number;
angularVelocityX: number;
angularVelocityY: number;
angularVelocityZ: number;
damage: number;
carriedBy: number | null;
securedBy: MoversTeam | null;
lastDamageTick: number;
}
export interface MoversAuthorityState {
players: MoversAuthorityPlayer[];
furniture: MoversAuthorityFurniture[];
yellowScore: number;
blueScore: number;
yellowDoorTicks: number;
blueDoorTicks: number;
elapsedTicks: number;
round: number;
resetTicks: number;
winner: MoversTeam | "draw" | null;
demolitionStarted: boolean;
nextEventId: number;
}
export interface MoversPlayerView {
id: number;
team: MoversTeam;
bot: boolean;
x: number;
z: number;
velocityX: number;
velocityZ: number;
yaw: number;
/** Exact stamina is private to its owning player. */
stamina: number | null;
carryingId: number | null;
inputForward: number;
inputStrafe: number;
sprinting: boolean;
grabbing: boolean;
}
export interface MoversFurnitureView {
id: number;
kind: FurnitureKind;
x: number;
y: number;
z: number;
velocityX: number;
velocityY: number;
velocityZ: number;
yaw: number;
rotationX: number;
rotationY: number;
rotationZ: number;
rotationW: number;
angularVelocityX: number;
angularVelocityY: number;
angularVelocityZ: number;
damage: number;
carriedBy: number | null;
securedBy: MoversTeam | null;
}
export interface MoversClientState {
players: MoversPlayerView[];
furniture: MoversFurnitureView[];
yellowScore: number;
blueScore: number;
yellowDoorTicks: number;
blueDoorTicks: number;
elapsedTicks: number;
round: number;
resetTicks: number;
winner: MoversTeam | "draw" | null;
demolitionStarted: boolean;
events: MoversPresentationEvent[];
}
export type MoversAuthorityEvent =
| { id: number; type: "grabbed"; playerId: number; itemId: number; team: MoversTeam }
| { id: number; type: "thrown"; playerId: number; itemId: number; team: MoversTeam }
| { id: number; type: "damaged"; itemId: number; amount: number; remainingValue: number }
| { id: number; type: "secured"; playerId: number; itemId: number; kind: FurnitureKind; team: MoversTeam; value: number }
| { id: number; type: "doors"; team: MoversTeam }
| { id: number; type: "demolition" }
| { id: number; type: "winner"; team: MoversTeam | "draw"; round: number };
export type MoversPerception = MoversAuthorityEvent;
export interface MoversPresentationEvent {
receivedTick: number;
event: MoversPerception;
}
export interface MoversGameContract {
authority: MoversAuthorityState;
client: MoversClientState;
input: MoversInput;
authorityEvent: MoversAuthorityEvent;
perceptionEvent: MoversPerception;
}

View File

@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
FURNITURE,
MOVERS_BOT_IDS,
MOVERS_PHYSICS_BACKEND,
MOVERS_SNAPSHOT_RATE,
MOVERS_TICK_RATE,
moversGame,
} from "../dist/index.js";
function inputPacket(sequence, targetTick, input = {}) {
return {
sequence,
targetTick,
observedTick: Math.max(0, targetTick - 1),
input: {
forward: 0,
strafe: 0,
sprint: false,
grab: false,
throwItem: false,
closeDoors: false,
...input,
},
};
}
test("Bad Movers is a fourth complete game built through defineMultiplayerGame", () => {
assert.equal(moversGame.tickRateHz, MOVERS_TICK_RATE);
assert.equal(moversGame.snapshotRateHz, MOVERS_SNAPSHOT_RATE);
const server = moversGame.createServer();
assert.equal(server.currentState.players.filter((player) => player.bot).length, MOVERS_BOT_IDS.length);
assert.equal(server.currentState.furniture.length, Object.keys(FURNITURE).length);
assert.deepEqual(MOVERS_PHYSICS_BACKEND, {
name: "Box3D",
version: "0.1.0",
bindingVersion: "0.2.0",
runtime: "WebAssembly SIMD",
solver: "single-threaded deterministic",
subSteps: 4,
});
server.addPlayer(1);
server.addPlayer(2);
assert.equal(server.currentState.players.find((player) => player.id === 1).team, "yellow");
assert.equal(server.currentState.players.find((player) => player.id === 2).team, "blue");
});
test("real Box3D gravity advances full 3D furniture transforms", () => {
const server = moversGame.createServer();
const box = server.currentState.furniture.find((item) => item.kind === "mystery-box");
Object.assign(box, {
x: 0,
y: 8,
z: 0,
velocityX: 0,
velocityY: 0,
velocityZ: 0,
});
for (let tick = 0; tick < 12; tick += 1) server.step();
assert.ok(box.y < 7, `expected Box3D gravity to drop the box, got y=${box.y}`);
assert.ok(box.velocityY < 0);
assert.ok(Number.isFinite(box.rotationX + box.rotationY + box.rotationZ + box.rotationW));
});
test("independent Box3D authorities produce the same deterministic match", () => {
const first = moversGame.createServer();
const second = moversGame.createServer();
for (let tick = 0; tick < 600; tick += 1) {
first.step();
second.step();
}
assert.deepEqual(first.currentState, second.currentState);
});
test("only the owning mover receives exact stamina", () => {
const server = moversGame.createServer();
server.addPlayer(1);
server.addPlayer(2);
const yellowView = server.createSnapshot(1, 0).state;
const blueView = server.createSnapshot(2, 0).state;
assert.equal(yellowView.players.find((player) => player.id === 1).stamina, 100);
assert.equal(yellowView.players.find((player) => player.id === 2).stamina, null);
assert.equal(blueView.players.find((player) => player.id === 1).stamina, null);
assert.equal(blueView.players.find((player) => player.id === 2).stamina, 100);
assert.equal("nextEventId" in yellowView, false);
});
test("furniture only scores when a mover closes their own truck doors", () => {
const server = moversGame.createServer();
server.addPlayer(1);
const player = server.currentState.players.find((candidate) => candidate.id === 1);
const urn = server.currentState.furniture.find((item) => item.kind === "urn");
Object.assign(player, { x: -34, z: 0 });
Object.assign(player, { carryingId: urn.id, grabbing: false });
Object.assign(urn, { x: -35, z: 0, damage: 10, carriedBy: player.id });
assert.equal(server.currentState.yellowScore, 0);
assert.equal(server.submitInput(1, inputPacket(1, 1, { closeDoors: true })).accepted, true);
const events = server.step().events;
assert.equal(urn.securedBy, "yellow");
assert.equal(server.currentState.yellowScore, Math.round(FURNITURE.urn.value * 0.9));
assert.ok(events.some((event) => event.type === "doors" && event.team === "yellow"));
assert.ok(events.some((event) => event.type === "secured" && event.itemId === urn.id));
});
test("Bad Movers bots can complete physical deliveries headlessly", () => {
const server = moversGame.createServer();
let secured = 0;
for (let tick = 0; tick < 2_400; tick += 1) {
for (const event of server.step().events) {
if (event.type === "secured") secured += 1;
}
}
assert.ok(secured >= 2, `expected bot deliveries, saw ${secured}`);
assert.ok(server.currentState.yellowScore + server.currentState.blueScore > 0);
assert.ok(server.currentState.furniture.every((item) => Number.isFinite(item.x + item.z + item.damage)));
});
test("Bad Movers input and state round-trip through the generic protocol", () => {
const server = moversGame.createServer();
server.addPlayer(7);
const client = moversGame.createClient();
client.initialize(7, server.createSnapshot(7, 10));
const packet = client.createInput({
forward: 1,
strafe: -0.5,
sprint: true,
grab: true,
throwItem: false,
closeDoors: false,
}, 1);
const decoded = moversGame.protocol.decodeClient(
moversGame.protocol.encodeClient({ kind: "input", packet }),
);
assert.equal(decoded.kind, "input");
assert.deepEqual(decoded.packet, packet);
assert.equal(server.submitInput(7, decoded.packet).accepted, true);
server.step();
client.step();
client.reconcile(server.createSnapshot(7, 20));
assert.ok(client.currentState.players.some((player) => player.id === 7));
});