Building Multiplayer Minesweeper with Laravel, Livewire and Reverb
Published on by Jared Short
Writing PHP was my first venture into web programming some 20 years ago. WordPress, Joomla, Drupal, Zend, you name it I probably had shipped something in it.
I took a step away from all that professionally around a decade ago and ventured off into Ruby, JS, TypeScript and lately Rust land. However, the recent uptick in PHP being discussed piqued my interest. I wanted to see where things were. Reader, I was in for a treat. Even if it did take a few licks (of reading the docs and fighting with configs) to get to the center of the lollipop.
My strategy when learning something new is generally to pick a silly project and then do whatever it takes to make it work. I’ll have one or two hard requirements for things I want to see happen, and all the rest is malleable.
Here is the joyful little app I ended up building. A multiplayer minesweeper Laravel app, with Livewire, Reverb and hosted on Fly.io.
You can play it at https://multiplayerminesweeper.lol/ if you want (open a couple tabs and share the link with yourself for a “multiplayer” experience). My two non-negotiable hard requirements: One, I wanted to be able to play minesweeper with a couple friends on a sizable (at least 20x20) grid on the web easily. Two, I didn’t want to write any React / Vue / anything. A touch of javascript if necessary was fine, but I wanted to avoid it where I could.
I’m happy to say, mission accomplished. The playable grid works pretty well up to 40x40. Turns out that kind of takes a long time to solve, and 30x30 seems more to be a more reasonable game length. And more importantly, I wrote almost no frontend javascript code. I leaned entirely on the capabilities of the tools provided by Laravel.
How, you might ask, is this possible in a traditionally request / response bound language and server? I sketched a quick diagram that shows a slightly abbreviated flow.
It’s a deceptively simple concept. When using Livewire components, Laravel wires up some simple javascript that when you take an action submits a “POST” request, and this is used by the Livewire components server side to generate and return new HTML in the response which gets swapped into the page. There are bits of state managed for you so the Livewire component that gets passed back and forth, so for instance maintaining something like a counter feels a bit like magic.
However, this left me with a problem in that a singular client would only ever see the latest state of things if and when it submitted its own moves forcing the update function call and getting the newest state patched into their HTML.
This is where Laravel Echo, a websocket client, and Reverb, a first party websocket server, end up being the big winners. We can subscribe to the Reverb server from each of the frontend Clients, with a tiny bit of JS from the provided Laravel Echo to subscribe to the game channel. When any client makes a move on the board, the Laravel server broadcasts an “update” message to all subscribers via the Reverb server. Each client then goes and picks up the latest state to patch into their HTML. Below is an abbreviated flow from the perspective of a “Viewer” of a move and the “Player” of a move.
I did initially have some problems with large payload sizes across the wire during an update, and it was super easy to cheat. It turns out that Livewire happily sends along all the properties on the class unless you inform it otherwise. I had a board
property that was generated at the beginning of each game with the locations of all the mines, and other information like if there were mines in range (aka: the number squares). I was inadvertently hydrating this board into the state of the component that went across the wire with every call. Simply making the prop protected
solves that problem. Finally, implementing gzipping shrunk the payloads across the wire tremendously since it was mostly repetitive HTML for each cell, ~300Kb to ~12Kb.
That’s it. That’s all it took and I had what feels like a little single page app to play a 900 cell game of minesweeper with a couple friends without writing any Javascript. The great part is, I’m sure this could all be vastly improved. Laravel, specifically Livewire, has quite a few neat ways to make optimizations. Importantly though, my silly project still works even without needing to be an expert. Could it be better? Yes. But in mere hours I built something that satisfied my predefined hard requirements.
I threw everything up on fly.io and started testing with friends. It totally worked, but because the payloads could be a little bit sizable (especially before I gzipped things), a more distant friend, in the geographical sense, said it could be a bit laggy. I played around with putting instances of the app distributed to a few regions to get things closer to players since the Fly platform made that so easy. This worked, but I realize that this caused a problem given I was using SQLite for my database. The game would only ever exist and be accessible to a player if it was started on the server they were routed to.
I ended up using a neat Fly feature to resolve this, the fly-replay header. In the case where a game had been started on a different server it let me dynamically route requests from one server that doesn’t have that game state on it, to the one that does, without the user being any the wiser.
In the best case, geographically close players have a better experience since no replay / re-routing is required. In the worst case for games with highly distributed players, players far away from the game host incur some latency, as I haven’t yet figured out how to beat the physics of the speed of light. However, at least it is routing that traffic behind the Fly Proxy, and not a likely weaker client connection.
Side note: Fly.io was kind enough to sponsor this silly project, and gave me a link that gives anyone reading a $50 credit if they want to play with Fly.io themselves. Even if you aren’t using their platform right now, you can grab the credits and they won’t expire on you!
It wasn’t all rainbows and 200’s
I want to be very clear... The following all comes from a place of acknowledging that Laravel and the ecosystem have done so much well that the sharp edges are particularly noticeable. The process of growing a framework and ecosystem should appreciate the new developer experience as a cornerstone. This was my singular developer new-to-the-framework experience.
Coming in as a complete novice to the Laravel ecosystem is disorienting at best. The sheer amount of flexibility in the framework along with its depth of features and integrated products inflict an analysis paralysis on newcomers. I wasn’t quite sure which, or if I needed any 25 different things listed under the ecosystem tab (answer, yes).
Getting going had a couple false starts figuring out what approach to take. Eventually I ended up with a very spicy stew from a local and Sail based environment. It’s all still a bit broken, but like my hand-me-down Dodge Stratus in high school, it got the job done.
I think what demoralized and slowed me down the most, there was no definitive “do this to get started”. There are docs, yes, honestly probably too many versions of “getting started”. It was a choose your own adventure where paths diverge, converge at points, lead off a cliff on another path (several times I deleted whole folders, attempted to uninstall everything and get back to a fresh state), and at one point you end up in an alley where you are offered some salve (Laravel Herd) to soothe all your aches and pains for a couple silver coins.
Were I to do this all again, I’d have a better idea of where to go and what I’d want. But my experience here is highly contrasted by experiences like install node, run npx create-next-app@latest
or install ruby, run gem install rails
.
When it came to actual functionality, it took me quite a bit of scouring the docs to understand that Livewire and Echo were the things I needed to enable a pattern of inducing a Livewire update when another client updates. But Echo then mentions Redis is needed, and something else called Reverb is optional, but you can use Pusher channels if you want instead? What was Reverb, Pusher channels? We all know I got there in the end, I needed a websocket broker and Reverb is simply the first party one provided by Laravel.
While reading the docs is not something I’m not proclaiming as a negative, and it is a side-effect of the “not all batteries included, but they are available” that seems to be the strategy of Laravel, it is painful in a lot of cases to have so many options documented without clear indicators of preference. If I was diving into laravel professionally, I’d likely read the documentation religiously and this would all be less of a problem. But for a new developer trying to set things up, all these options add up to a layer of hidden complexities.
As an illustration of hidden complexity, in the sketch at the beginning of the article... you see a redis powered queue system. It took me quite a while to realize I needed to actually run queue workers somewhere in my local dev, it didn’t just happen with the normal starting up of a dev server. For probably a solid hour I just thought my code was wrong. In retrospect, duh, but without catching the small two-sentence blurb in the docs under a h4 header, I wouldn’t have realized it for much longer.
Finally, when it came to shipping everything on fly.io, while packaging up a container and shipping was easy... with Laravel itself there are lots of configurations for everything because of all the flexibility in how you can model an architecture. You are mostly left to your own devices and experience for figuring it out. Lacking the requisite Laravel specific experience, I mostly resorted to guessing and playing documentation telephone with AI.
I simply wanted to put Reverb on a /socket
path on my main domain. As far as I could tell this had to be possible (it is). And while the incantation is fairly trivial at the end of the day, the docs all make various assumptions, for example that Reverb will be running on its own subdomain. Even the pre-populated .env
configuration files account for wanting to have Laravel be able to hit Redis inside of a network while your clients will need a DNS resolution across a domain.
To reiterate the opening paragraph of this section. I only took the time to write this because I care. There is a saying we use a lot at my day job, “Reality has a surprising amount of detail” (John Salvatier). When I look at everything Laravel has done and can do, it is not surprising to me that getting started is a bit complicated. The reality is that Laravel grew and evolved to the needs of those depending on it to do real work, real work is complicated. Every last thing I raised here is solvable with skilled and guided hands pruning and tending the garden of the ecosystem.
I’m impressed, the hype is earned
Regardless of the pains expressed, I still came away having enjoyed the journey and the project as a whole. I get why Laravel is gaining steam, I understand why people love the ecosystem. I appreciate and value what the community is building.
I’m not going to drop all my toolsets and jump to Laravel tomorrow at the day job, but I’m also pretty sure my next silly build with PHP isn’t going to be another decade away..
With over two decades of software engineering experience, Jared spends his time working on "silly projects" and has made a hobby of collecting hobbies—all with the goal of learning as much as possible about the world and how it works.