Skip to content

Latest commit

 

History

History
936 lines (766 loc) · 26 KB

File metadata and controls

936 lines (766 loc) · 26 KB
title Writing a Multisynq View
description Learn how to build responsive, interactive views that display model state and handle user input while maintaining perfect synchronization

Views are the interactive layer of Multisynq applications - they handle user input, display model state, and provide the user interface. Unlike models, views have full access to browser APIs and can use any JavaScript libraries. However, they must follow specific patterns to maintain synchronization.

Core Principle: Read-Only Model Access

**THE MOST IMPORTANT RULE**: Views must **NEVER** write directly to the model. All model changes must go through events. **Views read from model, publish events for changes**
class GameView extends Multisynq.View {
    init() {
        this.model = this.wellKnownModel("GameModel");
        this.setupUI();
        this.startRenderLoop();
        
        // Subscribe to model events
        this.subscribe("game", "state-changed", this.updateDisplay);
        this.subscribe("game", "player-joined", this.addPlayerToUI);
        this.subscribe("game", "player-left", this.removePlayerFromUI);
    }
    
    setupUI() {
        this.canvas = document.getElementById('gameCanvas');
        this.ctx = this.canvas.getContext('2d');
        
        // ✅ Handle user input by publishing events
        this.canvas.addEventListener('click', (event) => {
            const rect = this.canvas.getBoundingClientRect();
            const x = event.clientX - rect.left;
            const y = event.clientY - rect.top;
            
            // ✅ Publish event for model to handle
            this.publish("input", "player-click", { x, y });
        });
        
        this.canvas.addEventListener('keydown', (event) => {
            // ✅ Send input events to model
            this.publish("input", "key-press", { 
                key: event.key,
                timestamp: Date.now() // View can use real time
            });
        });
    }
    
    startRenderLoop() {
        // ✅ Views can use setTimeout/setInterval
        this.renderFrame();
    }
    
    renderFrame() {
        // ✅ Read directly from model for efficiency
        this.drawBackground();
        this.drawPlayers();
        this.drawUI();
        
        // Continue render loop
        requestAnimationFrame(() => this.renderFrame());
    }
    
    drawPlayers() {
        // ✅ Read model state directly
        const players = this.model.players;
        
        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        
        for (const [playerId, player] of players) {
            this.ctx.fillStyle = player.color;
            this.ctx.fillRect(
                player.position.x - 10,
                player.position.y - 10,
                20, 20
            );
            
            // Draw player name
            this.ctx.fillStyle = 'white';
            this.ctx.fillText(
                player.name,
                player.position.x,
                player.position.y - 15
            );
        }
    }
    
    updateDisplay() {
        // ✅ React to model changes
        this.updateScore();
        this.updateGameState();
    }
    
    updateScore() {
        const scoreElement = document.getElementById('score');
        if (scoreElement) {
            // ✅ Read from model to update UI
            scoreElement.textContent = `Score: ${this.model.score}`;
        }
    }
}

GameView.register("GameView");
**Never directly modify the model**
class BadView extends Multisynq.View {
    init() {
        this.model = this.wellKnownModel("GameModel");
        this.setupBadHandlers();
    }
    
    setupBadHandlers() {
        const button = document.getElementById('addPlayer');
        
        button.addEventListener('click', () => {
            // ❌ NEVER write directly to model
            this.model.playerCount++;
            this.model.players.push({ name: "New Player" });
            
            // ❌ NEVER call model methods directly from view
            this.model.startGame();
            
            // ❌ NEVER modify model properties
            this.model.gameState = "playing";
        });
    }
    
    handlePlayerMove(x, y) {
        // ❌ NEVER update model directly
        this.model.currentPlayer.position.x = x;
        this.model.currentPlayer.position.y = y;
        
        // This breaks synchronization!
    }
}
Direct model modification will cause synchronization errors and break the multi-user experience.

View Architecture Patterns

**Create specialized sub-views**
class MainView extends Multisynq.View {
    init() {
        this.model = this.wellKnownModel("GameModel");
        
        // Create sub-views
        this.gameView = new GameCanvasView(this.model);
        this.uiView = new UIView(this.model);
        this.chatView = new ChatView(this.model);
    }
}

class GameCanvasView extends Multisynq.View {
    init(model) {
        this.model = model;
        this.setupCanvas();
        this.startRendering();
    }
    
    // Specialized game rendering
}

