Hacker News new | past | comments | ask | show | jobs | submit login
How to build undo/redo in a multiplayer environment by Liveblocks (liveblocks.io)
174 points by moritzplassnig on June 9, 2022 | hide | past | favorite | 42 comments



I've implemented undo/redo a number of times. I agree whole-heartedly that the Command pattern is the way to do it. Undo has a reputation for being difficult, but my experience is that it's smooth sailing as long as you build it into the tool on day 1. If the app is architected around undo, it's easy, but trying to retrofit it onto an application later is always a nightmare.

This is very similar to the experience of writing a networked multiplayer game. If you build a single-player game and try to bolt multiplayer on later, you're gonna have a bad time. But if you design it for multiplayer initially and treat single-player mode as essentially just a multiplayer game with only one player, it's relatively easy.

I think both of these come down to the same core issue: mutating state.

When playing a game, or editing a document, you are mutating some state. To support undo, you need to capture all of those mutations so that you can reverse them. To support multiplayer, you need to capture them so that they can be synchronized with the other players.

It's trivially easy in most programs to just directly mutate some state by setting fields or by calling methods that do that under the hood. So, if you just start coding, you will end up with mutation happening everywhere. At that point, you have already lost.

But if you design your application for undo, you isolate the document state from the rest of the application so that the only way to modify it is by going through the undo/redo mechanism. (In other words, the only way to apply a change is to create a Command object which does it on your behalf.) Likewise, if you design for multiplayer, you'll build a separation between game state and the rest of the application. Then the program has a well-defined interface that can modify the state.

Once all mutation goes through a narrow well-defined interface, it's relatively easy to grow the application over time without compromising undo or multiplayer.

But if you're adding that afterwards, you have to dig through the program to find every single piece of code that changes some state. It's hell.


For https://curvefever.pro we actually decided to store the state of the world for undo purposes, this way you can go anywhere back in time in constant time, and to redo you execute the commands again. The big advantage is that you don't have to create an undo function for each command (which might be tricky in some cases, as many times it still involves storing state) and you don't have to iterate through/apply all the commands to go back at a specific time. To save memory, we actually only store the state every 5 ticks (so if you want go to back to tick 23, you go back to tick 20 and run the simulation forward for 3 ticks).


To be more clear, in case an "undo" of a player action has to happen, the state before that action is loaded, the action is removed and the world ticked forward again with all the commands (excepting the removed one).


This is some great nugget of knowledge, thanks for sharing. it's too bad all my programming enthusiasm is going into a 9-5


If you want more nuggets from this poster, I suggest reading his games programming book. It’s light reading (but full of great concepts and explanations) that I personally read outside my 9-5 for pleasure.

https://gameprogrammingpatterns.com/


Command-pattern is the secret sauce for the ultimate and optimal Undo functionality. Glad you're spreading the good word!



I know slightly too much about the problem space to have questions about how it's implemented, but I just want to say this is one of the neatest presentations I've seen on this site. It's a great article on an interesting topic and made 100x better by the visualisations


Thank you so much!

Would love to nerd out on the implementation too at some point if you'd like. :)


Agreed, the visualizations are really nice.


Nice work! Regarding your question on how to handle undoing a command on a shape that doesn't exist anymore, is there a way we could automatically recreate the shape?


It's technically possible by caching only deleted shapes if any operations in the undo stack are associated with them. But is it really the UX we want every time it's happening?

Let's imagine that we work together on a design file that contains hundreds of icons. I'm responsible for adjusting the color palette while you're removing the old icons that are not used anymore. If I undo my last color update on all icons, I probably don't want to recreate the icon inadvertently and override the triage you're doing! So IMO, it's more of UX question than a technical question. Is there a better default solution that we can find to improve these edge cases? My initial thought is to have a small "toast" letting the user know that the undo operation couldn't be applied because X does not exist anymore, with a link to the version history panel to allow him to reinsert X if it wants to.


> Depending on the use case, the state of features like user selection, user page selection, user zoom setting, etc. could be included in the undo/redo stack to provide a great experience.

Does anyone have an idea what these use cases could be? My mental model for undo/redo is based on productivity applications and I’m at a loss; I’m genuinely curious though since this is something I’ve been implementing.


