init commit

This commit is contained in:
usernames122
2025-10-12 18:03:20 +02:00
commit 88d231e367
20 changed files with 2528 additions and 0 deletions

30
js/clientmain.js Normal file
View File

@@ -0,0 +1,30 @@
import { DataModel } from "./core/DataModel.js";
import * as THREE from "three";
import { NetworkService } from "./instances/NetworkService.js";
import { ReplicatorService } from "./instances/ReplicatorService.js";
import { RenderService } from "./instances/RenderService.js";
import { Workspace } from "./instances/Workspace.js";
import { DebugTextService } from "./instances/DebugTextService.js"; // Client-only debug overlay manager
const dm = new DataModel();
dm.SetParent(null); // root
// Create workspace
const ws = new Workspace();
ws.SetParent(dm);
const net = new NetworkService();
net.SetParent(dm);
net.isServer = false; // client mode
await net.connect("ws://localhost:8080"); // connect to server
const render = new RenderService(dm);
render.SetParent(dm);
render.start();
// Start replicator
const replication = new ReplicatorService(dm, net); // Automatically parents to dm
// Create DebugTextService
const debugText = new DebugTextService();
debugText.SetParent(dm);

View File

@@ -0,0 +1,40 @@
// A simple event system that mimics Roblox's RBXScriptSignal.
export class BO3ScriptSignal {
constructor() {
this._connections = [];
}
// Connect a function to the signal.
Connect(fn) {
const connection = { fn, connected: true };
this._connections.push(connection);
return {
Disconnect: () => {
connection.connected = false;
this._connections = this._connections.filter(c => c !== connection);
},
};
}
// Fire all connected callbacks with arguments.
Fire(...args) {
for (const c of this._connections) {
if (c.connected) {
try {
c.fn(...args);
} catch (err) {
console.error("[BO3ScriptSignal] Error in connected function:", err);
}
}
}
}
// Disconnect all listeners.
DisconnectAll() {
this._connections = [];
}
get [Symbol.toStringTag]() {
return "BO3ScriptSignal";
}
}

15
js/core/DataModel.js Normal file
View File

@@ -0,0 +1,15 @@
import {Instance} from "../instances/Instance.js";
export class DataModel extends Instance {
constructor() {
super("DataModel");
this.InstanceId = "DataModelRoot"; // Fixed ID for root
}
GetService(name) {
// Loop through children by class name
return this.Children.find(c => c.ClassName === name) || null;
}
GetDataModel() {return this;} // Override to return self
} // Just a container for the whole instance tree
// cuz this is an Entity Component System, we need a root entity
// Assume all children of DataModel are services or top-level game objects

140
js/core/util.js Normal file
View File

