Skip to content

Latest commit

 

History

History
975 lines (791 loc) · 23.6 KB

File metadata and controls

975 lines (791 loc) · 23.6 KB
title Writing a Multisynq Model
description Learn the essential constraints and best practices for building synchronized models that work perfectly across all users

Models are the heart of Multisynq applications - they contain all shared state and business logic. To maintain perfect synchronization across all users, models must follow specific constraints and patterns. This guide covers everything you need to know to write robust, synchronized models.

Core Constraints

**Critical**: Models must follow these constraints to maintain synchronization. Violating these rules will break multi-user functionality. **Models must produce identical results**
  • Same inputs → Same outputs
  • No randomness (use Multisynq.Random)
  • No external dependencies
  • No system calls
// ✅ Deterministic
this.position.x += this.velocity.x;

// ❌ Non-deterministic
this.position.x += Math.random();
**All model state must be saveable**
  • No functions in state
  • No DOM references
  • No external objects
  • Pure data structures
// ✅ Serializable
this.gameState = "playing";
this.players = new Map();

// ❌ Not serializable
this.callback = () => {};
this.element = document.div;

Model Registration

**Every model class must be registered** when defined for proper serialization. **Register simple model classes**
class Player extends Multisynq.Model {
    init(options) {
        this.name = options.name || "Anonymous";
        this.position = { x: 0, y: 0 };
        this.health = 100;
    }
    
    move(dx, dy) {
        this.position.x += dx;
        this.position.y += dy;
    }
}

// REQUIRED: Register the class
Player.register("Player");
The string name **must match** the class name exactly. **Register all model subclasses**
class Game extends Multisynq.Model {
    init() {
        this.players = new Map();
        this.world = World.create();
    }
    
    addPlayer(name) {
        const player = Player.create({ name });
        this.players.set(player.id, player);
        return player;
    }
}

class World extends Multisynq.Model {
    init() {
        this.width = 800;
        this.height = 600;
        this.obstacles = [];
    }
}

class Player extends Multisynq.Model {
    init(options) {
        this.name = options.name;
        this.position = { x: 0, y: 0 };
    }
}

// Register ALL model classes
Game.register("Game");
World.register("World");
Player.register("Player");

Model Creation and Destruction

**Never use `new`** to create model instances. Always use `create()` and `destroy()`. **Use `create()` for model instantiation**
class GameManager extends Multisynq.Model {
    init() {
        this.entities = new Map();
        this.nextId = 1;
    }
    
    spawnPlayer(name) {
        // ✅ Correct: Use create()
        const player = Player.create({
            id: this.nextId++,
            name: name,
            position: this.getSpawnPoint()
        });
        
        this.entities.set(player.id, player);
        this.publish("game", "player-spawned", player.id);
        
        return player;
    }
    
    removePlayer(playerId) {
        const player = this.entities.get(playerId);
        if (player) {
            // ✅ Correct: Use destroy()
            player.destroy();
            this.entities.delete(playerId);
            this.publish("game", "player-removed", playerId);
        }
    }
    
    getSpawnPoint() {
        return {
            x: Math.floor(this.world.width / 2),
            y: Math.floor(this.world.height / 2)
        };
    }
}

class Player extends Multisynq.Model {
    init(options) {
        // This is called automatically by create()
        this.id = options.id;
        this.name = options.name;
        this.position = options.position;
        this.health = 100;
        
        console.log(`Player ${this.name} created at`, this.position);
    }
    
    destroy() {
        // Cleanup logic before destruction
        this.publish("player", "dying", this.id);
        
        // Call parent destroy
        super.destroy();
    }
}

GameManager.register("GameManager");
Player.register("Player");
**Don't use `new` or constructors**
class BadModel extends Multisynq.Model {
    constructor(options) {
        // ❌ NEVER implement constructor
        super();
        this.data = options.data;
    }
    
    createChild() {
        // ❌ NEVER use new
        const child = new ChildModel({ parent: this });
        return child;
    }
}