class UIView extends Multisynq.View {
    init(model) {
        this.model = model;
        this.setupUI();
        this.bindEvents();
    }
    
    // UI management
}
**Proper event-driven architecture**
class PlayerView extends Multisynq.View {
    init() {
        this.model = this.wellKnownModel("GameModel");
        
        // Listen to model events
        this.subscribe("player", "moved", this.onPlayerMove);
        this.subscribe("player", "died", this.onPlayerDeath);
        this.subscribe("game", "ended", this.onGameEnd);
        
        this.setupInputHandlers();
    }
    
    setupInputHandlers() {
        // Convert user input to events
        document.addEventListener('keydown', (e) => {
            this.publish("input", "key-down", {
                key: e.key,
                playerId: this.getMyPlayerId()
            });
        });
    }
    
    onPlayerMove(data) {
        // React to model changes
        this.updatePlayerPosition(data.playerId, data.position);
    }
}

View Lifecycle and Updates

**Optimize view updates for performance**
class PerformantGameView extends Multisynq.View {
    init() {
        this.model = this.wellKnownModel("GameModel");
        this.canvas = document.getElementById('canvas');
        this.ctx = this.canvas.getContext('2d');
        
        // Track what needs updating
        this.dirtyRegions = new Set();
        this.lastModelState = null;
        
        this.startRenderLoop();
        
        // Listen for specific model changes
        this.subscribe("game", "player-moved", this.markPlayerDirty);
        this.subscribe("game", "object-changed", this.markObjectDirty);
    }
    
    startRenderLoop() {
        this.renderFrame();
    }
    
    renderFrame() {
        // Only update if model changed
        if (this.hasModelChanged()) {
            this.updateChangedElements();
            this.lastModelState = this.getModelSnapshot();
        }
        
        requestAnimationFrame(() => this.renderFrame());
    }
    
    hasModelChanged() {
        // Efficient change detection
        const currentState = this.getModelSnapshot();
        return JSON.stringify(currentState) !== JSON.stringify(this.lastModelState);
    }
    
    getModelSnapshot() {
        // Create lightweight state snapshot
        return {
            playerCount: this.model.players.size,
            gameState: this.model.gameState,
            lastUpdate: this.model.lastUpdateTime
        };
    }
    
    updateChangedElements() {
        // Only redraw changed regions
        for (const region of this.dirtyRegions) {
            this.redrawRegion(region);
        }
        this.dirtyRegions.clear();
    }
    
    markPlayerDirty(data) {
        this.dirtyRegions.add(`player-${data.playerId}`);
    }
    
    markObjectDirty(data) {
        this.dirtyRegions.add(`object-${data.objectId}`);
    }
    
    redrawRegion(region) {
        // Efficient partial redraws
        if (region.startsWith('player-')) {
            const playerId = region.replace('player-', '');
            this.drawPlayer(playerId);
        } else if (region.startsWith('object-')) {
            const objectId = region.replace('object-', '');
            this.drawObject(objectId);
        }
    }
}

PerformantGameView.register("PerformantGameView");
**Handle real-time model changes**
class RealtimeView extends Multisynq.View {
    init() {
        this.model = this.wellKnownModel("GameModel");
        this.setupRealTimeUpdates();
        
        // Subscribe to all model changes
        this.subscribe("model", "*", this.onModelChange);
    }
    
    setupRealTimeUpdates() {
        // Different update frequencies for different elements
        this.startHighFrequencyUpdates(); // Player positions
        this.startMediumFrequencyUpdates(); // UI elements
        this.startLowFrequencyUpdates(); // Background elements
    }
    
    startHighFrequencyUpdates() {
        this.updatePlayerPositions();
        this.future(16).startHighFrequencyUpdates(); // 60fps
    }
    
    startMediumFrequencyUpdates() {
        this.updateUI();
        this.future(100).startMediumFrequencyUpdates(); // 10fps
    }
    
    startLowFrequencyUpdates() {
        this.updateBackground();
        this.future(1000).startLowFrequencyUpdates(); // 1fps
    }
    
    updatePlayerPositions() {
        // Read current positions from model
        for (const [playerId, player] of this.model.players) {
            this.interpolatePlayerMovement(playerId, player);
        }
    }
    
