I woke up one morning with a thought: “What if I wrote a web game without writing any Javascript?” Here at Revelry, we love Elixir and LiveView, and I wanted to see how far I could go without touching any `undefined`s or `async`s.
And I did it.
If you want to play the game first before reading the rest of the article (or just want to play something fun), you can find it here:
https://flappyphoenix.fly.dev/
And if you just want to look at the code, you can find it over here!
https://github.com/stuartjohnpage/flappyphoenix
While there is some JavaScript in there (most of which comes from the bare necessities of LiveView, and one hook which I was forced to implement which I will explain later).
So to that end, I’d like to take you through my journey of executing a fun, fast-paced game in LiveView – including enemies, power-ups and bad-mannered score announcements. I think it stands as an example of everything that Elixir + Phoenix + LiveView really excels at, and also, where it falls down without the help of that lovely little language everyone loves to hate (or just… more servers).
First Steps
I liked Flappy Bird. You know, the mobile game craze where you mindlessly tap to avoid walls, and get your cute little avatar to go as far as it can. So that was really my first idea – build Flappy Bird in Phoenix.
Hopefully this evokes some memories — you can check out the OG sprite on Wikipedia.
The problem was (and I only found this out about halfway through working on the code) that this had been done before. That exact same principle – build Flappy Bird in LiveView – had been explored five years ago. And it’s a pretty good and faithful clone too! Go check it out if you are interested!
But that fine was fine by me. I was writing this game as an exercise for myself – and by then, I’d already started to explore avenues beyond the original game mechanics, and started to have a few cheeky ideas of my own. Instead of dodging walls, I’d like to have to dodge enemies instead.
Early on, LiveView gave me some quick wins. It was pretty easy to bind my keyboard inputs to events, and make my little phoenix fly. In fact, nothing about the development process at any point was really hampered by my stack choice; I used a GenSever for the game state, and everything flowed from there. As we’ll see, that wasn’t even necessary, because LiveView can handle its own state and send messages to itself. Later on, I added PubSub, which was delightfully easy. Phoenix provides lots of help to make making things as straightforward as possible, and lots of little helpers to glue everything together.
My first real hurdle building a 2D game is quite common, but its frequency makes it no less painful.
Collision Detection: AABB vs. SAT vs. My Sanity
For the uninitiated (lucky you), there are a couple of different ways to perform collision detection between two things in a 2D game. MDN provides some really nice instruction around it if you are interested in building something out yourself: 2D collision detection MDN *
The first I explored was relatively straightforward: AABB, or Axis-Aligned Bounding Box collision detection (or maybe, Always A Bit Broken collision detection). This involves checking whether two rectangles (in this case, the bird and an enemy) overlap. The algorithm relies on checking if any gaps exist between the rectangle edges, which signifies a collision. A key point here is that they *have* to be rectangles. There’s no room for any other polygons here.
The maths wasn’t too hard to get my head around, so I built it out. Unfortunately, when using this method, my little phoenix would collide with its enemies and cause game over, despite the fact that visually there was a gap.
This was no good. So I looked for something a little more accurate, which is when I moved on to SAT, or Separating Axis Theorem (or rather, Stu’s Agonizing Trial). SAT works and offers more precision than AABB by projecting shapes onto axes derived from their edges, using normalized vectors, and seeing if those projections touch.
If that’s a little confusing (it was for me, anyways) a good way to visualize it is that the algorithm takes each shape, and casts a shadow of it onto different lines. Imagine shining a flashlight at two shapes from different angles – if you can find any angle where the shadows don’t overlap, the shapes aren’t touching! This technique works great for any shape with straight edges (as long as they’re not curved inward).
It just so happened that there’s a little open-source library with a permissive license called Collidex. It’s pretty old, and I didn’t need much more than the SAT that they had implemented, so I swiped it and plugged it in. It worked great – the only problem was that I had to manually adjust the coordinates of my SVGs to essentially draw the 6, 7 or 10 sided points I needed to represent my Phoenix and its enemies. This is a great time to remind you:
0,0 on a webpage is top left.
Again, for the people in the back who labored under their classical Cartesian delusion that 0,0 is bottom left (just patently wrong) and spent hours figuring out coordinates that were just wrong:
0,0 on a webpage is top left.
Anyways, with that done, I had some really nice collision detection between my bird and my enemies. Let’s talk about game state management.
PubSub Messaging
At first, I approached the game state management in a funky, and as it turns out, somewhat redundant way. I had my engine set up to tick every 30ms, and my LiveView set up to *also* tick every 30 seconds, and poll the server for updates. But LiveView processes already maintain their own state and can handle timed updates through self-sent messages – so I was essentially making things more complicated than they needed to be (as well as having to deal with two separate tick rates).
When I realized I was being silly, rather than rip out the GenServer and combine it with the LiveView, I decided to use PubSub, which Phoenix makes absurdly easy. I set things up so that LiveView would subscribe to a `game_state` topic upon creation, which the engine would publish updates to. One tick rate to rule them all!
Unfortunately (or fortunately, as it would later turn out) something I discovered when I pushed my game up to the web for the first time was that every player was playing the same game. That is: one game session was shared between every user – every user was giving inputs to the same Liveview – and communicating with just one GenSever. Think Twitch plays Pokémon, but worse.
This actually proved to be a happy accident. I’m a big fan of games where the game snidely lets you know how bad you are (à la “come on you noob, you can score higher than *that*”). And it’s even better if everyone playing the game at that moment can see it – “Player28 stinks!”. Oh, the shame!
First I solved the problem of shared game state by subscribing every new player to a new topic and spinning up a new GenServer to publish to that topic. But I kept the global topic – which I could now use *exclusively* to publish rude messages to!
Performance woes
I was so excited to show everyone my new game. It worked flawlessly locally, and surprising even me, on the web. I even managed to get my brother-in-laws (who live fairly locally: remember this) to try and dunk on each other with some high scores and mean messages. But then…
“Hey friends!”, I said over-excitedly via Discord to my friends in Europe, “click this link and play this game I made! I didn’t use any JavaScript! I think it’s un-hackable, because the client doesn’t control everything!”.
Laughter ensued. The game was unplayable.
Important things to note if you want to play a (non-optimized) web game where the server is the definitive authority on *everything*:
- Hard-wire into a fiber connection.
- Be in the same
continent country state townroom as the server.
It was awful. In the end, in order to show them, I had to screenshare while *I* played the game, hardly what you want when you’re showing off to your friends.
In addition, even when playing in the most ideal of circumstances (save for running a local server), the game will slow to a crawl when there’s hundreds of enemies on-screen. As it turns out, constant and large WebSocket messages are not a great idea.
How I should fix it
If I had the time (and the inclination to write some JavaScript) I could probably solve most (if not all) of the problems my friends experienced using Phoenix Hooks (the built-in LiveView escape hatch to plug in some client-side JavaScript):
- Predictive movement: The client can handle immediate bird position updates while waiting for server confirmation.
- Selective updates: Only sync critical game state (collisions, scoring) with the server, while handling visual effects locally.
This approach would maintain the benefits of server authority (anti-cheat, for the most part) while delivering responsiveness. The server remains the source of truth for important game events, but the client can handle visual updates and non-critical interactions independently.
If you dive into the source code, you’ll see I am actually using a hook already – my vision of no-JavaScript purity was dashed by my brother-in-laws, who both kept cheating by resizing the window to make the bird tiny. So I did end up adding a hook to listen for window resize events and change the game state accordingly to compensate.
Thanks Will. Thanks Andrew.
I could also deploy the game to additional servers in different regions to generally reduce latency for my friends in Europe.
Lessons Learned
I had fun. I really did – I was motivated to build this game, and I still have it up on fly.io. It’s fun to play (as long as you are in the Southern US, and really Texas preferably.)
My biggest take-away was that client-side code is (in most instances) essential to a smooth user experience in responsive games. Without it, the game becomes impregnable to cheaters (yay) but so unplayable no one actually wants to play it (boo).
There are ways around this (looking at you, Google Stadia) like spreading the game across lots of servers in multiple regions, and performance optimizations regarding the size and frequency of the server messages: but for most games, the best solution is to write the client-side code to offset the latency.
Phoenix and LiveView really are a joy to work with. They give you easy access to do things that would require quite a lot of complexity if you were to use other frameworks. And really, the server-side drawback is a) only a problem if you need responses from the server *constantly* and b) if you refuse to write the LiveView hooks that would negate that problem.
Disclaimer:
This blog post reflects my personal journey in building a fun side project to explore game development concepts. The logos and assets used, including those of open-source frameworks such as Phoenix, React, and Rails, are employed in good faith under fair use principles for educational and illustrative purposes only. All referenced logos are trademarks of their respective owners. This project is not affiliated with, endorsed by, or sponsored by any of the frameworks or their maintainers.
We're building an AI-powered Product Operations Cloud, leveraging AI in almost every aspect of the software delivery lifecycle. Want to test drive it with us? Join the ProdOps party at ProdOps.ai.