// This will break synchronization!
Using `new` or implementing constructors will break snapshot restoration and synchronization.

Initialization with init()

**Always use `init()` for initialization**, never constructors. The `init()` method is called only for new instances, not when restoring from snapshots. **Structure your `init()` method correctly**
class GameObject extends Multisynq.Model {
    init(options = {}) {
        // Set default values first
        this.position = { x: 0, y: 0 };
        this.velocity = { x: 0, y: 0 };
        this.health = 100;
        this.maxHealth = 100;
        this.alive = true;
        
        // Apply options
        if (options.position) {
            this.position = { ...options.position };
        }
        if (options.health !== undefined) {
            this.health = options.health;
            this.maxHealth = options.health;
        }
        
        // Setup behaviors
        this.setupPhysics();
        this.startAI();
        
        // Subscribe to events
        this.subscribe(this.id, "damage", this.takeDamage);
        this.subscribe(this.id, "heal", this.heal);
    }
    
    setupPhysics() {
        // Start physics update loop
        this.future(1000/60).updatePhysics();
    }
    
    startAI() {
        // Start AI decision loop
        this.future(500).makeDecision();
    }
    
    takeDamage(damage) {
        this.health = Math.max(0, this.health - damage);
        if (this.health <= 0 && this.alive) {
            this.die();
        }
    }
    
    heal(amount) {
        this.health = Math.min(this.maxHealth, this.health + amount);
    }
    
    die() {
        this.alive = false;
        this.publish("game", "entity-died", this.id);
        
        // Remove after death animation
        this.future(2000).destroy();
    }
    
    updatePhysics() {
        if (!this.alive) return;
        
        this.position.x += this.velocity.x / 60;
        this.position.y += this.velocity.y / 60;
        
        // Continue physics loop
        this.future(1000/60).updatePhysics();
    }
    
    makeDecision() {
        if (!this.alive) return;
        
        // AI decision logic here
        this.chooseAction();
        
        // Continue AI loop
        this.future(500).makeDecision();
    }
}

GameObject.register("GameObject");
**Avoid these initialization errors**
class BadModel extends Multisynq.Model {
    constructor() {
        // ❌ Never implement constructor
        super();
        this.setupData();
    }
    
    init(options) {
        // ❌ Don't call setup methods that might fail
        this.connectToExternalAPI(); // Could fail
        
        // ❌ Don't use system time
        this.createdAt = Date.now();
        
        // ❌ Don't store function references
        this.callback = options.onComplete;
        
        // ❌ Don't access global variables
        this.config = window.gameConfig;
        
        // ❌ Don't use async operations
        this.loadDataAsync();
    }
    
    async loadDataAsync() {
        // ❌ Async not allowed in models
        const data = await fetch('/api/data');
        this.data = data;
    }
}

Constants and Global Data

**No global variables** in models. Use `Multisynq.Constants` for shared constants. **Properly define and use constants**
// Define constants before session starts
const Q = Multisynq.Constants;

Q.GAME = {
    WORLD_WIDTH: 800,
    WORLD_HEIGHT: 600,
    GRAVITY: 0.5,
    JUMP_FORCE: -12,
    PLAYER_SPEED: 200
};

Q.PHYSICS = {
    STEP_MS: 1000 / 60,  // 60fps physics
    MAX_VELOCITY: 500,
    FRICTION: 0.8
};

Q.GAMEPLAY = {
    PLAYER_HEALTH: 100,
    DAMAGE_COOLDOWN: 1000,
    RESPAWN_TIME: 3000
};

class Game extends Multisynq.Model {
    init() {
        this.world = {
            width: Q.GAME.WORLD_WIDTH,
            height: Q.GAME.WORLD_HEIGHT
        };
        
        this.physics = {
            gravity: Q.GAME.GRAVITY,
            friction: Q.PHYSICS.FRICTION
        };
        
        // Start physics loop
        this.future(Q.PHYSICS.STEP_MS).physicsStep();
    }
    
    physicsStep() {
        // Use constants in calculations
        for (const player of this.players.values()) {
            player.velocity.y += Q.GAME.GRAVITY;
            player.velocity.x *= Q.PHYSICS.FRICTION;
            
            // Clamp velocity
            const maxVel = Q.PHYSICS.MAX_VELOCITY;
            player.velocity.x = Math.max(-maxVel, Math.min(maxVel, player.velocity.x));
            player.velocity.y = Math.max(-maxVel, Math.min(maxVel, player.velocity.y));
        }
        
        this.future(Q.PHYSICS.STEP_MS).physicsStep();
    }
    
    createPlayer(name) {
        return Player.create({
            name: name,
            health: Q.GAMEPLAY.PLAYER_HEALTH,
            speed: Q.GAME.PLAYER_SPEED
        });
    }
}

Game.register("Game");
Constants are recursively frozen once the session starts, preventing accidental modification. **Don't use global variables**
// ❌ Don't do this - not synchronized
const WORLD_WIDTH = 800;
let gameState = "playing";
var playerCount = 0;

class BadModel extends Multisynq.Model {
    init() {
        // ❌ These may not be synchronized
        this.width = WORLD_WIDTH;
        this.state = gameState;
        this.playerCount = playerCount;
        
        // ❌ Modifying globals breaks sync
        playerCount++;
        gameState = "active";
    }
}

This breaks synchronization because global variables aren't saved in snapshots and may have different values on different devices.

Synchronization Rules

**Models must be isolated from external systems**
class SynchronizedModel extends Multisynq.Model {
    init() {
        // ✅ Use simulation time
        this.startTime = this.now();
        
        // ✅ Use Multisynq random
        this.randomSeed = this.random(1000);
        
        // ✅ Use constants
        this.maxPlayers = Q.GAME.MAX_PLAYERS;
    }
    
    gameLoop() {
        // ✅ Deterministic calculations
        const elapsed = this.now() - this.startTime;
        this.updateGameState(elapsed);
        
        this.future(Q.PHYSICS.STEP_MS).gameLoop();
    }
}

class UnsynchronizedModel extends Multisynq.Model {
    init() {
        // ❌ Never use system time
        this.startTime = Date.now();
        
        // ❌ Never use Math.random
        this.randomValue = Math.random();
        
        // ❌ Never access browser APIs
        this.windowWidth = window.innerWidth;
        
        // ❌ Never make network requests
        this.fetchUserData();
    }
    
    async fetchUserData() {
        // ❌ No async/await in models
        const data = await fetch('/api/user');
        this.userData = data;
    }
}
**Models must be synchronous and deterministic**
class SyncModel extends Multisynq.Model {
    init() {
        this.data = new Map();
        this.processQueue = [];
        
        // ✅ Use scheduled processing instead of async
        this.processData();
    }
    
    addWork(work) {
        this.processQueue.push(work);
    }
    
    processData() {
        // ✅ Process work synchronously in chunks
        const startTime = this.now();
        while (this.processQueue.length > 0 && this.now() - startTime < 5) {
            const work = this.processQueue.shift();
            this.processWorkItem(work);
        }
        
        // Continue processing next frame
        this.future(16).processData();
    }
    
    processWorkItem(work) {
        // Synchronous processing only
        const result = this.calculateResult(work);
        this.data.set(work.id, result);
    }
}

class AsyncModel extends Multisynq.Model {
    async init() {
        // ❌ Never use async init
        this.data = await this.loadData();
    }
    
    async processRequest(request) {
        // ❌ Never use async methods
        const response = await fetch('/api/process', {
            method: 'POST',
            body: JSON.stringify(request)
        });
        return response.json();
    }
    
    handleTimer() {
        // ❌ Never use setTimeout/setInterval
        setTimeout(() => {
            this.doSomething();
        }, 1000);
    }
}
**Don't create Model → View → Model event chains**
class GoodModel extends Multisynq.Model {
    init() {
        this.gameState = "waiting";
        this.players = new Map();
        
        // ✅ Listen to view events only
        this.subscribe("input", "player-action", this.handlePlayerAction);
    }
    