    interpolatePlayerMovement(playerId, player) {
        // Smooth movement between updates
        const element = this.getPlayerElement(playerId);
        if (element) {
            const currentPos = this.getElementPosition(element);
            const targetPos = player.position;
            
            // Smooth interpolation
            const smoothPos = {
                x: currentPos.x + (targetPos.x - currentPos.x) * 0.1,
                y: currentPos.y + (targetPos.y - currentPos.y) * 0.1
            };
            
            this.setElementPosition(element, smoothPos);
        }
    }
    
    onModelChange(event) {
        // React to specific model changes
        switch (event.type) {
            case "player-joined":
                this.addPlayerToView(event.data);
                break;
            case "player-left":
                this.removePlayerFromView(event.data);
                break;
            case "game-state-changed":
                this.updateGameStateUI(event.data);
                break;
        }
    }
}

RealtimeView.register("RealtimeView");

Input Handling Best Practices

**Handle game controls efficiently**
class GameInputView extends Multisynq.View {
    init() {
        this.model = this.wellKnownModel("GameModel");
        this.inputState = {
            keys: new Set(),
            mouse: { x: 0, y: 0, buttons: new Set() }
        };
        
        this.setupInputHandlers();
        this.startInputProcessing();
    }
    
    setupInputHandlers() {
        // Track key states
        document.addEventListener('keydown', (e) => {
            if (!this.inputState.keys.has(e.key)) {
                this.inputState.keys.add(e.key);
                this.publish("input", "key-down", { key: e.key });
            }
        });
        
        document.addEventListener('keyup', (e) => {
            this.inputState.keys.delete(e.key);
            this.publish("input", "key-up", { key: e.key });
        });
        
        // Track mouse state
        this.canvas.addEventListener('mousemove', (e) => {
            const rect = this.canvas.getBoundingClientRect();
            this.inputState.mouse.x = e.clientX - rect.left;
            this.inputState.mouse.y = e.clientY - rect.top;
            
            // Only send if mouse moved significantly
            if (this.shouldSendMouseUpdate()) {
                this.publish("input", "mouse-move", {
                    x: this.inputState.mouse.x,
                    y: this.inputState.mouse.y
                });
            }
        });
        
        this.canvas.addEventListener('mousedown', (e) => {
            this.inputState.mouse.buttons.add(e.button);
            this.publish("input", "mouse-down", {
                button: e.button,
                x: this.inputState.mouse.x,
                y: this.inputState.mouse.y
            });
        });
        
        this.canvas.addEventListener('mouseup', (e) => {
            this.inputState.mouse.buttons.delete(e.button);
            this.publish("input", "mouse-up", {
                button: e.button,
                x: this.inputState.mouse.x,
                y: this.inputState.mouse.y
            });
        });
    }
    
    startInputProcessing() {
        this.processInputState();
    }
    
    processInputState() {
        // Send continuous input state for held keys
        if (this.inputState.keys.size > 0) {
            this.publish("input", "keys-held", {
                keys: Array.from(this.inputState.keys)
            });
        }
        
        // Continue processing
        this.future(16).processInputState(); // 60fps
    }
    
    shouldSendMouseUpdate() {
        // Throttle mouse updates
        const now = Date.now();
        if (!this.lastMouseUpdate || now - this.lastMouseUpdate > 16) {
            this.lastMouseUpdate = now;
            return true;
        }
        return false;
    }
}

GameInputView.register("GameInputView");
**Handle mobile touch interactions**
class TouchInputView extends Multisynq.View {
    init() {
        this.model = this.wellKnownModel("GameModel");
        this.touches = new Map();
        this.setupTouchHandlers();
    }
    
    setupTouchHandlers() {
        this.canvas.addEventListener('touchstart', (e) => {
            e.preventDefault();
            
            for (const touch of e.changedTouches) {
                this.touches.set(touch.identifier, {
                    x: touch.clientX,
                    y: touch.clientY,
                    startTime: Date.now()
                });
                
                this.publish("input", "touch-start", {
                    id: touch.identifier,
                    x: touch.clientX,
                    y: touch.clientY
                });
            }
        });
        
        this.canvas.addEventListener('touchmove', (e) => {
            e.preventDefault();
            
            for (const touch of e.changedTouches) {
                const touchData = this.touches.get(touch.identifier);
                if (touchData) {
                    touchData.x = touch.clientX;
                    touchData.y = touch.clientY;
                    
                    this.publish("input", "touch-move", {
                        id: touch.identifier,
                        x: touch.clientX,
                        y: touch.clientY
                    });
                }
            }
        });
        
        this.canvas.addEventListener('touchend', (e) => {
            e.preventDefault();
            
            for (const touch of e.changedTouches) {
                const touchData = this.touches.get(touch.identifier);
                if (touchData) {
                    const duration = Date.now() - touchData.startTime;
                    
                    this.publish("input", "touch-end", {
                        id: touch.identifier,
                        x: touch.clientX,
                        y: touch.clientY,
                        duration: duration
                    });
                    
                    // Check for tap vs swipe
                    if (duration < 200) {
                        this.publish("input", "tap", {
                            x: touch.clientX,
                            y: touch.clientY
                        });
                    }
                    
                    this.touches.delete(touch.identifier);
                }
            }
        });
    }
}

