├── .nvmrc ├── docs ├── tutorials │ ├── 1 Hello World │ │ ├── index.html │ │ ├── style.css │ │ └── script.js │ ├── 5 3D Animation │ │ ├── index.html │ │ ├── style.css │ │ └── script.js │ ├── 2 Simple Animation │ │ ├── index.html │ │ ├── style.css │ │ └── script.js │ ├── 4 View Smoothing │ │ ├── index.html │ │ ├── style.css │ │ └── script.js │ ├── images │ │ ├── discord.png │ │ ├── SessionBadge.png │ │ ├── multiblaster.gif │ │ ├── 3DAnimationSettings.png │ │ └── HelloWorldSettings.png │ ├── 3 Multiuser Chat │ │ ├── index.html │ │ ├── style.css │ │ └── script.js │ ├── 2_8_random.md │ ├── structure.json │ ├── 2_7_snapshots.md │ ├── 2_5_sim_time_and_future.md │ ├── 2_1_model_view_synchronizer.md │ ├── 2_3_writing_a_multisynq_view.md │ ├── 2_6_events_pub_sub.md │ ├── 2_2_writing_a_multisynq_app.md │ ├── 2_4_writing_a_multisynq_model.md │ ├── 2_9_data.md │ └── 1_2_simple_animation.md └── README.md ├── examples ├── hello │ ├── multisynq-client.esm.js │ ├── multisynq-client.esm.js.map │ ├── index.html │ └── hello.js ├── hello_typescript │ ├── src │ │ ├── vite-env.d.ts │ │ ├── main.ts │ │ ├── counter.ts │ │ └── style.css │ ├── vite.config.js │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── tsconfig.json │ └── public │ │ ├── typescript.svg │ │ ├── vite.svg │ │ └── multisynq.svg └── hello_node │ ├── package.json │ ├── package-lock.json │ └── hello_node.js ├── .editorconfig ├── .gitignore ├── client ├── teatime │ ├── thirdparty-patched │ │ ├── qrcodejs │ │ │ ├── bower.json │ │ │ ├── LICENSE │ │ │ └── README.md │ │ └── seedrandom │ │ │ └── seedrandom.js │ ├── src │ │ ├── priorityQueue.js │ │ ├── node-messenger.js │ │ ├── node-urlOptions.js │ │ ├── offline.js │ │ ├── node-stats.js │ │ ├── urlOptions.js │ │ ├── node-html.js │ │ ├── realms.js │ │ ├── upload.worker.js │ │ ├── messenger.js │ │ └── hashing.js │ └── index.js └── math │ └── math.js ├── jsdoc.json ├── multisynq-client.js ├── RELEASE.md ├── .github └── workflows │ └── docs.yml ├── package.json ├── esbuild-plugin-inline-worker.mjs ├── watch.mjs ├── README.md ├── esbuild.mjs └── LICENSE.txt /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/jod 2 | -------------------------------------------------------------------------------- /docs/tutorials/1 Hello World/index.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /docs/tutorials/5 3D Animation/index.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /examples/hello/multisynq-client.esm.js: -------------------------------------------------------------------------------- 1 | ../../bundled/multisynq-client.esm.js -------------------------------------------------------------------------------- /docs/tutorials/2 Simple Animation/index.html: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /examples/hello/multisynq-client.esm.js.map: -------------------------------------------------------------------------------- 1 | ../../bundled/multisynq-client.esm.js.map -------------------------------------------------------------------------------- /examples/hello_typescript/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /docs/tutorials/4 View Smoothing/index.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/tutorials/images/discord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multisynq/multisynq-client/HEAD/docs/tutorials/images/discord.png -------------------------------------------------------------------------------- /docs/tutorials/images/SessionBadge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multisynq/multisynq-client/HEAD/docs/tutorials/images/SessionBadge.png -------------------------------------------------------------------------------- /docs/tutorials/images/multiblaster.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multisynq/multisynq-client/HEAD/docs/tutorials/images/multiblaster.gif -------------------------------------------------------------------------------- /docs/tutorials/images/3DAnimationSettings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multisynq/multisynq-client/HEAD/docs/tutorials/images/3DAnimationSettings.png -------------------------------------------------------------------------------- /docs/tutorials/images/HelloWorldSettings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multisynq/multisynq-client/HEAD/docs/tutorials/images/HelloWorldSettings.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /docs/tutorials/4 View Smoothing/style.css: -------------------------------------------------------------------------------- 1 | body { display: flex; margin: 0; width: 100vw; height: 100vh; user-select: none; } 2 | #canvas { 3 | max-width: 100%; 4 | max-height: 100%; 5 | margin: auto; 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build 2 | dist 3 | bundled 4 | .tsbuildinfo 5 | 6 | # Dependencies 7 | node_modules 8 | 9 | # Editors 10 | .vscode 11 | *.code-workspace 12 | 13 | # OS metadata 14 | .DS_Store 15 | Thumbs.db 16 | /_site 17 | -------------------------------------------------------------------------------- /docs/tutorials/3 Multiuser Chat/index.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
-------------------------------------------------------------------------------- /docs/tutorials/5 3D Animation/style.css: -------------------------------------------------------------------------------- 1 | #qr { position: fixed; width: 150px; height: 150px; top: 10px; right: 10px; } 2 | #session { position: fixed; width: 80px; height: 80px; top: 170px; right: 10px; } 3 | body { margin: 0 } 4 | #three { position: fixed; } 5 | -------------------------------------------------------------------------------- /examples/hello_typescript/vite.config.js: -------------------------------------------------------------------------------- 1 | /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */ 2 | 3 | import { defineConfig } from 'vite'; 4 | import commonjs from 'vite-plugin-commonjs'; 5 | 6 | export default defineConfig({ 7 | base: '', // allow relative URLs 8 | plugins: [commonjs()], 9 | }); 10 | -------------------------------------------------------------------------------- /docs/tutorials/1 Hello World/style.css: -------------------------------------------------------------------------------- 1 | #qr { position: fixed; width: 150px; height: 150px; top: 10px; right: 30px; } 2 | #session { position: fixed; width: 80px; height: 80px; top: 150px; right: 40px; } 3 | body { 4 | display: flex; 5 | flex-flow: wrap; 6 | user-select: none; 7 | } 8 | #countDisplay { 9 | margin: auto; 10 | font: 64px sans-serif; 11 | } -------------------------------------------------------------------------------- /docs/tutorials/2_8_random.md: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Croquet Labs 2 | 3 | Multisynq guarantees that the same sequence of random numbers is generated within the model on each device. 4 | If you call `Math.random()` within the model it will return the same number on all machines. 5 | 6 | Calls to `Math.random()` within the view will behave normally. Different machines will receive different random numbers. 7 | -------------------------------------------------------------------------------- /examples/hello_typescript/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/hello_node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hello_node", 3 | "version": "1.0.0", 4 | "description": "Node example", 5 | "main": "hello_node.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node hello_node.js", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "Croquet Labs", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@multisynq/client": "file:../.." 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/hello_typescript/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + TS + Multisynq 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/hello_typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hello-typescript", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "typescript": "^5.8.3", 13 | "vite": "^6.3.5", 14 | "vite-plugin-commonjs": "^0.10.4" 15 | }, 16 | "dependencies": { 17 | "@multisynq/client": "file:../.." 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/teatime/thirdparty-patched/qrcodejs/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qrcode.js", 3 | "version": "0.0.1", 4 | "homepage": "https://github.com/davidshimjs/qrcodejs", 5 | "authors": [ 6 | "Sangmin Shim", "Sangmin Shim (http://jaguarjs.com)" 7 | ], 8 | "description": "Cross-browser QRCode generator for javascript", 9 | "main": "qrcode.js", 10 | "ignore": [ 11 | "bower_components", 12 | "node_modules", 13 | "index.html", 14 | "index.svg", 15 | "jquery.min.js", 16 | "qrcode.min.js" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /client/teatime/src/priorityQueue.js: -------------------------------------------------------------------------------- 1 | import FastPriorityQueue from "fastpriorityqueue"; 2 | 3 | export default class PriorityQueue extends FastPriorityQueue { 4 | poll() { 5 | const result = super.poll(); 6 | this.array[this.size] = null; // release memory 7 | return result; 8 | } 9 | 10 | asArray() { 11 | const array = []; 12 | this.forEach(item => array.push(item)); 13 | return array; 14 | } 15 | 16 | asUnsortedArray() { 17 | return this.array.slice(0, this.size); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Multisynq Client Docs 2 | 3 | These docs are automatically deployed at https://multisynq.github.io/multisynq-client/ 4 | 5 | ## Building 6 | 7 | To build the docs, use `build-docs.sh` which generates them into the `_site` directory. 8 | This is also what the GitHub action uses to deploy them as GitHub pages. 9 | 10 | It uses [JSDoc](https://jsdoc.app) to build the class documentation from structured comments in the source code (in particular `index.js`, `model.js`, `view.js`, `session.js`), as well as tutorials from markdown files in this directory. 11 | 12 | -------------------------------------------------------------------------------- /docs/tutorials/3 Multiuser Chat/style.css: -------------------------------------------------------------------------------- 1 | #qr { position: fixed; width: 150px; height: 150px; top: 10px; right: 30px; } 2 | #session { position: fixed; width: 80px; height: 80px; top: 150px; right: 40px; } 3 | 4 | html, body, #chat { height: 100%; margin: 0; } 5 | body,input { font: 24px sans-serif; } 6 | #chat { display: flex; flex-flow: row wrap; } 7 | #chat > * { margin: 5px 10px; padding: 10px; border: 1px solid #999; } 8 | #textIn,#sendButton { flex: 1 0 0; } 9 | #textOut { height: calc(100% - 100px); flex: 1 100%; overflow: auto } 10 | #textIn { flex-grow: 100 } 11 | #sendButton { background-color: #fff; border: 2px solid #000 } 12 | -------------------------------------------------------------------------------- /examples/hello_typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /client/teatime/src/node-messenger.js: -------------------------------------------------------------------------------- 1 | // generate stub class for Node.js 2 | 3 | class M { 4 | constructor() { 5 | this.ready = false; 6 | } 7 | 8 | setReceiver(_receiver) { } 9 | 10 | setIframeEnumerator(_func) { } 11 | 12 | on(_event, _method) { } 13 | 14 | detach() { } 15 | 16 | removeSubscription(_event, _method) { } 17 | 18 | removeAllSubscriptions() { } 19 | 20 | receive(_msg) { } 21 | 22 | handleEvent(_event, _data, _source) { } 23 | 24 | send(_event, _data, _directWindow) { } 25 | 26 | // findIndex(_array, _method) { } 27 | 28 | startPublishingPointerMove() { } 29 | 30 | stopPublishingPointerMove() { } 31 | } 32 | 33 | export const Messenger = new M(); 34 | -------------------------------------------------------------------------------- /examples/hello/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hello World 5 | 15 | 22 | 23 | 24 |
...
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/tutorials/2 Simple Animation/style.css: -------------------------------------------------------------------------------- 1 | #qr { position: fixed; width: 150px; height: 150px; top: 10px; right: 30px; } 2 | #session { position: fixed; width: 80px; height: 80px; top: 160px; right: 30px; } 3 | 4 | body { 5 | display: flex; 6 | flex-flow: wrap; 7 | user-select: none; 8 | } 9 | 10 | body { 11 | margin: 0; 12 | overflow: hidden; 13 | font: 12px sans-serif; 14 | background: #999; 15 | } 16 | .root { 17 | position: absolute; 18 | width: 1100px; 19 | height: 1100px; 20 | background: #333; 21 | overflow: hidden; 22 | z-index: -1; 23 | } 24 | .circle { 25 | position: absolute; 26 | width: 100px; 27 | height: 100px; 28 | border-radius: 50px; 29 | } 30 | .roundRect { 31 | position: absolute; 32 | width: 100px; 33 | height: 100px; 34 | border-radius: 25px; 35 | } -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "include": [ 4 | "docs/QUICKSTART.md", 5 | "client/teatime/index.js", 6 | "client/teatime/src/model.js", 7 | "client/teatime/src/view.js", 8 | "client/teatime/src/session.js" 9 | ] 10 | }, 11 | "opts": { 12 | "destination": "./_site/", 13 | "access": ["public"], 14 | "tutorials": "docs/tutorials", 15 | "encoding": "utf8", 16 | "recurse": false, 17 | "verbose": true, 18 | "mainpagetitle": "Multisynq @CLIENT_VERSION@" 19 | }, 20 | "plugins": ["plugins/markdown"], 21 | "markdown": { 22 | "parser": "gfm", 23 | "hardwrap": false, 24 | "idInHeadings": true 25 | }, 26 | "templates": { 27 | "cleverLinks": false, 28 | "monospaceLinks": false, 29 | "default": { 30 | "outputSourceFiles": false, 31 | "includeDate": false, 32 | "useLongnameInNav": true, 33 | "staticFiles": { 34 | "include": [ 35 | "docs/tutorials" 36 | ], 37 | "includePattern": "\\.(png|jpg|svg|gif)$" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/hello_typescript/src/main.ts: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import typescriptLogo from '/typescript.svg' 3 | import viteLogo from '/vite.svg' 4 | import multisynqLogo from '/multisynq.svg' 5 | import { setupCounter } from './counter.ts' 6 | 7 | document.querySelector('#app')!.innerHTML = ` 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |

Vite + TypeScript + Multisynq

19 |
20 | 21 |
22 |

23 | Click on the Vite, TypeScript, and Multisynq logos to learn more 24 |