    handlePlayerAction(data) {
        // ✅ Process input and update state
        const player = this.players.get(data.playerId);
        if (player) {
            player.processAction(data.action);
        }
        
        // ✅ Notify views of state change (local only)
        this.publish("game", "state-updated", {
            gameState: this.gameState,
            playerCount: this.players.size
        });
    }
    
    startGame() {
        this.gameState = "playing";
        
        // ✅ Notify views locally
        this.publish("ui", "game-started", {});
    }
}

class BadModel extends Multisynq.Model {
    init() {
        this.waitingForViewResponse = false;
        
        // ❌ Don't expect responses from views
        this.subscribe("view-response", "confirmation", this.handleViewResponse);
    }
    
    doSomething() {
        // ❌ Don't query views for information
        this.publish("view-query", "get-user-preference", {});
        this.waitingForViewResponse = true;
        
        // ❌ Don't wait for view responses
        this.future(100).checkViewResponse();
    }
    
    checkViewResponse() {
        if (this.waitingForViewResponse) {
            // ❌ This creates unreliable behavior
            this.future(100).checkViewResponse();
        }
    }
}

Advanced: Non-Model Objects

Sometimes you need utility classes that aren't models. Use the `types()` system to handle their serialization. **Simple utility classes with default serialization**
// Utility class that isn't a Model
class Vector2D {
    constructor(x = 0, y = 0) {
        this.x = x;
        this.y = y;
    }
    
    add(other) {
        return new Vector2D(this.x + other.x, this.y + other.y);
    }
    
    multiply(scalar) {
        return new Vector2D(this.x * scalar, this.y * scalar);
    }
    
    magnitude() {
        return Math.sqrt(this.x * this.x + this.y * this.y);
    }
    
    normalize() {
        const mag = this.magnitude();
        return mag > 0 ? this.multiply(1 / mag) : new Vector2D(0, 0);
    }
}

class PhysicsModel extends Multisynq.Model {
    static types() {
        return {
            "Vector2D": Vector2D  // Use default serialization
        };
    }
    
    init() {
        this.position = new Vector2D(100, 100);
        this.velocity = new Vector2D(0, 0);
        this.acceleration = new Vector2D(0, 0.5); // gravity
    }
    
    updatePhysics() {
        // Use Vector2D methods
        this.velocity = this.velocity.add(this.acceleration);
        this.position = this.position.add(this.velocity);
        
        // Bounce off ground
        if (this.position.y > 500) {
            this.position.y = 500;
            this.velocity = new Vector2D(this.velocity.x, -this.velocity.y * 0.8);
        }
        
        this.future(1000/60).updatePhysics();
    }
}

PhysicsModel.register("PhysicsModel");
**Complex classes with custom `write()` and `read()` methods**
// Complex utility class needing custom serialization
class GameBoard {
    constructor(width, height) {
        this.width = width;
        this.height = height;
        this.cells = new Array(width * height).fill(0);
        this.specialData = new Map(); // Maps aren't JSON serializable
    }
    
    getCellIndex(x, y) {
        return y * this.width + x;
    }
    
    setCell(x, y, value) {
        if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
            this.cells[this.getCellIndex(x, y)] = value;
        }
    }
    
    getCell(x, y) {
        if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
            return this.cells[this.getCellIndex(x, y)];
        }
        return 0;
    }
    
    setSpecialData(key, value) {
        this.specialData.set(key, value);
    }
}

class BoardGameModel extends Multisynq.Model {
    static types() {
        return {
            "GameBoard": {
                cls: GameBoard,
                write: (board) => ({
                    width: board.width,
                    height: board.height,
                    cells: board.cells,
                    specialData: Array.from(board.specialData.entries())
                }),
                read: (data) => {
                    const board = new GameBoard(data.width, data.height);
                    board.cells = data.cells;
                    board.specialData = new Map(data.specialData);
                    return board;
                }
            }
        };
    }
    
    init() {
        this.board = new GameBoard(10, 10);
        this.currentPlayer = 1;
        
        // Initialize board
        this.setupInitialBoard();
    }
    