TouchInputView.register("TouchInputView");
**Provide immediate feedback for responsive UI**
class ResponsiveView extends Multisynq.View {
    init() {
        this.model = this.wellKnownModel("GameModel");
        this.predictedState = new Map(); // Local predictions
        this.setupAnticipatoryFeedback();
    }
    
    setupAnticipatoryFeedback() {
        this.canvas.addEventListener('click', (e) => {
            const rect = this.canvas.getBoundingClientRect();
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;
            
            // 1. Immediate visual feedback
            this.showClickEffect(x, y);
            
            // 2. Predict what will happen
            this.predictPlayerMovement(x, y);
            
            // 3. Send event to model
            this.publish("input", "player-click", { x, y });
        });
        
        // Listen for model confirmation
        this.subscribe("player", "moved", this.onPlayerMoved);
    }
    
    showClickEffect(x, y) {
        // Immediate visual feedback
        const effect = document.createElement('div');
        effect.className = 'click-effect';
        effect.style.left = x + 'px';
        effect.style.top = y + 'px';
        document.body.appendChild(effect);
        
        // Remove after animation
        setTimeout(() => {
            effect.remove();
        }, 500);
    }
    
    predictPlayerMovement(targetX, targetY) {
        const myPlayerId = this.getMyPlayerId();
        const player = this.model.players.get(myPlayerId);
        
        if (player) {
            // Predict smooth movement
            this.predictedState.set(myPlayerId, {
                startPos: { ...player.position },
                targetPos: { x: targetX, y: targetY },
                startTime: Date.now(),
                duration: 1000 // Predicted animation time
            });
            
            this.animatePredictedMovement(myPlayerId);
        }
    }
    
    animatePredictedMovement(playerId) {
        const prediction = this.predictedState.get(playerId);
        if (!prediction) return;
        
        const elapsed = Date.now() - prediction.startTime;
        const progress = Math.min(elapsed / prediction.duration, 1);
        
        // Smooth interpolation
        const currentPos = {
            x: prediction.startPos.x + (prediction.targetPos.x - prediction.startPos.x) * progress,
            y: prediction.startPos.y + (prediction.targetPos.y - prediction.startPos.y) * progress
        };
        
        // Update visual position
        this.drawPlayerAt(playerId, currentPos);
        
        if (progress < 1) {
            requestAnimationFrame(() => this.animatePredictedMovement(playerId));
        } else {
            this.predictedState.delete(playerId);
        }
    }
    
    onPlayerMoved(data) {
        // Model has confirmed movement - stop prediction and use actual position
        if (this.predictedState.has(data.playerId)) {
            this.predictedState.delete(data.playerId);
        }
        
        // Update to actual position
        this.drawPlayerAt(data.playerId, data.position);
    }
}

ResponsiveView.register("ResponsiveView");

Best Practices Summary

**Maintain perfect sync**
  • Never write directly to model
  • Always use events for changes
  • Read model state for display
  • Handle predictions carefully
// ✅ Always use events
this.publish("input", "action", data);

// ❌ Never modify directly
this.model.property = value;
**Optimize rendering**
  • Use efficient change detection
  • Implement partial updates
  • Throttle high-frequency events
  • Cache expensive calculations
// ✅ Efficient updates
if (this.hasChanged()) {
    this.updateDisplay();
}
**Responsive interaction**
  • Provide immediate feedback
  • Use predictive animations
  • Handle edge cases gracefully
  • Support multiple input methods
// ✅ Immediate feedback
this.showFeedback();
this.publish("input", "action", data);
**Clean organization**
  • Use modular sub-views
  • Separate concerns clearly
  • Handle lifecycle properly
  • Document event interfaces
// ✅ Modular design
this.gameView = new GameView();
this.uiView = new UIView();