@@ -0,0 +1,140 @@
import * as THREE from "three";
function guidGenerator() {
var S4 = function() {
return (((1+Math.random())*0x10000)|0).toString(16).substring(1);
};
return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4());
}
// Pure JS SHA-256 (works in browser or Node)
// Returns hex string
function sha256(message) {
// UTF-8 encode string to bytes
const msgBytes = new TextEncoder().encode(message);
// Constants (first 32 bits of the fractional parts of the cube roots of the first 64 primes)
const K = new Uint32Array([
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
]);
// Initial hash values (first 32 bits of the fractional parts of the square roots of the first 8 primes)
let H = new Uint32Array([
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,
0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
]);
// Pre-processing: padding the message
const l = msgBytes.length * 8;
// Append 0x80 then zero bytes, then 64-bit big-endian length
const k = (512 - ((l + 8 + 64) % 512)) % 512; // number of zero bits
const totalBits = l + 1 + k + 64;
const totalBytes = totalBits / 8;
const padded = new Uint8Array(totalBytes);
// copy message
padded.set(msgBytes);
// append 1 bit (0x80)
padded[msgBytes.length] = 0x80;
// append length in bits as 64-bit big-endian integer
const lenPos = totalBytes - 8;
for (let i = 0; i < 8; i++) {
padded[lenPos + 7 - i] = (l >>> (i * 8)) & 0xff;
}
// Process the message in successive 512-bit chunks
const W = new Uint32Array(64);
function ROTR(n, x) { return (x >>> n) | (x << (32 - n)); }
function Σ0(x) { return ROTR(2,x) ^ ROTR(13,x) ^ ROTR(22,x); }
function Σ1(x) { return ROTR(6,x) ^ ROTR(11,x) ^ ROTR(25,x); }
function σ0(x) { return ROTR(7,x) ^ ROTR(18,x) ^ (x >>> 3); }
function σ1(x) { return ROTR(17,x) ^ ROTR(19,x) ^ (x >>> 10); }
for (let chunkStart = 0; chunkStart < padded.length; chunkStart += 64) {
// prepare message schedule W[0..63]
for (let i = 0; i < 16; i++) {
const j = chunkStart + i * 4;
W[i] = (padded[j] << 24) | (padded[j + 1] << 16) | (padded[j + 2] << 8) | (padded[j + 3]);
}
for (let i = 16; i < 64; i++) {
W[i] = (σ1(W[i - 2]) + W[i - 7] + σ0(W[i - 15]) + W[i - 16]) >>> 0;
}
// initialize working variables
let a = H[0], b = H[1], c = H[2], d = H[3];
let e = H[4], f = H[5], g = H[6], h = H[7];
for (let t = 0; t < 64; t++) {
const T1 = (h + Σ1(e) + ((e & f) ^ (~e & g)) + K[t] + W[t]) >>> 0;
const T2 = (Σ0(a) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
h = g;
g = f;
f = e;
e = (d + T1) >>> 0;
d = c;
c = b;
b = a;
a = (T1 + T2) >>> 0;
}
// compute intermediate hash value
H[0] = (H[0] + a) >>> 0;
H[1] = (H[1] + b) >>> 0;
H[2] = (H[2] + c) >>> 0;
H[3] = (H[3] + d) >>> 0;
H[4] = (H[4] + e) >>> 0;
H[5] = (H[5] + f) >>> 0;
H[6] = (H[6] + g) >>> 0;
H[7] = (H[7] + h) >>> 0;
}
// produce the final hash (big-endian)
let hex = "";
for (let i = 0; i < 8; i++) {
hex += ("00000000" + H[i].toString(16)).slice(-8);
}
return hex;
}
function v3Encoder(vec3) { // THREE.Vector3 to Float32Array(3)
const arr = new Float32Array(3);
arr[0] = vec3.x;
arr[1] = vec3.y;
arr[2] = vec3.z;
return arr;
}
function v3Decoder(arr) { // Float32Array(3) to THREE.Vector3
return new THREE.Vector3(arr[0], arr[1], arr[2]);
}
function eulerEncoder(euler) { // THREE.Euler to Float32Array(3)
const arr = new Float32Array(3);
arr[0] = euler.x;
arr[1] = euler.y;
arr[2] = euler.z;
return arr;
}
function eulerDecoder(arr) { // Float32Array(3) to THREE.Euler
return new THREE.Euler(arr[0], arr[1], arr[2]);
}
const encoders = {
Vector3: { encode: v3Encoder, decode: v3Decoder },
Euler: { encode: eulerEncoder, decode: eulerDecoder },
// Add more as needed
};
export { guidGenerator, sha256, encoders };

152
js/instances/BasePart.js Normal file
View File

@@ -0,0 +1,152 @@
import * as THREE from "three";
import * as CANNON from "cannon-es";
import { Instance } from "./Instance.js";
export class BasePart extends Instance {
constructor() {
super("BasePart");
// === Visual properties ===
this.Color = 0x00ff00;
this.Size = new THREE.Vector3(1, 1, 1);
this.Position = new THREE.Vector3(0, 0, 0);
this.Orientation = new THREE.Euler(0, 0, 0);
this.Anchored = false; // Roblox-style toggle
// === Three.js setup ===
const geometry = new THREE.BoxGeometry(this.Size.x, this.Size.y, this.Size.z);
const material = new THREE.MeshStandardMaterial({ color: this.Color });
this.mesh = new THREE.Mesh(geometry, material);
this.mesh.castShadow = true;
this.mesh.receiveShadow = true;
this.mesh.userData.basePart = this;
// === Cannon.js setup ===
const shape = new CANNON.Box(
new CANNON.Vec3(this.Size.x / 2, this.Size.y / 2, this.Size.z / 2)
);
this.body = new CANNON.Body({
mass: 1,
position: new CANNON.Vec3(this.Position.x, this.Position.y, this.Position.z),
shape: shape,
});
this.body.userData = { basePart: this };
this._originalMass = this.body.mass; // store default mass
this._renderService = null; // assigned when added to scene
}
// === Scene lifecycle ===
OnAddedToScene(parent) {
const dm = this.GetDataModel();
const renderService = dm.GetService("RenderService");
this._renderService = renderService;
if (renderService.scene) renderService.scene.add(this.mesh);
if (!renderService.physicsWorld) renderService._initPhysicsWorld();
renderService.physicsWorld.addBody(this.body);
this._applyAnchoredState();
}
// === Anchoring logic ===
setAnchored(value) {
if (this.Anchored === value) return;
this.Anchored = value;
this._applyAnchoredState();
}
_applyAnchoredState() {
if (!this.body) return;
if (this.Anchored) {
this.body.type = CANNON.Body.STATIC;
this.body.mass = 0;
this.body.velocity.set(0, 0, 0);
this.body.angularVelocity.set(0, 0, 0);
} else {
this.body.type = CANNON.Body.DYNAMIC;
this.body.mass = this._originalMass || 1;
}
this.body.updateMassProperties();
}
// === Updates ===
updateVisual() {
// Only update mesh from physics if not anchored
if (!this.Anchored) {
this.mesh.position.copy(this.body.position);
this.mesh.quaternion.copy(this.body.quaternion);
}
}
updateBodyPosition() {
this.body.position.set(this.Position.x, this.Position.y, this.Position.z);
this.body.quaternion.setFromEuler(
this.Orientation.x,
this.Orientation.y,
this.Orientation.z
);
this.body.velocity.set(0, 0, 0);
this.body.angularVelocity.set(0, 0, 0);
}
updateSizes() {
// Update Three geometry
this.mesh.geometry.dispose();
this.mesh.geometry = new THREE.BoxGeometry(this.Size.x, this.Size.y, this.Size.z);
// Update Cannon shape
this.body.shapes = [];
const newShape = new CANNON.Box(
new CANNON.Vec3(this.Size.x / 2, this.Size.y / 2, this.Size.z / 2)
);
this.body.addShape(newShape);
this.body.updateMassProperties();
}
OnReplicated() {
if (this.IsReplicated) {
this.body.type = CANNON.Body.KINEMATIC;
this.body.velocity.set(0, 0, 0);
this.body.angularVelocity.set(0, 0, 0);
}
this.body.position.copy(this.Position);
this.body.quaternion.setFromEuler(
this.Orientation.x,
this.Orientation.y,
this.Orientation.z
);
this.updateVisual();
}
OnPhysicsTick() {
if (this.body.position.y < -1000) {
// Fell out of world, destroy like in Roblox
this.Destroy();
console.debug("BasePart fell out of world and was destroyed.");
return;
}
if (this.IsReplicated || this.Anchored) return;
this.Position.set(
this.body.position.x,
this.body.position.y,
this.body.position.z
);
this.Orientation.setFromQuaternion(this.body.quaternion);
}
// === Cleanup ===
Destroy() {
if (this._renderService) {
if (this.mesh.parent) this.mesh.parent.remove(this.mesh);
if (this._renderService.physicsWorld)
this._renderService.physicsWorld.removeBody(this.body);
}
super.Destroy();
}
}

View File

@@ -0,0 +1,105 @@
import { Instance } from "./Instance.js";
import { BO3ScriptSignal } from "../core/BO3ScriptSignal.js";
export class DebugTextService extends Instance {
constructor() {
super("DebugTextService");
this.isClient = true; // Mark client-only
this._renderService = null;
this._networkService = null;
this._lastBytes = 0;
this._bps = 0;
this._lastMeasure = performance.now();
// Tracks text lines
this._lines = [];
// Hook into render loop later
}
OnAddedToScene(parent) {
const dm = this.GetDataModel();
// Get references to RenderService and NetworkService
this._renderService = dm.GetService("RenderService");
this._networkService = dm.GetService("NetworkService");
if (!this._renderService) {
console.warn("[DebugTextService] No RenderService found!");
return;
}
// Initialize debug text array
this._renderService.debugText = [];
// Hook into RenderStepped
this._renderService.RenderStepped.Connect((dt) => this._onRenderStep(dt));
}
// Called each RenderStepped frame (~60fps)
_onRenderStep(dt) {
this._lines.length = 0; // clear
// === Network stats ===
if (this._networkService) {
const now = performance.now();
const bytesNow = this._networkService.totalBytes || 0;
const deltaBytes = bytesNow - this._lastBytes;
const deltaTime = (now - this._lastMeasure) / 1000;
if (deltaTime > 0.25) { // smooth every 250ms
this._bps = deltaBytes / deltaTime;
this._lastBytes = bytesNow;
this._lastMeasure = now;
}
this._lines.push(
`Net: ${(this._bps / 1024).toFixed(2)} KB/s`
);
}
// === Instance tree summary ===
const dm = this.GetDataModel();
if (dm && dm.Children) {
const totalInstances = this._countInstances(dm);
this._lines.push(`Instances: ${totalInstances}`);
}
// === Misc stats ===
if (this._renderService.physicsWorld) {
const bodies = this._renderService.physicsWorld.bodies.length;
this._lines.push(`Physics Bodies: ${bodies}`);
}
// === Output ===
this._renderService.debugText.length = 0;
this._renderService.debugText.push(...this._lines);
// Generate instance tree (for future use)
const instanceTree = this._generateTree(dm);
// Push this to text overlay if needed
this._renderService.debugText.push(...instanceTree);
}
_countInstances(instance) {
let count = 1;
if (!instance.Children) return count;
for (const child of instance.Children) {
count += this._countInstances(child);
}
return count;
}
_generateTree(instance, depth = 0) {
let lines = [];
const indent = " ".repeat(depth);
lines.push(`${indent}- ${instance.ClassName} (${instance.Name}) [${instance.InstanceId}]`);
if (instance.Children) {
for (const child of instance.Children) {
lines.push(...this._generateTree(child, depth + 1));
}
}
return lines;
}
}
// Displays debug text overlay on screen with network and instance stats

View File

@@ -0,0 +1,6 @@
import { Instance } from "./Instance.js";
export class ExampleService extends Instance {
constructor() {
super("ExampleService");
}
} // Basic example of a service in BO3

123
js/instances/Instance.js Normal file
View File

@@ -0,0 +1,123 @@
import { BO3ScriptSignal } from "../core/BO3ScriptSignal.js";
import { guidGenerator } from "../core/util.js";
let instanceCounter = 0;
export class Instance {
constructor(className = "Instance") {
this.ClassName = className;
this.Name = `${className}${instanceCounter++}`;
this.Parent = null;
this.Children = [];
this.InstanceId = guidGenerator(); // Unique identifier, used for replication
// Signals
this.AncestryChanged = new BO3ScriptSignal();
this.Changed = new BO3ScriptSignal();
}
// Set a property dynamically
SetProperty(prop, value) {
this[prop] = value;
this.Changed.Fire(prop, value);
}
GetDataModel() { // Recursively calls itself on parent until DataModel is found, since DataModel overrides this method to return itself
if (this.Parent) return this.Parent.GetDataModel();
}
FindInstanceRecursively(instanceId) {
if (this.InstanceId === instanceId) return this;
if (this.Children) {
for (const child of this.Children) {
if (typeof child.FindInstanceRecursively === 'function') {
const found = child.FindInstanceRecursively(instanceId);
if (found) return found;
}
}
}
return null;
}
OnReplicated() {
// Hook for when instance is replicated to client. Override in subclasses.
}
// Parent management
SetParent(newParent) {
if (this.Parent === newParent) return;
// Remove from old parent
if (this.Parent) {
const oldChildren = this.Parent.Children;
this.Parent.Children = oldChildren.filter(c => c !== this);
}
const oldParent = this.Parent;
this.Parent = newParent;
// Add to new parent
if (newParent) {
newParent.Children.push(this);
}
try {
this.AncestryChanged.Fire(this, oldParent);
} catch {
console.warn("AncestryChanged signal error! May be caused by ReplicatorService during replication.");
}
if (oldParent === null && newParent !== null && this.OnAddedToScene) {
// If added to scene (i.e. RenderService), call hook
setTimeout(() => {
this.OnAddedToScene(newParent);
}, 0); // Defer to avoid issues during replication
}
}
// Utility: find first child with name
FindFirstChild(name) {
return this.Children.find(c => c.Name === name) || null;
}
// Utility: create deep clone
Clone() {
const clone = new Instance(this.ClassName);
for (const key of Object.keys(this)) {
if (key !== "Parent" && key !== "Children") {
clone[key] = structuredClone(this[key]);
}
}
for (const child of this.Children) {
const childClone = child.Clone();
childClone.SetParent(clone);
}
return clone;
}
Destroy() {
// Remove from parent
const dm = this.GetDataModel(); // Get DataModel for replication service, BEFORE removing parent. otherwise can't find it.
this.SetParent(null);
// Recursively destroy children
for (const child of [...this.Children]) {
if (typeof child.Destroy === 'function') {
child.Destroy();
} else {
child.SetParent(null);
}
}
// Clear signals
this.AncestryChanged.DisconnectAll();
this.Changed.DisconnectAll();
// Request replicator to remove this instance if needed
if (dm) {
const replicator = dm.GetService("ReplicatorService");
if (replicator) {
replicator.requestReplicateDestroy(this);
} else {
console.warn("Destroy: No ReplicatorService found in DataModel.");
}
} else {
console.warn("Destroy: No DataModel found in ancestry. Are you destroying an unparented instance?");
}
} // Garbage collected after this
}

View File

@@ -0,0 +1,63 @@
import * as THREE from "three";
import { BasePart } from "./BasePart.js";
export class LerpedBasePart extends BasePart { // Extends BasePart to add smooth interpolation for networked parts
constructor() {
super();
this.ClassName = "LerpedBasePart"; // 👈 Roblox-style ClassName
this._lastReplicatedState = null;
this._nextReplicatedState = null;
}
// Called when replication data arrives from the server
OnReplicated() {
// Keep old state to interpolate from
this._lastReplicatedState = this._nextReplicatedState;
// Store new replication state
this._nextReplicatedState = {
pos: this.Position.clone(),
quat: new THREE.Quaternion().setFromEuler(this.Orientation),
time: performance.now(),
};
// Apply anchored state from server
this._applyAnchoredState?.();
this.updateSizes(); // In case Size changed
}
// Smooth visual updates
updateVisual(dt) {
// Dont lerp anchored parts — they dont move
if (this.Anchored) {
this.mesh.position.copy(this.Position);
this.mesh.quaternion.setFromEuler(this.Orientation);
return;
}
if (this._nextReplicatedState && this._lastReplicatedState) {
// Interpolate between last and next states
const now = performance.now();
const t = Math.min(
(now - this._lastReplicatedState.time) / 100, // ~10Hz replication → 100ms
1
);
this.mesh.position.lerpVectors(
this._lastReplicatedState.pos,
this._nextReplicatedState.pos,
t
);
this.mesh.quaternion.slerpQuaternions(
this._lastReplicatedState.quat,
this._nextReplicatedState.quat,
t
);
} else {
// Fallback to default behavior
this.mesh.position.copy(this.body.position);
this.mesh.quaternion.copy(this.body.quaternion);
console.warn("LerpedBasePart: No replication data yet, using physics state.");
}
}
}

View File

@@ -0,0 +1,200 @@
let WebSocketImpl;
let WebSocketServer;
if (typeof window !== "undefined") {
WebSocketImpl = WebSocket;
} else {
// Dynamically require 'ws' only on server side
const wsModule = await import("ws");
WebSocketImpl = wsModule.default;
WebSocketServer = wsModule.WebSocketServer;
}
import { Instance } from "./Instance.js";
import { BO3ScriptSignal } from "../core/BO3ScriptSignal.js";
/**
* NetworkService
* - Handles both client & server networking
* - Binary framed packets: [uint32 length][ptype][psub][payload]
* - Fires PacketReceived signal for incoming packets
*/
export class NetworkService extends Instance {
constructor() {
super("NetworkService");
/** @type {boolean} */
this.isServer = false;
/** @type {WebSocketServer|null} */
this.wss = null;
/** @type {WebSocket|null} */
this.client = null;
/** @type {Map<WebSocket, any>} */
this.clients = new Map();
/** @type {Map<number, Function>} */
this.handlers = new Map();
/** @type {BO3ScriptSignal} */
this.PacketReceived = new BO3ScriptSignal();
// Total bytes received (for stats)
this.totalBytes = 0;
// Interval to clear totalBytes every second
setInterval(() => {
this.totalBytes = 0;
}, 1000);
}
// ==========================
// === Utility Functions ===
// ==========================
static pktKey(ptype, psub) {
return (ptype << 8) | psub;
}
registerHandler(ptype, psub, fn) {
const key = NetworkService.pktKey(ptype, psub);
this.handlers.set(key, fn);
}
unregisterHandler(ptype, psub) {
const key = NetworkService.pktKey(ptype, psub);
this.handlers.delete(key);
}
invokeHandler(ptype, psub, payload, client = null) {
const key = NetworkService.pktKey(ptype, psub);
const handler = this.handlers.get(key);
if (handler) handler(payload, client);
this.PacketReceived.Fire({ ptype, psub, payload, client });
}
// ======================
// === Packet Encode ===
// ======================
encodePacket(ptype, psub, payload = new Uint8Array()) {
if (!(payload instanceof Uint8Array)) {
if (typeof payload === "string") {
payload = new TextEncoder().encode(payload);
} else if (payload instanceof ArrayBuffer) {
payload = new Uint8Array(payload);
} else {
throw new Error("Payload must be Uint8Array, ArrayBuffer, or string");
}
}
const body = new Uint8Array(2 + payload.length);
body[0] = ptype;
body[1] = psub;
body.set(payload, 2);
const header = new ArrayBuffer(4);
new DataView(header).setUint32(0, body.length);
return new Uint8Array([...new Uint8Array(header), ...body]);
}
decodePacket(buffer) {
const view = new DataView(buffer);
const length = view.getUint32(0);
const body = new Uint8Array(buffer, 4, length);
const ptype = body[0];
const psub = body[1];
const payload = body.slice(2);
return { ptype, psub, payload };
}
// ============================
// === Client Functionality ===
// ============================
async connect(url) {
this.isServer = false;
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
this.client = ws;
ws.binaryType = "arraybuffer";
ws.onopen = () => {
console.log(`[NetworkService] Connected to ${url}`);
resolve();
};
ws.onmessage = (event) => {
const buf = event.data instanceof ArrayBuffer ? event.data : event.data.buffer;
this.totalBytes += Math.max(buf.byteLength,0); // safeguard
const { ptype, psub, payload } = this.decodePacket(buf);
this.invokeHandler(ptype, psub, payload);
};
ws.onerror = (err) => reject(err);
ws.onclose = () => {
console.warn("[NetworkService] Disconnected from server.")
alert("Disconnected from server! If this was unintentional, please refresh the page. You might have been kicked by the server.\n\n(If you are the server host, check if the servers running.)");
};
});
}
send(ptype, psub, payload) {
if (!this.client || this.client.readyState !== WebSocket.OPEN) return;
const packet = this.encodePacket(ptype, psub, payload);
this.client.send(packet);
}
// ============================
// === Server Functionality ===
// ============================
listen(port = 3000, host = "0.0.0.0") {
this.isServer = true;
this.wss = new WebSocketServer({ port, host });
console.log(`[NetworkService] Listening on ws://${host}:${port}`);
this.wss.on("connection", (ws) => {
this.clients.set(ws, {});
console.log("[NetworkService] New client connected. Total clients:", this.clients.size);
ws.binaryType = "arraybuffer";
ws.on("message", (msg) => {
const buf = msg instanceof ArrayBuffer ? msg : msg.buffer;
const { ptype, psub, payload } = this.decodePacket(buf);
this.invokeHandler(ptype, psub, payload, ws);
});
ws.on("close", () => {
this.clients.delete(ws)
console.log("[NetworkService] Client disconnected. Total clients:", this.clients.size);
});
});
}
broadcast(ptype, psub, payload) {
if (!this.isServer || !this.wss) return;
const packet = this.encodePacket(ptype, psub, payload);
for (const ws of this.clients.keys()) {
if (ws.readyState === WebSocket.OPEN) ws.send(packet);
}
}
sendToClient(ws, ptype, psub, payload) {
if (!this.isServer) return;
const packet = this.encodePacket(ptype, psub, payload);
if (ws.readyState === WebSocket.OPEN) ws.send(packet);
}
Broadcast(ptype, psub, payload) {
console.warn("[NetworkService] Broadcast is deprecated, use broadcast() instead.");
this.broadcast(ptype, psub, payload);
}
// ============================
// === Shutdown ===
// ============================
close() {
if (this.isServer && this.wss) {
for (const ws of this.clients.keys()) ws.close();
this.wss.close();
} else if (this.client) {
this.client.close();
}
}
}

View File

@@ -0,0 +1,185 @@
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { Instance } from "./Instance.js";
import { BO3ScriptSignal } from "../core/BO3ScriptSignal.js";
import * as CANNON from "cannon-es";
export class RenderService extends Instance {
constructor() {
super("RenderService");
// Add canvas to DOM
if (typeof document !== "undefined") {
// Three.js setup
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(this.renderer.domElement);
// Controls and loader
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.loader = new GLTFLoader();
}
// Handle window resize
if (typeof window !== "undefined") {
window.addEventListener("resize", () => this.updateWindowSize());
// Basic lighting
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
this.scene.add(light);
// Default test cube (you can remove this later)
/*const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
this.scene.add(cube);*/
// I commented out the cube because it was annoying me
// just like the blender default cube
// Camera position
this.camera.position.z = 5;
}
// Rendering state
this._running = false;
this._lastFrame = 0;
this.isServer = false; // If this is true it will ignore rendering and only run physics. Useful for dedicated servers.
// Event signals
this.RenderStepped = new BO3ScriptSignal();
this.Heartbeat = new BO3ScriptSignal();
this.Stepped = new BO3ScriptSignal();
// Physics world
this.physicsWorld = null;
this._initPhysicsWorld();
// Debug text
this.debugText = ["Debug text!"]; // Line array
// Debug overlay
if (typeof document !== "undefined") {
this.overlay = document.createElement("canvas");
this.overlay.style.position = "absolute";
this.overlay.style.top = "0";
this.overlay.style.left = "0";
this.overlay.style.pointerEvents = "none";
this.overlay.width = window.innerWidth;
this.overlay.height = window.innerHeight;
document.body.appendChild(this.overlay);
this.debugCtx = this.overlay.getContext("2d");
}
}
// === Instance Overrides ===
Destroy() {
if (typeof window !== "undefined") {
window.removeEventListener("resize", this.updateWindowSize);
}
if (this.renderer.domElement?.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
}
// === Rendering ===
updateWindowSize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
if (this.overlay) {
this.overlay.width = window.innerWidth;
this.overlay.height = window.innerHeight;
}
}
// === Main loop ===
start() {
if (this._running) return;
this._running = true;
const loop = (time) => {
if (!this._running) return;
const dt = (time - this._lastFrame) / 1000;
this._lastFrame = time;
this.renderFrame(dt);
if (typeof window !== "undefined") requestAnimationFrame(loop);
};
if (typeof window !== "undefined") {
requestAnimationFrame(loop);
} else {
// Server-side loop at ~60Hz
setInterval(() => {
const now = Date.now();
const dt = (now - this._lastFrame) / 1000;
this._lastFrame = now;
this.renderFrame(dt);
}, 1000 / 60);
}
}
stop() {
this._running = false;
}
_initPhysicsWorld() {
this.physicsWorld = new CANNON.World();
this.physicsWorld.gravity.set(0, -9.81, 0);
}
renderFrame(dt) {
// Emit Stepped signals
this.Stepped.Fire(dt);
if (this.physicsWorld) {
this.physicsWorld.step(1 / 60, dt, 3);
}
if (!this.isServer) {
// update all BaseParts visuals
this.scene.traverse(obj => {
const base = obj.userData?.basePart;
if (base) {
base.updateVisual();
//base.clientUpdate(dt);
//console.debug("Updated visual for BasePart:", base.Name);
} else if (obj.isMesh && !obj.userData?.checked) {
obj.userData.checked = true; // mark it so we dont warn every frame
console.debug("[RenderService] Non-BasePart mesh (ignored):", obj.name || obj);
}
});
this.RenderStepped.Fire(dt);
// Render debug text
if (this.debugCtx && this.debugText.length > 0) {
const ctx = this.debugCtx;
ctx.clearRect(0, 0, this.overlay.width, this.overlay.height);
ctx.save();
ctx.font = "14px Arial";
ctx.fillStyle = "white";
ctx.textAlign = "left";
ctx.textBaseline = "top";
this.debugText.forEach((line, i) => {
ctx.fillText(line, 10, 10 + i * 16);
});
ctx.restore();
}
this.controls.update();
this.renderer.render(this.scene, this.camera);
} else {
// Update position variables for all BaseParts from physicsWorld, scene doesnt exist
this.physicsWorld.bodies.forEach(body => {
const base = body.userData?.basePart;
if (base) {
base.OnPhysicsTick();
}
});
}
this.Heartbeat.Fire(dt);
} // Just like roblox, Heartbeat is always fired, RenderStepped only on clients, Stepped always
}

View File

@@ -0,0 +1,232 @@
import { Instance } from "./Instance.js";
import { sha256, encoders } from "../core/util.js"; // Since SubtleCrypto isnt available in http only, we use a JS implementation
export class ReplicatorService extends Instance {
constructor(datamodel, network) {
super("ReplicatorService");
// Get tickloop from RenderService
this._renderService = datamodel.GetService("RenderService");
if (!this._renderService) {
throw new Error("ReplicatorService requires RenderService in DataModel");
}
this._networkService = network;
if (!this._networkService) {
throw new Error("ReplicatorService requires NetworkService in DataModel");
}
this.bytesSent = 0; // For client stats
this.bytesReceived = 0; // For client stats
this.frameCounter = 0; // To prevent spamming the replication, run it every 10 frames
if (!this._networkService.isServer) {
this._renderService.Heartbeat.Connect((dt) => this._onHeartbeatCli(dt));
this._networkService.registerHandler(0x00, 0x02, (payload) => {
this.bytesReceived += payload.byteLength + 2; // +2 for ptype and psub
const data = JSON.parse(new TextDecoder().decode(payload));
// Find instance by InstanceId using FindInstanceRecursively on datamodel
const inst = this.Parent.FindInstanceRecursively(data.InstanceId);
if (!inst) {
// Instance not found, create a new one
import(`./${data.ClassName}.js`).then((module) => {
const NewClass = module[data.ClassName];
if (!NewClass) {
console.warn(`ReplicatorService: Class ${data.ClassName} not found for replication.`);
return;
}
const newInst = new NewClass();
newInst.InstanceId = data.InstanceId; // Set the same InstanceId
newInst.Name = data.Name;
// Set properties
for (const [key, value] of Object.entries(data.Properties)) {
if (value && typeof value === 'object' && value._type && encoders[value._type]) {
newInst[key] = encoders[value._type].decode(value.value);
} else {
newInst[key] = value;
}
}
newInst.IsReplicated = true; // Mark as replicated to disable physics etc if needed
console.debug('ReplicatorService: Received new instance!');
// Set parent
if (data.ParentId) {
// Find parent by InstanceId using FindInstanceRecursively
const parentInst = this.GetDataModel().FindInstanceRecursively(data.ParentId);
if (parentInst) {
newInst.SetParent(parentInst);
} else {
console.warn(`ReplicatorService: Parent with InstanceId ${data.ParentId} not found. Double check server code.`);
}
newInst.OnReplicated(); // Call hook for when instance is replicated
} else {
console.warn(`ReplicatorService: No ParentId provided for instance ${data.InstanceId}! Double check server code.`);
}
console.log(`ReplicatorService: Created new instance ${newInst.Name} (${newInst.InstanceId}) from server.`);
}).catch(err => {
console.error(`ReplicatorService: Failed to load class ${data.ClassName} for replication.`, err);
});
} else {
// Instance found, update properties
for (const [key, value] of Object.entries(data.Properties)) {
if (value && typeof value === 'object' && value._type && encoders[value._type]) {
inst[key] = encoders[value._type].decode(value.value);
} else {
inst[key] = value;
}
}
// Handle destruction if ParentId is not provided (instance destroyed)
if (!data.ParentId) {
// Remove instance from parent/children and data model
if (typeof inst.Destroy === 'function') {
inst.Destroy();
}
console.log(`ReplicatorService: Instance ${inst.Name} (${inst.InstanceId}) destroyed (ParentId is nil).`);
return;
}
// Update parent if changed
if (data.ParentId) {
if (!inst.Parent || inst.Parent.InstanceId !== data.ParentId) {
// Find new parent by InstanceId using FindInstanceRecursively
const newParentInst = this.GetDataModel().FindInstanceRecursively(data.ParentId);
if (newParentInst) {
inst.SetParent(newParentInst);
} else {
console.warn(`ReplicatorService: Parent with InstanceId ${data.ParentId} not found for existing instance. Double check server code.`);
}
}
} else {
console.warn(`ReplicatorService: No ParentId provided for existing instance ${data.InstanceId}! Double check server code.`);
}
inst.OnReplicated(); // Call hook for when instance is replicated
//console.log(`ReplicatorService: Updated instance ${inst.Name} (${inst.InstanceId}) from server.`);
}
});
} else {
this._renderService.Heartbeat.Connect((dt) => this._onHeartbeat(dt));
}
this.SetParent(datamodel);
this.lastClearedStatCheck = 0; // If above 1 second, clear stats
console.log("ReplicatorService initialized.");
}
// Client-side heartbeat, receive updates from server
_onHeartbeatCli(delta) {
this.lastClearedStatCheck += delta;
if (this.lastClearedStatCheck >= 1) {
console.log(`Client stats: ${this.bytesSent} bytes sent, ${this.bytesReceived} bytes received in the last second.`);
this.bytesSent = 0;
this.bytesReceived = 0;
this.lastClearedStatCheck = 0;
}
}
_onHeartbeat(delta) {
// Clear stats every second
this.lastClearedStatCheck += delta;
if (this.lastClearedStatCheck >= 1) { console.log(`Server stats: ${this.bytesSent} bytes sent, ${this.bytesReceived} bytes received in the last second.`);
this.bytesSent = 0;
this.bytesReceived = 0;
this.lastClearedStatCheck = 0;
}
this.frameCounter += delta;
if (this.frameCounter < (1/30)) { // Run replication at ~30fps max
return;
} else {
this.frameCounter = 0;
};
// For each instance in valid services, replicate its state to clients. Keep old state hash to avoid spamming unchanged data.
const toReplicate = [];
// Get workspace and its children
const workspace = this.Parent.GetService("Workspace");
if (workspace) {
toReplicate.push(...workspace.Children);
}
// Send over the pipe
for (const inst of toReplicate) {
// Check property hashes to see if anything changed
let hashState = "";
for (const key of Object.keys(inst)) {
if (key === "Parent" || key === "Children") continue;
let val;
try {
val = JSON.stringify(inst[key]);
} catch {
val = "[unserializable]";
}
hashState += `${key}:${val};`;
}
const hash = sha256(hashState);
if (inst._lastHash !== hash) {
// Something changed, replicate
inst._lastHash = hash;
const payload = {
InstanceId: inst.InstanceId,
ClassName: inst.ClassName,
Name: inst.Name,
Properties: {},
ParentId: inst.Parent ? inst.Parent.InstanceId : null,
};
for (const key of Object.keys(inst)) {
// Skip internals and heavy engine data
if (
key.startsWith("_") || // private/internal fields
key === "Parent" ||
key === "Children" ||
key === "mesh" ||
key === "body" ||
key === "renderer" ||
key === "scene" ||
key === "controls" ||
key === "physicsWorld" ||
key === "loader"
) {
continue;
}
// Get type
const val = inst[key];
const type = val && val.constructor ? val.constructor.name : typeof val;
if (type === "function" || type === "undefined" || type === "symbol" || type === "BO3ScriptSignal") {
continue; // skip non-serializable types
}
// If we have an encoder, encode and store type info
if (encoders[type]) {
payload.Properties[key] = { _type: type, value: encoders[type].encode(val) };
} else {
payload.Properties[key] = val;
}
}
//console.debug(JSON.stringify(payload));
// Send to all clients
let outpay = JSON.stringify(payload);
this.bytesSent += outpay.length + 2; // +2 for ptype and psub
this._networkService.broadcast(0x00, 0x02, outpay); // Refer to ptype and psub definitions to see what these mean
// Update the _lastHash property to avoid re-sending unchanged data
inst._lastHash = hash;
//console.log(`Replicated instance ${inst.Name} (${inst.InstanceId}) to clients.`);
}
}
}
requestReplicateDestroy(instance,ignoreIS) { // Server side function to request destruction of an instance on clients
if (!this._networkService.isServer) {
if (!ignoreIS) console.warn("ReplicatorService: requestReplicateDestroy can only be called on server.");
// ignoreIS is for internal server calls to avoid spamming the warning
return;
}
if (!instance || !instance.InstanceId) {
console.warn("ReplicatorService: requestReplicateDestroy requires a valid instance with InstanceId.");
return;
}
const payload = {
InstanceId: instance.InstanceId,
ClassName: instance.ClassName,
Name: instance.Name,
Properties: {}, // No properties needed for destruction
ParentId: null, // Null ParentId indicates destruction
};
let outpay = JSON.stringify(payload);
this.bytesSent += outpay.length + 2; // +2 for ptype and psub
this._networkService.broadcast(0x00, 0x02, outpay); // Refer to ptype and psub definitions to see what these mean
console.log(`ReplicatorService: Requested destruction of instance ${instance.Name} (${instance.InstanceId}) on clients.`);
}
}

