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.
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");
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.
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);
}
}
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");
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");
🎮 Game Input
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");
📱 Touch Input
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");
🎯 Anticipatory Input
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");
Maintain perfect sync
// ✅ Always use events
this.publish("input", "action", data);
// ❌ Never modify directly
this.model.property = value;
Optimize rendering
// ✅ Efficient updates
if (this.hasChanged()) {
this.updateDisplay();
}
Responsive interaction
// ✅ Immediate feedback
this.showFeedback();
this.publish("input", "action", data);
Clean organization
// ✅ Modular design
this.gameView = new GameView();
this.uiView = new UIView();
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 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.
Avoid these common view development errors:
❌ Direct Model Mutation
// ❌ 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 });
});
❌ Model-View Ping-Pong
// ❌ 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 });
}
}
❌ Blocking Operations
// ❌ 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));
}
}
}
Learn to build the synchronized models that power your views
Master communication between models and views
See view patterns in action with animated examples
Build interactive multi-user interfaces
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.