Hi all! Next to my job as a Tech Lead at Poki, I like to spend some of my free time chasing my passion: game development. I’ve been at Poki for a long time, and next to the awesome team, a big reason for that is because I believe in web as a great destination for games.
This post is a deep-dive into how levels (and especially the graphics) are created for Joyrider, but before we get there, a little bit of context:
One project leads to another
Supernova: small size, simple experience
Good conversion to play, or an as small as possible loading drop-off, is one of the main pillars of creating a successful web game. This is achieved by multiple things, the most important one being a small initial file-size.
My goal when creating Supernova was centered around this idea. Basically, I wanted to create the smallest possible game, while still providing a polished (though simple) experience.
I would say that was a success, by using Three.js and relying on mostly procedural graphics, the game ended up at around 200kb. And I’m quite happy with how far I managed to push the geometric psychedelic style within this budget. The users seem happy too, the game has a great rating of 4.5 out of 5.
Joyrider: more goals, more better (😅)
For Joyrider, my goals were not as straightforward. The project actually started out as an exploration of building my own ECS framework with RxJS. Originally I intended to make a simple game again so I could focus on this framework, but somewhere along the way I realised that I wanted to make a game that forced me to explore more sides of the game development process such as 2D art, level design, sound design and others.
In the end I landed on the idea of creating a bike trials game, very much inspired by a classic series: RedLynx Trials, which I played and loved in high-school. My aim was for it to be a slightly more casual-friendly experience while still keeping the trials vibe of controlling your character’s weight alongside the bike’s acceleration.
The project’s scope ended up a lot bigger than I initially intended, but since these games are personal projects, I don’t have any deadlines to work with. As long as I enjoyed the process, I’d get there eventually. The project took about two to three times longer to complete compared to Supernova, and was much more challenging at times, but eventually I got it done. Don’t be afraid to challenge yourself!
During the entire process of designing and developing the game, the conversion to play was always in the back of my mind. This started with the choice of tech by creating my own framework (as opposed to using an existing engine with more overhead), with PixiJS for graphics, Planck.js (a Box2D implementation in JS) for physics and howler.js for audio.
Run-time level graphics generation
It was important to me for Joyrider to have some basic sense of world-building. I really wanted to have a Super Mario Bros 3 style overworld through which the game levels and shops were accessed, and the game would need to support various biomes. I wanted to avoid having to download too many sprites for all these biomes and levels and keep the file-size as small as possible, but I still wanted to have great control over the level geometry as this would be a physics game where small changes in geometry could have a big impact.
The solution? Designing levels with polygonal geometry, and drawing the graphics at run-time. I realised that this could prove to be a challenge especially for mobile devices, but I had faith and started the work.
Step 1: Designing the levels
Before I got to worry about generating the graphics, I first needed level geometry. Initially I intended to have my own level editor, but quickly decided to drop that idea due to the insane scope it would add to an already big project. While I was researching techniques of building good physics simulations, I landed on the amazing Youtube channel of iforce2d, who not only does some insane things with Box2D, but has also built his own editor called R.U.B.E. (Really Useful Box2D Editor).
The editor is super powerful, fairly priced, and the creator provides a ton of tutorials on his Youtube channel. I ended up using only a fraction of the features the tool provides, but it gave me a great foundation to build my levels. I started with a trial and quickly decided to purchase and use the editor for the project, and I’m still very happy with it.
A Joyrider level in R.U.B.E. consists of the following:
- Static bodies which contain the main geometry for the level
- Dynamic/kinematic bodies and joints for things like rope bridges or moving platforms
- Static sensors that get translated to things like kill, finish and camera volumes
- Image objects which get translated into simple or complex entities in the game engine, for things like warning signs and checkpoints
Once a level is complete, it is directly exported from the editor to a json file. This file contains all the information necessary for a Box2D engine to run the simulation. On top of that, the custom attributes I’ve specified in the editor allow the passing of more information to the game engine about things like ground material or camera volume configuration.
Step 2: Getting the level ready for the engine
Read along with the raw export of the first level here. The file that gets exported from R.U.B.E. has a lot of info, but it’s not fully ready yet.
Translating to game coordinates and reducing decimals
First I need to translate the coordinates to game world space. This is a simple piece of logic that just inverts every Y coordinate, and multiplies all X and Y coordinates by our WORLD_SPACE constant (which is 100).
While I’m doing this, I also round up all coordinates as there’s really no need for 10 decimal places of detail. This reduces the size of the level file, leading to savings in download time, and has the added benefit of making the merging of polygons a bit easier in the next step.
A limitation of Box2D is that it does not support concave polygons. A great feature of R.U.B.E. is that it handles this issue elegantly, and upon exporting automatically splits up your concave polygons into multiple convex polygons. However, this makes it hard to create nice-looking graphics on top, as those require one continuous polygon.
My implementation basically comes down to brute-forcing all the polygon pairs until I’ve tried everything. It’s not the fastest, but since this is all part of a build step it doesn’t need to be fast, it just needs to work. You can see the relevant code here.
During this step I also hash the resulting polygons, and give all of them a unique identifier based on this hash. This helps later on by not having to generate graphics multiple times for identical polygons.
In the end there’s two sets of polygons per body. One for Box2D to handle the physics, and one for the generation of sprites.
You can view the resulting level file after these changes here. The final minified level json weighs in at 38KB before getting Brotli compressed on the Poki game CDN. I decided to ship the levels as part of the main game bundle as they are very suitable for compression and thus barely impact the game’s total file-size.
Step 3: Generating the world
Now that all the level data is prepared and ready for the engine, it’s time to start worrying about drawing the world.
Drawing the sprites
Drawing the sprites happens as soon as somebody selects a level.
As a last thing before drawing the sprites, I check the capabilities of the user’s device. Two things are important:
- Does the device support workers?
Hopefully yes! This allows doing the sprite generation off the main thread, and helps performance greatly. But if not, I just fall back to doing this on the main thread.
- Are we dealing with a mobile device, and more specifically, an iOS device?
For mobile devices I make the general assumption that there’s less power to work with. For these devices, I lower the resolution of the to-be generated sprite to 50%. For iOS, I lower it even further to 25%, as those devices were especially prone to crashing. Lower resolution means smaller sprites means a lower memory footprint, means happy devices.
Now we’re all set!
The drawing logic itself is quite straightforward. I first create an OffScreenCanvas or Canvas Element (depending on whether we’re in a worker) at the size of the polygon. Then I use the Canvas API to draw this polygon onto the 2D context. The sprites are nothing more than a nice filled pattern surrounded by a couple of strokes.
Easy does it! However there is one more thing to deal with, on most mobile GPU’s, the maximum size of an image is reasonably low. To be safe, I assume we can have no images larger than 2048x2048. Although most of the level polygons are below this limit, not all of them are.
To fix this, I split up the image into chunks of 2048x2048. I extract these chunks by using context.getImageData. Then I use createImageBitmap (polyfilled as not all browsers support it) to turn this ImageData back into a format that can be used directly in PixiJS.
All these chunks are sent back over to the main thread, alongside the necessary data for stitching them back together.
The resulting images are saved to cache, keyed on the hash that was generated during the build step. In some cases same polygon is re-used for multiple bodies and it would be a waste to do this process again when I can just re-use the same sprite. This also improves run-time performance as the GPU can re-use the same image as well.
Loading everything into the game
Finally, the game entities are generated. All the chunks are combined together into a single PixiJS container so it looks like a whole again. Next to the polygons, there’s also some other entities to generate (such as checkpoints).
The entities are split into two types, static and dynamic. I do this so I can destroy and re-generate the dynamic entities once a player crashes / restarts, while keeping the static ones alive during the entire level duration.
And that’s it, we now have a level :)
So after doing all this, what did I learn, and was it worth it?
- Level design and R.U.B.E.
It was really nice to be able to draw the levels with polygons. It gave me an immense amount of freedom in designing levels, and also having different types of geometry for the different biomes. Plus I had some artistic freedom in giving levels personality through only the base geometry.
However, I do now also see the value of having pre-set level blocks that are re-used. Because I could make all the geometry I wanted, it was hard to determine the difficulty of a level without a lot of testing. Having some reusable elements (e.g. ramps of different difficulties, or other obstacles) that were balanced once, would have made it easier to provide a smooth difficulty curve. Though it’s hard to say, because it was now also pretty easy for example to decrease a ramps incline if it “felt” too steep, it just required a lot of play testing.
I never regretted using R.U.B.E., I didn’t run into any limitations during development and would use it again. The only bad part here is that it doesn’t support the latest MacOS versions, which required me to stay on High Sierra longer than I otherwise would’ve liked. Plus I won’t be able to use it anymore once I upgrade, so that’s very sad.
All in all, I’m happy with the end result!
2. Stress-testing and memory issues
I found out reasonably late into production that there were some things I simply could not get away with. Mostly there being an upper limit on how big geometry could get. On my iPad Pro, geometry that was too big would magically disappear. Some mobile devices would choke when trying to keep a large piece of geometry in memory as I was slicing it up to send it to the GPU.
Luckily there were not many levels built with huge pieces of geometry, so only a few had to be changed, also it was relatively easy to split these pieces up into multiple pieces due to the nature of the polygonal editor. However, it would have been nice to realise this early and keep it in mind more while creating levels.
3. Does it work?
Mostly, yes. Based on my analytics, the level loading only causes around 2% of players to drop-off on desktop. On mobile devices, this can get up to 15%, but for most levels it’s below 5%. The levels with higher drop-off I am still optimizing, as it’s still usually a case of geometry being too big.
Especially as I improve the mobile numbers, I think these numbers are worthwhile to provide the types of levels I want to provide at a low file-size. The final game has around 30 levels and weighs in at 2.7mb (of which 1mb is audio, which doesn’t need to load for you to start the game). The conversion to play is 82% on desktop and 70% on mobile.
That’s all for today. I hope my process can help you with your (web) games in the future. In case of any questions, feel free to reach out to me through Twitter! Also, don’t forget to play Joyrider here.
And if you are interested in releasing your web game to millions of players globally, give us a visit at developers.poki.com.
Below you can find some additional art and sketches from the development of Joyrider :)