25 |
26 | ` 27 | 28 | setupCounter(document.querySelector('#counter')!) 29 | -------------------------------------------------------------------------------- /multisynq-client.js: -------------------------------------------------------------------------------- 1 | import { 2 | Model, 3 | View, 4 | Session, 5 | Data, 6 | Constants, 7 | App, 8 | VERSION, 9 | } from "./client/teatime"; 10 | 11 | console.log(`Multisynq ${VERSION}`); 12 | 13 | export { 14 | Model, 15 | View, 16 | Session, 17 | Data, 18 | Constants, 19 | App, 20 | VERSION, 21 | } 22 | 23 | const Multisynq = { 24 | Model, 25 | View, 26 | Session, 27 | Data, 28 | Constants, 29 | App, 30 | VERSION, 31 | }; 32 | 33 | Model.Multisynq = Multisynq; 34 | View.Multisynq = Multisynq; 35 | 36 | // hook for future browser devtools 37 | if (typeof __MULTISYNQ_DEVTOOLS__ !== 'undefined') { 38 | __MULTISYNQ_DEVTOOLS__.dispatchEvent(new CustomEvent('load', { 39 | detail: { 40 | version: VERSION, 41 | lib: Multisynq, 42 | } 43 | })); 44 | } 45 | 46 | if (typeof globalThis !== 'undefined') { 47 | if (globalThis.__MULTISYNQ__) { 48 | console.warn( 'WARNING: Multiple instances of Multisynq being imported.' ); 49 | } else { 50 | globalThis.__MULTISYNQ__ = VERSION; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /client/teatime/thirdparty-patched/qrcodejs/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | --------------------- 3 | Copyright (c) 2012 davidshimjs 4 | 5 | Permission is hereby granted, free of charge, 6 | to any person obtaining a copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, 8 | including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /docs/tutorials/1 Hello World/script.js: -------------------------------------------------------------------------------- 1 | // Multisynq Tutorial 1 2 | // Hello World 3 | // Croquet Labs (C) 2025 4 | 5 | class MyModel extends Multisynq.Model { 6 | 7 | init() { 8 | this.count = 0; 9 | this.subscribe("counter", "reset", this.resetCounter); 10 | this.future(100).tick(); 11 | } 12 | 13 | resetCounter() { 14 | this.count = 0; 15 | } 16 | 17 | tick() { 18 | this.count += 0.1; 19 | this.future(100).tick(); 20 | } 21 | 22 | } 23 | 24 | MyModel.register("MyModel"); 25 | 26 | class MyView extends Multisynq.View { 27 | 28 | constructor(model) { 29 | super(model); 30 | this.model = model; 31 | countDisplay.onclick = event => this.counterReset(); 32 | this.update(); 33 | } 34 | 35 | counterReset() { 36 | this.publish("counter", "reset"); 37 | } 38 | 39 | update() { 40 | countDisplay.textContent = this.model.count; 41 | } 42 | 43 | } 44 | 45 | Multisynq.Session.join({ 46 | appId: "io.codepen.multisynq.hello", 47 | apiKey: "234567_Paste_Your_Own_API_Key_Here_7654321", 48 | name: "public", 49 | password: "none", 50 | model: MyModel, 51 | view: MyView}); -------------------------------------------------------------------------------- /examples/hello_node/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hello_node", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "hello_node", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@multisynq/client": "file:../.." 13 | } 14 | }, 15 | "../..": { 16 | "name": "@multisynq/client", 17 | "version": "1.0.4", 18 | "license": "Apache-2.0", 19 | "dependencies": { 20 | "@stdlib/stdlib": "^0.3.2", 21 | "crypto-js": "^4.2.0", 22 | "fast-json-stable-stringify": "^2.1.0", 23 | "fastpriorityqueue": "^0.7.5", 24 | "minimist": "^1.2.8", 25 | "node-datachannel": "^0.28.0", 26 | "pako": "^2.1.0", 27 | "toastify-js": "^1.12.0", 28 | "ws": "^8.18.3" 29 | }, 30 | "devDependencies": { 31 | "esbuild": "^0.25.5", 32 | "esbuild-node-externals": "^1.13.0", 33 | "esbuild-plugin-inline-worker": "^0.1.1", 34 | "esbuild-plugin-replace": "^1.3.0" 35 | } 36 | }, 37 | "node_modules/@multisynq/client": { 38 | "resolved": "../..", 39 | "link": true 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/teatime/thirdparty-patched/qrcodejs/README.md: -------------------------------------------------------------------------------- 1 | # QRCode.js 2 | QRCode.js is javascript library for making QRCode. QRCode.js supports Cross-browser with HTML5 Canvas and table tag in DOM. 3 | QRCode.js has no dependencies. 4 | 5 | ## Basic Usages 6 | ``` 7 |
8 | 11 | ``` 12 | 13 | or with some options 14 | 15 | ``` 16 |
17 | 27 | ``` 28 | 29 | and you can use some methods 30 | 31 | ``` 32 | qrcode.clear(); // clear the code. 33 | qrcode.makeCode("http://naver.com"); // make another code. 34 | ``` 35 | 36 | ## Browser Compatibility 37 | IE6~10, Chrome, Firefox, Safari, Opera, Mobile Safari, Android, Windows Mobile, ETC. 38 | 39 | ## License 40 | MIT License 41 | 42 | ## Contact 43 | twitter @davidshimjs 44 | 45 | [![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/davidshimjs/qrcodejs/trend.png)](https://bitdeli.com/free "Bitdeli Badge") 46 | -------------------------------------------------------------------------------- /examples/hello_typescript/public/typescript.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/hello_typescript/src/counter.ts: -------------------------------------------------------------------------------- 1 | import { Model, View, Session } from '@multisynq/client' 2 | 3 | class Counter extends Model { 4 | 5 | counter = 0 6 | 7 | init() { 8 | this.counter = 0; 9 | this.subscribe("counter", "reset", this.resetCounter); 10 | this.future(1000).tick(); 11 | } 12 | 13 | resetCounter() { 14 | this.counter = 0; 15 | this.publish("counter", "update"); 16 | } 17 | 18 | tick() { 19 | this.counter++; 20 | this.publish("counter", "update"); 21 | this.future(1000).tick(); 22 | } 23 | 24 | } 25 | Counter.register("MyModel"); 26 | 27 | 28 | export function setupCounter(element: HTMLButtonElement) { 29 | 30 | class Button extends View { 31 | model: Counter; 32 | 33 | constructor(model: Counter) { 34 | super(model); 35 | this.model = model; 36 | this.subscribe("counter", "update", this.updateCounter); 37 | element.onclick = () => this.publish("counter", "reset"); 38 | this.updateCounter(); 39 | } 40 | 41 | updateCounter() { 42 | element.innerHTML = `${this.model.counter}`; 43 | } 44 | } 45 | 46 | Session.join({ 47 | appId: "io.multisynq.hello-typescript", 48 | apiKey: "234567_Paste_Your_Own_API_Key_Here_7654321", 49 | name: location.origin + location.pathname, // one session per URL 50 | password: "shared", 51 | model: Counter, 52 | view: Button, 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # How to release 2 | 3 | The multisynq client release consists of publishing the new npm and updating the docs: 4 | 5 | [ ] release new client from this repo via `npm publish` 6 | - replace old version number with new version number in package.json and README 7 | - commit with a message ending in the version number (e.g. "Prerelease 1.1.0-0" or "Release 1.1.0") 8 | - `npm run build`, make sure it prints the version number correctly (without any +... extension, "bumped" and "clean" should both be true) 9 | ``` 10 | Building Multisynq 1.1.0-0 11 | bumped: true, clean: true 12 | ``` 13 | - test, push 14 | - for release: `npm publish` (and continue with next steps) 15 | - for prerelease: `npm publish --tag pre` (and skip the rest) 16 | 17 | [ ] update docs **tbd** 18 | 19 | ## Downstream projects 20 | 21 | *Decide which of these need to be updated depending on what changes were made to the library* 22 | 23 | [ ] release `multisynq-react` with new dependency 24 | 25 | [ ] update multisynq-react-mondrian example (depends on `multisynq-react`) 26 | 27 | [ ] release `react-together` with new dependency (depends on `multisynq-react`) 28 | 29 | [ ] release `react-together-primereact` with new dependency (depends on `react-together`) 30 | 31 | [ ] release `react-together-ant-design` with new dependency (depends on `react-together`) 32 | 33 | [ ] update multiblaster 34 | 35 | [ ] update multicar 36 | 37 | [ ] update apps.multisynq.io 38 | -------------------------------------------------------------------------------- /docs/tutorials/structure.json: -------------------------------------------------------------------------------- 1 | { 2 | "1_1_hello_world": { 3 | "title": "💻 Hello World" 4 | }, 5 | "1_2_simple_animation": { 6 | "title": "💻 Simple Animation" 7 | }, 8 | "1_3_multiuser_chat": { 9 | "title": "💻 Multiuser Chat" 10 | }, 11 | "1_4_view_smoothing": { 12 | "title": "💻 View Smoothing" 13 | }, 14 | "1_5_3d_animation": { 15 | "title": "💻 3D Animation" 16 | }, 17 | "1_6_multiblaster": { 18 | "title": "🕹️ Multiblaster" 19 | }, 20 | "2_1_model_view_synchronizer": { 21 | "title": "💡 Model-View-Synchronizer" 22 | }, 23 | "2_2_writing_a_multisynq_app": { 24 | "title": "💡 Writing a Multisynq Application" 25 | }, 26 | "2_3_writing_a_multisynq_view": { 27 | "title": "💡 Writing a Multisynq View" 28 | }, 29 | "2_4_writing_a_multisynq_model": { 30 | "title": "💡 Writing a Multisynq Model" 31 | }, 32 | "2_5_sim_time_and_future": { 33 | "title": "💡 Simulation Time and Future Sends" 34 | }, 35 | "2_6_events_pub_sub": { 36 | "title": "💡 Events & Publish/Subscribe" 37 | }, 38 | "2_7_snapshots": { 39 | "title": "💡 Snapshots" 40 | }, 41 | "2_8_random": { 42 | "title": "💡 Randomness & Determinism" 43 | }, 44 | "2_9_data": { 45 | "title": "💡 Data API" 46 | }, 47 | "2_A_persistence": { 48 | "title": "💡 Persistence API" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /examples/hello_typescript/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/tutorials/2_7_snapshots.md: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Croquet Labs 2 | 3 | Snapshots are copies of the model state that are saved to the cloud. When your Multisynq application is running, the reflector will periodically request one of the participants to perform a snapshot. 4 | 5 | Snapshots provide automatic save functionality for your application. If you quit or reload while your application is running, it will automatically reload the last snapshot when the application restarts. 6 | 7 | (When you write your initialization routine for your View, take into account that the Model may just have reloaded from a prior snapshot.) 8 | 9 | More importantly, snapshots are how new users synchronize when they join an existing session. When you join an existing session, the following series of events will occur: 10 | 11 | 1. The local model is initialized with data from the last snapshot. 12 | 2. The reflector resends the local model all events that were transmitted after the last snapshot was taken. 13 | 3. The model simulates all the events to bring the snapshot up-to-date 14 | 4. The local view initializes itself to match the state of the model and subscribes to model events 15 | 16 | The combination of loading the last snapshot and replaying all the intervening events brings the new user in sync with the other users in the session. 17 | 18 | ## Snapshot Performance 19 | 20 | The snapshot code is currently unoptimized, so you may experience a performance hitch when the snapshot is taken. The Multisynq development team is working to resolve this issue and make snapshots invisible to both the user and developer, but for the time being you may see your application occasionally pause if your model is very large. 21 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Documentation 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | # Allow manual trigger 9 | workflow_dispatch: 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 18 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: false 22 | 23 | jobs: 24 | # Build job 25 | build: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Setup Node.js 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: '18' 35 | cache: 'npm' 36 | 37 | - name: Install dependencies 38 | run: npm ci --ignore-scripts 39 | 40 | - name: Build documentation 41 | run: ./build-docs.sh 42 | 43 | - name: Setup Pages 44 | uses: actions/configure-pages@v4 45 | 46 | - name: Upload artifact 47 | uses: actions/upload-pages-artifact@v3 48 | with: 49 | path: './_site' 50 | 51 | # Deployment job 52 | deploy: 53 | environment: 54 | name: github-pages 55 | url: ${{ steps.deployment.outputs.page_url }} 56 | runs-on: ubuntu-latest 57 | needs: build 58 | # Only deploy on pushes to main branch 59 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' 60 | steps: 61 | - name: Deploy to GitHub Pages 62 | id: deployment 63 | uses: actions/deploy-pages@v4 64 | -------------------------------------------------------------------------------- /client/teatime/src/node-urlOptions.js: -------------------------------------------------------------------------------- 1 | import minimist from 'minimist'; 2 | const argObj = minimist(process.argv.slice(2)); 3 | 4 | const actualHostname = "localhost"; 5 | 6 | class UrlOptions { 7 | constructor() { 8 | parseOptions(this); 9 | } 10 | 11 | /** 12 | * - has("debug", "recv", false) matches debug=recv and debug=send,recv 13 | * - has("debug", "recv", true) matches debug=norecv and debug=send,norecv 14 | * - has("debug", "recv", "localhost") defaults to true on localhost, false otherwise 15 | * 16 | * @param {String} key - key for list of items 17 | * @param {String} item - value to look for in list of items 18 | * @param {Boolean|String} defaultValue - if string, true on that hostname, false otherwise 19 | */ 20 | has(key, item, defaultValue) { 21 | if (typeof defaultValue !== "boolean") defaultValue = this.isHost(defaultValue); 22 | const urlString = this[key]; 23 | if (typeof urlString !== "string") return defaultValue; 24 | const urlItems = urlString.split(','); 25 | if (defaultValue === true) item =`no${item}`; 26 | if (item.endsWith("s")) item = item.slice(0, -1); 27 | if (urlItems.includes(item) || urlItems.includes(`${item}s`)) return !defaultValue; 28 | return defaultValue; 29 | } 30 | 31 | isHost(hostname) { 32 | return actualHostname === hostname; 33 | } 34 | 35 | isLocalhost() { 36 | return this.isHost("localhost"); 37 | } 38 | } 39 | 40 | function parseOptions(target) { 41 | for (const key of Object.keys(argObj)) { 42 | if (key === "_") continue; 43 | 44 | let val = argObj[key]; 45 | if (typeof val === 'string' && val[0] === "[") val = val.slice(1, -1).split(","); // handle string arrays 46 | target[key] = val; 47 | } 48 | } 49 | 50 | const urlOptions = new UrlOptions(); 51 | export default urlOptions; 52 | -------------------------------------------------------------------------------- /examples/hello_typescript/src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | #app { 39 | max-width: 1280px; 40 | margin: 0 auto; 41 | padding: 2rem; 42 | text-align: center; 43 | } 44 | 45 | .logo { 46 | height: 6em; 47 | padding: 1.5em; 48 | will-change: filter; 49 | transition: filter 300ms; 50 | } 51 | .logo:hover { 52 | filter: drop-shadow(0 0 2em #646cffaa); 53 | } 54 | .logo.vanilla:hover { 55 | filter: drop-shadow(0 0 2em #3178c6aa); 56 | } 57 | 58 | .card { 59 | padding: 2em; 60 | } 61 | 62 | .read-the-docs { 63 | color: #888; 64 | } 65 | 66 | button { 67 | border-radius: 8px; 68 | border: 1px solid transparent; 69 | padding: 0.6em 1.2em; 70 | font-size: 1em; 71 | font-weight: 500; 72 | font-family: inherit; 73 | background-color: #1a1a1a; 74 | cursor: pointer; 75 | transition: border-color 0.25s; 76 | } 77 | button:hover { 78 | border-color: #646cff; 79 | } 80 | button:focus, 81 | button:focus-visible { 82 | outline: 4px auto -webkit-focus-ring-color; 83 | } 84 | 85 | @media (prefers-color-scheme: light) { 86 | :root { 87 | color: #213547; 88 | background-color: #ffffff; 89 | } 90 | a:hover { 91 | color: #747bff; 92 | } 93 | button { 94 | background-color: #f9f9f9; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@multisynq/client", 3 | "version": "1.1.0", 4 | "description": "Real-time multiplayer framework for web applications", 5 | "author": "Croquet Labs", 6 | "exports": { 7 | ".": { 8 | "node": { 9 | "types": "./dist/multisynq-client.d.ts", 10 | "import": "./dist/multisynq-client-node.mjs", 11 | "require": "./dist/multisynq-client-node.cjs" 12 | }, 13 | "default": { 14 | "types": "./dist/multisynq-client.d.ts", 15 | "import": "./dist/multisynq-client.esm.js", 16 | "require": "./dist/multisynq-client.cjs.js" 17 | } 18 | } 19 | }, 20 | "types": "dist/multisynq-client.d.ts", 21 | "files": [ 22 | "dist", 23 | "bundled" 24 | ], 25 | "jsdelivr": "bundled/multisynq-client.min.js", 26 | "unpkg": "bundled/multisynq-client.min.js", 27 | "license": "Apache-2.0", 28 | "keywords": [ 29 | "multisynq", 30 | "multiplayer", 31 | "multiuser", 32 | "collaboration", 33 | "realtime" 34 | ], 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/multisynq/multisynq-client.git" 38 | }, 39 | "bugs": { 40 | "url": "https://github.com/multisynq/multisynq-client/issues" 41 | }, 42 | "homepage": "https://github.com/multisynq/multisynq-client#readme", 43 | "scripts": { 44 | "build": "node esbuild.mjs", 45 | "watch": "node watch.mjs", 46 | "prepare": "npm run build" 47 | }, 48 | "dependencies": { 49 | "@stdlib/stdlib": "^0.3.2", 50 | "crypto-js": "^4.2.0", 51 | "fast-json-stable-stringify": "^2.1.0", 52 | "fastpriorityqueue": "^0.7.5", 53 | "minimist": "^1.2.8", 54 | "node-datachannel": "^0.28.0", 55 | "pako": "^2.1.0", 56 | "toastify-js": "^1.12.0", 57 | "ws": "^8.18.3" 58 | }, 59 | "devDependencies": { 60 | "chokidar": "^4.0.3", 61 | "esbuild": "^0.25.5", 62 | "esbuild-node-externals": "^1.13.0", 63 | "esbuild-plugin-replace": "^1.3.0", 64 | "find-cache-dir": "^5.0.0", 65 | "jsdoc": "^4.0.4" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /docs/tutorials/2_5_sim_time_and_future.md: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Croquet Labs 2 | 3 | The model has no concept of real-world time. All it knows about is _simulation time_. 4 | 5 | Simulation time is the time in milliseconds since a session began. Any model can get the current simulation time by calling `this.now()`. 6 | 7 | While a session is active, the reflector will send a steady stream of heartbeat ticks to every connected user. Simulation time only advances when a heartbeat tick is received. 8 | 9 | What this means is that if you make a rapid series of calls to `this.now()`, it will always return the same value. It will not return a different value until the next heartbeat tick was received and processed. 10 | 11 | If you want to schedule a process to run in the future, don't poll `this.now()`, instead use _future send_. For example: 12 | ``` 13 | myTick() { 14 | // ... do some stuff ... 15 | this.future(100).myTick(); 16 | } 17 | ``` 18 | This creates a routine that will execute `myTick` every time 100 milliseconds have elapsed. If your simulation needs to update continuously, you will want to set up a tick routine in your model. Call it once at the end of the model's `init()` code, and ensure that it schedules itself to be called again each time it runs. 19 | 20 | The delay value passed to `future` does not need to be a whole number. For example, if you want something to run 60 times a second, you could pass it the value `1000/60`. 21 | 22 | Note that individual sub-models can have their own tick routines, so different parts of your simulation can run at different rates. Models can even have multiple future sends active at the same time. For example, you could have a model that updates its position 60 times a second, and check for collisions 20 times a second. 23 | 24 | Future can also be used for things besides ticks. It's a general-purpose scheduling tool. For example, if you wanted a sub-model to destroy itself half a second in the future, you could call: 25 | ``` 26 | this.future(500).destroy(); 27 | ``` 28 | (Views can also use `future` but they operate on normal system time.) 29 | -------------------------------------------------------------------------------- /client/teatime/src/offline.js: -------------------------------------------------------------------------------- 1 | const LATENCY = 50; 2 | 3 | export class OfflineSocket { 4 | constructor() { 5 | this.url = "(offline)"; 6 | this.readyState = WebSocket.CONNECTING; 7 | this.bufferedAmount = 0; 8 | setTimeout(() => { 9 | this.readyState = WebSocket.OPEN; 10 | if (this.onopen) this.onopen(); 11 | }, LATENCY); 12 | this.start = Date.now(); 13 | } 14 | 15 | send(data) { 16 | const {id, action, args} = JSON.parse(data); 17 | switch (action) { 18 | case 'JOIN': { 19 | this.id = id; 20 | this.ticks = args.ticks.tick; 21 | this.seq = -16 >>> 0; 22 | this.reply('SYNC', { messages: [], time: this.time, seq: this.seq, tove: args.tove, reflector: "offline"}); 23 | this.reply('RECV', [this.time, ++this.seq, {what: 'users', joined: [args.user], active: 1, total: 1}]); 24 | this.tick(); 25 | return; 26 | } 27 | case 'SEND': { 28 | const msg = [...args]; 29 | msg[0] = this.time; 30 | msg[1] = ++this.seq; 31 | this.reply('RECV', msg); 32 | return; 33 | } 34 | case 'PULSE': 35 | return; 36 | default: throw Error("Offline unhandled " + action); 37 | } 38 | } 39 | 40 | close(code, reason) { 41 | this.readyState = WebSocket.CLOSING; 42 | setTimeout(() => { 43 | this.readyState = WebSocket.CLOSED; 44 | if (this.onclose) this.onclose({code, reason}); 45 | }, LATENCY); 46 | } 47 | 48 | get time() { return Date.now() - this.start; } 49 | 50 | tick() { 51 | clearInterval(this.ticker); 52 | this.ticker = setInterval(() => { 53 | this.reply('TICK', {time: this.time}); 54 | }, this.ticks); 55 | } 56 | 57 | reply(action, args) { 58 | setTimeout(() => { 59 | if (this.onmessage) this.onmessage({ 60 | data: JSON.stringify({id: this.id, action, args}) 61 | }); 62 | }, LATENCY); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /docs/tutorials/2_1_model_view_synchronizer.md: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Croquet Labs 2 | 3 | Multisynq Overview 4 | 5 | Every Multisynq application consists of two parts: 6 | 7 | - The **view** handles user input and output by interacting with the DOM. 8 | It processes all keyboard / mouse / touch events, and determines what is displayed on the screen. 9 | 10 | - The **model** handles all calculation and simulation. This is where the actual work of the application takes place. 11 | 12 | **The state of the model is guaranteed to always be identical for all users.** However, the state of the view is not. Different users might be running on different hardware platforms, or might display different representations of the simulation. 13 | 14 | Internal communications between the model and view are handled through **events**. Whenever an object publishes an event, all objects that have subscribed to that event will execute a handler function. 15 | 16 | When a Multisynq application starts up, it becomes part of a **session**. Other users running the same application with the same session ID will also join the same session. The state of the model on every device in the session will be identical. 17 | 18 | The routing of events is handled automatically. If an event from a view is handled by a model, the model isn't sent the event directly. Instead Multisynq bounces the event off a synchronizer. 19 | 20 | **Synchronizers** are stateless, public, message-passing services located in the cloud. When a synchronizer receives an event from a user, it mirrors it to all the other users in the same session. 21 | 22 | **Snapshots** are archived copies of a model's state. Multisynq periodically takes snapshots of the model state and saves it to the cloud. When a new user joins a session, it can synch with the other users by loading one of these snapshots. 23 | 24 | - Input/output is routed through the view. 25 | - The view can read from the model, but can't write to it. 26 | - Events from view to model are reflected to all users. 27 | - All other events (model-model, view-view, and especially model-to-view) are only executed locally. 28 | - Model state is automatically saved to (and loaded from) snapshots. 29 | -------------------------------------------------------------------------------- /esbuild-plugin-inline-worker.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | // copy of https://github.com/mitschabaude/esbuild-plugin-inline-worker 4 | // modified to work with node (using base64 instead of blob) 5 | 6 | import esbuild from 'esbuild'; 7 | import findCacheDir from 'find-cache-dir'; 8 | import fs from 'fs'; 9 | import path from 'path'; 10 | 11 | export {inlineWorkerPlugin as default}; 12 | 13 | function inlineWorkerPlugin(extraConfig) { 14 | return { 15 | name: 'esbuild-plugin-inline-worker', 16 | 17 | setup(build) { 18 | build.onLoad( 19 | {filter: /\.worker.(js|jsx|ts|tsx)$/}, 20 | async ({path: workerPath}) => { 21 | // let workerCode = await fs.promises.readFile(workerPath, { 22 | // encoding: 'utf-8', 23 | // }); 24 | 25 | let workerCode = await buildWorker(workerPath, extraConfig); 26 | return { 27 | contents: `import inlineWorker from '__inline-worker' 28 | export default function Worker() { 29 | return inlineWorker(${JSON.stringify(workerCode)}); 30 | } 31 | `, 32 | loader: 'js', 33 | }; 34 | } 35 | ); 36 | 37 | build.onResolve({filter: /^__inline-worker$/}, ({path}) => { 38 | return {path, namespace: 'inline-worker'}; 39 | }); 40 | build.onLoad({filter: /.*/, namespace: 'inline-worker'}, () => { 41 | return {contents: inlineWorkerFunctionCode, loader: 'js'}; 42 | }); 43 | }, 44 | }; 45 | } 46 | 47 | const inlineWorkerFunctionCode = ` 48 | export default function inlineWorker(scriptText) { 49 | let base64 = btoa(scriptText); 50 | let url = new URL(\`data:application/javascript;base64,\${base64}\`); 51 | let worker = new Worker(url); 52 | return worker; 53 | } 54 | `; 55 | 56 | let cacheDir = findCacheDir({ 57 | name: 'esbuild-plugin-inline-worker', 58 | create: true, 59 | }); 60 | 61 | async function buildWorker(workerPath, extraConfig) { 62 | let scriptNameParts = path.basename(workerPath).split('.'); 63 | scriptNameParts.pop(); 64 | scriptNameParts.push('js'); 65 | let scriptName = scriptNameParts.join('.'); 66 | let bundlePath = path.resolve(cacheDir, scriptName); 67 | 68 | if (extraConfig) { 69 | delete extraConfig.entryPoints; 70 | delete extraConfig.outfile; 71 | delete extraConfig.outdir; 72 | } 73 | 74 | await esbuild.build({ 75 | entryPoints: [workerPath], 76 | bundle: true, 77 | minify: true, 78 | outfile: bundlePath, 79 | target: 'es2017', 80 | format: 'esm', 81 | ...extraConfig, 82 | }); 83 | 84 | return fs.promises.readFile(bundlePath, {encoding: 'utf-8'}); 85 | } 86 | -------------------------------------------------------------------------------- /watch.mjs: -------------------------------------------------------------------------------- 1 | import chokidar from 'chokidar'; 2 | import { spawn } from 'child_process'; 3 | 4 | // Directories and files to watch 5 | const watchPatterns = [ 6 | 'multisynq-client.js', 7 | 'client', 8 | 'esbuild.mjs', 9 | 'esbuild-plugin-inline-worker.mjs' 10 | ]; 11 | 12 | console.log('Starting watch mode...'); 13 | console.log('Watching files:', watchPatterns); 14 | 15 | // Debounce function to avoid multiple builds 16 | let buildTimeout; 17 | let isBuilding = false; 18 | let initialBuildDone = false; 19 | 20 | function debouncedBuild() { 21 | if (buildTimeout) { 22 | clearTimeout(buildTimeout); 23 | } 24 | buildTimeout = setTimeout(() => { 25 | if (!isBuilding) { 26 | if (initialBuildDone) { 27 | console.log('\n🔄 File change detected, rebuilding...'); 28 | } 29 | runBuild(); 30 | } 31 | }, 500); // 500ms debounce 32 | } 33 | 34 | function runBuild() { 35 | if (isBuilding) { 36 | console.log('⏳ Build already in progress, skipping...'); 37 | return; 38 | } 39 | 40 | isBuilding = true; 41 | console.log('Running build...'); 42 | const buildProcess = spawn('node', ['esbuild.mjs'], { 43 | stdio: 'inherit', 44 | shell: false 45 | }); 46 | 47 | buildProcess.on('close', (code) => { 48 | isBuilding = false; 49 | if (code === 0) { 50 | console.log('✅ Build completed successfully'); 51 | initialBuildDone = true; 52 | } else { 53 | console.log('❌ Build failed with code:', code); 54 | } 55 | }); 56 | } 57 | 58 | // Initialize watcher 59 | const watcher = chokidar.watch(watchPatterns, { 60 | ignored: [ 61 | /(^|[\/\\])\../, // ignore dotfiles 62 | '**/node_modules/**', 63 | '**/dist/**', 64 | '**/bundled/**' 65 | ], 66 | persistent: true, 67 | awaitWriteFinish: { 68 | stabilityThreshold: 100, 69 | pollInterval: 100 70 | } 71 | }); 72 | 73 | // Watch events 74 | watcher 75 | .on('ready', () => { 76 | console.log('👀 Watcher ready.'); 77 | }) 78 | .on('change', (path) => { 79 | console.log(`📝 File changed: ${path}`); 80 | debouncedBuild(); 81 | }) 82 | .on('add', (path) => { 83 | if (initialBuildDone) console.log(`➕ File added: ${path}`); 84 | debouncedBuild(); 85 | }) 86 | .on('unlink', (path) => { 87 | console.log(`🗑️ File removed: ${path}`); 88 | debouncedBuild(); 89 | }) 90 | .on('error', (error) => { 91 | console.error('Watcher error:', error); 92 | }); 93 | 94 | // Handle process termination 95 | process.on('SIGINT', () => { 96 | console.log('\n🛑 Stopping watch mode...'); 97 | watcher.close(); 98 | process.exit(0); 99 | }); 100 | 101 | console.log('Press Ctrl+C to stop watching'); -------------------------------------------------------------------------------- /docs/tutorials/2_3_writing_a_multisynq_view.md: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Croquet Labs 2 | 3 | Multisynq makes no assumptions about how you implement the view. It operates like a normal JS application. You can directly access the DOM and instantiate whatever sub-objects or data types that you need, use any libraries etc. 4 | 5 | The contents of the view are not replicated across machines. Because of this, you generally use the view only for handling input and output. If the user taps a button or clicks somewhere on screen, the view turns this action into an event that it publishes for the model to receive. And whenever the model changes, the view updates the visual representation that it displays on the screen. But in general, all of the actual calculation of the application should be done inside the model. 6 | 7 | In order to update output quickly, the view has a reference to the model and can _read_ from it directly. However … 8 | 9 | ## **The view must NEVER write directly to the model!** 10 | 11 | This is the **most important** rule of creating a stable Multisynq application. The view is given direct access to the model for efficiency, but in order for the local copy of the model to stay in synch with the remote copies of other users, _all changes to the model that originate in the view must be done through **events**_. That way they will be mirrored by the synchronizer to every user in the session. 12 | 13 | ### Other good practices for writing views: 14 | 15 | **Create sub-views inside your main view.** You can derive other classes from the {@link View} base class and instantiate them during execution. Sub-views have access to all the same services as your main view, so they can schedule their own tick operations and publish and subscribe to events. 16 | 17 | **Access the model through your main view.** Your main view receives a permanent reference to the main model when it is created. This reference can be stored and used to read directly from the model. 18 | 19 | **Use the `future()` operator to create ticks.** If you want something to happen regularly in the view, use the future operator to schedule a looping tick. This is just for readability, you're free to use `setTimeout` or `setInterval` etc. in view code. 20 | 21 | **Don't reply to the model.** Avoid having the model send an event to the view that requires the view to send a "reply" event back. This will result in large cascades of events that will choke off normal execution. 22 | 23 | **Anticipate the model for immediate feedback.** Latency in Multisynq is low, but it's not zero. If you want your application to feel extremely responsive (for example, if the player is controlling a first-person avatar) drive the output directly from the input, then correct the output when you get the official simulation state from the updated model. 24 | -------------------------------------------------------------------------------- /client/math/math.js: -------------------------------------------------------------------------------- 1 | /* To rebuild math-dist.js: 2 | 3 | npm ci 4 | npx rollup -c 5 | 6 | */ 7 | 8 | import acos from "@stdlib/math/base/special/acos"; 9 | import acosh from "@stdlib/math/base/special/acosh"; 10 | import asin from "@stdlib/math/base/special/asin"; 11 | import asinh from "@stdlib/math/base/special/asinh"; 12 | import atan from "@stdlib/math/base/special/atan"; 13 | import atanh from "@stdlib/math/base/special/atanh"; 14 | import atan2 from "@stdlib/math/base/special/atan2"; 15 | import cbrt from "@stdlib/math/base/special/cbrt"; 16 | import cos from "@stdlib/math/base/special/cos"; 17 | import cosh from "@stdlib/math/base/special/cosh"; 18 | import exp from "@stdlib/math/base/special/exp"; 19 | import expm1 from "@stdlib/math/base/special/expm1"; 20 | import log from "@stdlib/math/base/special/ln"; // ln because stdlib.log() has 2 args 21 | import log1p from "@stdlib/math/base/special/log1p"; 22 | import log10 from "@stdlib/math/base/special/log10"; 23 | import log2 from "@stdlib/math/base/special/log2"; 24 | import sin from "@stdlib/math/base/special/sin"; 25 | import sinh from "@stdlib/math/base/special/sinh"; 26 | import tan from "@stdlib/math/base/special/tan"; 27 | import tanh from "@stdlib/math/base/special/tanh"; 28 | 29 | if (typeof globalThis.MultisynqMath === "undefined") globalThis.MultisynqMath = {}; 30 | 31 | Object.assign(globalThis.MultisynqMath, { acos, acosh, asin, asinh, atan, atanh, atan2, cbrt, cos, cosh, exp, expm1, log, log1p, log10, log2, sin, sinh, tan, tanh }); 32 | 33 | // workaround for iOS Safari bug giving inconsistent results for stdlib's pow() 34 | //globalThis.MultisynqMath.pow = require("@stdlib/math/base/special/pow"); 35 | const mathPow = Math.pow; // the "native" method 36 | function isInfinite(x) { return x === Infinity || x === -Infinity; } 37 | function isInteger(x) { return Number.isInteger(x); } 38 | globalThis.MultisynqMath.pow = (x, y) => { 39 | if (isNaN(x) || isNaN(y)) return NaN; 40 | if (isInfinite(x) || isInfinite(y)) return mathPow(x, y); 41 | if (x === 0 || y === 0) return mathPow(x, y); 42 | if (x < 0 && !isInteger(y)) return NaN; 43 | 44 | // removed: if (isInteger(x) && isInteger(y)) return mathPow(x, y); 45 | // ...because it turns out that even on integer cases, the base Math.pow can be inconsistent across browsers (e.g., 5,-4 giving 0.0016 or 0.0015999999999999999). 46 | // nonetheless, we handle integer powers 1 to 4 explicitly, so that at least these will avoid the rounding errors that tend to emerge when calculating via logs. 47 | if (y === 1) return x; 48 | if (y === 2) return x*x; 49 | if (y === 3) return x*x*x; 50 | if (y === 4) return x*x*x*x; 51 | 52 | // remaining cases: 53 | // x -ve, y integer other than those handled above 54 | // x +ve, y anything other than integers handled above 55 | let signResult = 1; 56 | if (x < 0) { 57 | x *= -1; 58 | signResult = mathPow(-1, y); 59 | } 60 | const absPow = globalThis.MultisynqMath.exp(globalThis.MultisynqMath.log(x) * y); 61 | return absPow * signResult; 62 | }; 63 | 64 | -------------------------------------------------------------------------------- /docs/tutorials/2_6_events_pub_sub.md: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Croquet Labs 2 | 3 | Models and Views communicate using events. They use the same syntax for sending and receiving events. These functions are only available to classes that are derived from {@link Model} or {@link View}, so exposing them is one reason to define sub-models and sub-views. 4 | 5 | - `publish(scope, event, data)` 6 | - `subscribe(scope, event, this.handler)` 7 | - `unsubscribe(scope, event)` 8 | - `unsubscribeAll()` 9 | 10 | **Publish** sends an event to all models and views that have subscribed to it. 11 | 12 | - _Scope_ is a namespace so you can use the same event in different contexts (String). 13 | - _Event_ is the name of the event itself (String). 14 | - _Data_ is an optional argument containing additional information (any serializable type). 15 | 16 | **Subscribe** registers a model or a view to receive the specified events. 17 | 18 | - _handler_ is a function that accepts the event data structure as its argument. 19 | - in a view, the handler can be any function 20 | - in a model, the handler *must* use the form `this.someMethodName`.
21 | That's because functions cannot be serialized so actually only `"someMethodName"` is extracted from the function and stored. 22 | 23 | **Unsubscribe** unregisters the model or view so it will no longer receive the event. 24 | 25 | **UnsubscribeAll** unregisters all current subscriptions. Called automatically when you `destroy` a model or a view. 26 | 27 | ## Scopes 28 | 29 | _TODO: ... mention `model.id`, global scopes (`sessionId`, `viewId`) ..._ 30 | 31 | ## Event Handling 32 | 33 | Depending on where the event originates and which objects are subscribed to it, the events are routed differently: 34 | 35 | - _Model-to-Model_ - The event handler is executed immediately, before the publish call returns. 36 | 37 | - _Model-to-View_ - By default, the event is queued and will be handled by the local view when the current model simulation has finished. 38 | 39 | - _View-to-View_ - By default, the event is queued and will be handled in the same update cycle. 40 | 41 | - _View-to-Model_ - The event is transmitted to the reflector and mirrored to all users. It will be handled during the next model simulation. 42 | 43 | Note that multiple models and views can subscribe to the same event. Multisynq will take care of routing the event to each subscriber using the appropriate route, meaning that a view subscriber and a model subscriber will receive the event at slightly different times. 44 | 45 | ## Best practices 46 | 47 | Publish and subscribe can be used to establish a direct communications channel between different parts of the model and the view. For example, suppose you have several hundred AI agents that are running independently in the model, and each one has a graphical representation in the view. If you call publish and subscribe using the agent's id as the scope, an event from a particular actor will only be delivered to its corresponding representation and vice versa. 48 | 49 | Avoid creating chains of events that run from the model to the view then back to the model. View events can be triggered by the user, or by a timer, or by some other external source, but they should never be triggered by the model. Doing so can trigger a large cascade of events that will choke the system. 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multisynq Client 2 | 3 | *Multisynq lets you build real-time multiuser apps without writing server-side code. Unlike traditional client/server architectures, the multiplayer code is executed on each client in a synchronized virtual machine, rather than on a server. Multisynq is available as a JavaScript library that synchronizes apps using Multisynq's global DePIN network.* 4 | 5 | * can be hosted as a **static website** 6 | * **no server-side** code needed 7 | * **no networking** code needed 8 | * independent of UI framework 9 | 10 | ## Getting Started 11 | 12 | Get a free API key from [multisynq.io](https://multisynq.io/coder). 13 | 14 | ### Install 15 | 16 | npm i @multisynq/client 17 | 18 | You can also use the Multisynq pre-bundled files, e.g. via a script tag 19 | 20 | 21 | 22 | or via direct import as a module 23 | 24 | import * as Multisynq from "https://cdn.jsdelivr.net/npm/@multisynq/client@1.1.0/bundled/multisynq-client.esm.js"; 25 | 26 | ### Code 27 | 28 | Structure your app into a synchronized part (subclassed from [Multisynq.Model](https://multisynq.github.io/multisynq-client/Model.html)) and a local part interacting with it via events (subclassed from [Multisynq.View](https://multisynq.github.io/multisynq-client/View.html)). Use [Multisynq.Session.join()](https://multisynq.github.io/multisynq-client/Session.html#.join) with your API key to join a session. 29 | 30 | ### Deploy 31 | 32 | Upload your app to a web server. That's it. No deployment of anything except your HTML+JS. 33 | 34 | ## Docs and Tutorials 35 | 36 | Follow the docs and tutorials in this repo (deployed at https://multisynq.github.io/multisynq-client/) as well as the documentation at [docs.multisynq.io](https://docs.multisynq.io) and the example apps in the [Multisynq GitHub repos](http://github.com/multisynq). 37 | 38 | ### The Prime Directive 39 | 40 | *Your Multisynq Model must be completely self-contained.* 41 | 42 | The model must only interact with the outside world via subscriptions to user input events that are published by a view. Everything else needs to be 100% deterministic. The model must not read any state from global variables, and the view must not modify model state directly, only via publishing events (although the view is allowed to read from the model). 43 | 44 | Besides being deterministic, the model must be serializable – it needs to store state in an object-oriented style. That means in particular that the model cannot store functions, which JavaScript does not allow to be introspected for serialization. That also means you cannot use async code in the model. On the view side outside the model you're free to use any style of programming you want. 45 | 46 | ## Servers and Networking 47 | 48 | Multisynq runs on the [Multisynq DePIN network](https://multisynq.io) which automatically selects a server close to the first connecting user in a session. 49 | 50 | All application code and data is only processed on the clients. All network communication and external data storage is end-to-end encrypted by the random session password – since the server does not process application data there is no need for it to be decrypted on the server. This makes Multisynq one of the most private real-time multiplayer solutions available. 51 | 52 | ## Open Source 53 | 54 | The Multisynq Client is licensed under Apache 2.0. 55 | 56 | ## Change Log 57 | 58 | ### [1.1] - 2025-07-24 59 | 60 | Moved client source code from dependency into this repo; new build system; minor fixes. 61 | 62 | ### [1.0] - 2025-04-23 63 | 64 | Public release -------------------------------------------------------------------------------- /docs/tutorials/2_2_writing_a_multisynq_app.md: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Croquet Labs 2 | 3 | To create a Multisynq application, you need to define two classes that inherit from the base classes {@link Model} and {@link View} from the Multisynq client library: 4 | 5 | ``` 6 | class MyModel extends Multisynq.Model { 7 | init() { 8 | ... 9 | } 10 | } 11 | MyModel.register("MyModel"); 12 | 13 | class MyView extends Multisynq.View { 14 | constructor(model) { 15 | super(model); 16 | ... 17 | } 18 | } 19 | ``` 20 | 21 | Your view will contain all your input and output code, and your model will contain all your simulation code. 22 | 23 | (Note that every time you define a new model subclass, you must `register("name")` it so that Multisynq knows it exists, and under which name to find its instances in a snapshot.) 24 | 25 | ## Launching a session 26 | 27 | You launch a session by calling {@link Session.join} from the Multisynq client library. Its arguments are the id of your app (which needs to be unique, so using a reverse-DNS name like `"com.example.myapp"` is good), name and password of the session you're creating, the class types of your model and your view, and a set of session options (described below). 28 | 29 | The session name exists to distinguish multiple sessions per app. You may use our `autoSession` helper which parses URL search parameters and creates a new random session name if necessary. 30 | The session password encrypts all data sent via the internet. If your app does not use data worth protecting, you still need to provide a dummy password. You may use our `autoPassword` helper which parses the URL hash and creates a new random password if necessary, appending it to the url for sharing. (Note: both `autoSession` and `autoPassword` return promises. `Session.join` waits for all promises to resolve). In production you probably want to add some UI letting users type in the password. 31 | 32 | ``` 33 | const apiKey = "your_api_key"; // paste from multisynq.io/coder 34 | const appId = "com.example.myapp"; 35 | const name = Multisynq.App.autoSession(); 36 | const password = Multisynq.App.autoPassword(); 37 | Multisynq.Session.join({ apiKey, appId, name, password, model: MyModel, view: MyView }); 38 | ``` 39 | 40 | Starting the session will do the following things: 41 | 42 | 1. Connect to a nearby public synchronizer using the provided [API key](https://multisynq.io/coder) 43 | 2. Instantiate the model 44 | 3. a) Run the initialization code in the model's init routine -or-
45 | b) Initialize the model from a saved snapshot 46 | 4. Instantiate the view, passing the view constructor a reference to the model 47 | 5. Create a main event loop and begin executing 48 | 49 | The main loop runs each time the window performs an animation update — commonly, 60 times per second. On each iteration of the main loop, it will first process all pending events in the model, then process all pending events in the view, then call {@link View#render}. 50 | 51 | **Note that the code in your model's `init()` routine only runs the first time the application launches.** If another user joins a session that's in progress, they will load the most recent snapshot of model state. The same is true if you quit a session and rejoin it later. 52 | 53 | **TODO:** mention how session ids are derived from code hashes and url session slugs 54 | 55 | ## Advanced Topic: Creating Your Own Main Loop 56 | 57 | If you want more control over your main loop, you can pass out the `step: "manual"` directive and write a main loop yourself. For example: 58 | 59 | ``` 60 | const session = await Multisynq.Session.join({..., step: "manual"}); 61 | window.requestAnimationFrame(frame); 62 | 63 | function frame(now) { 64 | if (session.view) { 65 | session.view.myInputMethod(); 66 | session.step(now); 67 | session.view.myOutputMethod(); 68 | } 69 | window.requestAnimationFrame(frame); 70 | } 71 | ``` 72 | -------------------------------------------------------------------------------- /docs/tutorials/2_4_writing_a_multisynq_model.md: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Croquet Labs 2 | 3 | Unlike the view, there are limits to what the model can do if it is going to stay synched across all the machines in the session: 4 | 5 | **Model classes must be registered when defined.** Call `MyModel.register("MyModel")` every time you define a new {@link Model} subclass. 6 | 7 | **Use `create` and `destroy` to instantiate or dispose of models.** Do not use `new` to create sub-models. These models should be created/destroyed using the syntax `mySubModel.create()` and `mySubModel.destroy()`. Your `init` is called as part of the `create()` process. 8 | 9 | **Use `init` to initialize models.** Do not implement a constructor. Model classes only call `init` when they are instantiated for the first time. Put all initialization code in this method. If you put initialization code in the constructor, it would also run when the model is reloaded from a snapshot. 10 | 11 | **No global variables.** All variables in the model must be defined in the main model itself, or in sub-models instantiated by the main model. This way Multisynq can find them and save them to the snapshot. Instead, use Multisynq.Constants. The Constants object is recursively frozen once a session has started to avoid accidental modification. Here we assign the variable Q to Multisynq.Constants as a shorthand. 12 | 13 | ``` 14 | const Q = Multisynq.Constants; 15 | Q.BALL_NUM = 25; // how many balls do we want? 16 | Q.STEP_MS = 1000 / 30; // bouncing ball speed in virtual pixels / step 17 | Q.SPEED = 10; // max speed on a dimension, in units/s 18 | ``` 19 | 20 | This lets you use write ```this.future(Q.STEP_MS).step();``` where the STEP_MS value is registered and replicated. Just using a global STEP_MS could work in some cases, but there is no guarantee that the value will be replicated, so it could cause an accidental desyncing of the system. 21 | 22 | **No regular classes.** All objects in the model must be derived from the Model base class. (Mostly. See below for more information.) 23 | 24 | **No outside references.** The model must not use system services such as _Date.now()_, or reference JS globals such as _window_. 25 | 26 | **No asynchronous functions.** Do not use _Promises_ or declare a function call with the _async_ keyword inside the model. 27 | 28 | **Do not store function references or transmit them in events.** Functions cannot be serialized as part of the model state. (It's fine to use function references that exist temporarily, such as in a forEach call. You just shouldn't store them.) 29 | 30 | **Don't query the view.** Don't publish events that trigger the view to respond to the model with another event. This can create a cascade of events that clogs the system. 31 | 32 | 33 | 34 | ## Advanced Topic: Non-Model Objects in the Model 35 | 36 | In general, every object in the model should be a subclass of {@link Model}. However, sometimes it's useful to be able to use the occasional non-model utility class inside your model code. This is allowed, as long as you provide Multisynq with information about how to save and restore the non-model class. 37 | 38 | Model classes that use non-model objects must include a special static method named `types()` that declares all of the non-model classes: 39 | 40 | ``` 41 | class MyModel extends Multisynq.Model { 42 | static types() { 43 | return { 44 | "MyFile.MyClass": MyClass, 45 | } 46 | } 47 | } 48 | ``` 49 | 50 | This would use the default serializer to serialize the internals of that class. If you need to customize the serialization, add `write()` and `read()` methods that convert to and from the classes the serializer can handle (which is JSON plus built-in types like `Map`, `Set`, `Uint8Array` etc.): 51 | 52 | ``` 53 | class MyModel extends Multisynq.Model { 54 | static types() { 55 | return { 56 | "MyFile.MyClass": { 57 | cls: MyClass, 58 | write: c => ({x: c.x}), 59 | read: ({x}) => new MyClass(x) 60 | } 61 | } 62 | } 63 | } 64 | ``` 65 | 66 | This example shows a type definition for a non-model class that stores a single piece of data, the variable x. It includes methods to extract the class data into a standard data format, and then restore a new version of the class from the stored data. 67 | -------------------------------------------------------------------------------- /examples/hello_node/hello_node.js: -------------------------------------------------------------------------------- 1 | // Hello World Node Example 2 | // 3 | // Croquet Labs, 2025 4 | // 5 | // This is an example of a simple Multisynq application. It creates a counter that counts up ten 6 | // times per second. Pressing return resets it to zero. The model is exactly the same as the HTML 7 | // Hello World example, so they can join the same session. 8 | 9 | import * as Multisynq from "@multisynq/client"; 10 | 11 | //------------------------------------------------------------------------------------------ 12 | // Define our model. MyModel has a tick method that executes 10 times per second. It updates 13 | // the value of a counter. It also listens for reset events from the view. When it receives 14 | // one, it resets the counter to zero. Note that the model does not need to broadcast the 15 | // change to the counter. The code is executed on all clients in the same session, so the 16 | // counter is automatically updated on all clients. 17 | //------------------------------------------------------------------------------------------ 18 | 19 | class MyModel extends Multisynq.Model { 20 | 21 | // Note that models are initialized with "init" instead of "constructor"! 22 | init() { 23 | this.counter = 0; 24 | this.subscribe("counter", "reset", this.resetCounter); 25 | this.future(100).tick(); 26 | } 27 | 28 | // this method is called when the model receives a reset event from the view. It resets 29 | // the counter to zero. Note that this method is called on all clients in the same session 30 | // at the same time, so the value is automatically updated on all clients. 31 | resetCounter() { 32 | this.counter = 0; 33 | } 34 | 35 | // this method calls itself every 100ms via the future() mechanism. It is similar to 36 | // setTimeout() but it is deterministic. It will be executed on all clients in the same 37 | // session at the same time. 38 | tick() { 39 | this.counter += 0.1; 40 | this.future(100).tick(); 41 | } 42 | 43 | } 44 | 45 | // Register our model class with the serializer so when another user joins the session, 46 | // the model can be reconstructed from the stored snapshot 47 | MyModel.register("MyModel"); 48 | 49 | //------------------------------------------------------------------------------------------ 50 | // Define our view. MyView listens for update events from the model. If it receives 51 | // one, it logs the current count. 52 | // TODO: Add a way to reset the counter via node client (maybe read console keyboard input?) 53 | //------------------------------------------------------------------------------------------ 54 | 55 | class MyView extends Multisynq.View { 56 | 57 | constructor(model) { // The view gets a reference to the model when the session starts. 58 | super(model); 59 | this.model = model; 60 | this.update(); // Get the current count on start up. 61 | this.counter = 0; 62 | } 63 | 64 | reset() { 65 | this.publish("counter", "reset"); 66 | } 67 | 68 | update() { 69 | if (this.model.counter !== this.counter) { 70 | this.counter = this.model.counter; 71 | process.stdout.write(`\x1b[2K\rCounter: ${this.counter.toFixed(1)}`); 72 | } 73 | } 74 | } 75 | 76 | // when a key is pressed, publish a reset event to the model 77 | process.stdin.setRawMode(true); 78 | process.stdin.resume(); 79 | process.stdin.on("data", (key) => { 80 | if (key.toString() === "\u0003") { // Ctrl-C 81 | process.exit(); 82 | } else if (session.view) { 83 | session.view.publish("counter", "reset"); 84 | } 85 | }); 86 | 87 | //------------------------------------------------------------------------------------------ 88 | // Join the Teatime session and spawn our model and view. 89 | //------------------------------------------------------------------------------------------ 90 | 91 | if (process.argv.length < 4) { 92 | console.log("Usage: node hello_node.js "); 93 | process.exit(1); 94 | } 95 | 96 | const session = await Multisynq.Session.join({ 97 | apiKey: "234567_Paste_Your_Own_API_Key_Here_7654321", 98 | appId: "io.multisynq.hello", 99 | name: process.argv[2], 100 | password: process.argv[3], 101 | model: MyModel, 102 | view: MyView, 103 | step: "manual", 104 | }); 105 | 106 | setInterval(() => session.step(), 100); 107 | -------------------------------------------------------------------------------- /examples/hello/hello.js: -------------------------------------------------------------------------------- 1 | // Hello World Example 2 | // 3 | // Multisynq Labs, 2025 4 | // 5 | // This is an example of a simple Multisynq application. It creates a counter that counts up ten 6 | // times per second. Clicking on it resets it to zero. The counter is replicated across the network 7 | // and will respond to clicks from any user in the same session. 8 | 9 | import * as Multisynq from "@multisynq/client"; 10 | 11 | //------------------------------------------------------------------------------------------ 12 | // Define our model. MyModel has a tick method that executes 10 times per second. It updates 13 | // the value of a counter. It also listens for reset events from the view. When it receives 14 | // one, it resets the counter to zero. Note that the model does not need to broadcast the 15 | // change to the counter. The code is executed on all clients in the same session, so the 16 | // counter is automatically updated on all clients. 17 | //------------------------------------------------------------------------------------------ 18 | 19 | class MyModel extends Multisynq.Model { 20 | 21 | // Note that models are initialized with "init" instead of "constructor"! 22 | init() { 23 | this.counter = 0; 24 | this.subscribe("counter", "reset", this.resetCounter); 25 | this.future(100).tick(); 26 | } 27 | 28 | // this method is called when the model receives a reset event from the view. It resets 29 | // the counter to zero. Note that this method is called on all clients in the same session 30 | // at the same time, so the value is automatically updated on all clients. 31 | resetCounter() { 32 | this.counter = 0; 33 | } 34 | 35 | // this method calls itself every 100ms via the future() mechanism. It is similar to 36 | // setTimeout() but it is deterministic. It will be executed on all clients in the same 37 | // session at the same time. 38 | tick() { 39 | this.counter += 0.1; 40 | this.future(100).tick(); 41 | } 42 | 43 | } 44 | 45 | // Register our model class with the serializer so when another user joins the session, 46 | // the model can be reconstructed from the stored snapshot 47 | MyModel.register("MyModel"); 48 | 49 | //------------------------------------------------------------------------------------------ 50 | // Define our view. MyView listens for click events on the window. If it receives one, it 51 | // broadcasts a reset event. It also constantly updates the counter on the screen with the 52 | // current count. 53 | //------------------------------------------------------------------------------------------ 54 | 55 | class MyView extends Multisynq.View { 56 | 57 | constructor(model) { // The view gets a reference to the model when the session starts. 58 | super(model); 59 | this.model = model; 60 | this.clickHandler = event => this.onclick(event); 61 | document.addEventListener("click", this.clickHandler, false); 62 | this.update(); // Update the view with the initial value of the counter. 63 | } 64 | 65 | // the view must only interact with the model via events. It is allowed to directly read 66 | // but must never write to the model, which would break determinism. 67 | onclick() { 68 | this.publish("counter", "reset"); 69 | } 70 | 71 | // update() is called constantly via requestAnimationFrame. It can direcly read from 72 | // the model 73 | update() { 74 | document.getElementById("counter").innerHTML = this.model.counter.toFixed(1); 75 | } 76 | 77 | // when the session is interrupted, we need to stop listening for events 78 | // because the view will get constructed again wiht a new model when the session 79 | // recovers 80 | detach() { 81 | document.removeEventListener("click", this.clickHandler); 82 | super.detach(); 83 | } 84 | } 85 | 86 | //------------------------------------------------------------------------------------------ 87 | // Join the session and spawn our model and view. 88 | // If there is no session name on the URL, a new random session name and password will be 89 | // generated. If there is a session name, it will be used to join the session. 90 | // The session name and password are stored in the URL, so you can share the link with other users 91 | // to join the same session. 92 | //------------------------------------------------------------------------------------------ 93 | 94 | Multisynq.Session.join({ 95 | apiKey: "234567_Paste_Your_Own_API_Key_Here_7654321", 96 | appId: "io.multisynq.hello", 97 | model: MyModel, 98 | view: MyView, 99 | }); 100 | -------------------------------------------------------------------------------- /client/teatime/src/node-stats.js: -------------------------------------------------------------------------------- 1 | const frames = []; 2 | let connected = false; 3 | let currentFrame = newFrame(0); 4 | let currentSecond = {}; 5 | 6 | function newFrame(now) { 7 | return { 8 | start: now, 9 | total: 0, 10 | items: {}, 11 | users: 0, 12 | backlog: 0, 13 | network: 0, 14 | latency: 0, 15 | activity: 1000, 16 | connected 17 | }; 18 | } 19 | 20 | function endCurrentFrame(timestamp) { 21 | // add current frame to end 22 | currentFrame.total = timestamp - currentFrame.start; 23 | frames.push(currentFrame); 24 | while (frames.length > 120) frames.shift(); 25 | } 26 | 27 | const stack = []; 28 | const networkTraffic = {}; // network stats accumulators 29 | 30 | export const Stats = { 31 | animationFrame(timestamp, stats={}) { 32 | endCurrentFrame(timestamp); 33 | currentFrame = newFrame(timestamp); 34 | // controller.stepSession invokes this with a stats object with entries 35 | // { backlog, starvation, latency, activity, users }. below are methods 36 | // for each key, recording the supplied values in currentFrame. 37 | for (const [key, value] of Object.entries(stats)) this[key](value); 38 | }, 39 | begin(item) { 40 | // start inner measurement 41 | const now = performance.now(); 42 | currentFrame.items[item] = (currentFrame.items[item] || 0) - now; 43 | // stop outer measurement 44 | const outer = stack[stack.length - 1]; 45 | if (outer) currentFrame.items[outer] += now; 46 | stack.push(item); 47 | return now; 48 | }, 49 | end(item) { 50 | // stop inner measurement 51 | const now = performance.now(); 52 | currentFrame.items[item] += now; 53 | // start outer measurement 54 | const expected = stack.pop(); 55 | if (expected !== item) throw Error(`Unmatched stats calls: expected end("${expected}"), got end("${item}")`); 56 | const outer = stack[stack.length - 1]; 57 | if (outer) currentFrame.items[outer] -= now; 58 | return now; 59 | }, 60 | backlog(ms) { 61 | currentFrame.backlog = Math.max(ms, currentFrame.backlog); 62 | }, 63 | starvation(ms) { 64 | currentFrame.network = ms; 65 | }, 66 | latency(ms) { 67 | currentFrame.latency = ms; 68 | }, 69 | activity(ms) { 70 | currentFrame.activity = ms; 71 | }, 72 | users(users) { 73 | currentFrame.users = users; 74 | }, 75 | connected(bool) { 76 | currentFrame.connected = connected = bool; 77 | }, 78 | // accumulate network traffic 79 | networkTraffic, 80 | addNetworkTraffic(key, bytes, storeForAudit=false) { 81 | networkTraffic[key] = (networkTraffic[key] || 0) + bytes; 82 | if (storeForAudit) networkTraffic[`audit_${key}`] = (networkTraffic[`audit_${key}`] || 0) + bytes; 83 | }, 84 | resetAuditStats() { 85 | for (const key in networkTraffic) { 86 | if (key.startsWith('audit_')) networkTraffic[key] = 0; 87 | } 88 | }, 89 | // the stats gathered here (iff globalThis.logMessageStats is truthy) are reported by 90 | // Stats.stepSession (below), which is invoked by controller.stepSession on every step. 91 | perSecondTally(stats = {}) { 92 | if (!globalThis.logMessageStats) return; 93 | 94 | for (const [key, value] of Object.entries(stats)) currentSecond[key] = (currentSecond[key] || 0) + value; 95 | }, 96 | stepSession(_timestamp, report=false) { 97 | const second = Math.floor(Date.now() / 1000); 98 | 99 | if (!globalThis.logMessageStats) { 100 | // no reporting needed. keep updating the per-second record, ready for logging 101 | // to start. 102 | currentSecond = { second }; 103 | return null; 104 | } 105 | 106 | let result = null; 107 | if (second !== currentSecond.second) { 108 | // don't report if no messages have been requested or sent 109 | if (currentSecond.second && report && (currentSecond.requestedMessages || currentSecond.sentMessagesTotal)) { 110 | result = { ...currentSecond }; 111 | // if multiple seconds have passed, add a sampleSeconds property 112 | const sampleSeconds = second - currentSecond.second; 113 | if (sampleSeconds !== 1) result.sampleSeconds = sampleSeconds; 114 | // average the size of bundles, and the delays in sending messages via a bundle 115 | if (result.sentBundles) { 116 | result.averageDelay = Math.round(10 * result.sendDelay / result.sentMessagesTotal) / 10; 117 | result.averageBundlePayload = Math.round(result.sentBundlePayload / result.sentBundles); 118 | } 119 | // clean up 120 | delete result.second; 121 | delete result.sendDelay; 122 | delete result.sentBundlePayload; 123 | } 124 | currentSecond = { second }; 125 | } 126 | return result; 127 | } 128 | }; 129 | -------------------------------------------------------------------------------- /client/teatime/src/urlOptions.js: -------------------------------------------------------------------------------- 1 | const sessionFromPath = false; // old server 2 | let sessionApp = ""; 3 | let sessionArgs = ""; 4 | 5 | class UrlOptions { 6 | constructor() { 7 | this.getSession(); 8 | parseUrlOptionString(this, window.location.search.slice(1)); 9 | parseUrlOptionString(this, sessionFromPath ? window.location.hash.slice(1) : sessionArgs); 10 | if (window.location.pathname.indexOf('/ar.html') >= 0) this.ar = true; 11 | } 12 | 13 | /** 14 | * - has("debug", "recv", false) matches debug=recv and debug=send,recv 15 | * - has("debug", "recv", true) matches debug=norecv and debug=send,norecv 16 | * - has("debug", "recv", "localhost") defaults to true on localhost, false otherwise 17 | * 18 | * @param {String} key - key for list of items 19 | * @param {String} item - value to look for in list of items 20 | * @param {Boolean|String} defaultValue - if string, true on that hostname, false otherwise 21 | */ 22 | has(key, item, defaultValue = false) { 23 | if (typeof defaultValue === "string") defaultValue = this.isHost(defaultValue); 24 | const urlString = this[key]; 25 | if (typeof urlString !== "string") return defaultValue; 26 | const urlItems = urlString.split(','); 27 | if (defaultValue === true) item =`no${item}`; 28 | if (item.endsWith("s")) item = item.slice(0, -1); 29 | if (urlItems.includes(item) || urlItems.includes(`${item}s`)) return !defaultValue; 30 | return defaultValue; 31 | } 32 | 33 | /** Extract session from either path or hash or option 34 | * - on an old server, it was "/app/session/with/slashes" 35 | * - elsewhere, it is "...#session/with/slashes&..." 36 | * - or optionally, passed as "session=session/with/slashes" 37 | * @return {String} "" or "session/with/slashes" 38 | */ 39 | getSession() { 40 | // extract app and session from /(app)/(session) 41 | if (sessionFromPath) { 42 | const PATH_REGEX = /^\/([^/]+)\/(.*)$/; 43 | const pathMatch = window.location.pathname.match(PATH_REGEX); 44 | if (pathMatch) { 45 | sessionApp = pathMatch[1]; // used in setSession() 46 | return pathMatch[2]; 47 | } 48 | } else { 49 | // extract session and args from #(session)&(arg=val&arg) 50 | const HASH_REGEX = /^#([^&]+)&?(.*)$/; 51 | const hashMatch = window.location.hash.match(HASH_REGEX); 52 | if (hashMatch) { 53 | // if first match includes "=" it's not a session 54 | if (hashMatch[1].includes("=")) { 55 | sessionArgs = `${hashMatch[1]}&${hashMatch[2]}`; 56 | return ""; 57 | } 58 | sessionArgs = hashMatch[2]; // used in setSession() 59 | return hashMatch[1]; 60 | } 61 | } 62 | // check session arg 63 | if (typeof this.session === "string") { 64 | sessionArgs = window.location.hash.slice(1); 65 | return this.session; 66 | } 67 | // no session 68 | return ""; 69 | } 70 | 71 | setSession(session, replace=false) { 72 | // make sure sessionFromPath, sessionApp and sessionArgs 73 | // are initialized 74 | if (sessionFromPath == null) this.getSession(); 75 | const {search, hash} = window.location; 76 | const url = sessionFromPath 77 | ? `/${sessionApp}/${session}${search}${hash}` 78 | : `#${session}${sessionArgs ? "&" + sessionArgs: ""}`; 79 | if (replace) window.history.replaceState({}, "", url); 80 | else window.history.pushState({}, "", url); 81 | } 82 | 83 | isHost(hostname) { 84 | const actualHostname = window.location.hostname; 85 | if (actualHostname === hostname) return true; 86 | if (hostname !== "localhost") return false; 87 | // answer true for a variety of localhost equivalents 88 | if (actualHostname.endsWith(".ngrok.io")) return true; 89 | if (window.location.protocol === "file:") return true; 90 | return ["127.0.0.1", "[::1]"].includes(actualHostname); 91 | } 92 | 93 | isLocalhost() { 94 | return this.isHost("localhost"); 95 | } 96 | } 97 | 98 | function parseUrlOptionString(target, optionString) { 99 | if (!optionString) return; 100 | for (const arg of optionString.split("&")) { 101 | const keyAndVal = arg.split("="); 102 | const key = keyAndVal[0]; 103 | let val = true; 104 | if (keyAndVal.length > 1) { 105 | val = decodeURIComponent(keyAndVal.slice(1).join("=")); 106 | if (val.match(/^(true|false|null|[0-9.]*|["[{].*)$/)) { 107 | try { val = JSON.parse(val); } catch (e) { 108 | if (val[0] === "[") val = val.slice(1, -1).split(","); // handle string arrays 109 | // if not JSON use string itself 110 | } 111 | } 112 | } 113 | target[key] = val; 114 | } 115 | } 116 | 117 | const urlOptions = new UrlOptions(); 118 | export default urlOptions; 119 | -------------------------------------------------------------------------------- /docs/tutorials/2 Simple Animation/script.js: -------------------------------------------------------------------------------- 1 | // Multisynq Tutorial 2 2 | // Simple Animation 3 | // Croquet Labs (C) 2025 4 | 5 | //------------ Models-------------- 6 | // Models must NEVER use global variables. 7 | // Instead use the Multisynq.Constants object. 8 | 9 | const Q = Multisynq.Constants; 10 | Q.BALL_NUM = 25; // how many balls do we want? 11 | Q.STEP_MS = 1000 / 30; // bouncing ball tick interval in ms 12 | Q.SPEED = 10; // max speed on a dimension, in units/s 13 | 14 | class RootModel extends Multisynq.Model { 15 | 16 | init(options) { 17 | super.init(options); 18 | this.children = []; 19 | for (let i = 0; i < Q.BALL_NUM; i++) 20 | this.add(BallModel.create()); 21 | this.add(BallModel.create({type:'roundRect', pos: [500, 500], color: "white", ignoreTouch: true})); 22 | } 23 | 24 | add(child) { 25 | this.children.push(child); 26 | this.publish(this.id, 'child-added', child); 27 | } 28 | } 29 | 30 | RootModel.register("RootModel"); 31 | 32 | class BallModel extends Multisynq.Model { 33 | 34 | init(options={}) { 35 | super.init(); 36 | const r = max => Math.floor(max * this.random()); // return a random integer below max 37 | this.allowTouch = !options.ignoreTouch; 38 | this.type = options.type || 'circle'; 39 | this.color = options.color || `hsla(${r(360)},${r(50)+50}%,50%,0.5)`; 40 | this.pos = options.pos || [r(1000), r(1000)]; 41 | this.speed = this.randomSpeed(); 42 | this.subscribe(this.id, 'touch-me', this.startStop); 43 | this.alive = options.ignoreTouch || r(100) > 20; // arrange for roughly 1 in 5 balls to start as stationary. 44 | this.future(Q.STEP_MS).step(); 45 | } 46 | 47 | moveTo(pos) { 48 | const [x, y] = pos; 49 | this.pos[0] = Math.max(0, Math.min(1000, x)); 50 | this.pos[1] = Math.max(0, Math.min(1000, y)); 51 | this.publish(this.id, 'pos-changed', this.pos); 52 | } 53 | 54 | randomSpeed() { 55 | const xs = this.random() * 2 - 1; 56 | const ys = this.random() * 2 - 1; 57 | const speedScale = Q.SPEED / (Math.sqrt(xs*xs + ys*ys)); 58 | return [xs * speedScale, ys * speedScale]; 59 | } 60 | 61 | moveBounce() { 62 | const [x, y] = this.pos; 63 | if (x<=0 || x>=1000 || y<=0 || y>=1000) 64 | this.speed=this.randomSpeed(); 65 | this.moveTo([x + this.speed[0], y + this.speed[1]]); 66 | } 67 | 68 | startStop(){ if (this.allowTouch) this.alive = !this.alive } 69 | 70 | step() { 71 | if (this.alive) this.moveBounce(); 72 | this.future(Q.STEP_MS).step(); 73 | } 74 | } 75 | 76 | BallModel.register("Ball"); 77 | 78 | //------------ View-------------- 79 | let SCALE = 1; // model uses a virtual 1000x1000 space 80 | let OFFSETX = 50; // top-left corner of view, plus half shape width 81 | let OFFSETY = 50; // top-left corner of view, plus half shape height 82 | const TOUCH ='ontouchstart' in document.documentElement; 83 | 84 | class RootView extends Multisynq.View{ 85 | 86 | constructor(model) { 87 | super(model); 88 | 89 | this.element = document.getElementById('animation'); 90 | if (TOUCH) this.element.ontouchstart = e => e.preventDefault(); 91 | this.resize(); 92 | window.onresize = () => this.resize(); 93 | model.children.forEach(child => this.attachChild(child)); 94 | } 95 | 96 | attachChild(child) { 97 | this.element.appendChild(new BallView(child).element); 98 | } 99 | 100 | resize() { 101 | const size = Math.max(50, Math.min(window.innerWidth, window.innerHeight)); 102 | SCALE = size / 1100; 103 | OFFSETX = (window.innerWidth - size) / 2; 104 | OFFSETY = 0; 105 | this.element.style.transform = `translate(${OFFSETX}px,${OFFSETY}px) scale(${SCALE})`; 106 | this.element.style.transformOrigin = "0 0"; 107 | OFFSETX += 50 * SCALE; 108 | OFFSETY += 50 * SCALE; 109 | } 110 | 111 | detach() { 112 | super.detach(); 113 | let child; 114 | while (child = this.element.firstChild) this.element.removeChild(child); 115 | } 116 | } 117 | 118 | class BallView extends Multisynq.View { 119 | 120 | constructor(model) { 121 | super(model); 122 | const el = this.element = document.createElement("div"); 123 | el.view = this; 124 | el.className = model.type; 125 | el.id = model.id; 126 | el.style.backgroundColor = model.color; 127 | this.move(model.pos); 128 | this.subscribe(model.id, { event: 'pos-changed', handling: "oncePerFrame" }, this.move); 129 | this.enableTouch(); 130 | } 131 | 132 | move(pos) { 133 | this.element.style.left = pos[0] + "px"; 134 | this.element.style.top = pos[1] + "px"; 135 | } 136 | 137 | enableTouch() { 138 | const el = this.element; 139 | if (TOUCH) el.ontouchstart = start => { 140 | start.preventDefault(); 141 | this.publish(el.id, 'touch-me'); 142 | }; 143 | else el.onmousedown = start => { 144 | start.preventDefault(); 145 | this.publish(el.id, 'touch-me'); 146 | }; 147 | } 148 | } 149 | 150 | Multisynq.Session.join({ 151 | appId: "io.codepen.multisynq.simpleanim", 152 | apiKey: "234567_Paste_Your_Own_API_Key_Here_7654321", 153 | name: "public", 154 | password: "none", 155 | model: RootModel, 156 | view: RootView 157 | }); -------------------------------------------------------------------------------- /docs/tutorials/3 Multiuser Chat/script.js: -------------------------------------------------------------------------------- 1 | // Multisynq Tutorial 3 2 | // Multiuser Chat 3 | // Croquet Labs (C) 2025 4 | 5 | class ChatModel extends Multisynq.Model { 6 | 7 | init() { 8 | this.views = new Map(); 9 | this.participants = 0; 10 | this.history = []; // { viewId, html } items 11 | this.lastPostTime = null; 12 | this.inactivity_timeout_ms = 60 * 1000 * 20; // constant 13 | this.subscribe(this.sessionId, "view-join", this.viewJoin); 14 | this.subscribe(this.sessionId, "view-exit", this.viewExit); 15 | this.subscribe("input", "newPost", this.newPost); 16 | this.subscribe("input", "reset", this.resetHistory); 17 | } 18 | 19 | viewJoin(viewId) { 20 | const existing = this.views.get(viewId); 21 | if (!existing) { 22 | const nickname = this.randomName(); 23 | this.views.set(viewId, nickname); 24 | } 25 | this.participants++; 26 | this.publish("viewInfo", "refresh"); 27 | } 28 | 29 | viewExit(viewId) { 30 | this.participants--; 31 | this.views.delete(viewId); 32 | this.publish("viewInfo", "refresh"); 33 | } 34 | 35 | newPost(post) { 36 | const postingView = post.viewId; 37 | const nickname = this.views.get(postingView); 38 | const chatLine = `${nickname}: ${this.escape(post.text)}`; 39 | this.addToHistory({ viewId: postingView, html: chatLine }); 40 | this.lastPostTime = this.now(); 41 | this.future(this.inactivity_timeout_ms).resetIfInactive(); 42 | } 43 | 44 | addToHistory(item){ 45 | this.history.push(item); 46 | if (this.history.length > 100) this.history.shift(); 47 | this.publish("history", "refresh"); 48 | } 49 | 50 | resetIfInactive() { 51 | if (this.lastPostTime !== this.now() - this.inactivity_timeout_ms) return; 52 | 53 | this.resetHistory("due to inactivity"); 54 | } 55 | 56 | resetHistory(reason) { 57 | this.history = [{ html: `chat reset ${reason}` }]; 58 | this.lastPostTime = null; 59 | this.publish("history", "refresh"); 60 | } 61 | 62 | escape(text) { // Clean up text to remove html formatting characters 63 | return text.replace("&", "&").replace("<", "<").replace(">", ">"); 64 | } 65 | 66 | randomName() { 67 | const names =["Acorn", "Allspice", "Almond", "Ancho", "Anise", "Aoli", "Apple", "Apricot", "Arrowroot", "Asparagus", "Avocado", "Baklava", "Balsamic", "Banana", "Barbecue", "Bacon", "Basil", "Bay Leaf", "Bergamot", "Blackberry", "Blueberry", "Broccoli", "Buttermilk", "Cabbage", "Camphor", "Canaloupe", "Cappuccino", "Caramel", "Caraway", "Cardamom", "Catnip", "Cauliflower", "Cayenne", "Celery", "Cherry", "Chervil", "Chives", "Chipotle", "Chocolate", "Coconut", "Cookie Dough", "Chamomile", "Chicory", "Chutney", "Cilantro", "Cinnamon", "Clove", "Coriander", "Cranberry", "Croissant", "Cucumber", "Cupcake", "Cumin", "Curry", "Dandelion", "Dill", "Durian", "Earl Grey", "Eclair", "Eggplant", "Espresso", "Felafel", "Fennel", "Fig", "Garlic", "Gelato", "Gumbo", "Halvah", "Honeydew", "Hummus", "Hyssop", "Ghost Pepper", "Ginger", "Ginseng", "Grapefruit", "Habanero", "Harissa", "Hazelnut", "Horseradish", "Jalepeno", "Juniper", "Ketchup", "Key Lime", "Kiwi", "Kohlrabi", "Kumquat", "Latte", "Lavender", "Lemon Grass", "Lemon Zest", "Licorice", "Macaron", "Mango", "Maple Syrup", "Marjoram", "Marshmallow", "Matcha", "Mayonnaise", "Mint", "Mulberry", "Mustard", "Natto", "Nectarine", "Nutmeg", "Oatmeal", "Olive Oil", "Orange Peel", "Oregano", "Papaya", "Paprika", "Parsley", "Parsnip", "Peach", "Peanut Butter", "Pecan", "Pennyroyal", "Peppercorn", "Persimmon", "Pineapple", "Pistachio", "Plum", "Pomegranate", "Poppy Seed", "Pumpkin", "Quince", "Raspberry", "Ratatouille", "Rosemary", "Rosewater", "Saffron", "Sage", "Sassafras", "Sea Salt", "Sesame Seed", "Shiitake", "Sorrel", "Soy Sauce", "Spearmint", "Strawberry", "Strudel", "Sunflower Seed", "Sriracha", "Tabasco", "Tahini", "Tamarind", "Tandoori", "Tangerine", "Tarragon", "Thyme", "Tofu", "Truffle", "Tumeric", "Valerian", "Vanilla", "Vinegar", "Wasabi", "Walnut", "Watercress", "Watermelon", "Wheatgrass", "Yarrow", "Yuzu", "Zucchini"]; 68 | return names[Math.floor(Math.random() * names.length)]; 69 | } 70 | } 71 | 72 | ChatModel.register("ChatModel"); 73 | 74 | class ChatView extends Multisynq.View { 75 | 76 | constructor(model) { 77 | super(model); 78 | this.model = model; 79 | sendButton.onclick = () => this.send(); 80 | this.subscribe("history", "refresh", this.refreshHistory); 81 | this.subscribe("viewInfo", "refresh", this.refreshViewInfo); 82 | this.refreshHistory(); 83 | this.refreshViewInfo(); 84 | if (model.participants === 1 && 85 | !model.history.find(item => item.viewId === this.viewId)) { 86 | this.publish("input", "reset", "for new participants"); 87 | } 88 | } 89 | 90 | send() { 91 | const text = textIn.value; 92 | textIn.value = ""; 93 | if (text === "/reset") { 94 | this.publish("input", "reset", "at user request"); 95 | } else { 96 | this.publish("input", "newPost", {viewId: this.viewId, text}); 97 | } 98 | } 99 | 100 | refreshViewInfo() { 101 | nickname.innerHTML = "Nickname: " + this.model.views.get(this.viewId); 102 | viewCount.innerHTML = "Current Participants: " + this.model.participants; 103 | } 104 | 105 | refreshHistory() { 106 | textOut.innerHTML = "Welcome to Multisynq Chat!

" + 107 | this.model.history.map(item => item.html).join("
"); 108 | textOut.scrollTop = Math.max(10000, textOut.scrollHeight); 109 | } 110 | } 111 | 112 | Multisynq.Session.join({ 113 | appId: "io.codepen.multisynq.chat", 114 | apiKey: "234567_Paste_Your_Own_API_Key_Here_7654321", 115 | name: "public", 116 | password: "none", 117 | model: ChatModel, 118 | view: ChatView 119 | }); -------------------------------------------------------------------------------- /examples/hello_typescript/public/multisynq.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | logo/short/blue@3x 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /client/teatime/src/node-html.js: -------------------------------------------------------------------------------- 1 | import urlOptions from "./node-urlOptions"; 2 | import { toBase64url } from "./hashing"; 3 | 4 | // this is the default App.messageFunction 5 | export function showMessageInConsole(msg, options = {}) { 6 | const level = options.level; 7 | console.log(`${level === 'status' ? "" : (level + ": ")} ${msg}`); 8 | } 9 | 10 | export function displayError(msg, options={}) { 11 | return msg && App.showMessage(msg, { ...options, level: 'error' }); 12 | } 13 | 14 | export function displayWarning(msg, options={}) { 15 | return msg && App.showMessage(msg, { ...options, level: 'warning' }); 16 | } 17 | 18 | export function displayStatus(msg, options={}) { 19 | return msg && App.showMessage(msg, { ...options, level: 'status' }); 20 | } 21 | 22 | export function displayAppError(where, error, level = "error") { 23 | console.error(`Error during ${where}`, error); 24 | const userStack = (error.stack || '').split("\n").filter(l => !l.match(/multisynq-.*\.min.js/)).join('\n'); 25 | App.showMessage(`Error during ${where}: ${error.message}\n\n${userStack}`, { 26 | level, 27 | duration: level === "error" ? 10000 : undefined, 28 | stopOnFocus: true, 29 | }); 30 | } 31 | 32 | export const App = { 33 | get libName() { return "Multisynq" }, 34 | 35 | sessionURL: null, 36 | root: false, // root for messages, the sync spinner, and the info dock (defaults to document.body) 37 | sync: false, // whether to show the sync spinner while starting a session, or catching up 38 | messages: false, // whether to show status messages (e.g., as toasts) 39 | 40 | // the following can take a DOM element, an element ID, or false (to suppress) 41 | badge: false, // the two-colour session badge and 5-letter moniker 42 | stats: false, // the frame-by-frame stats display 43 | qrcode: false, 44 | 45 | // make a fancy collapsible dock of info widgets (currently badge, qrcode, stats). 46 | // disable any widget by setting e.g. { stats: false } in the options. 47 | makeWidgetDock() { }, 48 | 49 | // build widgets in accordance with latest settings for root, badge, stats, and qrcode. 50 | // called internally immediately after a session is established. 51 | // can be called by an app at any time, to take account of changes in the settings. 52 | makeSessionWidgets() { }, 53 | 54 | // make a canvas painted with the qr code for the currently set sessionURL (if there is one). 55 | makeQRCanvas() { return null; }, 56 | 57 | clearSessionMoniker() { }, 58 | 59 | showSyncWait(_bool) { }, 60 | 61 | // messageFunction(msg, options) - where options from internally generated messages will include { level: 'status' | 'warning' | 'error' } 62 | messageFunction: showMessageInConsole, 63 | 64 | showMessage(msg, options={}) { 65 | // thin layer on top of messageFunction, to discard messages if there's nowhere 66 | // (or no permission) to show them 67 | if (urlOptions.nomessages || App.root === false || App.messages === false || !App.messageFunction) { 68 | if (options.level === "warning") console.warn(msg); 69 | if (options.level === "error") console.error(msg); 70 | return null; 71 | } 72 | 73 | return App.messageFunction(msg, options); 74 | }, 75 | 76 | // this is also used in prerelease.js [or is it?] 77 | isMultisynqHost(hostname) { 78 | return hostname.endsWith("multisynq.io") 79 | || ["localhost", "127.0.0.1", "[::1]"].includes(hostname) 80 | || hostname.endsWith("ngrok.io"); 81 | }, 82 | 83 | // sanitized session URL (always without @user:password and #hash, and without query if not same-origin as multisynq.io) 84 | referrerURL() { 85 | return "http://localhost/node.html"; 86 | }, 87 | 88 | // session name is typically `${app}/${fragment}` where 89 | // "app" is constant and "fragment" comes from this autoSession 90 | autoSession(options = { key: 'q' }) { 91 | if (typeof options === "string") options = { key: options }; 92 | if (!options) options = {}; 93 | const key = options.key || 'q'; 94 | let fragment = urlOptions[key] || ''; 95 | if (fragment) try { fragment = decodeURIComponent(fragment); } catch (ex) { /* ignore */ } 96 | // if not found, create random fragment 97 | else { 98 | fragment = Math.floor(Math.random() * 36**10).toString(36); 99 | console.warn(`no ${App.libName} session name provided, using "${fragment}"`); 100 | } 101 | if (urlOptions.has("debug", "session")) console.log(`${App.libName}.App.autoSession: "${fragment}"`); 102 | // return Promise for future-proofing 103 | const retVal = Promise.resolve(fragment); 104 | // warn about using it directly 105 | retVal[Symbol.toPrimitive] = () => { 106 | console.warn(`Deprecated: ${App.libName}.App.autoSession() return value used directly. It returns a promise now!`); 107 | return fragment; 108 | }; 109 | return retVal; 110 | }, 111 | 112 | autoPassword(options = { key: 'pw' }) { 113 | const key = options.key || 'pw'; 114 | let password = urlOptions[key] || ''; 115 | // create random password if none provided 116 | if (!password) { 117 | const buffer = require('crypto').randomBytes(16); // eslint-disable-line global-require 118 | password = toBase64url(buffer); 119 | console.warn(`no ${App.libName} session password provided, using "${password}"`); 120 | } 121 | if (urlOptions.has("debug", "session")) console.log(`${App.libName}.App.autoPassword: "${password}"`); 122 | // return Promise for future-proofing 123 | const retVal = Promise.resolve(password); 124 | // warn about using it directly 125 | retVal[Symbol.toPrimitive] = () => { 126 | console.warn(`Deprecated: ${App.libName}.App.autoPassword() return value used directly. It returns a promise now!`); 127 | return password; 128 | }; 129 | return retVal; 130 | }, 131 | }; 132 | -------------------------------------------------------------------------------- /client/teatime/src/realms.js: -------------------------------------------------------------------------------- 1 | import urlOptions from "./_URLOPTIONS_MODULE_"; // eslint-disable-line import/no-unresolved 2 | import { viewDomain } from "./domain"; 3 | 4 | 5 | let DEBUG = { 6 | get subscribe() { 7 | // replace with static value on first call 8 | DEBUG = { subscribe: urlOptions.has("debug", "subscribe", false) }; 9 | return DEBUG.subscribe; 10 | } 11 | }; 12 | 13 | 14 | class ModelRealm { 15 | constructor(vm) { 16 | /** @type import('./vm').default */ 17 | this.vm = vm; 18 | } 19 | register(model) { 20 | return this.vm.registerModel(model); 21 | } 22 | deregister(model) { 23 | this.vm.deregisterModel(model.id); 24 | } 25 | publish(event, data, scope) { 26 | this.vm.publishFromModel(scope, event, data); 27 | } 28 | subscribe(model, scope, event, methodName) { 29 | if (DEBUG.subscribe) console.log(`Model.subscribe("${scope}:${event}", ${model} ${(""+methodName).replace(/\([\s\S]*/, '')})`); 30 | return this.vm.addSubscription(model, scope, event, methodName); 31 | } 32 | unsubscribe(model, scope, event, methodName='*') { 33 | if (DEBUG.subscribe) console.log(`Model.unsubscribe(${scope}:${event}", ${model} ${(""+methodName).replace(/\([\s\S]*/, '')})`); 34 | this.vm.removeSubscription(model, scope, event, methodName); 35 | } 36 | unsubscribeAll(model) { 37 | if (DEBUG.subscribe) console.log(`Model.unsubscribeAll(${model} ${model.id})`); 38 | this.vm.removeAllSubscriptionsFor(model); 39 | } 40 | 41 | future(model, tOffset, methodName, methodArgs) { 42 | if (__currentRealm && __currentRealm.equal(this)) { 43 | return this.vm.future(model, tOffset, methodName, methodArgs); 44 | } 45 | throw Error(`Model.future() called from outside: ${model}`); 46 | } 47 | 48 | cancelFuture(model, methodOrMessage) { 49 | if (__currentRealm && __currentRealm.equal(this)) { 50 | return this.vm.cancelFuture(model, methodOrMessage); 51 | } 52 | throw Error(`Model.cancelFuture() called from outside: ${model}`); 53 | } 54 | 55 | random() { 56 | return this.vm.random(); 57 | } 58 | 59 | now() { 60 | return this.vm.time; 61 | } 62 | 63 | equal(otherRealm) { 64 | return otherRealm instanceof ModelRealm && otherRealm.vm === this.vm; 65 | } 66 | 67 | isViewRealm() { return false; } 68 | } 69 | 70 | class ViewRealm { 71 | constructor(vm) { 72 | /** @type import('./vm').default */ 73 | this.vd = viewDomain; 74 | this.vm = vm; // if vm !== controller.vm, this view is invalid 75 | this.controller = vm.controller; // controller stays the same even across reconnects 76 | } 77 | 78 | valid() { 79 | return this.vm === this.controller.vm; 80 | } 81 | 82 | register(view) { 83 | return viewDomain.register(view); 84 | } 85 | 86 | deregister(view) { 87 | viewDomain.deregister(view); 88 | } 89 | 90 | publish(event, data, scope) { 91 | this.vm.publishFromView(scope, event, data); 92 | } 93 | subscribe(event, subscriberId, callback, scope, handling="queued") { 94 | if (DEBUG.subscribe) console.log(`View[${subscriberId}].subscribe("${scope}:${event}" ${callback ? callback.name || (""+callback).replace(/\([\s\S]*/, '') : ""+callback} [${handling}])`); 95 | viewDomain.addSubscription(scope, event, subscriberId, callback, handling); 96 | } 97 | unsubscribe(event, subscriberId, callback=null, scope) { 98 | if (DEBUG.subscribe) console.log(`View[${subscriberId}].unsubscribe("${scope}:${event}" ${callback ? callback.name || (""+callback).replace(/\([\s\S]*/, '') : "*"})`); 99 | viewDomain.removeSubscription(scope, event, subscriberId, callback); 100 | } 101 | unsubscribeAll(subscriberId) { 102 | if (DEBUG.subscribe) console.log(`View[${subscriberId}].unsubscribeAll()`); 103 | viewDomain.removeAllSubscriptionsFor(subscriberId); 104 | } 105 | 106 | future(view, tOffset) { 107 | const vm = this.vm; 108 | return new Proxy(view, { 109 | get(_target, property) { 110 | if (typeof view[property] === "function") { 111 | const methodProxy = new Proxy(view[property], { 112 | apply(_method, _this, args) { 113 | setTimeout(() => { if (view.id) inViewRealm(vm, () => view[property](...args), true); }, tOffset); 114 | } 115 | }); 116 | return methodProxy; 117 | } 118 | throw Error("Tried to call " + property + "() on future of " + Object.getPrototypeOf(view).constructor.name + " which is not a function"); 119 | } 120 | }); 121 | } 122 | 123 | random() { 124 | return Math.random(); 125 | } 126 | 127 | now() { 128 | return this.vm.time; 129 | } 130 | 131 | externalNow() { 132 | return this.controller.reflectorTime; 133 | } 134 | 135 | extrapolatedNow() { 136 | return this.controller.extrapolatedTime; 137 | } 138 | 139 | isSynced() { 140 | return !!this.controller.synced; 141 | } 142 | 143 | equal(otherRealm) { 144 | return otherRealm instanceof ViewRealm && otherRealm.vm === this.vm; 145 | } 146 | 147 | isViewRealm() { return true; } 148 | } 149 | 150 | let __currentRealm = null; 151 | 152 | /** @returns {ModelRealm | ViewRealm} */ 153 | export function currentRealm(errorIfNoRealm="Tried to execute code that requires realm outside of realm.") { 154 | if (!__currentRealm && errorIfNoRealm) { 155 | throw Error(errorIfNoRealm); 156 | } 157 | return __currentRealm; 158 | } 159 | 160 | export function inModelRealm(vm, callback) { 161 | if (__currentRealm !== null) { 162 | throw Error("Can't switch realms from inside realm"); 163 | } 164 | try { 165 | __currentRealm = new ModelRealm(vm); 166 | return callback(); 167 | } finally { 168 | __currentRealm = null; 169 | } 170 | } 171 | 172 | export function inViewRealm(vm, callback, force=false) { 173 | if (__currentRealm !== null && !force) { 174 | throw Error("Can't switch realms from inside realm"); 175 | } 176 | const prevRealm = __currentRealm; 177 | try { 178 | __currentRealm = new ViewRealm(vm); 179 | return callback(); 180 | } finally { 181 | __currentRealm = prevRealm; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /client/teatime/index.js: -------------------------------------------------------------------------------- 1 | // official exports 2 | export { default as Model } from "./src/model"; 3 | export { default as View } from "./src/view"; 4 | export { default as Data } from "./src/data"; 5 | export { Session, Constants, deprecatedStartSession as startSession } from "./src/session"; 6 | export { App } from "./src/_HTML_MODULE_"; // eslint-disable-line import/no-unresolved 7 | 8 | // unofficial exports 9 | export { default as Controller, MULTISYNQ_VERSION as VERSION } from "./src/controller"; 10 | export { currentRealm } from "./src/realms"; 11 | export { Messenger } from "./src/_MESSENGER_MODULE_"; // eslint-disable-line import/no-unresolved 12 | 13 | // putting event documentation here because JSDoc errors when parsing controller.js at the moment 14 | 15 | /** 16 | * **Published when a new user enters the session, or re-enters after being temporarily disconnected.** 17 | * 18 | * This is a model-only event, meaning views can not handle it directly. 19 | * 20 | * The event's payload will be the joining view's `viewId`. However, if 21 | * `viewData` was passed to [Session.join]{@link Session.join}, the event payload will 22 | * be an object `{viewId, viewData}`. 23 | * 24 | * **Note:** Each `"view-join"` event is guaranteed to be followed by a [`"view-exit"` event]{@link event:view-exit} 25 | * when that user leaves the session, or when the session is cold-started from a persistent snapshot. 26 | * 27 | * Hint: In the view, you can access the local viewId as [this.viewId]{@link View#viewId}, and compare 28 | * it to the argument in this event, e.g. to associate the view side with an avatar on the model side. 29 | * 30 | * @example 31 | * class MyModel extends Multisynq.Model { 32 | * init() { 33 | * this.userData = {}; 34 | * this.subscribe(this.sessionId, "view-join", this.addUser); 35 | * this.subscribe(this.sessionId, "view-exit", this.deleteUser); 36 | * } 37 | * addUser(viewId) { 38 | * this.userData[viewId] = { start: this.now() }; 39 | * this.publish(this.sessionId, "user-added", viewId); 40 | * } 41 | * deleteUser(viewId) { 42 | * const time = this.now() - this.userData[viewId].start; 43 | * delete this.userData[viewId]; 44 | * this.publish(this.sessionId, "user-deleted", {viewId, time}); 45 | * } 46 | * } 47 | * MyModel.register("MyModel"); 48 | * class MyView extends Multisynq.View { 49 | * constructor(model) { 50 | * super(model); 51 | * for (const viewId of Object.keys(model.userData)) this.userAdded(viewId); 52 | * this.subscribe(this.sessionId, "user-added", this.userAdded); 53 | * this.subscribe(this.sessionId, "user-deleted", this.userDeleted); 54 | * } 55 | * userAdded(viewId) { 56 | * console.log(`${ this.viewId === viewId ? "local" : "remote"} user ${viewId} came in`); 57 | * } 58 | * userDeleted({viewId, time}) { 59 | * console.log(`${ this.viewId === viewId ? "local" : "remote"} user ${viewId} left after ${time / 1000} seconds`); 60 | * } 61 | * } 62 | * @event view-join 63 | * @property {String} scope - [this.sessionId]{@link Model#sessionId} 64 | * @property {String} event - `"view-join"` 65 | * @property {String|Object} viewId - the joining user's local `viewId`, or an object `{viewId, viewData}` 66 | * @public 67 | */ 68 | 69 | /** 70 | * **Published when a user leaves the session, or is disconnected.** 71 | * 72 | * This is a model-only event, meaning views can not handle it directly. 73 | * 74 | * This event will be published when a view tab is closed, or is disconnected due 75 | * to network interruption or inactivity. A view is deemed to be inactive if 76 | * 10 seconds pass without an execution of the Multisynq [main loop]{@link Session.join}; 77 | * this will happen if, for example, the browser tab is hidden. As soon as the tab becomes 78 | * active again the main loop resumes, and the session will reconnect, causing 79 | * a [`"view-join"` event]{@link event:view-join} to be published. The `viewId` 80 | * will be the same as before. 81 | * 82 | * If `viewData` was passed to [Session.join]{@link Session.join}, the event payload will 83 | * be an object `{viewId, viewData}`. 84 | * 85 | * **Note:** when starting a new session from a snapshot, `"view-exit"` events will be 86 | * generated for all of the previous users before the first [`"view-join"` event]{@link event:view-join} 87 | * of the new session. 88 | * 89 | * #### Example 90 | * See [`"view-join"` event]{@link event:view-join} 91 | * @event view-exit 92 | * @property {String} scope - [this.sessionId]{@link Model#sessionId} 93 | * @property {String} event - `"view-exit"` 94 | * @property {String|Object} viewId - the user's `viewId`, or an object `{viewId, viewData}` 95 | * @public 96 | */ 97 | 98 | /** 99 | * **Published when the session backlog crosses a threshold.** (see {@link View#externalNow} for backlog) 100 | * 101 | * This is a non-synchronized view-only event. 102 | * 103 | * If this is the main session, it also indicates that the scene was revealed (if data is `true`) 104 | * or hidden behind the overlay (if data is `false`). 105 | * ```js 106 | * this.subscribe(this.viewId, "synced", this.handleSynced); 107 | * ``` 108 | * The default loading overlay is a CSS-only animation. 109 | * You can either customize the appearance, or disable it completely and show your own in response to the `"synced"` event. 110 | * 111 | * **Customizing the default loading animation** 112 | * 113 | * The overlay is structured as 114 | * ```html 115 | *
116 | *
117 | *
118 | * ``` 119 | * so you can customize the appearance via CSS using 120 | * ```css 121 | * #multisynq_spinnerOverlay { ... } 122 | * #multisynq_loader:before { ... } 123 | * #multisynq_loader { ... } 124 | * #multisynq_loader:after { ... } 125 | * ``` 126 | * where the _overlay_ is the black background and the _loader_ with its `:before` and `:after` elements is the three animating dots. 127 | * 128 | * The overlay `
` is added to the document’s `` by default. 129 | * You can specify a different parent element by its `id` string or DOM element: 130 | * ```js 131 | * Multisynq.App.root = element; // DOM element or id string 132 | * ``` 133 | * **Replacing the default overlay** 134 | * 135 | * To disable the overlay completely set the _App_ root to `false`. 136 | * ```js 137 | * Multisynq.App.root = false; 138 | * ``` 139 | * To show your own overlay, handle the `"synced"` event. 140 | * 141 | * @event synced 142 | * @property {String} scope - [this.viewId]{@link View#viewId} 143 | * @property {String} event - `"synced"` 144 | * @property {Boolean} data - `true` if in sync, `false` if backlogged 145 | * @public 146 | */ 147 | -------------------------------------------------------------------------------- /client/teatime/src/upload.worker.js: -------------------------------------------------------------------------------- 1 | // this is our UploadWorker 2 | 3 | import { deflate } from 'pako/dist/pako_deflate.js'; // eslint-disable-line import/extensions 4 | import Base64 from "crypto-js/enc-base64"; 5 | import AES from "crypto-js/aes"; 6 | import SHA256 from "crypto-js/sha256"; 7 | import WordArray from "crypto-js/lib-typedarrays"; 8 | import HmacSHA256 from "crypto-js/hmac-sha256"; 9 | 10 | // NOTE: if you add a new import, you must also add it to "dependencies" in package.json 11 | // so thet it gets bundled. The "peerDependencies" are not bundled. 12 | 13 | // esbuild will uncomment the line below in the case of a Node.js build 14 | // _IF_NODE_ import * as _WORKER_THREADS from 'worker_threads'; 15 | 16 | /* eslint-disable-next-line */ 17 | const NODE = _IS_NODE_; // replaced by esbuild 18 | 19 | let poster; 20 | let fetcher = fetch; 21 | if (NODE) { 22 | const { parentPort } = _WORKER_THREADS; // imported above 23 | parentPort.on('message', msg => handleMessage({ data: msg })); 24 | poster = msg => parentPort.postMessage({ data: msg }); 25 | } else { 26 | onmessage = handleMessage; 27 | poster = postMessage; 28 | } 29 | 30 | const offlineFiles = new Map(); 31 | 32 | function handleMessage(msg) { 33 | const { job, cmd, server, path: templatePath, buffer, keyBase64, gzip, 34 | referrer, id, appId, persistentId, MULTISYNQ_VERSION, debug, what, offline } = msg.data; 35 | if (offline) fetcher = offlineStore; 36 | switch (cmd) { 37 | case "uploadEncrypted": uploadEncrypted(templatePath); break; 38 | case "getOfflineFile": getOfflineFile(msg.data.url); break; 39 | default: console.error("Unknown worker command", cmd); 40 | } 41 | 42 | function encrypt(bytes) { 43 | const start = Date.now(); 44 | const plaintext = WordArray.create(bytes); 45 | const key = Base64.parse(keyBase64); 46 | const hmac = HmacSHA256(plaintext, key); 47 | const iv = WordArray.random(16); 48 | const { ciphertext } = AES.encrypt(plaintext, key, { iv }); 49 | // Version 0 used Based64: 50 | // const encrypted = "CRQ0" + [iv, hmac, ciphertext].map(wordArray => wordArray.toString(Base64)).join(''); 51 | // Version 1 is binary: 52 | const encrypted = new ArrayBuffer(4 + iv.sigBytes + hmac.sigBytes + ciphertext.sigBytes); 53 | const view = new DataView(encrypted); 54 | let i = 0; 55 | view.setUint32(i, 0x43525131, false); i += 4; //"CRQ1" 56 | // CryptoJS WordArrays are big-endian 57 | for (const array of [iv, hmac, ciphertext]) { 58 | for (const word of array.words) { 59 | view.setInt32(i, word, false); i += 4; 60 | } 61 | } 62 | if (debug) console.log(id, `${what} encrypted (${encrypted.byteLength} bytes) in ${Date.now() - start}ms`); 63 | return encrypted; 64 | } 65 | 66 | function compress(bytes) { 67 | const start = Date.now(); 68 | const compressed = deflate(bytes, { gzip: true, level: 1 }); // sloppy but quick 69 | if (debug) console.log(id, `${what} compressed (${compressed.length} bytes) in ${Date.now() - start}ms`); 70 | return compressed; 71 | } 72 | 73 | function hash(bytes) { 74 | const start = Date.now(); 75 | const sha256 = SHA256(WordArray.create(bytes)); 76 | const base64 = Base64.stringify(sha256); 77 | const base64url = base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); 78 | if (debug) console.log(id, `${what} hashed (${bytes.byteLength} bytes) in ${Date.now() - start}ms`); 79 | return base64url; 80 | } 81 | 82 | async function getUploadUrl(path) { 83 | if (offline) { 84 | const url = `offline:///${path}`; 85 | return { url, uploadUrl: url }; 86 | } 87 | const start = Date.now(); 88 | const url = `${server.url}/${path}`; 89 | if (!server.apiKey) return { url, uploadUrl: url }; 90 | 91 | const response = await fetcher(url, { 92 | headers: { 93 | "X-Croquet-Auth": server.apiKey, 94 | "X-Croquet-App": appId, 95 | "X-Croquet-Id": persistentId, 96 | "X-Croquet-Session": id, 97 | "X-Croquet-Version": MULTISYNQ_VERSION, 98 | "X-Croquet-Path": (new URL(referrer)).pathname, 99 | }, 100 | referrer 101 | }); 102 | 103 | const { ok, status, statusText } = response; 104 | if (!ok) throw Error(`Error in signing URL: ${status} - ${statusText}`); 105 | 106 | const { error, read, write } = await response.json(); 107 | if (error) throw Error(error); 108 | if (debug) console.log(id, `${what} upload authorized in ${Date.now() - start}ms`); 109 | return { url: read, uploadUrl: write }; 110 | } 111 | 112 | async function uploadEncrypted(path) { 113 | try { 114 | let body = encrypt(gzip ? compress(buffer) : buffer); 115 | if (NODE) body = new Uint8Array(body); // buffer needs to be put in an array 116 | if (path.includes("%HASH%")) path = path.replace("%HASH%", hash(body)); 117 | const { uploadUrl, url } = await getUploadUrl(path); 118 | const start = Date.now(); 119 | const { ok, status, statusText } = await fetcher(uploadUrl, { 120 | method: "PUT", 121 | mode: "cors", 122 | headers: { "Content-Type": "application/octet-stream" }, 123 | referrer, 124 | body 125 | }); 126 | if (!ok) throw Error(`server returned ${status} ${statusText} for PUT ${uploadUrl}`); 127 | if (debug) console.log(id, `${what} uploaded (${status}) in ${Date.now() - start}ms ${url}`); 128 | poster({ job, url, ok, status, statusText, bytes: NODE ? body.length : body.byteLength }); 129 | } catch (e) { 130 | if (debug) console.error(`${id} upload error ${e.message}`); 131 | poster({ job, ok: false, status: -1, statusText: e.message }); 132 | } 133 | } 134 | 135 | function offlineStore(requestUrl, options) { 136 | if (debug) console.log(id, `storing ${requestUrl}`); 137 | offlineFiles.set(requestUrl, options.body); 138 | return { ok: true, status: 201, statusText: "Offline created" }; 139 | } 140 | 141 | function getOfflineFile(requestUrl) { 142 | const body = offlineFiles.get(requestUrl); 143 | if (!body) { 144 | if (debug) console.error(`${id} file not found ${requestUrl}`); 145 | poster({ job, ok: false, status: -1, statusText: "Offline file not found" }); 146 | return; 147 | } 148 | if (debug) console.log(id, `retrieved ${requestUrl}`); 149 | poster({ job, ok: true, status: 200, statusText: "Offline file found", body, bytes: NODE ? body.length : body.byteLength }); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /client/teatime/thirdparty-patched/seedrandom/seedrandom.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 David Bau. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 19 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 20 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 21 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | Patched 2025 by Croquet Labs to: 24 | - remove import of `crypto` module 25 | - use ES6 module syntax. 26 | - remove Math patching 27 | - remove global entropy pool and autoseeding 28 | - remove unused code 29 | 30 | */ 31 | 32 | // 33 | // The following constants are related to IEEE 754 limits. 34 | // 35 | 36 | var width = 256, // each RC4 output is 0 <= x < 256 37 | chunks = 6, // at least six RC4 outputs for each double 38 | digits = 52, // there are 52 significant digits in a double 39 | startdenom = Math.pow(width, chunks), 40 | significance = Math.pow(2, digits), 41 | overflow = significance * 2, 42 | mask = width - 1; 43 | 44 | // 45 | // seedrandom() 46 | // 47 | function seedrandom(seed, options) { 48 | var key = []; 49 | options = options || {}; 50 | 51 | if (options.entropy) throw new Error('this version of seedrandom does not support entropy'); 52 | if (seed == null && !options.state) throw new Error('this version of seedrandom requires a seed'); 53 | 54 | // Flatten the seed string 55 | mixkey(flatten(seed, 3), key); 56 | 57 | // Use the seed to initialize an ARC4 generator. 58 | var arc4 = new ARC4(key); 59 | 60 | // This function returns a random double in [0, 1) that contains 61 | // randomness in every bit of the mantissa of the IEEE 754 value. 62 | var prng = function() { 63 | var n = arc4.g(chunks), // Start with a numerator n < 2 ^ 48 64 | d = startdenom, // and denominator d = 2 ^ 48. 65 | x = 0; // and no 'extra last byte'. 66 | while (n < significance) { // Fill up all significant digits by 67 | n = (n + x) * width; // shifting numerator and 68 | d *= width; // denominator and generating a 69 | x = arc4.g(1); // new least-significant-byte. 70 | } 71 | while (n >= overflow) { // To avoid rounding up, before adding 72 | n /= 2; // last byte, shift everything 73 | d /= 2; // right using integer math until 74 | x >>>= 1; // we have exactly the desired bits. 75 | } 76 | return (n + x) / d; // Form the number within [0, 1). 77 | }; 78 | 79 | prng.int32 = function() { return arc4.g(4) | 0; } 80 | prng.quick = function() { return arc4.g(4) / 0x100000000; } 81 | prng.double = prng; 82 | 83 | return (options.pass || 84 | function(prng, state) { 85 | if (state) { 86 | // Load the arc4 state from the given state if it has an S array. 87 | if (state.S) { copy(state, arc4); } 88 | // Only provide the .state method if requested via options.state. 89 | prng.state = function() { return copy(arc4, {}); } 90 | } 91 | return prng; 92 | })( 93 | prng, 94 | options.state); 95 | } 96 | 97 | // 98 | // ARC4 99 | // 100 | // An ARC4 implementation. The constructor takes a key in the form of 101 | // an array of at most (width) integers that should be 0 <= x < (width). 102 | // 103 | // The g(count) method returns a pseudorandom integer that concatenates 104 | // the next (count) outputs from ARC4. Its return value is a number x 105 | // that is in the range 0 <= x < (width ^ count). 106 | // 107 | function ARC4(key) { 108 | var t, keylen = key.length, 109 | me = this, i = 0, j = me.i = me.j = 0, s = me.S = []; 110 | 111 | // The empty key [] is treated as [0]. 112 | if (!keylen) { key = [keylen++]; } 113 | 114 | // Set up S using the standard key scheduling algorithm. 115 | while (i < width) { 116 | s[i] = i++; 117 | } 118 | for (i = 0; i < width; i++) { 119 | s[i] = s[j = mask & (j + key[i % keylen] + (t = s[i]))]; 120 | s[j] = t; 121 | } 122 | 123 | // The "g" method returns the next (count) outputs as one number. 124 | (me.g = function(count) { 125 | // Using instance members instead of closure state nearly doubles speed. 126 | var t, r = 0, 127 | i = me.i, j = me.j, s = me.S; 128 | while (count--) { 129 | t = s[i = mask & (i + 1)]; 130 | r = r * width + s[mask & ((s[i] = s[j = mask & (j + t)]) + (s[j] = t))]; 131 | } 132 | me.i = i; me.j = j; 133 | return r; 134 | // For robust unpredictability, the function call below automatically 135 | // discards an initial batch of values. This is called RC4-drop[256]. 136 | // See http://google.com/search?q=rsa+fluhrer+response&btnI 137 | })(width); 138 | } 139 | 140 | // 141 | // copy() 142 | // Copies internal state of ARC4 to or from a plain object. 143 | // 144 | function copy(f, t) { 145 | t.i = f.i; 146 | t.j = f.j; 147 | t.S = f.S.slice(); 148 | return t; 149 | }; 150 | 151 | // 152 | // flatten() 153 | // Converts an object tree to nested arrays of strings. 154 | // 155 | function flatten(obj, depth) { 156 | var result = [], typ = (typeof obj), prop; 157 | if (depth && typ == 'object') { 158 | for (prop in obj) { 159 | try { result.push(flatten(obj[prop], depth - 1)); } catch (e) {} 160 | } 161 | } 162 | return (result.length ? result : typ == 'string' ? obj : obj + '\0'); 163 | } 164 | 165 | // 166 | // mixkey() 167 | // Mixes a string seed into a key that is an array of integers, and 168 | // returns a shortened string seed that is equivalent to the result key. 169 | // 170 | function mixkey(seed, key) { 171 | var stringseed = seed + '', smear, j = 0; 172 | while (j < stringseed.length) { 173 | key[mask & j] = 174 | mask & ((smear ^= key[mask & j] * 19) + stringseed.charCodeAt(j++)); 175 | } 176 | return tostring(key); 177 | } 178 | 179 | // 180 | // tostring() 181 | // Converts an array of charcodes to a string 182 | // 183 | function tostring(a) { 184 | return String.fromCharCode.apply(0, a); 185 | } 186 | 187 | export default seedrandom; 188 | -------------------------------------------------------------------------------- /client/teatime/src/messenger.js: -------------------------------------------------------------------------------- 1 | // There are three kinds of messages: 2 | // 1. An app to the container. 3 | // 2. The container to a single app 4 | // 3. The container to all apps 5 | 6 | // The TeaTime framework creates a singleton instance of M and install it to Multisynq.Messenger 7 | // To use the Messenger object, the client needs to set the receiver object for invoking the handler for an incoming message: 8 | // Multisynq.Messenger.setReceiver(this); 9 | 10 | // where "this" is a view side object that handles incoming messages. 11 | 12 | // To listen on an incoming message, the receiver calls: 13 | // Multisynq.Messenger.on(event, callback>); 14 | 15 | // To send a message: 16 | // Multisynq.Messenger.send(event, data, receipent); 17 | 18 | // An app can send a message only to the container so the recipient argument will be ignored. 19 | // The container can send a message to a specific Window by supplying the third argument. 20 | 21 | // When a message is received, the function or the method specified by the method name is invoked with the object provided for the Messenger constructor as "this". 22 | 23 | // The object follows the "structured clone algorithm, but let us say that it should be 24 | // JSONable 25 | 26 | // An example on the container side looks like this (the view class or the expander, is an instance of PasteUpView in this example): 27 | 28 | // init() { 29 | // Multisynq.Messenger.setReceiver(this); 30 | // Multisynq.Messenger.onC"requestUserInfo", "sendUserInfo"); 31 | // } 32 | // 33 | // and aPasteUpView.sendUsernfo looks like: 34 | // sendUserInfo(data, source) { 35 | // const userInfo = this.model._get("userInfo")[this.viewId]; 36 | // Multisynq.Messenger.send("userInfo", userInfo, source); 37 | // // where the last argument specifies that this is a directed message 38 | // } 39 | 40 | // The container needs to be careful what information it sends to an app. 41 | 42 | // On the container side, there is a method called setIframeEnumerator, where Multisynq, of a future container app, specifies a way to enumerate all relevant iframes. 43 | 44 | // For a cursor movement, an app may do: 45 | // Multisynq.Messenger.send("pointerPosition", {x, y}); 46 | 47 | // The container side PasteUpView would have a subscriber: 48 | 49 | // handlePointerMove(data, source) { 50 | // let iframe = this.apps[source]; // this.apps would be a WeakMap 51 | // let translatedPostion = f(iframe.style.transformation... data.x, ... data.y); 52 | // this.pointerMoved(transatedPosition); 53 | // } 54 | 55 | class M { 56 | constructor() { 57 | this.ready = false; 58 | this.isInIframe = window.top !== window; 59 | this.subscriptions = {}; 60 | this.enumerator = null; 61 | } 62 | 63 | setReceiver(receiver) { 64 | this.receiver = receiver; 65 | this.ready = true; 66 | } 67 | 68 | setIframeEnumerator(func) { 69 | this.enumerator = func; 70 | } 71 | 72 | on(event, method) { 73 | if (!this.receiver) {throw Error("setReceiver() has not been called");} 74 | if (typeof method === "string") { 75 | method = this.receiver[method]; 76 | } 77 | 78 | if (!method) {throw Error("Messenger.on: the second argument must be a method name or a function");} 79 | 80 | if (!this.subscriptions[event]) { 81 | this.subscriptions[event] = []; 82 | } else if (this.findIndex(this.subscriptions[event], method) >= 0) { 83 | throw Error(`${method} is already subscribed`); 84 | } 85 | this.subscriptions[event].push(method); 86 | 87 | if (!this.listener) { 88 | this.listener = msg => this.receive(msg); 89 | window.addEventListener("message", this.listener); 90 | } 91 | } 92 | 93 | detach() { 94 | if (this.listener) { 95 | window.removeEventListener("message", this.listener); 96 | this.listener = null; 97 | } 98 | 99 | this.stopPublishingPointerMove(); 100 | 101 | this.receiver = null; 102 | this.subscriptions = {}; 103 | this.enumerator = null; 104 | this.ready = false; 105 | } 106 | 107 | removeSubscription(event, method) { 108 | if (typeof method === "string") { 109 | method = this.receiver[method]; 110 | } 111 | 112 | const handlers = this.subscriptions[event]; 113 | if (handlers) { 114 | const indexToRemove = this.findIndex(handlers, method); 115 | handlers.splice(indexToRemove, 1); 116 | if (handlers.length === 0) delete this.subscriptions[event]; 117 | } 118 | } 119 | 120 | removeAllSubscriptions() { 121 | this.subscriptions = {}; 122 | } 123 | 124 | receive(msg) { 125 | const {event, data} = msg.data; 126 | const source = msg.source; 127 | 128 | this.handleEvent(event, data, source); 129 | } 130 | 131 | handleEvent(event, data, source) { 132 | const handlers = this.subscriptions[event]; 133 | if (!handlers) {return;} 134 | handlers.forEach(handler => { 135 | handler.call(this.receiver, data, source); 136 | }); 137 | } 138 | 139 | send(event, data, directWindow) { 140 | if (this.isInIframe) { 141 | window.top.postMessage({event, data}, "*"); 142 | return; 143 | } 144 | 145 | if (directWindow) { 146 | directWindow.postMessage({event, data}, "*"); 147 | return; 148 | } 149 | 150 | if (!this.enumerator) {return;} 151 | 152 | const iframes = this.enumerator(); 153 | iframes.forEach(iframe => { 154 | iframe.contentWindow.postMessage({event, data}, "*"); 155 | // or we still pass a strong created from iframe as target origin 156 | }); 157 | } 158 | 159 | findIndex(array, method) { 160 | const mName = method.name; 161 | return array.findIndex(entry => { 162 | const eName = entry.name; 163 | if (!mName && !eName) { 164 | // when they are not proxied, a === comparison 165 | // for the case of both being anonymous 166 | return method === entry; 167 | } 168 | // otherwise, compare their names. 169 | // it is okay as the receiver is the same, 170 | // and the client should call removeSubscription if it wants to update the handler 171 | return mName === eName; 172 | }); 173 | } 174 | 175 | startPublishingPointerMove() { 176 | if (this._moveHandler) {return;} 177 | this._moveHandler = evt => this.send("pointerPosition", {x: evt.clientX, y: evt.clientY, type: evt.type}); 178 | window.document.addEventListener("pointermove", this._moveHandler, true); 179 | } 180 | 181 | stopPublishingPointerMove() { 182 | if (this._moveHandler) { 183 | window.document.removeEventListener("pointermove", this._moveHandler, true); 184 | this._moveHandler = null; 185 | } 186 | } 187 | 188 | } 189 | 190 | export const Messenger = new M(); 191 | -------------------------------------------------------------------------------- /esbuild.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import fs from 'fs'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import { execSync } from 'child_process'; 6 | import { replace } from 'esbuild-plugin-replace'; 7 | import { nodeExternalsPlugin } from 'esbuild-node-externals'; 8 | import inlineWorkerPlugin from './esbuild-plugin-inline-worker.mjs'; 9 | 10 | // We need a unique version string for each single build because this gets hashed 11 | // into the Session ID instead of the whole library source code. 12 | // The version string is derived from the package.json version and the git commit. 13 | // release: x.y.z 14 | // prerelease: x.y.z-v 15 | // clean tree: x.y.z-v+branch.commit 16 | // otherwise: x.y.z-v+branch.commit.user.date 17 | 18 | const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); 19 | const sources = ["./multisynq-client.js", "./client", "./esbuild.mjs"]; // check if these are committed 20 | const git_branch = execSync("git rev-parse --abbrev-ref HEAD").toString().trim(); // current branch 21 | const git_commit = execSync("git log -1 --pretty=format:%H -- .").toString().trim(); // last commit hash 22 | const git_date = execSync("git show --format='%as' -s " + git_commit).toString().trim(); // last commit date 23 | const git_message = execSync("git show --format='%s' -s " + git_commit).toString().trim(); // last commit message 24 | const git_bumped = git_message.endsWith(pkg.version); // last commit was "changelog and version bump to x.y.z" 25 | const git_clean = !execSync("git status --porcelain -- " + sources.join(" ")).toString().trim(); // relevant sources are committed 26 | 27 | const release = !pkg.version.includes('-'); 28 | const prerelease = pkg.version.includes('-') && (git_branch === "main" || git_branch === "dev") && git_bumped && git_clean; 29 | 30 | if (release) { 31 | // if you run into this error while developing, change the version in package.json to a prerelease version x.y.z-v 32 | if (!git_clean) throw Error(`Release build ${pkg.version} but git is not clean`); 33 | if (!git_bumped) throw Error(`Release build ${pkg.version} but not bumped`); 34 | if (git_branch !== "main") throw Error(`Release build ${pkg.version} but not on main branch`); 35 | } 36 | 37 | const date = new Date(); 38 | 39 | const VERSION = release || prerelease ? pkg.version 40 | : git_clean ? `${pkg.version}+${git_branch}.${git_commit}` 41 | : `${pkg.version}+${git_branch}.${git_commit}.${os.userInfo().username}.${date.toISOString()}`; 42 | 43 | console.log(`Building Multisynq ${VERSION}`); 44 | console.log(` bumped: ${git_bumped}, clean: ${git_clean}`); 45 | 46 | // common options for all builds 47 | const COMMON = { 48 | entryPoints: ['multisynq-client.js'], 49 | bundle: true, 50 | sourcemap: true, 51 | minify: true, 52 | banner: { 53 | js: 54 | `// (C) ${git_date.slice(0, 4)} ${pkg.author}\n` + 55 | `// Multisynq Client v${VERSION}\n` + 56 | `// Built on ${date.toISOString()}\n` 57 | }, 58 | }; 59 | 60 | const node_webrtc_import = ` 61 | if (!globalThis.loadingDataChannel) { 62 | globalThis.loadingDataChannel = new Promise(resolve => { 63 | import('node-datachannel/polyfill') 64 | .then(polyfill => { 65 | globalThis.RTCPeerConnection = polyfill.RTCPeerConnection; 66 | return import('node-datachannel'); 67 | }).then(ndc => { 68 | ndc.initLogger('Warning'); // 'Verbose' | 'Debug' | 'Info' | 'Warning' | 'Error' | 'Fatal'; 69 | ndc.preload(); 70 | resolve(); 71 | }); 72 | }); 73 | } 74 | await globalThis.loadingDataChannel; 75 | `; 76 | 77 | function createPlugins(is_node, bundle_all, esm) { 78 | if (is_node && bundle_all) { 79 | throw new Error('Cannot bundle all modules in node'); 80 | } 81 | // plugins for building worker (upload.worker.js) 82 | const workerPlugins = [ 83 | replace({ 84 | include: /\.js$/, 85 | exclude: /node_modules/, 86 | values: { 87 | '_IS_NODE_': is_node.toString(), 88 | '_MULTISYNQ_VERSION_': `"${VERSION}"`, 89 | '_IF_NODE_': (is_node ? '\n' : ''), // uncomment the rest of the line 90 | }, 91 | }), 92 | ]; 93 | // plugins for building client 94 | const plugins = [ 95 | inlineWorkerPlugin({ 96 | // config for building worker 97 | format: 'esm', // only esm appears to work, even in cjs builds 98 | minify: true, 99 | bundle: true, // bundle everything in the worker 100 | platform: is_node ? 'node' : 'browser', 101 | plugins: workerPlugins, 102 | }), 103 | // config for building client 104 | replace({ 105 | include: /\.js$/, 106 | exclude: /node_modules/, 107 | values: { 108 | '_IS_NODE_': is_node.toString(), 109 | '_ENSURE_WEBSOCKET_': (is_node ? `\nimport * as _WS from 'ws';\nglobalThis.WebSocket = _WS.WebSocket;\n` : ''), 110 | '_ENSURE_WORKER_': (is_node ? `\nimport * as _WORKER_THREADS from 'worker_threads';\nglobalThis.Worker = _WORKER_THREADS.Worker;\n` : ''), 111 | '_ENSURE_RTCPEERCONNECTION_': (is_node ? node_webrtc_import : ''), 112 | '_HTML_MODULE_': (is_node ? 'node-html' : 'html'), 113 | '_URLOPTIONS_MODULE_': (is_node ? 'node-urlOptions' : 'urlOptions'), 114 | '_STATS_MODULE_': (is_node ? 'node-stats' : 'stats'), 115 | '_MESSENGER_MODULE_': (is_node ? 'node-messenger' : 'messenger'), 116 | '_MULTISYNQ_VERSION_': `"${VERSION}"`, 117 | }, 118 | }), 119 | ]; 120 | 121 | // by default, all modules are bundled 122 | if (!bundle_all) { 123 | // bundle internal modules but not node_modules 124 | plugins.push(nodeExternalsPlugin({ 125 | allowList: [ 126 | /^crypto-js/, 127 | ], 128 | })); 129 | } 130 | 131 | return plugins; 132 | } 133 | 134 | // build the client in various formats 135 | cleanOutputDirectories([ 136 | 'dist', 137 | 'bundled', 138 | ]).then(() => esbuild.build({ // for browser bundlers 139 | ...COMMON, 140 | format: 'cjs', 141 | outfile: 'dist/multisynq-client.cjs.js', 142 | plugins: createPlugins(false, false, false), 143 | })).then(() => esbuild.build({ 144 | ...COMMON, 145 | format: 'esm', 146 | outfile: 'dist/multisynq-client.esm.js', 147 | plugins: createPlugins(false, false, true), 148 | })).then(() => esbuild.build({ // for node 149 | ...COMMON, 150 | format: 'cjs', 151 | platform: 'node', 152 | outfile: 'dist/multisynq-client-node.cjs', 153 | plugins: createPlugins(true, false, false), 154 | })).then(() => esbuild.build({ 155 | ...COMMON, 156 | format: 'esm', 157 | platform: 'node', 158 | outfile: 'dist/multisynq-client-node.mjs', 159 | plugins: createPlugins(true, false, true), 160 | })).then(() => esbuild.build({ // for CDN (pre-bundled) 161 | ...COMMON, 162 | format: 'iife', 163 | outfile: 'bundled/multisynq-client.min.js', 164 | globalName: 'Multisynq', 165 | plugins: createPlugins(false, true, false), 166 | })).then(() => esbuild.build({ 167 | ...COMMON, 168 | format: 'esm', 169 | outfile: 'bundled/multisynq-client.esm.js', 170 | plugins: createPlugins(false, true, true), 171 | })).then(() => { 172 | generateTypes() 173 | }).catch(error => { 174 | console.error(error); 175 | process.exit(1); 176 | }); 177 | 178 | 179 | function generateTypes() { 180 | // copy the types.d.ts file from client/types.d.ts to dist/multisynq-client.d.ts 181 | const inputFile = path.join('client', 'types.d.ts'); 182 | const outputFile = path.join('dist', 'multisynq-client.d.ts'); 183 | const data = fs.readFileSync(inputFile, 'utf8'); 184 | fs.writeFileSync(outputFile, data, 'utf8'); 185 | } 186 | 187 | async function cleanOutputDirectories(outputDirs) { 188 | for (const dir of outputDirs) { 189 | if (fs.existsSync(dir)) { 190 | fs.rmSync(dir, { recursive: true, force: true }); 191 | } 192 | } 193 | } 194 | 195 | -------------------------------------------------------------------------------- /docs/tutorials/4 View Smoothing/script.js: -------------------------------------------------------------------------------- 1 | // Multisynq Tutorial 4 2 | // View Smoothing 3 | // Croquet Labs (C) 2025 4 | 5 | // -- Constants -- 6 | const Q = Multisynq.Constants; 7 | Q.TICK_MS = 500; // milliseconds per actor tick 8 | Q.SPEED = 0.15; // dot movment speed in pixels per millisecond 9 | Q.CLOSE = 0.1; // minimum distance in pixels to a new destination 10 | Q.SMOOTH = 0.05; // weighting between extrapolated and current positions. 0 > SMOOTH >= 1 11 | 12 | // -- Vector Functions -- 13 | // 14 | // Because these functions don't save any state, they can be safely used by both the model and the view. 15 | 16 | function add(a,b) { 17 | return { x: (a.x + b.x), y: (a.y + b.y) }; 18 | } 19 | 20 | function subtract(a,b) { 21 | return { x: (a.x - b.x), y: (a.y - b.y) }; 22 | } 23 | 24 | function scale(v,s) { 25 | return {x: v.x * s, y: v.y * s}; 26 | } 27 | 28 | function dotProduct(a, b) { 29 | return a.x * b.x + a.y * b.y; 30 | } 31 | 32 | function magnitude(v) { 33 | return Math.sqrt(v.x * v.x + v.y * v.y); 34 | } 35 | 36 | function normalize(v) { 37 | const m = magnitude(v); 38 | return { 39 | x: v.x/m, 40 | y: v.y/m 41 | }; 42 | } 43 | 44 | function lerp(a, b, f = 0.5) { 45 | return { 46 | x: a.x + (b.x - a.x) * f, 47 | y: a.y + (b.y - a.y) * f, 48 | }; 49 | } 50 | 51 | // -- RootModel -- 52 | // 53 | // The root model handles join and exit events. All the real work occurs in the individual actors. 54 | 55 | class RootModel extends Multisynq.Model { 56 | init() { 57 | this.actors = new Map(); 58 | this.actorColors = new Map(); 59 | this.subscribe(this.sessionId, "view-join", this.viewJoin); 60 | this.subscribe(this.sessionId, "view-exit", this.viewDrop); 61 | this.hue = 0; 62 | } 63 | 64 | viewJoin(viewId) { 65 | let actorColor = this.actorColors.get(viewId); 66 | if (!actorColor) { 67 | actorColor = `hsl(${this.hue += 137.5}, 100%, 50%)`; 68 | this.actorColors.set(viewId, actorColor); 69 | } 70 | const actor = Actor.create(viewId); 71 | actor.color = actorColor; 72 | this.actors.set(viewId, actor); 73 | this.publish("actor", "join", actor); 74 | } 75 | 76 | viewDrop(viewId) { 77 | const actor = this.actors.get(viewId); 78 | this.actors.delete(viewId); 79 | actor.destroy(); 80 | this.publish("actor", "exit", actor); 81 | } 82 | } 83 | RootModel.register("RootModel"); 84 | 85 | // -- Actor -- 86 | // 87 | // Each ball is represented by an actor in the model. Every tick the actor moves toward its goal. If 88 | // if receives a 'goto' message from the view, it will change its destination. 89 | 90 | class Actor extends Multisynq.Model { 91 | init(viewId) { 92 | this.viewId = viewId; 93 | this.position = this.randomPosition(); 94 | this.goal = {...this.position}; 95 | this.velocity = {x: 0, y: 0}; 96 | this.future(Q.TICK_MS).tick(); 97 | this.subscribe(viewId, "goto", this.goto); 98 | } 99 | 100 | randomPosition() { 101 | return { x: this.random() * 500, y: this.random() * 500 }; 102 | } 103 | 104 | goto(goal) { 105 | this.goal = goal; 106 | const delta = subtract(goal, this.position); 107 | if (magnitude(delta) < Q.CLOSE) { 108 | this.goto(randomPosition()); 109 | } else { 110 | const unit = normalize(delta); 111 | this.velocity = scale(unit, Q.SPEED); 112 | } 113 | } 114 | 115 | arrived() { 116 | const delta = subtract(this.goal, this.position); 117 | return (dotProduct(this.velocity, delta) <= 0); 118 | } 119 | 120 | tick() { 121 | this.position = add(this.position, scale(this.velocity,Q.TICK_MS)); 122 | if (this.arrived()) this.goto(this.randomPosition()); 123 | this.publish(this.id, "moved", this.now()); 124 | this.future(Q.TICK_MS).tick(); 125 | } 126 | 127 | } 128 | Actor.register("Actor"); 129 | 130 | // -- View Globals -- 131 | 132 | const canvas = document.querySelector("#canvas"); 133 | const cc = canvas.getContext('2d'); 134 | let viewTime = 0; // The last time the view was updated is saved globally so pawns can use it. 135 | 136 | // -- RootView -- 137 | // 138 | // The root view handles clicks and join and exit events. Every update it tells all the pawns 139 | // to draw themselves. 140 | 141 | class RootView extends Multisynq.View { 142 | 143 | constructor(model) { 144 | super(model); 145 | this.pawns = new Map(); 146 | model.actors.forEach(actor => this.addPawn(actor)); 147 | 148 | this.subscribe("actor", "join", this.addPawn); 149 | this.subscribe("actor", "exit", this.removePawn); 150 | 151 | canvas.onclick = e => this.onClick(e); 152 | } 153 | 154 | onClick(e) { 155 | const r = canvas.getBoundingClientRect(); 156 | const scale = canvas.width / Math.min(r.width, r.height); 157 | const x = (e.clientX - r.left) * scale; 158 | const y = (e.clientY - r.top) * scale; 159 | this.publish(this.viewId, "goto", {x,y}); 160 | } 161 | 162 | addPawn(actor) { 163 | const pawn = new Pawn(actor); 164 | this.pawns.set(actor, pawn); 165 | } 166 | 167 | removePawn(actor) { 168 | const pawn = this.pawns.get(actor); 169 | if (!pawn) return; 170 | pawn.detach(); 171 | this.pawns.delete(actor); 172 | } 173 | 174 | update(time) { 175 | // if this is the first update in a while, jump all pawns' times 176 | // so they don't generate huge movement deltas 177 | if (time - viewTime > 1000) { 178 | for (const pawn of this.pawns.values()) pawn.lastMoved = time; 179 | } 180 | // make frame time accessible in event handlers 181 | viewTime = time; 182 | cc.strokeStyle = "black"; 183 | cc.clearRect(0, 0, canvas.width, canvas.height); 184 | cc.strokeRect(0, 0, canvas.width, canvas.height); 185 | for (const pawn of this.pawns.values()) pawn.update(); 186 | } 187 | 188 | } 189 | 190 | // -- Pawn -- 191 | // 192 | // Each actor in the model has corresponding pawn in the view. Pawns update their positions smoothly 193 | // each frame, even if their actor is updating itself much more infrequently. 194 | 195 | class Pawn extends Multisynq.View { 196 | 197 | constructor(actor) { 198 | super(actor); 199 | this.actor = actor; 200 | this.position = {...actor.position}; 201 | this.actorMoved(); 202 | this.subscribe(actor.id, {event: "moved", handling: "oncePerFrame"}, this.actorMoved); 203 | } 204 | 205 | actorMoved() { 206 | // Save when model was last updated 207 | this.lastMoved = viewTime; 208 | } 209 | 210 | update() { 211 | // If this is our pawn, draw our goal and our unsmoothed position from the model. 212 | 213 | if (this.actor.viewId === this.viewId) { 214 | this.draw(this.actor.goal, null, this.actor.color); 215 | this.draw(this.actor.position, "lightgrey"); 216 | } 217 | 218 | // Draw the smoothed positions of all the pawns, including our own. 219 | // First we extrapolate from the last position we received from the model. 220 | // Then we average our extrapolation with our current position. 221 | 222 | const delta = scale(this.actor.velocity, viewTime - this.lastMoved); 223 | const extrapolation = add(this.actor.position, delta); 224 | this.position = lerp(this.position, extrapolation, Q.SMOOTH); 225 | this.draw(this.position, this.actor.color); 226 | } 227 | 228 | draw({x, y}, color, border) { 229 | cc.strokeStyle = border; 230 | cc.fillStyle = color; 231 | cc.beginPath(); 232 | cc.arc(x, y, 10, 0, 2 * Math.PI); 233 | if (color) cc.fill(); 234 | if (border) cc.stroke(); 235 | } 236 | } 237 | 238 | Multisynq.Session.join({ 239 | appId: "io.codepen.multisynq.smooth", 240 | apiKey: "234567_Paste_Your_Own_API_Key_Here_7654321", 241 | name: "public", 242 | password: "none", 243 | model: RootModel, 244 | view: RootView, 245 | tps: 1000/Q.TICK_MS, 246 | }); -------------------------------------------------------------------------------- /client/teatime/src/hashing.js: -------------------------------------------------------------------------------- 1 | import stableStringify from "fast-json-stable-stringify"; 2 | import WordArray from "crypto-js/lib-typedarrays"; 3 | import sha256 from "crypto-js/sha256"; 4 | import urlOptions from "./_URLOPTIONS_MODULE_"; // eslint-disable-line import/no-unresolved 5 | import { App } from "./_HTML_MODULE_"; // eslint-disable-line import/no-unresolved 6 | 7 | const NODE = _IS_NODE_; // replaced by esbuild 8 | 9 | let digest; 10 | if (globalThis.crypto && globalThis.crypto.subtle && typeof globalThis.crypto.subtle.digest === "function") { 11 | digest = globalThis.crypto.subtle.digest.bind(globalThis.crypto.subtle); 12 | } else { 13 | digest = (algorithm, arrayBuffer) => { 14 | if (algorithm !== "SHA-256") throw Error(`${App.libName}: only SHA-256 available`); 15 | const inputWordArray = WordArray.create(arrayBuffer); 16 | const outputWordArray = sha256(inputWordArray); 17 | const bytes = cryptoJsWordArrayToUint8Array(outputWordArray); 18 | return bytes.buffer; 19 | }; 20 | } 21 | 22 | export function cryptoJsWordArrayToUint8Array(wordArray) { 23 | const l = wordArray.sigBytes; 24 | const words = wordArray.words; 25 | const result = new Uint8Array(l); 26 | let i = 0, j = 0; 27 | while (i < l) { 28 | const w = words[j++]; 29 | result[i++] = (w & 0xff000000) >>> 24; if (i === l) break; 30 | result[i++] = (w & 0x00ff0000) >>> 16; if (i === l) break; 31 | result[i++] = (w & 0x0000ff00) >>> 8; if (i === l) break; 32 | result[i++] = (w & 0x000000ff); 33 | } 34 | return result; 35 | } 36 | 37 | function funcSrc(func) { 38 | // this is used to provide the source code for hashing, and hence for generating 39 | // a session ID. we do some minimal cleanup to unify the class / function strings 40 | // as provided by different browsers. 41 | function cleanup(str) { 42 | const openingBrace = str.indexOf('{'); 43 | const closingBrace = str.lastIndexOf('}'); 44 | if (openingBrace === -1 || closingBrace === -1 || closingBrace < openingBrace) return str; 45 | const head = str.slice(0, openingBrace).replace(/\s+/g, ' ').replace(/\s\(/, '('); 46 | const body = str.slice(openingBrace + 1, closingBrace); 47 | return `${head.trim()}{${body.trim()}}`; 48 | } 49 | let src = cleanup("" + func); 50 | if (!src.startsWith("class")) { 51 | // possibly class has been minified and replaced with function definition 52 | // add source of prototype methods 53 | const p = func.prototype; 54 | if (p) src += Object.getOwnPropertyNames(p).map(n => `${n}:${cleanup("" + p[n])}`).join(''); 55 | } 56 | return src; 57 | // remnants of an experiment (june 2019) in deriving the same hash for code 58 | // that is semantically equivalent, even if formatted differently - so that 59 | // tools such as Codepen, which mess with white space depending on the 60 | // requested view type (e.g., debug or not), would nonetheless generate 61 | // the same session ID for all views. 62 | // our white-space standardisation involved stripping space immediately 63 | // inside a brace, and at the start of each line: 64 | 65 | // .replace(/\{\s+/g, '{').replace(/\s+\}/g, '}').replace(/^\s+/gm, '')}}`; 66 | 67 | // upon realising that Codepen also reserves the right to inject code, such 68 | // as into "for" loops to allow interruption, we decided to abandon this 69 | // approach. users should just get used to different views having different 70 | // session IDs. 71 | } 72 | 73 | export function fromBase64url(base64) { 74 | return new Uint8Array(atob(base64.padEnd((base64.length + 3) & ~3, "=") 75 | .replace(/-/g, "+") 76 | .replace(/_/g, "/")).split('').map(c => c.charCodeAt(0))); 77 | } 78 | 79 | export function toBase64url(bits) { 80 | return btoa(String.fromCharCode(...new Uint8Array(bits))) 81 | .replace(/=/g, "") 82 | .replace(/\+/g, "-") 83 | .replace(/\//g, "_"); 84 | } 85 | 86 | /** return buffer hashed into 256 bits encoded using base64 (suitable in URL) */ 87 | export async function hashBuffer(buffer) { 88 | // MS Edge does not like empty buffer 89 | if (buffer.length === 0) return "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"; 90 | const bits = await digest("SHA-256", buffer); 91 | return toBase64url(bits); 92 | } 93 | 94 | function debugHashing() { return urlOptions.has("debug", "hashing", false); } 95 | 96 | let debugHashes = {}; 97 | 98 | const encoder = new TextEncoder(); 99 | 100 | /** return string hashed into 256 bits encoded using base64 (suitable in URL) */ 101 | export async function hashString(string) { 102 | const buffer = encoder.encode(string); 103 | const hash = await hashBuffer(buffer); 104 | debugHashes[hash] = {string, buffer}; 105 | return hash; 106 | } 107 | 108 | const hashPromises = []; 109 | const codeHashCache = {}; // persistentID to { codeHashes, computedCodeHash }, cached on first JOIN in case additional code or constants (presumably for a different session) get registered later, and the user explicitly leaves and re-joins 110 | 111 | export function addClassHash(cls, classId) { 112 | const source = funcSrc(cls); 113 | const hashPromise = hashString(`${classId}:${source}`); 114 | hashPromises.push(hashPromise); 115 | hashPromise.then(hash => debugHashes[hash].what = `Class ${classId}`); 116 | } 117 | 118 | export function addConstantsHash(constants) { 119 | // replace functions with their source 120 | const json = JSON.stringify(constants, (_, val) => typeof val === "function" ? funcSrc(val) : val); 121 | if (json === "{}") return; 122 | // use a stable stringification 123 | const obj = JSON.parse(json); 124 | const string = stableStringify(obj); 125 | const hashPromise = hashString(string); 126 | hashPromises.push(hashPromise); 127 | hashPromise.then(hash => debugHashes[hash].what = `${App.libName} Constants`); 128 | } 129 | 130 | /** generate persistentId for the vm */ 131 | export async function hashNameAndOptions(appIdAndName, options) { 132 | return hashString(appIdAndName + stableStringify(options)); 133 | } 134 | 135 | const logged = new Set(); 136 | 137 | export async function hashSessionAndCode(persistentId, developerId, params, hashOverride, sdk_version) { 138 | // codeHashes are from registered user models and constants (in hashPromises). 139 | // jul 2021: note that if multiple sessions are loaded in the same tab, *all* 140 | // sessions' models and constants registered up to this point will be taken into 141 | // account. later we'd like to provide an interface (perhaps through App) for 142 | // registering each session's resources separately. 143 | let codeHashes; 144 | /** identifies the code being executed - user code, constants, multisynq */ 145 | let computedCodeHash; 146 | const cached = codeHashCache[persistentId]; 147 | let cacheAnnotation = ""; 148 | if (cached) { 149 | // the cached codeHashes list is only used in logging, and logging will 150 | // only happen if the final derived session ID has changed. 151 | codeHashes = cached.codeHashes; 152 | computedCodeHash = cached.computedCodeHash; 153 | cacheAnnotation = " (code hashing from cache)"; 154 | } else { 155 | codeHashes = await Promise.all(hashPromises); 156 | computedCodeHash = await hashString([sdk_version, ...codeHashes].join('|')); 157 | codeHashCache[persistentId] = { codeHashes, computedCodeHash }; 158 | } 159 | // let developer override hashing (at their own peril) 160 | const effectiveCodeHash = hashOverride || computedCodeHash; 161 | /** identifies the session */ 162 | const id = await hashString(persistentId + '|' + developerId + stableStringify(params) + effectiveCodeHash); 163 | // log all hashes if debug=hashing 164 | if (debugHashing() && !logged.has(id)) { 165 | const charset = NODE ? 'utf-8' : [...document.getElementsByTagName('meta')].find(el => el.getAttribute('charset')); 166 | if (!charset) console.warn(`${App.libName}: Missing declaration. ${App.libName} model code hashing might differ between browsers.`); 167 | debugHashes[computedCodeHash].what = "Version ID"; 168 | debugHashes[persistentId].what = "Persistent ID"; 169 | debugHashes[id].what = "Session ID"; 170 | if (effectiveCodeHash !== computedCodeHash) { 171 | codeHashes.push(computedCodeHash); // for allHashes 172 | debugHashes[computedCodeHash].what = "Computed Version ID (replaced by overrideHash)"; 173 | debugHashes[effectiveCodeHash] = { what: "Version ID (as specified by overrideHash)"}; 174 | } 175 | const allHashes = [...codeHashes, effectiveCodeHash, persistentId, id].map(each => ({ hash: each, ...debugHashes[each]})); 176 | console.log(`${App.libName}: Debug Hashing for session ${id}${cacheAnnotation}`, allHashes); 177 | logged.add(id); 178 | } 179 | if (!debugHashing()) debugHashes = {}; // clear debugHashes to save memory 180 | return { id, persistentId, codeHash: effectiveCodeHash, computedCodeHash }; 181 | } 182 | -------------------------------------------------------------------------------- /docs/tutorials/2_9_data.md: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Croquet Labs 2 | 3 | Multisynq offers secure bulk data storage service for apps. A Multisynq application can upload a file, typically media content or a document file, to the Multisynq file server. The `store()` function returns a *data handle* that can be sent to replicated models in a Multisynq message, and then other participants can `fetch()` the stored data. Off-loading the actual bits of data to a file server and keeping only its meta data in the model is a lot more efficient than trying to send that data via `publish`/`subscribe`. It also allows caching. 4 | 5 | Just like snapshots and persistent data, data uploaded via the Data API is end-to-end encrypted with the session password. That means it can only be decoded from within the session. 6 | 7 | Optionally, you can create a *shareable handle* where each data is encrypted individually with a random key, which becomes part of the data handle. 8 | Its string form can be shared between sessions and even apps. If you keep this kind of handle stored in the model it is protected by the general end-to-end encryption of the session. 9 | If it leaks, however, anyone will be able to access and decrypt that data, unlike with the default, non-shareable handle. 10 | 11 | Following is a full example of the Data API. 12 | 13 | ~~~~ HTML 14 | 15 | 16 | 17 | Data + Persistence Example 18 | 19 | 20 | 21 | 22 | click to import picture, or drag-and-drop one 23 | 129 | 130 | 131 | ~~~~ 132 | 133 | When a user drops an image file onto the browser window, or clicks in the window to get the file dialog and chooses a file, the `addFile()` of the `DataTestView` is invoked. It calls [data.store()]{@link Data#store} with `data` as an `ArrayBuffer`. [data.store()]{@link Data#store} returns asynchronously the data handle, then an `"add-asset"` event with the handle is published and the handle gets stored in the model by the `addAsset()` event handler. In addition to the handle, this example also stores some meta data, like file name, MIME type, and file size. 134 | 135 | In `addAsset()`, the model also publishes an `"asset-added"` event for all views. The views fetch the data from the file server by calling [data.fetch()]{@link Data#fetch}. Then they create a `Blob` object and use it as the `background-image` CSS style. Now the views of every user show the first user's image. 136 | 137 | By default, [data.store()]{@link Data#store} does not preserve the `ArrayBuffer` data, it is detached when it is transferred to the WebWorker that handles encrypting and uploading data (see [ArrayBuffer]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer#transferring_arraybuffers} and [Transferable objects]{@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects}). This is done for efficiency, and the reason why in this example we put the file's size into a variable beforehand (after storing it would be `0`). If you need to use the same data after you call [data.store()]{@link Data#store}, pass `{keep: true}` in the store options, which will transfer a copy instead. 138 | 139 | To be able to access the uploaded data even when the app code changes, the data handle needs to be [persisted]{@link Model#persistSession}, so the data handle can be recreated in a new session with the same `appId` and session `name` but modified code (see [Persistence]{@tutorial 2_A_persistence} tutorial). Since persistence needs JSON data, we use [Data.toId()]{@link Data.toId} to create a string representation of the handle, and recreate the equivalent data handle by calling [Data.fromId()]{@link Data.fromId}. 140 | 141 | # Best Practices 142 | Keep in mind that accessing external services is responsibility of the view, as the model should be concerned with the logical data. Calling [data.store()]{@link Data#store} and [data.fetch()]{@link Data#fetch} is done by the view asynchronously, and the view notifies the model via a Multisynq message. 143 | 144 | You will most likely to store the `id` for the data handle created by [data.toId()]{@link Data.toId} for persistent data. It is indeed fine to store the id in the model as the primary data, and the view creates the data handle from it by calling [data.fromId()]{@link Data.fromId} before fetching data. 145 | 146 | As in the example above, you can use an `input` DOM element with `type="file"` to get the browser's file dialog or camera roll dialog. However, while the dialog is opened, on some systems (like iOS) the JavaScript execution is suspended and the Multisynq network connection may disconnect while the user takes a long time to select a file or take a photo. We handle this case by storing the information of the chosen file in a global variable, and upload it when the view gets constructed again. 147 | 148 | Notice that `init()` of the view calls `assetAdded` when there already is `model.asset` so that a view that joined later shows the same image. This is a common pattern that is not limited to apps that uses Data API, but in general Multisynq views should be initialized to reflect the current model state when constructed, without relying on events. -------------------------------------------------------------------------------- /docs/tutorials/1_2_simple_animation.md: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Croquet Labs 2 | 3 | This tutorial will teach you how to create multi-user shared animations and interactions. If you click one of the bouncing objects it will stop moving. Click again and it will start bouncing again. This tutorial isn't really that much more complex than the Hello World application. It just has a few more moving parts and really demonstrates how the model is used to compute a simulation and how the view is used to display it and interact with it. 4 | 5 |

6 | See the Pen 7 | Simple Animation by Multisynq (@multisynq) 8 | on CodePen. 9 |

10 | 11 | 12 | 13 | ## **Try it out!** 14 | The first thing to do is click or scan the QR code above. This will launch a new Codepen instance of this session. If you compare the two sessions, you will see that the animated simulations are identical. The balls all move and bounce exactly the same. You can stop and start any ball by clicking on it, which will start or stop it in every session. You can't stop the rounded rectangle - it is just like a regular ball but ignores user actions. Any reader of this documentation can start or stop the balls while they are animating. You may notice that this is happening. It just means there is someone else out there working with the tutorial at the same time as you. 15 | 16 | There are three things we will learn here. 17 | 18 | 1. Creating a simulation model. 19 | 2. Creating an interactive view. 20 | 3. How to safely communicate between them. 21 | 22 | 23 | ## Simple Animation Model 24 | 25 | Our application uses two Multisynq Model subclasses, MyModel and BallModel. Both these classes need to be registered with Multisynq. 26 | 27 | In addition, this app makes use of [Multisynq.Constants]{@link Constants}. 28 | Although models must not use global variables, global constants are fine. 29 | To ensure that all users in a session use the same value of these constants, add them to the Multisynq.Constants object. 30 | Multisynq.Constants is recursively frozen once a session has started, to avoid accidental modification. 31 | Here we assign Multisynq.Constants into the variable Q as a shorthand. 32 | 33 | ``` 34 | const Q = Multisynq.Constants; 35 | Q.BALL_NUM = 25; // how many balls do we want? 36 | Q.STEP_MS = 1000 / 30; // bouncing ball tick interval in ms 37 | Q.SPEED = 10; // max speed on a dimension, in units/s 38 | ``` 39 | 40 | MyModel is the root model, and is therefore what will be passed into [Multisynq.Session.join]{@link Session.join}. 41 | In this app, MyModel also creates and stores the BallModel objects, holding them in the array MyModel.children. 42 | 43 | A BallModel is the model for a shaped, colored, bouncing ball. The model itself has no direct say in the HTML that will be used to display the ball. For the shape, for example, the model records just a string - either `'circle'` or `'roundRect'` - that the view will use to generate a visual element that (by the workings of the app's CSS) will be displayed as the appropriate shape. The BallModel also initializes itself with a random color, position, and speed vector. 44 | 45 | ```this.subscribe(this.id, 'touch-me', this.startStop);``` 46 | 47 | The BallModel [subscribes]{@link Model#subscribe} to the `'touch-me'` event, to which it will respond by stopping or restarting its motion. Each BallModel object individually subscribes to this event type, but only for events that are published using the BallModel's own ID as scope. Each ball's dedicated BallView object keeps a record of its model's ID, for use when publishing the `'touch-me'` events in response to user touches. 48 | 49 | ```this.future(Q.STEP_MS).step();``` 50 | 51 | Having completed its initialization, the BallModel [schedules]{@link Model#future} the first invocation of its own `step()` method. This is the same pattern as seen in the previous tutorial; `step()` will continue the stepping by re-scheduling itself each time. 52 | 53 | Worth noting here is that the step invocation applies just to one ball, with each BallModel taking care of its own update tick. That may seem like a lot of future messages for the system to handle (25 balls ticking at 30Hz will generate 750 messages per second) - but future messages are very efficient, involving little overhead beyond the basic method invocation. 54 | 55 | ``` 56 | BallModel.step() { 57 | if (this.alive) this.moveBounce(); 58 | this.future(Q.STEP_MS).step(); 59 | } 60 | ``` 61 | 62 | If the `alive` flag is set, the `step()` function will call `moveBounce()`. In any case, `step()` schedules the next step, the appropriate number of milliseconds in the future. 63 | 64 | ``` 65 | BallModel.moveBounce() { 66 | const [x, y] = this.pos; 67 | if (x<=0 || x>=1000 || y<=0 || y>=1000) 68 | this.speed = this.randomSpeed(); 69 | this.moveTo([x + this.speed[0], y + this.speed[1]]); 70 | } 71 | ``` 72 | 73 | `BallModel.moveBounce()` has the job of updating the position of a ball object, including bouncing off container walls when necessary. It embodies a simple strategy: if the ball is found to be outside the container bounds, `moveBounce()` replaces the ball's speed with a new speed vector `BallModel.randomSpeed()`. Because the new speed is random, it might turn out to take the ball a little further out of bounds - but in that case the ball will just try again, with another random speed, on the next `moveBounce`. 74 | 75 | ``` 76 | randomSpeed() { 77 | const xs = this.random() * 2 - 1; 78 | const ys = this.random() * 2 - 1; 79 | const speedScale = Q.SPEED / (Math.sqrt(xs*xs + ys*ys)); 80 | return [xs * speedScale, ys * speedScale]; 81 | } 82 | ``` 83 | 84 | The generation of new speed vectors is an example of our use of a replicated random-number generator. Every instance of this session will compute exactly the same sequence of random numbers. Therefore, when a ball bounces, every instance will come up with exactly the same new speed. 85 | 86 | ## Simple Animation View 87 | 88 | Like the Model, the View in this app comprises two classes: MyView and BallView. 89 | 90 | ### MyView 91 | 92 | MyView.constructor(model) will be called when an app session instance starts up. It is passed the MyModel object as an argument. The constructor's job is to build the visual representation of the model for this instance of the session. The root of that representation, in this app, is a "div" element that will serve as the balls' container. 93 | 94 | ```model.children.forEach(child => this.attachChild(child));``` 95 | 96 | The MyModel has children - the BallModel objects - for which MyView must also create a visual representation. It does so by accessing the model's children collection and creating a new view object for each child. 97 | 98 | Note that although it is fine for the view to access the model directly here to read its state - in this case, the children - the view **MUST NOT** modify the model (or its child models) in any way. 99 | 100 | ``` 101 | MyView.attachChild(child) { 102 | this.element.appendChild(new BallView(child).element); 103 | } 104 | ``` 105 | 106 | For each child BallModel a new BallView object is created. The BallView creates a document element to serve as the visual representation of the bouncing ball; the MyView object adds the element for each BallView as a child of its own element, the containing div. 107 | 108 | MyView also listens for "resize" events from the browser, and uses them to set a suitable size for the view by setting its scale (which also sets the scale for the children - i.e., the balls). When there are multiple users watching multiple instances of this app on browser windows of different sizes, the rescaling ensures that everyone still sees the same overall scene. 109 | 110 | ``` 111 | MyView.detach() { 112 | super.detach(); 113 | let child; 114 | while (child = this.element.firstChild) this.element.removeChild(child); 115 | } 116 | ``` 117 | 118 | When a session instance is shut down (including the reversible shutdown that happens if a tab is hidden for ten seconds or more), its root view is destroyed. If the instance is re-started, a completely new root view will be built. Therefore, on shutdown, the root view is sent `detach` to give it the chance to clean up its resources. MyView handles this by destroying all the child views that it has added to the `"animation"` `div` element during this session. 119 | 120 | ### BallView 121 | 122 | The BallView tracks the associated BallModel. 123 | 124 | BallView constructs a document element based on the type and color properties held by the BallModel, and sets the element's initial position using the model's pos property. 125 | 126 | ```this.subscribe(model.id, { event: 'pos-changed', handling: "oncePerFrame" }, this.move);``` 127 | 128 | The BallView subscribes to the 'pos-changed' event, which the BallModel publishes each time it updates the ball position. Like the 'touch-me' event, these events are sent in the scope of the individual BallModel's ID. No other ball's model or view will pay any attention to the events, which makes their distribution highly efficient. As a further efficiency consideration, the `handling: "oncePerFrame"` flag is used to ensure that even if multiple events for a given ball arrive within the same rendering frame, only one (the latest) will be passed to the subscribed handler. 129 | 130 | ```this.enableTouch();``` 131 | 132 | ``` 133 | BallView.enableTouch() { 134 | const el = this.element; 135 | if (TOUCH) el.ontouchstart = start => { 136 | start.preventDefault(); 137 | this.publish(el.id, 'touch-me'); 138 | }; else el.onmousedown = start => { 139 | start.preventDefault(); 140 | this.publish(el.id, 'touch-me'); 141 | }; 142 | } 143 | ``` 144 | BallView.enableTouch sets up the BallView element to publish a 'touch-me' event when the element is clicked on. 145 | The BallModel subscribes to the 'touch-me' event and toggles the ball motion on and off. 146 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019-2025 Croquet Labs 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /docs/tutorials/5 3D Animation/script.js: -------------------------------------------------------------------------------- 1 | // Multisynq Tutorial 5 2 | // 3D Animation Demo 3 | // Croquet Labs (C) 2025 4 | 5 | const Q = Multisynq.Constants; 6 | // Pseudo-globals 7 | Q.NUM_BALLS = 12; // number of bouncing balls 8 | Q.BALL_RADIUS = 0.25; 9 | Q.CENTER_SPHERE_RADIUS = 1.5; // a large sphere to bounce off 10 | Q.CENTER_SPHERE_NEUTRAL = 0xaaaaaa; // color of sphere before any bounces 11 | Q.CONTAINER_SIZE = 4; // edge length of invisible containing cube 12 | Q.STEP_MS = 1000 / 20; // step time in ms 13 | Q.SPEED = 1.5; // max speed on a dimension, in units/s 14 | 15 | class MyModel extends Multisynq.Model { 16 | 17 | init(options) { 18 | // force init 14 19 | super.init(options); 20 | this.centerSphereRadius = Q.CENTER_SPHERE_RADIUS; 21 | this.centerSpherePos = [0, 0, -Q.CONTAINER_SIZE/2]; // embedded half-way into the back wall 22 | this.children = []; 23 | for (let i = 0; i < Q.NUM_BALLS; i++) this.children.push(BallModel.create({ sceneModel: this })); 24 | this.subscribe(this.id, 'sphere-drag', this.centerSphereDragged); // someone is dragging the center sphere 25 | this.subscribe(this.id, 'reset', this.resetCenterSphere); // someone has clicked the center sphere 26 | } 27 | 28 | centerSphereDragged(pos) { 29 | this.centerSpherePos = pos; 30 | this.publish(this.id, 'sphere-pos-changed', pos); 31 | } 32 | 33 | resetCenterSphere() { 34 | this.publish(this.id, 'recolor-center-sphere', Q.CENTER_SPHERE_NEUTRAL); 35 | } 36 | } 37 | 38 | MyModel.register("MyModel"); 39 | 40 | class BallModel extends Multisynq.Model { 41 | 42 | init(options={}) { 43 | super.init(); 44 | this.sceneModel = options.sceneModel; 45 | 46 | const rand = range => Math.floor(range * Math.random()); // integer random less than range 47 | this.radius = Q.BALL_RADIUS; 48 | this.color = `hsl(${rand(360)},${rand(50)+50}%,50%)`; 49 | this.resetPosAndSpeed(); 50 | 51 | this.subscribe(this.sceneModel.id, 'reset', this.resetPosAndSpeed); // the reset event will be sent using the model id as scope 52 | 53 | this.future(Q.STEP_MS).step(); 54 | } 55 | 56 | // a ball resets itself by positioning at the center of the center-sphere 57 | // and giving itself a randomized velocity 58 | resetPosAndSpeed() { 59 | const srand = range => range * 2 * (Math.random() - 0.5); // float random between -range and +range 60 | this.pos = this.sceneModel.centerSpherePos.slice(); 61 | const speedRange = Q.SPEED * Q.STEP_MS / 1000; // max speed per step 62 | this.speed = [ srand(speedRange), srand(speedRange), srand(speedRange) ]; 63 | } 64 | 65 | step() { 66 | this.moveBounce(); 67 | this.future(Q.STEP_MS).step(); // arrange to step again 68 | } 69 | 70 | moveBounce() { 71 | this.bounceOffContainer(); 72 | this.bounceOffCenterSphere(); 73 | const pos = this.pos; 74 | const speed = this.speed; 75 | this.moveTo([ pos[0] + speed[0], pos[1] + speed[1], pos[2] + speed[2] ]); 76 | } 77 | 78 | bounceOffCenterSphere() { 79 | const pos = this.pos; 80 | const spherePos = this.sceneModel.centerSpherePos; // a model is allowed to read state of another model 81 | const distFromCenter = posArray => { 82 | let sq = 0; 83 | posArray.forEach((p, i) => { 84 | const diff = spherePos[i] - p; 85 | sq += diff * diff; 86 | }); 87 | return Math.sqrt(sq); 88 | }; 89 | const speed = this.speed; 90 | const threshold = Q.CENTER_SPHERE_RADIUS + this.radius; 91 | const distBefore = distFromCenter(pos); 92 | const distAfter = distFromCenter([ pos[0] + speed[0], pos[1] + speed[1], pos[2] + speed[2] ]); 93 | if (distBefore >= threshold && distAfter < threshold) { 94 | const unitToCenter = pos.map((p, i) => (spherePos[i] - p)/distBefore); 95 | const speedAcrossBoundary = speed[0] * unitToCenter[0] + speed[1] * unitToCenter[1] + speed[2] * unitToCenter[2]; 96 | this.speed = this.speed.map((v, i) => v - 2 * speedAcrossBoundary * unitToCenter[i]); 97 | this.publish(this.sceneModel.id, 'recolor-center-sphere', this.color); 98 | } 99 | } 100 | 101 | bounceOffContainer() { 102 | const pos = this.pos; 103 | const speed = this.speed; 104 | pos.forEach((p, i) => { 105 | if (Math.abs(p) > Q.CONTAINER_SIZE/2 - this.radius) speed[i] = Math.abs(speed[i]) * -Math.sign(p); 106 | }); 107 | } 108 | 109 | // the ball moves by recording its new position, then publishing that 110 | // position in an event that its view is expected to have subscribed to 111 | moveTo(pos) { 112 | this.pos = pos; 113 | this.publish(this.id, 'pos-changed', this.pos); 114 | } 115 | } 116 | 117 | BallModel.register("BallModel"); 118 | 119 | // one-time function to set up Three.js, with a simple lit scene 120 | function setUpScene() { 121 | const scene = new THREE.Scene(); 122 | scene.add(new THREE.AmbientLight(0xffffff, 0.5)); 123 | const light = new THREE.PointLight(0xffffff, 1); 124 | light.position.set(50, 50, 50); 125 | scene.add(light); 126 | 127 | const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 10000); 128 | camera.position.set(0, 0, 4); 129 | const threeCanvas = document.getElementById("three"); 130 | const renderer = new THREE.WebGLRenderer({ canvas: threeCanvas }); 131 | renderer.setClearColor(0xaa4444); 132 | 133 | function onWindowResize() { 134 | camera.aspect = window.innerWidth / window.innerHeight; 135 | camera.updateProjectionMatrix(); 136 | renderer.setSize(window.innerWidth, window.innerHeight); 137 | } 138 | window.addEventListener('resize', onWindowResize, false); 139 | onWindowResize(); 140 | 141 | // utility objects for managing pointer interaction 142 | const raycaster = new THREE.Raycaster(); 143 | let dragObject = null; 144 | let dragged; 145 | const dragOffset = new THREE.Vector3(); 146 | const dragPlane = new THREE.Plane(); 147 | const mouse = new THREE.Vector2(); 148 | const THROTTLE_MS = 1000 / 20; // minimum delay between pointer-move events that we'll handle 149 | let lastTime = 0; 150 | function setMouse(event) { 151 | mouse.x = (event.clientX / window.innerWidth) * 2 - 1; 152 | mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; 153 | } 154 | 155 | function onPointerDown(event) { 156 | event.preventDefault(); 157 | setMouse(event); // convert from window coords to relative (-1 to +1 on each of x, y) 158 | raycaster.setFromCamera(mouse, camera); 159 | const intersects = raycaster.intersectObjects(scene.children); 160 | for (let i = 0; i < intersects.length && !dragObject; i++) { 161 | const intersect = intersects[i]; 162 | const threeObj = intersect.object; 163 | if (threeObj.q_draggable) { // a flag that we set on just the central sphere 164 | dragObject = threeObj; 165 | dragged = false; // so we can detect a non-dragging click 166 | dragOffset.subVectors(dragObject.position, intersect.point); // position relative to pointer 167 | // set up for drag in vertical plane perpendicular to camera direction 168 | dragPlane.setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()), intersect.point); 169 | } 170 | } 171 | } 172 | threeCanvas.addEventListener('pointerdown', onPointerDown); 173 | 174 | function onPointerMove(event) { 175 | event.preventDefault(); 176 | 177 | // ignore if there is no drag happening 178 | if (!dragObject) return; 179 | 180 | // ignore if the event is too soon after the last one 181 | if (event.timeStamp - lastTime < THROTTLE_MS) return; 182 | lastTime = event.timeStamp; 183 | 184 | const lastMouse = {...mouse}; 185 | setMouse(event); 186 | // ignore if the event is too close on the screen to the last one 187 | if (Math.abs(mouse.x-lastMouse.x) < 0.01 && Math.abs(mouse.y - lastMouse.y) < 0.01) return; 188 | 189 | raycaster.setFromCamera(mouse, camera); 190 | const dragPoint = raycaster.ray.intersectPlane(dragPlane, new THREE.Vector3()); 191 | dragObject.q_onDrag(new THREE.Vector3().addVectors(dragPoint, dragOffset)); 192 | dragged = true; // a drag has happened (so don't treat the pointerup as a click) 193 | } 194 | threeCanvas.addEventListener('pointermove', onPointerMove); 195 | 196 | function onPointerUp(event) { 197 | event.preventDefault(); 198 | if (dragObject) { 199 | if (!dragged && dragObject.q_onClick) dragObject.q_onClick(); 200 | dragObject = null; 201 | } 202 | } 203 | threeCanvas.addEventListener('pointerup', onPointerUp); 204 | 205 | // function that the app must invoke when ready to render the scene 206 | // on each animation frame. 207 | function sceneRender() { renderer.render(scene, camera); } 208 | 209 | return { scene, sceneRender }; 210 | } 211 | 212 | class MyView extends Multisynq.View { 213 | 214 | constructor(model) { 215 | super(model); 216 | this.sceneModel = model; 217 | const sceneSpec = setUpScene(); // { scene, sceneRender } 218 | this.scene = sceneSpec.scene; 219 | this.sceneRender = sceneSpec.sceneRender; 220 | this.centerSphere = new THREE.Mesh( 221 | new THREE.SphereGeometry(model.centerSphereRadius, 16, 16), 222 | new THREE.MeshStandardMaterial({ color: Q.CENTER_SPHERE_NEUTRAL, roughness: 0.7 })); 223 | this.centerSphere.position.fromArray(model.centerSpherePos); 224 | this.scene.add(this.centerSphere); 225 | // set Multisynq app-specific properties for handling events 226 | this.centerSphere.q_onClick = () => this.publish(model.id, 'reset'); 227 | this.centerSphere.q_draggable = true; 228 | this.centerSphere.q_onDrag = posVector => this.posFromSphereDrag(posVector.toArray()); 229 | this.subscribe(model.id, 'sphere-pos-changed', this.moveSphere); 230 | this.subscribe(model.id, 'recolor-center-sphere', this.recolorSphere); 231 | model.children.forEach(childModel => this.attachChild(childModel)); 232 | } 233 | 234 | posFromSphereDrag(pos) { 235 | const limit = Q.CONTAINER_SIZE / 2; 236 | // constrain x and y to container (z isn't expected to be changing) 237 | [0, 1].forEach(i => { if (Math.abs(pos[i]) > limit) pos[i] = limit * Math.sign(pos[i]); }); 238 | this.publish(this.sceneModel.id, 'sphere-drag', pos); 239 | } 240 | 241 | moveSphere(pos) { 242 | // this method just moves the view of the sphere 243 | this.centerSphere.position.fromArray(pos); 244 | } 245 | 246 | recolorSphere(color) { 247 | this.centerSphere.material.color.copy(new THREE.Color(color)); 248 | } 249 | 250 | attachChild(childModel) { 251 | this.scene.add(new BallView(childModel).object3D); 252 | } 253 | 254 | update(time) { 255 | this.sceneRender(); 256 | } 257 | } 258 | 259 | class BallView extends Multisynq.View { 260 | 261 | constructor(model) { 262 | super(model); 263 | this.object3D = new THREE.Mesh( 264 | new THREE.SphereGeometry(model.radius, 12, 12), 265 | new THREE.MeshStandardMaterial({ color: model.color }) 266 | ); 267 | this.move(model.pos); 268 | this.subscribe(model.id, { event: 'pos-changed', handling: 'oncePerFrame' }, this.move); 269 | } 270 | 271 | move(pos) { 272 | this.object3D.position.fromArray(pos); 273 | } 274 | } 275 | 276 | Multisynq.Session.join({ 277 | appId: "io.codepen.multisynq.threed_anim", 278 | apiKey: "234567_Paste_Your_Own_API_Key_Here_7654321", 279 | name: "public", 280 | password: "none", 281 | model: MyModel, 282 | view: MyView, 283 | }); --------------------------------------------------------------------------------