Multisynq Client

repository·main·Indexed 42 days ago

https://github.com/multisynq/multisynq-client

A JavaScript real-time multiplayer framework (v1.1.0) for web applications that synchronizes application state across clients using a distributed DePIN network. It features a decoupled architecture separating synchronized logic (Multisynq.Model) from local UI logic (Multisynq.View), enabling serverless, end-to-end encrypted multiplayer experiences without requiring server-side code.

Tokens
30.2K
Snippets
78
Records
109
Agent score
51%

What's inside Multisynq

  1. Achieve smooth 60fps animation with view-side interpolation

    main

    If your model updates at a lower frequency (e.g., 20fps via this.future(50)), you can achieve smooth 60fps rendering by decoupling the rendering position from the model position using interpolation in the view.

    Implementation Pattern

    1. Store Render State: Use a WeakMap to map model objects to their current smoothed render state {x, y, a}. This avoids manual tracking of object creation/destruction.
    2. Interpolate: In each frame, calculate the difference between the model's position and the smoothed render position.
    3. Handle Jumps: If the distance between the model and the smoothed position is too large (e.g., Math.abs(dx) < 50 is false), snap the smoothed position directly to the model position to prevent 'sliding' during resets or teleports.
    4. Apply Smoothing: Otherwise, increment the smoothed position by a fraction of the difference (e.g., smoothed.x += dx * 0.3).
    // In the View class
    this.smoothing = new WeakMap();
    
    smoothPos(obj) {
        if (!this.smoothing.has(obj)) {
            this.smoothing.set(obj, { x: obj.x, y: obj.y, a: obj.a });
        }
        const smoothed = this.smoothing.get(obj);
        const dx = obj.x - smoothed.x;
        const dy = obj.y - smoothed.y;
        
        // if distance is large, don't smooth but jump to new position
        if (Math.abs(dx) < 50) smoothed.x += dx * 0.3; else smoothed.x = obj.x;
        if (Math.abs(dy) < 50) smoothed.y += dy * 0.3; else smoothed.y = obj.y;
        
        return smoothed;
    }
    
    // In the View's update method
    for (const asteroid of this.model.asteroids) {
        const { x, y, a } = this.smoothPos(asteroid);
        // use x, y, a for drawing...
    }
  2. Understand the Model-View architecture in Multisynq

    main

    Multisynq applications follow a strict separation of concerns between the view and the model to ensure state synchronization across multiple users:

    • The View: Responsible for user input (keyboard, mouse, touch) and output (DOM interaction). It determines what is displayed on the screen. The view can read from the model but cannot write to it directly.
    • The Model: Responsible for all calculations and simulations. The state of the model is guaranteed to be identical for all users within the same session.

    Communication between these two layers is handled via events. When an object publishes an event, all subscribed objects execute their handler functions.

  3. Understand simulation time in Multisynq Models

    main

    Multisynq Models do not use real-world time; they use simulation time governed by the reflector. Every event passing through the reflector is timestamped, and the model's simulation time advances to the timestamp of the last received event. This ensures all replicas stay synchronized regardless of their local real-world clocks.

    • Use this.now() within a Model to retrieve the current simulation time.
    • The reflector sends heartbeat ticks to advance simulation time even when no other events are occurring. The default frequency is 20 ticks per second, but this can be configured at session start.
    // Inside a Model
    const currentTime = this.now();
  4. Use Pub/Sub for Model and View communication

    main

    Models and Views in Multisynq communicate using a Publish/Subscribe (Pub/Sub) pattern. These methods are available to any class derived from Model or View.

    API Methods

    • publish(scope, event, data): Sends an event to all subscribers.
      • scope (String): A namespace to prevent event collisions (e.g., using a specific object ID).
      • event (String): The name of the event.
      • data (any serializable type): Optional payload.
    • subscribe(scope, event, handler): Registers the object to receive events.
      • In a View: The handler can be any function.
      • In a Model: The handler must be passed as a reference to a method on the instance (e.g., this.myHandlerName). Because functions cannot be serialized, Multisynq extracts the method name string to facilitate communication.
    • unsubscribe(scope, event): Unregisters the object from a specific event/scope.
    • unsubscribeAll(): Unregisters all current subscriptions. This is called automatically when a model or view is destroyed.
    // Example of subscribing in a Model (must use method reference)
    class MyModel extends Model {
      constructor() {
        super();
        this.subscribe("my-scope", "update-event", this.onUpdate);
      }
    
      onUpdate(data) {
        console.log("Received:", data);
      }
    }
  5. Implement temporary states like 'debris' using properties

    main

    You can simulate temporary states (like a ship turning into debris after a collision) by adding a property (e.g., wasHit) to your model.

    1. State Transition: When a collision occurs, set wasHit to a non-zero value.
    2. Lifecycle: In the move() method, increment this value over time. Once it reaches a threshold, call a reset() or destroy() method.
    3. Input Inhibition: Use the state to ignore user inputs (like thruster controls) while the object is in its 'debris' state.
    4. Visual Feedback: Use the incrementing value in your view's update() method to drive animations (e.g., moving line segments further apart to simulate an explosion).
    // Inside the model's move method
    move() {
        if (this.wasHit) {
            // keep drifting as debris for 3 seconds (assuming 60 steps)
            if (++this.wasHit > 60) this.reset();
        } else {
            // process thruster controls
            if (this.forward) this.accelerate(0.5);
            if (this.left) this.a -= 0.2;
            if (this.right) this.a += 0.2;
        }
        ...
    }
  6. Understand End-to-End Encryption for persistent data

    main

    Persistent data in Multisynq is end-to-end encrypted. The security model relies on the session password remaining known only to the client.

    • Security Mechanism: Data is decrypted only on the client running the app. Even if the storage servers are compromised, the data remains encrypted and unreadable without the session password.
    • Password Handling: By default, session passwords are placed in the URL #hash, which is not transmitted to servers. Using QR codes for joining sessions is a recommended way to maintain this security.
    • Trade-offs: Storing session names and passwords on a central server allows for user data recovery if a password is lost, but it removes the end-to-end encryption guarantee, as the server becomes a target for decrypting user data.
  7. Implement a Multisynq.View subclass

    main

    A Multisynq.View is responsible for two tasks: interpreting and displaying the model's state to the user, and capturing user input to publish events that the model can respond to.

    When implementing a view:

    1. Constructor: Pass the model to super(model). You must use the model reference solely for read-only access. Modifying the model directly from the view will break Multisynq replication.
    2. Subscribing to Model Events: Use this.subscribe(scope, event, callback) to listen for changes in the model. For example, this.subscribe("counter", "changed", this.counterChanged) will trigger counterChanged whenever the model publishes a "changed" event in the "counter" scope.
    3. Publishing User Input: To affect the model's behavior, the view must publish an event using this.publish(scope, event). Multisynq ensures that this event is received by every model instance in the session, regardless of which user's view published it.
    4. Initial Sync: Call your update/render method manually once in the constructor (e.g., this.counterChanged()) to ensure the view matches the model state immediately upon session start.
    class MyView extends Multisynq.View {
        constructor(model) {
            super(model);
            this.model = model;
            
            // 1. Handle user input
            countDisplay.onclick = event => this.counterReset();
            
            // 2. Subscribe to model changes
            this.subscribe("counter", "changed", this.counterChanged);
            
            // 3. Initial render
            this.counterChanged();
        }
    
        counterReset() {
            // Publish event to the model
            this.publish("counter", "reset");
        }
    
        counterChanged() {
            // Read-only access to model state
            countDisplay.textContent = this.model.count;
        }
    }
  8. Communicate from View to Model using `publish` and `subscribe`

    main

    Multisynq uses a pub/sub pattern primarily to communicate events from the local user's View to the shared Model (e.g., translating keyboard input into game actions).

    • publish(scope, eventName, data): Used in the View to send an event to the Model. You can use a specific viewId as the scope to ensure an event is only heard by the model instance associated with that specific player.
    • subscribe(scope, eventName, callback): Used in the Model to listen for those events.

    Note: This is not used for synchronizing values or communicating between different devices; it is strictly for local View-to-Model communication.

    // In the View (handling input)
    document.onkeydown = (e) => {
        if (e.key === "ArrowLeft") {
            this.publish(this.viewId, "left-thruster", true);
        }
    };
    
    // In the Model (handling the event)
    class Ship extends Multisynq.Model {
        init({ viewId }) {
            this.subscribe(viewId, "left-thruster", this.leftThruster);
        }
    
        leftThruster(active) {
            this.left = active;
        }
    }
  9. How to create an interactive view with Multisynq

    main

    Views are responsible for the visual representation of models and handling user input. A view is typically a subclass of a view component that receives a model in its constructor.

    Key patterns for views:

    • Composition: A root view (e.g., MyView) can iterate over a model's children and call this.attachChild(child) to create and append child views (e.g., BallView).
    • Model-View Communication:
      • Model to View: The view should subscribe to events published by the model (e.g., pos-changed) to update the UI. Use the handling: "oncePerFrame" option to prevent redundant updates if multiple events arrive within a single rendering frame.
      • View to Model: The view should publish events (e.g., touch-me) in response to user interactions (clicks, touches). These events should be published using the model's id as the scope.
    • Lifecycle: Implement a detach() method to clean up resources (like removing child elements) when the session or view is shut down.
    • Constraint: A view MUST NOT modify the model directly; it should only read state or publish events that the model handles.
    // Example: View subscribing to model updates
    this.subscribe(model.id, { event: 'pos-changed', handling: 'oncePerFrame' }, this.move);
    
    // Example: View publishing user interaction
    this.publish(el.id, 'touch-me');
  10. Define a Multisynq application using Model and View classes

    main

    A Multisynq application is structured into two primary components: a Model for simulation logic and a View for input and output (rendering/interaction).

    1. Model: Inherit from Multisynq.Model. Implement an init() method for setup logic. You must call MyModel.register("Name") so the library can identify the class during snapshot synchronization.
    2. View: Inherit from Multisynq.View. The constructor accepts the model instance as its first argument.

    Note: The init() routine in your model only runs during the very first launch of the application. Subsequent users joining an active session or users rejoining a session will load the state from a saved snapshot instead of running init().

    class MyModel extends Multisynq.Model {
        init() {
            // Simulation setup logic
        }
    }
    MyModel.register("MyModel");
    
    class MyView extends Multisynq.View {
        constructor(model) {
            super(model);
            // Input and output logic
        }
    }
  11. How deterministic randomness works in Multisynq

    main

    Multisynq ensures state synchronization by guaranteeing that the same sequence of random numbers is generated within the model on every device.

    • Inside the Model: If you call Math.random() within the model logic, it is intercepted to ensure it returns the same value across all machines in the network. This is critical for maintaining a consistent shared state.
    • Inside the View: If you call Math.random() within the view layer, it behaves like standard JavaScript. Different machines will receive different random numbers, making it suitable for purely local visual effects that do not affect the shared model state.
    // Inside the Model: Deterministic (Same on all devices)
    const sharedValue = Math.random(); 
    
    // Inside the View: Non-deterministic (Different on each device)
    const localVisualEffect = Math.random();