    setupInitialBoard() {
        for (let x = 0; x < this.board.width; x++) {
            for (let y = 0; y < this.board.height; y++) {
                // Set up initial game state
                if (y === 0) {
                    this.board.setCell(x, y, 1); // Player 1 pieces
                } else if (y === this.board.height - 1) {
                    this.board.setCell(x, y, 2); // Player 2 pieces
                }
            }
        }
        
        // Set special data
        this.board.setSpecialData("lastMove", null);
        this.board.setSpecialData("turnCount", 0);
    }
    
    makeMove(fromX, fromY, toX, toY) {
        const piece = this.board.getCell(fromX, fromY);
        if (piece === this.currentPlayer) {
            this.board.setCell(fromX, fromY, 0);
            this.board.setCell(toX, toY, piece);
            
            this.board.setSpecialData("lastMove", { fromX, fromY, toX, toY });
            this.board.setSpecialData("turnCount", 
                this.board.specialData.get("turnCount") + 1);
            
            this.currentPlayer = this.currentPlayer === 1 ? 2 : 1;
            
            this.publish("game", "move-made", {
                from: { x: fromX, y: fromY },
                to: { x: toX, y: toY },
                player: piece
            });
        }
    }
}

BoardGameModel.register("BoardGameModel");

Best Practices Summary

**Organize your models properly**
  • Register all model classes
  • Use create() and destroy()
  • Initialize in init() method
  • Keep models focused and single-purpose
class Player extends Multisynq.Model {
    init(options) {
        this.setupPlayer(options);
        this.startBehaviors();
    }
}
Player.register("Player");
**Optimize for synchronization**
  • Use constants for shared values
  • Batch operations when possible
  • Avoid unnecessary calculations
  • Clean up unused objects
// ✅ Efficient batching
batchUpdate() {
    this.updateMultipleEntities();
    this.future(Q.PHYSICS.STEP_MS).batchUpdate();
}
**Maintain synchronization**
  • No external dependencies
  • No async operations
  • No global variables
  • Deterministic behavior only
// ✅ Safe and synchronized
this.value = this.calculateDeterministic();
**Test thoroughly**
  • Test with multiple users
  • Verify snapshot restoration
  • Check deterministic behavior
  • Test edge cases
// Test both fresh start and snapshot load
console.log("Model state:", this.getState());

Common Mistakes

**Avoid these common model development errors:** ```js // ❌ NEVER do these in models: const now = Date.now(); // Use this.now() const random = Math.random(); // Use this.random() const element = document.getElementById('canvas'); // No DOM access const data = localStorage.getItem('data'); // No storage access const response = fetch('/api/data'); // No network calls ``` ```js // ❌ Don't store functions in model state class BadModel extends Multisynq.Model { init() { this.callback = () => console.log("Hi"); // Won't serialize this.handlers = new Map([ ['click', this.handleClick] // Won't work ]); } }

// ✅ Use method names instead class GoodModel extends Multisynq.Model { init() { this.eventHandlers = ['handleClick', 'handleMove']; this.subscribe("input", "click", this.handleClick); } }

</Accordion>

<Accordion title="❌ Async Operations" icon="clock">
```js
// ❌ No promises or async/await
class AsyncModel extends Multisynq.Model {
    async init() {
        this.data = await this.loadData(); // Breaks sync
    }
    
    handleClick() {
        setTimeout(() => {
            this.doSomething(); // Breaks sync
        }, 1000);
    }
}

// ✅ Use future() for timing
class SyncModel extends Multisynq.Model {
    init() {
        this.loadDataSync();
    }
    
    handleClick() {
        this.future(1000).doSomething(); // Synchronized
    }
}

Next Steps

Learn to build views that work with your models Master communication between models and views Understand timing and scheduling in models Learn synchronized random number generation Writing good Multisynq models is fundamental to building successful multiplayer applications. Follow these constraints carefully, and your models will synchronize perfectly across all users, providing a seamless collaborative experience.