This project was created as part of the Software-Systeme course held by Prof. Heidegger
at the THA. The goal of this Praktikum was to both
to teach how to create a real-time multiplayer application using an event-based system
and how SPA's can be created without having to resort external dependencies like react.
For that the task was to create a multiplayer canvas application where users can create/join sessions and draw different shapes on a canvas together. The canvas had support the drawing of 4 basic shapes (line, rectangle, circle, triangle), the option to choose a specific border and fill color (including a transparent option). Next to that the canvas had to support a z-order for the shapes, the selection of those shapes to move them, delete them or increase/decrease their z-order. Since the whole project should be a multiplayer application each of those actions should be able to performed by a user all while other users could interact with the canvas simultaneously.
- The target for the browser funcionality is set to "ES2021"
- The code should work with a recent version of Chrome/Firefox
node: Current version (v20.2.0)npm: Current version (v9.6.6)
- Install all dependencies:
npm install - Configure the software:
- Entierly optional and the project works without configuring it
- Create a
.envfile in the project dir (a.env.exaplecan be found with all possible env vars)- Most important env var is
NODE_ENVwhich determines how the frontend assets are served
- Most important env var is
- Adjust the
persistClientIdinsrc/client/main.ts- Setting it to false allows having mutliple canvas sessions in a browser session
- The client id for the browser client won't be persisted if this option is set to false
- Should be only set to
falsewhenNODE_ENVis set toproductionotherwise the live reload can bork the canvas session
- Build the project:
npm run build(only needed forNODE_ENV=produciton) - Start the project:
npm run start
- Bundler (
vite) - Typescript (v5.0.4)
- Linter and Formatting (
eslintandprettier) - Http server:
- Server itself:
@tinyhttp/app - Serving client assets:
vite(dev) andsirv(production) - Websockets:
wsandtinyws(middleware)
- Server itself:
dotenvfor environment variables
common/: Common types and dependencies for both the server and clientcommon/canvas: Base canvas types (shapes, storage, colors)common/{commands,events}: Typedefinitions for commands and events that are send over the websocketcommon/flow: Simple reactive streams implementation based on the schemantics of theFlowin Kotlin
server/: Server applicationserver/canvas: Domain logic for the canvasserver/http: Http modules for handing the frontend assets and websockets
client/: Client SPAclient/canvas: Domain logic for the canvasclient/components: UI components
The project follows the general advised architecture of an event based system with some minor tweaks. A websocket is used to communicate between the server and multiple clients. The messages that are sent through the websocket are categorised in commands (client sent) and events (server sent), the individual message types are explained in the next section. The client can send these commands to performs some remote action on the server while events represent that something was changed on the server.
The server manages the state of the canvases. It acts as a source of thruth for them and holds every state that belongs to the canvas. The server also manages the incoming connections and makes sure that clients can enter a cavas perform actions/receive events and then leave a canvas. One big difference that deviates from the advised architecture that a canvas in the server recevies commands rather than just events. The bigest difference is that the server not just accepts events from the clients and relays them to the other ones, but actually executes the requested actions and then emits events based on the performed actions. The commands act as the source of thruth of the canvas state and are designed to allow for a determinstic state restoration from replaying them. This allows to just persist the commands and restore a canvas out of them. The events themselves are not persisted as the can be determined from the executed commands.
The client allows the user to interact the application. The user can create new named canvases and join
them by clicking on one of them in the overview or by browsing to it's url. One entered a canvas he can
draw and interact with it. The user has the ability to mark a canvas as persistent to keep it open
even if no user has currenly joined it and to persist it when the server stops. The user can also leave a room.
If a room has no users anymore and is not persistent the room will be automatically removed. For every action
the user takes commands will be send to the server. Sending commands rather than just events was chosen
to improve the 'security' since the user could just send bad events over the websocket (eg selecting an already selected
shape by another user or just removing the selection of the other users). With the commands the client
can only request a change but not perform the change itself, the server first validates the actions before executing them.
The events act for the client as a source of thruth as a complete canvas (on the side of the client) can be
just restored from the received events.
We differentiate between two types of commands/events: Client commands/events and Canvas commands/events. The commands are always send by the client and the server will respond with events.
These commands will be send to the server to initialize the client and to manage the canvases on the server. The client can get notified on the available canvases, join or leave them.
Commands
ConnectCommand(clientId: string | null)- Initialize the connect handshake with the server
- If the client has already a
clientIdit will be passed to the event - Server responds with a
ConnectionEstablishedEvent(clientId: string) - That contains either the passed
clientIdor a generated one if noclientIdwas provided
CreateCanvasCommand(canvasName: string)- Request the creation of a new canvas
- Server responds with
CanvasCreatedEvent(canvas: SerializedCanvas)that contains the canvas metainformation
JoinCanvasCommand(canvasId: string): Join a canvas and start receiving events from itLeaveCanvasCommand: Leave the joined canvas and stop receiving eventsToggleCanvasPersistance: Toggle thepersistentsetting in the currently joined canvasPushCanvasCmdsCommands(cmds: CanvasCommand[])- Request new commands to the joined canvas
- Commands are batched to improve performance
Events
ConnectionEstablishedEvent: SeeConnectCommandCanvasesChangedEvent(canvases: Record<string, SerializedCanvas>):- Metainformation about the available canvases has changed
- Is recveied on every change
CanvasCreatedEvent: SeeCreateCanvasCommandReceiveCanvasEventsEvent(events: CanvasEvent[]):- Receive events for a currenlty joined canvas
- Either pushed because we requested some commands to be executed
- Or another client executed some commands
The canvas commands/events are performed on the currently joined canvas of the client. Both commands and events are batched to improve the performance. This means we will be sending less messages and redraws of the client's canvas should happen less often. Since the node event loop is single threaded this also means that the batched commands of a client is executed sequentialle before another batch can be executed allowing to not care about any race conditions that could happen.
Commands
AddShapeCommand(clientId: string, shape: SerializedShape)- Add a new shape to the canvas
- Since this is a new shape no checks need to be performed
- Server returns a
ShapeAddedEvent
RemoveShapeCommand(clientId: string, shapeId: string)- Request the deletion of a shape with the id
shapeId - For the deletin the shape has not to be selected by another user
- If successfull returns
ShapeRemovedEvent
- Request the deletion of a shape with the id
SelectShapesCommand(clientId: string, point: Point2D, cycle: boolean, multiselect: boolean)- Perform a selection
- The client can't perform add or remove individual shapes from the seletion to mitigate bad input
- The server selects or unselects shapes based on the point and passed options
- Shapes of other users won't be selected or unselected
- Server responds with:
ShapeSelectedEventandShapesUnselectedEventevents
SelectByShapeIdsCommand(clientId: string, shapeIds: string[])- Try to select indivial shapes
- Used for the drag and drop feature
- The server still checks if the provided
shapeIdsare selected by other clients - Server responds with:
ShapeSelectedEventandShapesUnselectedEventevents
UnselectAllShapesCommand(clientId: string)- Remove the complete selection of the user
- The server will just unselect all shapes of the user
- Server responds with:
ShapesUnselectedEventevents
Events
ShapeAddedEvent(shape: SerializedShape): SeeAddShapeCommandShapeRemovedEvent(shapeId: string): SeeRemoveShapeCommandShapeSelectedEvent(shapeId: string, clientId: string)- Shape has been successfully selected by
clientId
- Shape has been successfully selected by
ShapeUnselectedEvent(shapeId: string, clientId: string)- Shape has been successfully unselected by
clientId
- Shape has been successfully unselected by