View File

@@ -0,0 +1,8 @@
import { Instance } from "./Instance.js";
export class Workspace extends Instance {
constructor() {
super("Workspace");
this.InstanceId = "WorkspaceRoot"; // Fixed ID for Workspace (Bad idea to replicate services over network?)
// Most likely: yes, but we can filter them out in ReplicatorService
}
} // Workspace is the root container for all 3D objects in the scene

72
js/servermain.js Normal file
View File

@@ -0,0 +1,72 @@
import { NetworkService } from "./instances/NetworkService.js";
import { RenderService } from "./instances/RenderService.js";
import { ReplicatorService } from "./instances/ReplicatorService.js";
import { DataModel } from "./core/DataModel.js";
import { Workspace } from "./instances/Workspace.js";
import * as THREE from "three";
const dm = new DataModel();
dm.SetParent(null);
// === Workspace ===
const ws = new Workspace();
ws.SetParent(dm);
// === RenderService (headless physics) ===
const render = new RenderService(dm);
render.isServer = true;
render.start();
render.SetParent(dm);
// === Networking ===
const net = new NetworkService();
net.listen(8080);
net.SetParent(dm);
// Simple echo handler
net.registerHandler(0x01, 0x00, (payload, client) => {
console.log("Received from client:", new TextDecoder().decode(payload));
net.sendToClient(client, 0x01, 0x01, payload);
});
// === Replication Service ===
const replication = new ReplicatorService(dm, net);
// === Test parts ===
const { LerpedBasePart } = await import("./instances/LerpedBasePart.js");
// Falling cube
// Anchored ground
const ground = new LerpedBasePart();
ground.Size = new THREE.Vector3(10, 1, 10);
ground.Position.set(0, -0.5, 0);
ground.setAnchored(true);
ground.SetParent(ws);
ground.updateSizes();
// Spin ground
let angle = 0;
setInterval(() => {
angle += 0.01;
ground.Orientation.set(0, angle, 0);
ground.Position.set(5 * Math.sin(angle * 2), -0.5, 5 * Math.cos(angle * 2));
}, 16);
/*setInterval(() => {
const part = new LerpedBasePart();
part.Position.set(0, 35, 0);
part.SetParent(ws);
part.setAnchored(true);
part.updateVisual(1 / 60); // Ensure physics is initialized
setTimeout(() => {
part.updateBodyPosition(); // Reset physics to new position
part.setAnchored(false);
}, 2000);
part.updateSizes();
}, 1000);*/
// Falling cube singularity
const part = new LerpedBasePart();
part.Size = new THREE.Vector3(1, 1, 1);
part.Position.set(10, 5, 0);
part.setAnchored(false);
part.SetParent(ws);
part.updateSizes();