
multiplayer concepts §
this is an introductory guide designed to shows you the basics of cyxth colab starting from why you need colab in the first place, the various collaborative data types available at your disposal with real world examples and use cases. it also goes indepth on the basics user and state management and sharing presence data to elevate the colab user experience. We highly recommend reading this through before the other colab tutorials in this series.
First let’s dive into why we need colab in the first place.
Essence of colab §
You may ask ,Why do i need cyxth in the first place when i can just send and receive changes using websockets or something?.
Good question.
the short answer — consistency.
when you have multiple users editing the same piece of state conflict are guaranteed to happen and you have to ensure that the state is always consitent for all users. generally to ensure a consistent state users have to agree on what the state should look like. this is similar to what happens in git where a user has to manually merge and resolve conflicts.
For real-time multiplayer applications we need a way to automatically resolve conflicts when they arise and ensure the state is always consistent using a traditional pr and merge method will not suffice here. to ensure consistency you’ll ask.
What happens when two or more users edit text at the same index concurrently whose change will be in front of the other?, What happens when two users concurrently update and delete the same element in an array? or what happens when a user disconnects or goes offline while editing?, will there changes be reflected? what if the document has been modified when offline?. How well does my solution scale with more that two users or even how do i ensure the state shown to all users is consistent at all times?.
colab automatically resolves conflicts and ensures shared state is always consitent.
on top of that colab will handle real-time networking for you, cyxth is fast and scalable allowing upto 256 concurrent editors and any number of viewers on the same state. with offline editing capabilities ensuring no change is lost and more.
cyxth colab is based on conflict free reprecated data types (CRDTs) to ensure the state is always consitent. you dont have to know how CRDTs work or real-time networking to use cyxth. colab provides a nice API to work with various data types that looks like regular javascript objects and just works.
Getting Started §
to get started with colab first ensure you have a cyxth account. and that you have created atleast one instance with collaboration features enabled.
then install @cyxth/colab and @cyxth/core packages
pnpm install @cyxth/core @cyxth/colab Authorization §
only users authorized are allowed to access your various colab states. to authorize users first generate a EdDSA key pair with openssl set the public key in cyxth console and use your private key to sign a user id that connects to your cyxth instance. Follow this quick guide to setup authorization in your backend.
once you have auth running in your backend you will be able to generate user tokens for example. assuming you have the an auth service running on local port 8902.
const AUTH_URL = "http://localhost:8902/authorize";
const getToken = async (userId: string) => {
let stream = await fetch(AUTH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: userId }),
});
let token = await stream.json();
return token;
} colab setup §
after getting an authorization token we can connect to cyxth and prepare our colab instance.
import Cyxth from '@cyxth/core';
import Colab from '@cyxth/colab';
const APP_URL = "my-app.apps.cyxth.com";
const stateId = "our-docs";
let colab: Colab;
const multiplayer = async (token: TokenData) => {
const cyxth = await new Cyxth(APP_URL, [Colab]).connect(token);
const colab = await cyxth.colab();
await colab.createOrJoin(stateId);
}
going over the code. First we get our app url from the cyxth console, the state id is analogus to a channel users on the same state can modify and interact with the state. you can have predefined states where users can only join or request to join or allow users to create any number of states. we’ll talk more about state management later in this guide.
using the auth token obtained from a backend service we connect() to cyxth and then get a colab instance with cyxth.colab. with this
colab instance we are ready to add multiplayer features to our apps.
Modifying State §
to interact with the collaborative data type we use the changeContext function in colab. this returns a context that we use to
access to text, counter and tree data types, more about that in few.
// get change context
let context = colab.changeContext();
// get a text node
let doc = context.text("doc");
// modify text
doc.insert(0,"hello world");
// listen for changes
doc.handlers.insert = (index: number, value: string, ctx: Hctx) => {
console.log(ctx);
// update text
} a single state can have multiple nodes which are accessed using top level keys. in the snippet above we get a text node at the top level
key “doc”, using the node we can do actions like insert, delete and replace and handle their corresponding incoming changes. handlers are
generally called for both local and remote changes. each of the data type has actions and handlers for the same actions.
Data Types §
colab comes with 3 data types. text for collaborative text editing, counter an increment decrement counter and tree a json like
data structure to represent any structured state. operations made to these data types are always guaranteed to be consistent across all
editors. we discuss more on each below with examples.
Text §
the text node allows for collaborative text editing capabilities similar to those found in google docs. Users can modify the same piece
of text by insert, delete or replace without worrying about any conflicts. this node also allows for offline editing unlike google
docs so users can have bad connections or be offline and all their changes will be reflected when they are back online.
our multiplayer monaco-editor guide goes into depth of implementing a text editor with this node.
to get a text node from the change context
let doc: Text = context.text("doc"); the text node has only three actions
insert(index,value) =>insert textdelete(index,length) =>delete textreplace(index, length, value) =>replace text basically combining delete and insertvalue() =>get value of the text node
// insert "hello world"
doc.insert(0, "hello world");
// get value
console.log(doc.value()); // "hello world"
// delete world
doc.delete(6,5);
// replace hello
doc.replace(0, 6, "hi"); and the corresponding handlers are shown below.
type DelFragment = {index: number, length: number};
doc.handlers = {
insert(index: number, value: string, ctx: Hctx) {
// handle insert
},
delete(fragments: DelFragment[], ctx: Hctx){
// handle delete
},
replace(
fragments: DelFragment[],
index?: number,
value?: string,
ctx: Hctx
){
// handle replace
}
} handlers are triggered for both remote and local changes to the underlying text node. to know if a change is remote check isLocal flag in the handler context. when a user executes an insert it triggers the insert handler, a delete triggers the
delete handler and a replace basically combines both insert and delete handlers.
you can choose to only use the replace handler or insert and delete handlers separately. You will notice that the delete handler contains multiple fragments, this happens when a user deletes text while another user inserts in between the same text concurrently. our monaco-editor example goes in depth on why.
Counter §
this is the simplest colab node. Most reactive javascript framework have a counter example to show basic reactivity this node serves to show the same but with multiplayer functionality.
a counter node has only two actions increment and decrement that do exactly that respectively. it has only one handler update that
is triggered for both operations. on top of that the value method gets the counter value.
here is the simple counter example in svelte that works with multiple users and is always consistent across all users.
<script>
// ...
let context = colab.changeContex();
let clicks: Counter = context.counter("clicks")
let clicksValue = clicks.value();
clicks.handlers.update = (value:number, ctx: Hctx) => {
clicksValue = value;
}
</script>
<button onclick={() => clicks.decrement()}> - </button>
<p>{clicksValue}</p>
<button onclick={() => clicks.increment()}> + </button> Tree §
the tree node allows for nested maps and lists basically allowing you to turn any application state collaborative with ease. check out our great multiplayer svg editor example for a real world example after this.
the tree node has a defination similar to the one shown below, this shows that any JSON data can be represented with the tree with any nesting levels.
value := number | string | boolean;
element := value | list | tree ;
list := element[];
tree := Map<string, element>; to get the tree node simply use the text() method on the change context.
let tasks : {state: Tasks} = context.tree("tasks"); let’s explore the tree node by creating a simple multiplayer tasks app.
Tasks App Example §
first lets define the app state. our state is simply a list of tasks.
interface Task {
title: string;
description: string;
done: boolean;
date: number;
tags: string[]
}
type Tasks = Task[]; and the actions done on the state are as follows.
interface TaskActions {
createTask(index: number, task: Task): void;
deleteTask(index: number): void;
updateTask(index: number, task: Partial<Task>): void;
markAsDone(index: number, done: boolean): void;
addTags(index: number, tags: string[]): void;
delTag(index: number, tagIndex: number): void;
} we are not going to create yet another todo app atleast not the ui, we assume (and believe) you are able to create one in a few minutes with all these actions in a framework of your choice or even vanilla js. we are going to focus on colab features.
mutate state §
to mutate the state we can do so directly as you could with javascript.
let tasks: Tasks = context.tree("tasks").state;
// add a task
tasks.push({
title: "write docs",
description: "write the colab docs",
done: false,
date: new Date().getTime(),
tags: ["docs"]
});
// mark as done
tasks[0].done = true;
// delete a task
delete tasks[0];
// add tag
tasks[0].tags.push("colab");
// delete a tag
delete tasks[0].tags[1];
// update multiple keys
tasks[0].updte({
title: "update docs",
description: "update the calls docs"
}) note we have to get the state before we can modify it by invoking get state() method in Tree. and modifying it directly like we
could regular js object. the tree is not a regular js object though and most methods for objects will fail. for example Tree adds an
insert method for lists and implements only push and pop methods.
tasks.insert(0, {
title: "write docs",
description: "write the colab docs",
done: false,
date: new Date().getTime(),
tags: ["docs"]
});
you can call .value() on any key to retrieve the inner value. if the key has concurrent values that have not been overwritten yet they
will be shown in the concurrent key. the value key is always consistent with all users. you can still choose show the concurrent values in the
ui or use them to update state.
handle incoming changes §
tree changes can be summarized by on three actions insert, update and delete. for example a list push is just an insertion at last index. a pop is delete …etc. insert is only triggered for list inserts as it exist only for lists otherwise all changes are updates and deletes.
let tree = context.tree("tasks");
tree.handlers = {
insert(path: KeyPath, value: any, ctx: Hctx) {
// handle insert
},
update(path: KeyPath, value: any, ctx: Hctx) {
// handle updates
},
delete(path: KeyPath,ctx: Hctx){
// handle delete
}
}
type KeyPath = (string|number)[]; to handle some of our actions
// in handlers.insert
if (path.length === 1){
// add new task to ui
localTasks[path[0]] = value;
} else if (path.length === 3){
// add a tag
let [taskIndex,_ ,tagIndex] = path;
localTasks[taskIndex].tags[tagIndex] = value
}
using this handlers can get messy especially for deeply nested states. would it be nice if we had predefined actions like we do in counter and text. the next section explores how we can have that instead.
named actions & handlers §
unlike counter and text where we have predefined actions and handlers for trees we have to create our own to fit our specific needs
to avoid the messy built-in handlers where we have to walk the path in order to update the ui.
we start by defining our actions
let tree = context.tree("tasks");
let myTasks = tree.setActions((tasks: Task[]) => {
return {
createTask(task: Task){
tasks.push(task);
},
deleteTask(index: number){
delete tasks[index];
},
updateTask(index:number, task: Partial<Task>){
tasks[index].update(task)
},
markAsDone(index: number, done: boolean){
tasks[index].done = done;
},
addTag(index: number, tag: string){
tasks[index].tags.push(tag);
},
delTag(index: number, tagIndex: number){
delete tasks[index].tags[tagIndex];
}
}
}) the function setActions returns the same actions that you pass into it so you can now modify state like shown below.
note all actions must only contain one change to state and have no side effects i.e you can not interact with the ui in setActions.
myTasks.createTask({
title: "write docs",
description: "write the colab docs",
done: false,
date: new Date().getTime(),
tags: ["docs"]
});
myTasks.markAsDone(0, true);
myTasks.deleteTask(0);
// ... this all looks straight forward. it helps alot when defining our handlers, the handlers will look exactly similar to actions but with the added handler context. these handlers are triggered for local and remote changes with the handler context containing information about who triggered the events.
now lets define our handlers.
tree.setHandlers({
createTask(task: Task, ctx: Hctx){
// add task to ui
},
deleteTask(index: number, ctx: Hctx){
// remove task from ui
},
// ...
}) creating actions allows us to have handlers similar to the ones in counter and text. they automatically handle path filtering
for you. note for list push and pop there is always a pushIndex and popIndex in the handler context. you should not directly
push if the action had a push. the context also contains concurrent values if any, the userId and isLocal flag for local events.
named handlers are a great option if your application has some predefined actions i.e state is only modified in a given way. you can still combine this with the default handlers. if a change does not match any actions the default handlers are still called.
the recommended way to ensure consistency is to only update the ui and user facing data in handlers only and apply all incoming events. if you change the ui
directly without passing through colab you may end up with a mismatch in the underlying tree and ui. for example if you add a task without
passing through colab you will have incorrect indices when modifying the tasks further. you can use .value() on any key to get latest values.
all local changes are instant and will always be shown first ensuring instant feedback, if a remote has been applied and not handled in between edits the local change will be shown briefly before the other changes are shown.
Sharing Presence §
presence is an integral part for any real-time collaboration application. it serves to improve user experience avoid unnecessary conflicts by convey user intent. having consistency alone will render your application unusable or a pain to use. For a great collaboration experience users need to know what the other user is doing or what they are about to do and anticipate the actions.
For a real multiplayer application presence is key. when users think of multiplayer applications they think multiple cursors on the screen showing each user’s name or avatar, multiple selections and such
sharing presence with colab is done with the presence method. you can use this method to share binary, text or json data with all users
in the same state. listen for presence events with the on(presence) method, the presence event will contain the userId and the sent
data.
here is an example from the monaco editor tutorial.
// send cursors and selections
writer.onDidChangeCursorSelection((e) => {
let {
selectionStartColumn: selCol,
selectionStartLineNumber: selLin,
positionColumn: posCol,
positionLineNumber: posLin,
} = e.selection;
colab.presence({ selection: [selLin, selCol, posLin, posCol] });
});
// ..
// and listen for the events
colab.on("presence", (e) => {
if (!e.data.selection) return;
users[e.userId].selection = e.data.selection;
}) presence does not stop with just sending simple data, you can add real-time audio or video chat, add real-time text messaging and much
more, cyxth has calls and chat modules that will integrate seamlessly with your colab instance.
check out our monaco editor and svg editor tutorial on how we use presence to share cursors and selections.
Users & Channels §
colab provides the channel interface to create and manage states.
the most frequent used methods are explained herecreate method will create a new state, join allows users to join
an existing state, the load function is similar to join but works with offline users who are rejoining, modUsers for moderation
of users you can set the level of access per user i.e editor, viewer or no access.
a full list of all channel and user management methods is available in the reference all with examples.
conclusion §
in this guide we explored colab concepts, starting from its main goal to “automatically resolve conflicts and ensure consistent state”
we then looked at the three data types text, tree and counter and how they are used to modify the shared state and finally sharing user
presence and user management. after this we recommend checking our monaco editor tutorial for a real world example use of text and the svg
editor tutorial for tree.
-- ✌🏾