View vs Model Responsibilities

**What views should handle**
class ProperView extends Multisynq.View {
    init() {
        // ✅ UI management
        this.setupUserInterface();
        
        // ✅ Input handling
        this.setupInputHandlers();
        
        // ✅ Display updates
        this.startRenderLoop();
        
        // ✅ Animation and effects
        this.setupAnimations();
        
        // ✅ Browser API access
        this.setupAudio();
        this.setupLocalStorage();
        
        // ✅ External libraries
        this.setupThreeJS();
        this.setupChartJS();
    }
    
    handleUserInput() {
        // ✅ Convert input to events
        this.publish("input", "user-action", data);
    }
    
    updateDisplay() {
        // ✅ Read model state and update visuals
        const gameState = this.model.gameState;
        this.renderGameState(gameState);
    }
    
    playSound(soundName) {
        // ✅ Views can use browser APIs
        const audio = new Audio(`sounds/${soundName}.mp3`);
        audio.play();
    }
}
**What views should NOT handle**
class ImproperView extends Multisynq.View {
    init() {
        this.model = this.wellKnownModel("GameModel");
    }
    
    handleClick() {
        // ❌ Don't do game logic in views
        const player = this.model.currentPlayer;
        player.health -= 10;
        
        if (player.health <= 0) {
            this.model.gameState = "game-over";
        }
        
        // ❌ Don't manage game state in views
        this.model.score += 100;
        this.model.level++;
        
        // ❌ Don't handle collision detection in views
        for (const other of this.model.enemies) {
            if (this.checkCollision(player, other)) {
                other.destroy();
            }
        }
    }
    
    checkCollision(a, b) {
        // ❌ Game logic belongs in model
        return Math.abs(a.x - b.x) < 20 && Math.abs(a.y - b.y) < 20;
    }
}
Game logic, state management, and business rules must stay in the model for proper synchronization.

Common Mistakes

**Avoid these common view development errors:** ```js // ❌ NEVER modify model directly view.addEventListener('click', () => { this.model.player.x = newX; // Breaks sync this.model.gameState = "playing"; // Breaks sync this.model.players.push(newPlayer); // Breaks sync });

// ✅ Use events instead view.addEventListener('click', () => { this.publish("input", "move-player", { x: newX }); this.publish("game", "start-game", {}); this.publish("game", "add-player", { player: newPlayerData }); });

</Accordion>

<Accordion title="❌ Model-View Ping-Pong" icon="ping-pong">
```js
// ❌ Don't create event cascades
class BadView extends Multisynq.View {
    init() {
        this.subscribe("model", "needs-input", this.provideInput);
    }
    
    handleClick() {
        this.publish("model", "user-clicked", {});
    }
    
    provideInput() {
        // ❌ This creates cascading events
        this.publish("model", "input-response", { data: "..." });
    }
}

// ✅ Direct communication patterns
class GoodView extends Multisynq.View {
    handleClick() {
        // ✅ Direct action, no ping-pong
        this.publish("input", "click", { x, y });
    }
}
```js // ❌ Don't block the UI thread class BlockingView extends Multisynq.View { processLargeDataset() { // ❌ This will freeze the UI for (let i = 0; i < 1000000; i++) { this.processItem(i); } } }

// ✅ Use async processing class NonBlockingView extends Multisynq.View { async processLargeDataset() { // ✅ Process in chunks for (let i = 0; i < 1000000; i += 1000) { this.processChunk(i, i + 1000); await new Promise(resolve => setTimeout(resolve, 0)); } } }

</Accordion>
</AccordionGroup>

## Next Steps

<CardGroup cols={2}>
<Card title="Writing a Multisynq Model" icon="database" href="/tutorials/writing-multisynq-model">
  Learn to build the synchronized models that power your views
</Card>

<Card title="Events & Pub-Sub" icon="satellite" href="/tutorials/events-pub-sub">
  Master communication between models and views
</Card>

<Card title="Simple Animation" icon="play" href="/tutorials/simple-animation">
  See view patterns in action with animated examples
</Card>

<Card title="Multi-user Chat" icon="chat" href="/tutorials/multiuser-chat">
  Build interactive multi-user interfaces
</Card>
</CardGroup>

<Note>
Views are where your application comes to life for users. By following the read-only model principle and using events for all changes, you can build responsive, interactive interfaces that work perfectly across all users while maintaining the deterministic behavior required for synchronization.
</Note>