Working on a little 3D racing game called Trackminia

Ahoy! I've been making a small racing game, primarily inspired by "Trackmania 2" (my favorite in the series, because of its lovely drifting mechanics).

playdate-20240511-181645

This clip is recorded in the simulator - on the actual device, it wavers between 40 and 50 fps, varying depending on your viewpoint. It'd be sick if I could get it to maintain 50 fps on the Playdate, but it's feeling less and less likely as I add more stuff, lol. I'll probably end up locking it to either 30 or 40 fps, even if it's "sometimes" able to go higher.

Happy to answer any questions about wut's going on here, but to start, here's some general techy info:

  • Everything is written in C, mostly for performance reasons but also because I wanted more practice with native languages
  • The renderer is primarily a raycaster, like the original wolfenstein or doom
  • Extra objects are rasterized 3D meshes (mostly the car, in the clip above - I'm trying to avoid the "pre-rendered sprites for different viewing angles" style). Currently I'm relying on the SDK's built-in fillTriangle() helper to draw the meshes, but I'm gonna need to write my own at some point so that objects can get occluded by the walls
  • The dithering is based on a fullscreen blue-noise texture

Here's a twitter thread showing the development process of the game so far, including a bunch of video clips which were too big to embed in here.

Thanks for reading!

28 Likes

This looks incredible, love me some trackmania! Bird thread doesn’t load for me, but did you try other dither styles? I think this fullsvreen effect works really well, and it probably looks great on the screen!

Ope yeah i guess twitter requires an account to view a thread now (cooooool). Here's a mastodon thread instead, it's slightly shorter but has most of the videos.

When first starting to get the dithering to work, I did a white-noise pattern just to get something on the screen...it changed every frame, so it was basically nightmare mode.

Next, I tried traditional Bayer dithering, and that looked better, but it reminded me that I don't actually like the look of Bayer dithering very much (but, to each their own).

So then I tried a 16x16 blue-noise texture, and that was an improvement, but either my texture was bad or 16x16 is too small for this, because it had some pretty noticeable tiling artifacts...

...and that brings us to the current approach: a giant fullscreen blue-noise texture.

I'm only allowed to add one image per post since my account is new, so here's all four versions in one pic:

5 Likes

Just mentioned this on twitter, but don't miss the faster blit routine by @RPDev

1 Like

I'm definitely interested in fitting some more-standard blitting into the renderer, but it's tough because pretty much everything depends on per-pixel info! Walls/floors are based on raycasting, and those plus the sky are using the dither texture as per-pixel thresholds instead of it being a blittable sprite - and then for the 3D mesh parts like the car, I currently have to do per-pixel occlusion checks against the walls...when I wasn't doing that, I was able to do a similar "splat a pattern to the screen in 32-bit strips" trick (meshes use blittable patterns instead of the blue noise comparisons) and that was definitely faster, but the per-pixel occlusion is important for stuff like this finish-line banner:

Honestly though, I may need to figure out a way to incorporate some blitting to shave off more per-frame cost...at the moment it feels like I can either do that, or settle for locking to 30fps. 30 wouldn't be terrible, especially on a battery-powered handheld, and I do like the smoothness I get from all the per-pixel shenanigans...

Maybe I can store a "min/max z for each 32-pixel-wide column" (along with the z for each 1-pixel-wide column, like it does already), and then mesh scanline-strips could use that as a broadphase (if a scanline-strip's z range doesn't intersect the 32-wide column's z-range, either cull the whole strip or blit the whole strip, and only do full per-pixel occlusion checks if the ranges overlap).

1 Like

I've been working on replay recording and playback - aside from being able to watch your own best time for a course, this also means you can race against a recorded ghost, and to me that does some really nice things for the general feel of the game. It also lets you see the car from more viewing angles, which I can mark down as a win for my "draw the car as a full 3D mesh" decision!

2024-05-1322-09-14-ezgif.com-cut

Full video (~50 seconds):

Replay data is stored as input-keyframes using run-length encoding: each keyframe is a bitmask of the buttons you're holding (just a direct copy of the getButtonState() value, as one byte) and the number of frames that you held that particular combination of inputs (also one byte). With this setup, an average 30-second replay is only about 200 bytes! The downside is that any time the game's simulation changes, old replays becomes invalid (so I need to get that stuff Very Locked Down before releasing the game). It's unlikely that I'll do any networking stuff with this due to playdate limitations, but the small size still might be relevant if I can find a comfy way for people to make and share maps with each other (since each map will need at least one ghost-replay attached to it, and the replays are likely to be the heftiest part of a map file).

6 Likes

Improved the floor-sampling (sharper checkerboard with fewer samples, by doing some texel-space raycasting to figure out how far along the current scanline you'd have to go before hitting the next texel), and more interestingly, added a shadow to the player's car (draw an all-black version of the car mesh with a modified local-to-world projection and a mask-pattern).

2024-05-1422-08-37-ezgif.com-optimize

Longer clip (~45 seconds):

Pretty settled on targeting 30fps at this point so I can do more of these fun effects, maybe 40 if I can find more optimizations - currently it mostly stays at or above 40, but it dips a little below that in certain situations...so it feels worthwhile to settle for 30 and keep the extra breathing room just in case.

5 Likes

Getting into some main-menu stuff, so today I put together a (joyfully) overengineered title-card, featuring 3D text and a cloth sim. lol

2024-05-1701-29-14-ezgif.com-video-to-gif-converter

4 Likes

Very cool.

Trackminia was one of my early names for Daily Driver. And maybe Track Minier. I even did the chequered flag title screen (no longer used).

I also talked to the Trackmania creator at Nadeo, but he couldn't see how we could get the game through the Ubisoft corporate machine so it fizzled out. Maybe things would be different today, especially with your game being 3D?

At the time this was on the cards I talked with an American guy in Japan who did Trackmania style decals for cars. He was open to doing the graphics. Big ideas! First big game kind of scope. :joy:

Another codename I had was Crank Turismo. Fun times.

Eventually I went in my own direction.

2 Likes

lol, what a coincidence! Honestly "Trackminia" was the first title I thought of before even writing any code...but I haven't thought of anything I liked more, so I think it's feeling appropriate to lean into the demake vibe.

"Daily Driver" is a nice title, too - good find!

1 Like

You should totally lean into the demake!

All this happened for me back in 2020, and Nadeo in 2021, which feels like an eternity ago!

1 Like

I've been busy for a few days, so not too much progress here - but I've started getting into a track editor, which is a big missing piece in this thing. So far, all the tracks have been randomly generated - which I'd like to include as a "daily challenge" type of thing in the final game, but I'm almost certainly not gonna try to make a racing AI to generate ghosts for those procgen maps...so that means I end up needing pre-authored maps, with pre-recorded ghosts. "Hotseat multiplayer" where two people pass the Playdate back and forth to beat each other's increasingly-fast times seems like it could be fun, but there needs to be a single-player mode!

2024-05-2121-16-07-ezgif.com-video-to-gif-converter

Full video which shows this track getting created and then tested (40 seconds):

I may try to polish this editor enough to make it user-facing, but for now I'm just gonna be using it to make a set of built-in tracks (which will have some provided ghosts to compete against - presumably in true Trackmania style: Bronze/Silver/Gold/Author times to try to beat). I mentioned it briefly earlier in the thread, but a tough part of making a user-facing editor would be figuring out a comfy/easy way for people to share their tracks with each other...the Playdate doesn't have built-in networking support in the SDK, but you can at least use the serial port for USB transfers...but then it'd need some kind of companion app for your PC, and that's a whole other can of worms. Does anybody know if there's some undocumented way to make network requests? If I try to use libcurl or something, will my Playdate call the cops on me?

...ah whelp, now that I've written it out, maybe I'm gonna end up trying to add an AI racer, as well. lol

3 Likes

I doubt libcurl will do you any good, that probably still needs a socket API to sit on top of. Maybe you can write to hardware registers to take over the UART that talks to the ESP32…

1 Like

OOF - that's a clever idea but it sounds a little above my pay-grade, lol.

Another possible option I see...is that javascript has a SerialPort API now. It's only supported in chrome, edge, and opera so far, which is kind of a bummer, but having to use one of those browsers seems less shady than asking you to install an unsigned app on your desktop machine. It'd also make it easier to allow sharing a map as a URL (with the map/replay data as a param in the URL, instead of needing to store anything on a server), and clicking the link could take you to a page that says "To download this map, plug in your Playdate with the USB cable and start the Trackminia app" or something.

Thinking out loud: audio would have better browser support than serial – Blinkenrocket style, maybe with ggwave. More work to implement and get working reliably though, I suppose.

1 Like

Ahh that's a fun thought! You're right about audio having better browser support, though I think it'd require the user to have a sorta uncommon audio cable (which sends a signal from the computer's headphone jack to the Playdate's TRRS mic-input?), so I'm probably gonna stick with the option that only needs the standard cable which comes in the Playdate box!

In the ggwave demo videos it seems to work over the air, from speaker to microphone.

1 Like

OH wow lol, I had only checked the Blinkenrocket link and assumed the other was similar. That is extremely wacky and clever! The transfer rate is pretty unfortunate, though (8-16 bytes/second, according to the readme) - so I'm probably still favoring the USB approach for now (a track with a few replays attached could easily be 500+ bytes), though the idea of magic audio-transfer is still pretty tempting...

Warning, this post is entirely about some technical minutia (serialization in C) and has no fun animated gifs.

I'm getting into track-data handling now...conveniently, I had already written a serializer for C data for my first Playdate project (a music authoring tool that I made for my sister and her boyfriend - they bought me the device as a birthday gift, and they're music-people who also have a Playdate). The serializer is based on ideas from this wonderful write-up about how Media Molecule handles serialization (for games like Little Big Planet and Dreams).

The original version was writing human-readable files, but this time I have a bigger pressure to minify the files because I want to let people share tracks as URLs. I know that pico8-edu limits URL-based sharing to around 2,000 bytes, and I just sort of assume that zep picked that number for a good reason...lol. Maybe something about URL size limits in popular social media sites.

Anyway! To get smaller files, I added a flag to the serializer for ASCII-mode/binary-mode, and this game will use the binary mode, unless I'm debugging something and want to inspect the file more easily in ASCII mode. Some programmers who are reading this are now wringing their hands together, thinking about how I'm going to write a bug where the two versions of the format have mismatched behavior in some sneaky place...and to be honest, you're probably right, but also, yolo.

Here's a very simple track (only three track-pieces, and a short replay) in ASCII format:

The first line is the magic prefix TRAQUE (to make sure we're reading the expected type of file), then a byte "1" to say that we're in ASCII mode, and then a version number (this means that whenever I update the format, I can make things backwards-compatible). After that, we get the number of track-pieces, and then an entry for each piece. Finally, it stores a replay, which is a frame-count and then the raw bytes (converted to hex) of the replay keyframes (each keyframe is two bytes: first byte is "which buttons are you holding," second byte is "how many simulation-ticks did you hold this button-combo for"). This ASCII file is close to 350 bytes, which is concerningly large for such a barebones track. "Why don't you just zip it," you ask - doing so only takes it down to 300 bytes, so unfortunately it's not a huge win.

Next, here's the binary-version of a similar track:

I could show this in a hex editor, but it'd be equally meaningless, so I'm showing this screenshot because it looks funnier. ACK ACK! :alien:

You can see the same TRAQUE prefix as before, and then the rest is illegible because it's just binary data, but it contains the same type of info as the ASCII version (version number, track pieces, a replay). This version is only about 60 bytes - much better!

The usage code for the serializer is reasonably comfy - here's the function which serializes a track-piece. As described in the Media Molecule page, this same function is used for both reading and writing - the serializer stores a flag for "am I reading or writing right now," which helps to avoid a lot of annoying and bug-prone near-duplicate code.

SerializeBlockStart() and SerializeBlockEnd() write curly-braces into the file, and also change the indent-level (but only in ASCII mode - in binary mode, these functions do nothing). SerializeInt() takes a label (also only used in ASCII-mode), and a pointer to the int value that we're serializing. It's a pointer because the serializer might copy it or overwrite it, depending on whether it's currently reading or writing a file.

A more elaborate (but still pretty straightforward) example is serializing a replay:

Note that in this situation, we have to manually check if we're reading or writing - if we're reading, we need to reallocate replay->frames (a pointer to a list of keyframes) with a size based on the keyframe-count that we just read. SerializeBytes() can then serialize a pointer to a string of bytes with a given count.

The next thing to do is to actually load this data back into the game, which will be a fun milestone!

2 Likes

Doing some drudgery-type tasks on this lately, nothing flashy like new GFX - but I think I'm a good amount of the way through the doldrums now, and it's almost time to do some more fun stuff like making maps for a campaign-mode and getting some sounds implemented. Hooray!

A notable change is that the track files now store four separate replays of varying skill levels to compete against (based on the Trackmania setup: bronze, silver, gold, and the track's author), and you race against all of them at once:
2024-05-2822-17-16-ezgif.com-optimize

Seeing all these other cars is a pretty nice quality-boost to me, though it's also cemented the idea that I need to lock the game to 30fps. It's a little disappointing since I've been testing at 40-50fps for most of development, but to be honest the difference is only really noticeable to me in the desktop simulator...so if it still looks good on the device, and it means I don't have to compromise on the stuff that I've already added to the game, then I think it's a worthwhile tradeoff. Maybe a really smart programmer could get it back up to 40 or even 50, but I'm getting continuously more certain that this hypothetical programmer isn't me!

I've also started to dip my toes into the desktop companion-app, which I've decided should be a web page - it's using the relatively recent SerialPort API in javascript, which only works in chrome, edge, and opera...but that seems okay. Even if you're a mac user who also hates chrome, you still have another option with opera, at least. If you really, really don't want to use any of those browsers, I could also let you download the track file and upload it to your device manually (by putting it into data-disk mode). There's no track i/o yet, but I've got the site connecting to the device and receiving messages, so I'm feeling good about the idea being generally-possible.
Recording2024-05-31004118-ezgif.com-video-to-gif-converter

The real advantage of this setup is that it'll let you share a track you've made as a URL, which takes someone else to a web page which contains the track/replay data and also the instructions/mechanism for getting the track onto your device, all in one place. Aside from sharing a custom track with 4 replays/medals, it also seems like it'd be nice if you could share your best replay for the daily challenge (a procgen track seeded by the date), so you could basically "chess by mail" the daily challenge with a friend (or your social media followers, if I'm lucky).

4 Likes