In design tools, like Figma, undo sometimes just walks backwards through your selection (even if you didn't change anything). It'll even switch between pages. I'm interesting in seeing if there are other ones people can bring up


For rich text editing, undo should probably reset the scroll position and the cursor close the edit you're undoing.

I think reseting the time position would also make sense for video editing.

So basically, it's a way to get back as much as possible in the context you were at the time of the operation that is undone.


Thank you, this is both a good and nuanced example.

It would likely be a better UX if that scrolling (etc) brought the user back to the exact context they made that edit, but in the interest of laziness and impatience I'm just using a generic focus function.

It might be nice to have more exact context data available for the life of a session as a progressive enhancement, though I think it would have to be a clever encoding, else that data would be invalid in cases such as window resizes and edits made by collaborators that change position of the object being edited.


Interesting, that's a really good one, thank you.

Now that I'm thinking about it, when I used to use Autodesk Fusion 360 I think it did this a bit, but until now I thought it was just another bug: they had accidentally added some UI state changes to the undo stack.


Google Maps is one such example. Every time you click on something, change the zoom, toggle the layers, etc., it gets saved to state (and persisted to the URL). That way you can use your browser back/forward buttons to switch between different places or views of places, and also share that with your friends.

Miro (the collaborative whiteboard)/Figjam is another... the boards can grow pretty big in x-y space, and losing your place in space is very easy to do, especially if you ctrl-z a few times and forget what you were even working on. Just knowing that "somewhere on this board, something changed" isn't helpful if you can't see what it was.

Analytics is another such use case, for when you're composing queries or search filters, date ranges, etc. Google Analytics does something similar by giving each set of criteria its own unique URL, so that you can use the back/forward in your browser to go between states. Not exactly the same as an undo/redo button, but pretty much the same idea.

Another example: I work for a solar company, and we're building a web app that equipment installers can use to monitor the performance of rooftop solar arrays they've installed. It tracks, for example, each solar module's power production on a heatmap, arranged in the same layout as on the actual roof. There's a lot of interaction that can happen... selecting a particular unit, choosing a particular timeframe, deciding which metric you want to look at, search terms, table filters, sort order, etc.

None of that needs to be written to our database as actual changes to the layout and device pairings. But it would still be handy handy for users to be able to undo/redo, bookmark, permalink, etc.

I think of it this way: A set of UI states like that, taken as a whole, is in and of itself a form of "data"... it is a human user's carefully framed view of some point in time, and some selection of data, that they have deemed important. Being able to save and retrieve that state in case they make a mistake (cat jumped on the mouse) or want to share it with others ("hey, check out what's happening to this module on X date at 4pm") is helpful, even if it doesn't ever need to be written to the server (since it can be stored as local state and/or URL strings).


Light mode for the site please, those of us with Astigmatism can't use it. Supabase launched the same and they eventually had to add it because of demand, not sure why dark mode is so heavily default nowadays. https://jessicaotis.com/academia/never-use-white-text-on-a-b...


Noted. Thanks for the feedback. We do have a light theme version on the dashboard, it would be great to add it to the website too.


Hi HN,

We'll be here today to answer any questions you may have. Hope you enjoy the article!

Thanks, Steven


I worked on the implementation so don't hesitate if you have deeper technical questions that the article is not covering :)


As someone who's built this kind of multi-player undo/redo in the past all I can say is this looks amazing and I can't wait to try it in one of my projects - Thanks for building this!


Thank you! Can’t wait to see what you build with it.


> In a multiplayer command-based undo/redo system, we can also solve [intermediary commands] by pausing and resuming the history stack at the right time.

Suppose that instead of managing the overall state of the history stack, you gave each command a unique ID and allowed commands to be updated/overwritten as they develop? Apart from a few bytes of memory overhead per command, what am I missing?


Nice to see someone make a product out of Cloudflare Workers (and Durable Objects for sync?).

Also, excellent explanation and visualizations :)

Good luck with all of it


Yes! We're using Durable Object under the hood :) Thanks to you for providing such a great platform!


Oh, I dont work at cloudflare I'm just a user, have been building workers for about 3 years.


Love this style of interactive posts. I recently had to solve the same problem for my own app and did it pretty similarly.


Thank you!


For deleted items, could you not store a memo of the object in addition to the user-local changes they did?

So if you undo a change to an object that was delete by a different user, you can restore that object first to the last known state, then apply the undo?

I'm not sure this makes sense for other cases besides object deletion.


Yes that's good way to implement it!

But I'm still not sure this is the default behavior that we want, even it's only for deletion. I think it's more of a UX problem than an engineering problem as explained here: https://news.ycombinator.com/item?id=31682073

To take another example, for rich text editing, I don't think undoing a formatting operation on text that has been deleted at the same time should reinsert the text.


What a fantastic, well-written blog post - well done.

Out of curiosity, what's the max # of simultaneous connections per room that LiveBlocks can support? (it's hidden behind the enterprise signup flow today)


We support 20 simultaneous connections per room on the Pro plan. With the organization plan, we’ve been able to increase this to about 50 simultaneous connections per room depending on the use case.

We would technically be able to go beyond that but will likely require bigger servers. Depends on what you’re trying to build.


Is there a provider that has "websockets all over the world on edge" ? Right off the bat I am not a target customer because I don't use React. Coming from Vue/Svelte


Our code client does not depend on any front-end technology, so you can use it with Svelte or Vue. We have a few examples that use Vue and Svelte here: https://github.com/liveblocks/liveblocks/tree/main/examples

Our most advanced Svelte example is this one : https://pixelart.liveblocks.app/

If there is enough demand, we'll make an official wrapper for Vue and Svelte!

If you're looking for WebSocket without all the state synchronization we provide, there are a few well-known providers like Ably or Pusher.


love it! thanks for the response. So the state synchro is not possible in Ably? Trying to understand what liveblocks does on top of what Ably/Pusher provides


To summarize the differences between Ably/Pusher and Liveblocks, Ably/Pusher use a centralized Redis to broadcast messages to channels. Liveblocks has tiny isolated servers on the edge for every room. These two different architectures create the following trade-offs:

- Ably/Pusher are lower level than Liveblocks. It doesn't have any storage associated to a channel and does not solve any conflicts.

- Liveblocks provides API to migrate existing app into collaborative ones via integration with state management library like Redux/Zustand. I have a POC somewhere that try integrate with Vuex, would love to release that at some point!

- Ably/Pusher charges per WebSocket message sent. Liveblocks charges per WebSocket connection. Because of that, building features like cursors can become quite expensive on Pusher.

- Ably/Pusher lets you connect to multiple channels with a single WebSocket connection (because they're using a centralized Redis IIRC). Liveblocks requires a single WebSocket connection per room. Pusher is good for notifications systems, Liveblocks shines when you need to build an app like Figma or Google spreadsheet.

Hope it helps!


Fastly announced Fanout today, which is exactly this https://www.fastly.com/blog/unlocking-real-time-at-the-edge


Love the interactive visuals. Would be awesome to see how that would work with other use cases at some point. Is that something you’re planning to do?


By "other use cases", are you talking about apps that are not design tools like Notion or Google Spreadsheet? Or other edge cases related to undo/redo?


Yes, mainly text collaboration.




Consider applying for YC's Spring batch! Applications are open till Feb 11.

Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: