Learn how to build responsive, interactive views that display model state and handle user input while maintaining perfect synchronization
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");
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
}
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);
}
}
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");
🎮 Game Input
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
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
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");
// ✅ Always use events
this.publish("input", "action", data);
// ❌ Never modify directly
this.model.property = value;
// ✅ Efficient updates
if (this.hasChanged()) {
this.updateDisplay();
}
// ✅ Immediate feedback
this.showFeedback();
this.publish("input", "action", data);
// ✅ Modular design
this.gameView = new GameView();
this.uiView = new UIView();
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();
}
}
❌ 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));
}
}
}