Wave Racer | Dev Log

Short update:

I sunk some time over the weekend into refactoring my code. Along with some cruft and a too-long file, the refactor wasn't just for cleanliness:

  • Multiple CPU racers I had knowingly baked in a single "cpu" opponent in a lot of places, knowing I could abstract to multiple, later. Well, now was that time. So... now I can add multiple cpu racers and have them tied into things like collisions and the "AI" for racing the course. I was pleasantly surprised that I didn't see much of a CPU performance bump even when increasing the number of collidable objects. I was penciling in some optimization time there.
  • Ability to restart a race. This might sound simple but I had a lot of state and global vars strewn about. I organized my code into a handful of namespaces and was able to take better account of each piece of state such that I could reset the world. I ran into a good amount of memory and CPU creep on each reset for a while, but now things look stable.

I added in a menu item to 'restart race' so I can more easily play dev builds on the device (previously the race just started immediately on launch).

Seeing the 2 CPU racers mostly "just work" is great, starting to have fun racing and making passes at this point. Always more to tune...

multiple racers and a restart

I'll be circling back to my background writeup and experimenting with memory saving approaches there soon.

2 Likes

Infinitely scrollable, 360 degree, tiled background.

The end goal is for a player to drive a jetski in any direction and the background never runs out.

Here's an example animation just to set the stage, with other pieces of the game disabled so we can focus just on the background tiles:

ahh, bliss

Constraints

In theory, I don't need infinite scrolling for my game. I could find a way to keep a player within the confines of a course, and prevent travelling in all directions. I might even still do this.

However, I did not want to be limitted to a specific course size, I wanted to be able to flex the course size up and not worry about scaling the background.

Using a single, large background image with a confined course might work. However, this runs into a major issue in regards to image & memory size: since I am always rotating the view, I would need to have a 360 degree spritesheet for a large background image, which would almost certainly not fit into memory.

Another constraint is the number of tiles. I know upfront I can't just add 100's of background tile sprites/images, since the overhead in moving and rotating all of them would eat up my perf budget.

tl;dr try to use a reasonable number of tiles, to balance memory and performance.

Approach

Overview of the algorithm:

  • Move & rotate all background tiles in response to the user input (covered in my post above).
  • Detect any tiles that have moved out of view. Add these tiles to a list, let's call it freeTiles.
  • For every visible tile:
    • Check if each neighboring, visible, tile slot has a tile.
    • For any missing tile: move a tile from the free_tiles list to that slot.
    • If the free_tiles list is empty, create a new tile, and place it in that slot.

The algorithm works as long as there is 1 tile placed anywhere that is visible to start.

Showing an animation, starting from a single tile placement:

  • For that tile, check each of it's neighbors (north, east, south, west) to see if we need a tile placed.
  • In this case we need 3 tiles, so we create a tile and place in each slot.
  • On the next tick, we check the neighbors of all 4 tiles (including the 3 that were just placed), and perform the same operation, filling in any gaps.

fill animation

Now, let's look at the case where we are rotating and moving, and tiles are going out of view.

Let's assume we've reached this state where a tile has gone out of view.

nb This is for illustration only, this much of a gap would not be possible, as the missing tiles would have been detected immediately.

  • This tile has gone entirely out of view, so we add it to our freeTiles.
  • We run our algorithm for each tile, and find the missing slot.
  • Remove the tile from the freeTiles list, and move to the missing slot.

free tile move

And that's it!

No matter what happens, the background always fills itself in.

The re-use via the freeTiles list means that we only create enough tiles to cover the worst-case scenario - which is rotated such that just a tiny bit of corners of tiles are visible on the screen.

Perf tuning

Notice in the screenshots above that there is a + sign shaped check for the neighbors. The 4 offsets can be computed for the current rotation a single time per tick, then applied to each tile, making the neighbor computation quite cheap.

Currently, I landed on a tile size such that the algorithm maxes out at 13 tiles in the worst-case. This means there are 13 * 4 neighbor checks, and only 13 tiles are part of the rotating & moving logic (outlined in my previous big post). This number is just a place I landed considering tradeoffs, and can tune a bit. A larger tile size - I might only need, say, 9 tiles in the worst case, but I would need more memory for the image (or a smaller image, using more tiles, and more CPU for moving).

As mentioned previously, I am using a sprite sheet/image table of 360 degrees. For other game objects I can get away with 180 degrees, however, with the tiles being relatively large, the steps at 180 degrees are noticable, and you can see the shear at the edges of the tiles (tbh you can still see some if you look for it). This does mean that the actual images are large when loaded into memory - this is an area I hope to try a few things in soon and there are already some comments above!

Bonus

I wanted the water to feel more "real", so there's two more things I layered in here.

First, I can have the water move in a direction, for example, always move a little "East". This can be useful for giving a bit of a moving wave effect.

Second, I want the water to "shimmer" and not feel so static. To accomplish this, I actually have several image tables, each of which represent a frame in an animation. For example, right now I am using a 3 frame animation. I set a timer to increment the frame so it's not locked to the FPS. I can even start each tile at a different frame to give even more shimmer effect.

Showing the result of those two effects with zero movement on the player - to show both effects without the distraction of movement/rotation, but work when moving/rotating as well.

bg_move

And the final result:

riding

Future

I plan on making a distinct background for each level, but at this point that should "just" be on the graphics side and can plug them into this same algo.

I have some thoughts on how to keep positional information for tiles as well. For example, I want to have a rainy pond level, with rain pattering on the water and maybe some simple lilly pads or reeds. I'd want those lilly pads to not teleport as you race around. If I can reduce the memory footprint, I can run more frames for smoother animations as well.

5 Likes

Quick update on:

Finally getting into the flip options provided by the SDK.

My background images aren't horizontally or vertically symmetrical, so a mirrored single axis flip is not the same as the equivalent rotation, which means I can not leverage "flipX" or "flipY" flip options.

However, I can use flipXY, since that accurately represents a 180 degree rotation.

Rather than cutting down to 1/4 of the image table size, I can go down 1/2 - only compute the first 180 degree rotation and then use "flipXY" to get through 360 - which is still a very nice win!

I did briefly try using sprite:setRotation in increments of 90 degrees, as I saw a little chatter for that being optimized, but that quickly pegged CPU and I dropped a ton of frames.

Using setImage(image, flip) appears to be performant, I am seeing maybe a few extra % CPU doing a quick side by side, though that might be normal variation, but the drop is memory is a nice win:


(nb I have GC disabled)

3 Likes

Progress Update

tl;dr reworking handling and going deep into race balancing

First, a few smaller progress updates before going deeper into a topic:

  • Buoys now can be passed/failed. I haven't worked out the penalty for a buoy miss just yet but I've gone with a "simple" line segment collision with a line segment extending out from either side of the buoy. This is working well but I don't have a great visual representation of either the hit or miss of this just yet. Very likely a solid speed penalty for a miss, and max number of misses allowed.
  • Adding a finish line and basic lap times. I am using the same line segment collision detection on the finish line to reset the lap timer. While tuning (more below) I found I needed some real times to compare against, so built just enough to have lap times.

Deeper dive into handling and racer balancing

What I've spent the most time on recently is creating and tuning the different racing profiles, which lead into re-working some of the handling basics.

To set the stage, I'd like to have 3 riders with different handling profiles:

  • Profile 1 - Average across the board: fairly responsive handling, decent accelerationg and top speed.
  • Profile 2 - Very high acceleration and tight/responsive handling, trading off a lower top speed.
  • Profile 3 - High top speed, but loose/sluggish handling. Should be tough to navigate most courses.

I had previously (and, honestly, always) put some work/thought into the handling of the jetskis, outlined in my post above. However I found while pursuing these profiles, that I was still missing something.

I wound up re-working my "grip" physics, which is how much slippage there is compared to the desired direction. A zero grip would be a jetski sliding in one direction on ice, while spinning like a top - aka 100% slippage, the desired direction is never applied.

I previously had a version of this that "sort" of worked, but as I teased apart the profiles I realized it wasn't correct. Not going to go into details here but previously I was approximating the loss of grip by pushing along the X axis. At small numbers this felt okay but when I decreased grip further the result was way too much horizontal momentum applied, which doesn't make sense when the thrust is always from the back of the jetski. The new approach looks and feels more accurate and can easily be dialed up or down while still making sense (though at some point, very low grip doesn't make sense in the real world).

While tuning Profile 3 - my thought was I could just drop the (new & improved) grip until the handling felt sluggish enough. But, the result of the low-grip profile wasn't sluggish handling, but more felt like I was handling something on ice, instead of in water (though - ice level?). It just felt slippery and not "sluggish". It was hard to handle but not in a fun or realistic way. (It didn't feel like the handling of the original wave race).

Introducing "roll"

After lots of trial n error, (and replaying the original wave race 64), I realized I was missing an entire aspect of handling, something I am going to call - roll.

Explaining roll by way of example.

Let's say a jetski is holding a solid right hand turn, and the player quickly pulls the crank solidly to the left:

  • Tight roll: the jetski rolls rapidly to the left and almost immediately changes direction in response to the crank.
  • Loose roll: the jetski takes some time to start turning left. Even though the crank is pulled left, the jetski will still continue right for a moment, gradually straightening out, and finally getting to the desired left direction.

A loose roll introduces a lag in the controls, and requires some future planning by the player.

Roll is not a substitute for grip, in fact being able to tune both independently can yield some really interesting results. For profile 3 - low grip and a loose roll is starting to feel very right.

Visualizing these concepts across the 3 profiles using some lines. Explaining from the bottom to top:

  1. The bottom line is showing the exact rotation of the crank.
  2. The next line from the bottom is visualizing the amount of slippage (ie low grip). Having this line be vertical would be zero slippage.
  3. The next line (second from the top) is showing the desired direction to apply to the jetski per tick. This line is just a linear relationship to the raw crank value, they move at the same exact time. This is how all my handling was working, previously.
  4. The top line shows the final change in direction per tick applied to the jetski. When there is a loose roll, this line will follow/lag the line just below it.

Here's what I have for Profile 3:

Profile 3

Note how the top line follows & lags behind the line just below it. There are moments where this line stays on the opposite side of the crank, but then smoothly catches up to match the desired direction. This is the "loose roll" in action, and really creates that sluggish feeling. Being late to turn for a buoy is punished at this tuning.

In sharp contrast, here is Profile 2:

Profile 2

Notice how the lines all pretty much stay locked in. There is virtually no lag or roll, and there's only a little slippage. A little tougher to see but also a high acceleration, this profile pretty much follows the crank directly (but has a lower top speed).

Finally, Profile 1, a balance between the others:

Profile 1

Can see a little slippage and a little roll. It still feels like a jetski, takes some planning to handle, but isn't punishing.

For fun, showing an even more exaggerated/silly Profile 3, even less grip and looser roll, just to show off the concepts and what is possible, not meant to be a final playable profile:

silly

CPU/AI Handling

With a new grip, and a new "roll" concept, I then started to apply these to my CPU racers.

Selecting one of the racers for youself, you will then race against the other 2 profiles. The AI for the CPU racers do not use the exact same physics, though there are overlapping concepts. In short, the AI looks for the "next" buoy and applies some smoothing/maths for how to turn towards that buoy. This works great since the AI can get knocked around, either by you or by each other, and they'll always stay on course, and even compete for similar race lines. I will likely write more on this some day.

Balance

Now, the big challenge ahead for me is: balance.

I'd like playing with each of racing profiles to be somewhat competitive, with profile 3 ultimately rewarding high-skill, though with maybe a tight course to give an edge to other profiles. I'd also like the AI to be competitive with each other.

To measure the balance, I am running lots of practice laps and comparing times, and observing the CPU racers race with each other, tuning, and trying again. It's a very manual process thus far.

Getting the profiles to balance for the human player is tricky, but I feel like I am already sort-of close.

Having the CPU balanced is proving to be conceptually much harder. Having Profile 2 vs Profile 3 for example - the AI's handling being very reliable & perfect, profile 3 just rips. I am starting to introduce more counter-weighting specific to the CPU flavor of the profiles, but, it is time consuming. I have a handful of more ideas in this space, and will likely be focusing on it for a while, since it's a very, very core part of making this an actually playable game.

On the upside - the game is actually fun to play, even when not perfectly balanced! I've been very pleased with the AI - seeing one AI pass another then pass back is satisfying. So, hopefully I can find something that "good enough".

2 Likes

CPU Race Balancing

Primer on my CPU racing approach

Outlining the initial algorithm I am using for controlling how the CPU jet skis navigate the course:

  • Each CPU knows what the "next" buoy is.
  • For the next buoy, find an offset a short distance away on the "success" side of the buoy as the point to aim towards. The reason for this offset is because we want the CPU to go around the buoy and not directly through it.
  • Game-istic physics - on each tick:
    • Depending on: distance to the buoy, current speed and current heading make minor turning adjustments. If the CPU is far away and moving quickly, it is should turn less. If slowed and close to the buoy, it is allowed to turn more. This prevents the CPU from taking sharp angles to each buoy and kinda/sorta simulates a decent racing line with curves.
    • Each CPU is subject to the same physics around acceleration and loss of momentum, based on angle of the turn being made. This means the different racing profiles perform differently when used by the CPU. The caveat here is that handling/roll and grip for the profiles are less impactful (or ignored entirely) - there's a little bit of "cheating" here to allow the CPU to actually race the course, not get stuck, etc.

Aside from some of the squishy math for computing the turn angles, this approach felt pretty simple & reliable to me, and was able to get a modestly decent prototype CPUs to race against a couple months ago.

I initially played with Bézier curves but got a little hung up on the "tick" nature of the game loop - I would need to compute a point very close on the curve, re-find the angle to that point to turn towards. There's not much value in pre-computing a full curve, since the CPU might get bumped, so each tick there would have to be a full computation based on the current location relative to a buoy. I think there's some fruit on this tree but the computation was getting heavy for me, and I wasn't able to get a prototype quickly.

I also briefly toyed with the idea of pre-computed paths or rails through a course, but that would put a lot of pressure on needing to rotate complex paths, which would blow my perf budget. This might also lead to an unnatural feeling CPU. Hypothetically, if the CPU gets knocked off it's pre-computed rail, it would need to semi-awkwardly get back to the rail to continue forward, which might not feel like a natural movement (and then I'd also need to implement per-tick logic to get back on the rail).

A nice effect of this algorithm is that the CPU can start anywhere, get bumped around - either colliding with each other, a buoy, or the player - and it will correct itself and keep driving the course on the next tick. I've had probably 1000's of test laps and I haven't really seen the CPU go too erratic or lose the course, even when doing some spiteful collision testing. The paths it take feel realistic enough to me, but admit they aren't perfect.

Back to Balancing

I'd like each racing profile to be competitive, which includes when 2 CPU profiles are racing each other.

A fundamental issue I was facing was that profile 3 was OP/too good. The slightly-cheated algo for the CPU would allow profile 3 (high top end, poor handling) to be tough to beat by a player and would crush profile 2 (slower but good at turning). The issue is the CPU would take a "perfect" path through with the fastest jetski, which a human would struggle to do.

I knew I would have to introduce balances when the profile was controlled by the CPU. For example, I could limit the top end of the profile 3 (or increase for 2), or introduce more lost momentum when turning. Ie, find ways to make the CPU less perfect.

Balancing the race line

After much trial n error, I settled on a new idea that solved a handful of issues at once: balancing the racing line the CPU takes through the course.

The crux of the idea:

  • Each buoy has a "success" side and and "fail" side, from which I extend a line. These can be at any angle from the center of the buoy (useful to help with tight areas where fail/success lines might overlap).
  • Along the success line, I created 7 offset points at increasing distances from the center of the buoy.
  • Each CPU starts by following a race line by hitting a middle offset from the buoy.
  • At each buoy checkpoint (crossing the "success" line) - for each CPU, I determine if they are ahead or behind the player.
    • If the CPU is ahead of the player - increase the target buoy offset by one. ie, start following a path that travels through an offset that is further from the buoy.
    • If the CPU is behind the player - decrease the buoy offset by one. ie, start following a tighter race line.
  • To maintain the spirit of my racing profiles, I have a min/max offset for each profile, but otherwise the math/approach here is really simple, and I can lean on all my existing CPU turn mechanics.

Visualizing

Using a 90 deg rotated screen grab, with some viz debugging enabled.

For a refresher: the dark buoys should be passed on the right, white on the left. The solid line represents the success line, the dotted line represents the fail line.

Each of the 7 dots extending out from the success line are the positions of the 7 offsets.

image

The 7 offsets might not appear impactful, but, showing (an approximation!) of the worst path in blue:

image

And now overlaying the most optimal offset path in green:

image

If the CPU is ahead, I am pushing its race line out closer to the blue line, giving the player opportunity to catch up. If the CPU is behind, it will take a path closer to the green line to try to gain ground if the player slips up at all.

This was sort of an "ah ha" moment, I really like this approach for a few reasons:

  • I already had "physics" for turning the CPU: more turning == decreased speed and more distance to travel. By just adjusting the racing line the CPU takes, I can keep the existing CPU algorithm in tact while still having a big lever to control the overall time it takes the CPU to complete a lap.
  • It looks & feels like more natural racing than some straw man alternatives. For example, I spent a solid amount of time by playing with the core algorithm to try to balance: either more lost momentum when turning, or just increasing/decreasing the max speed. The end result didn't feel natural when you watched the CPU racing. There would be awkward slowdowns around turns for no reason, or having slower profiles overtake faster profiles on straights - things just didn't make sense. Having the CPU take slightly increasingly suboptimal paths feels natural to me - the hard part of racing IRL is keeping the line and speed, sometimes the CPU makes little mistakes that allow you to catch up if you can nail the correct line. It feels more like racing against humans.
  • The implementation wound up being very simple for how complex of an impact it has. I admit I was getting a little lost/frustrated in tweaking math, thresholds, hard-coding weights, trial n error, etc on other approaches. Since this whole project is just a hobby / not tied to revenue, sometimes it's just nice to feel good about a technical approach.
3 Likes

Simulating waves, proof of concept

I kept coming back to this idea in the back of my mind, I finally had a nugget of an idea on how this might be possible, maybe, and decided to give it a go.

The approach is fairly simple, it was just challenging to actually get working:

  1. Sample a handful of pixels of the background image underneath the jetski. If any pixels are black, then we have overlapped with a wave.
  2. Once we overlap with the wave - do some stuff: visually show that we hit a wave (bounce up a bit, etc) and jostle the jetski (push in a direction or simulate airtime with low grip, etc).

The first step is, by far, the hardest.

My background image tiles are constantly being both rotated and also moved in a rotation around the player. I finally figured out a way to measure the correct offset and get to the pixel information such that I can call image:sample(x, y) underneath the jetski, and have it work in all manner of movement/rotations, etc, and (importantly) in a performant manner.

I take this one step further and measure a 5 pixel line across the center of the jetski, just to have more surface area so that I don't pass through dithered pixels undetected (still not 100% perfect but working OK for now).

I've only spent a very brief amount of time on the second part - responding to the wave. For now I am crudely bumping the scale of the player to simulate a bounce and dropping the grip so it feels a little like getting thrown by the wave, just to start getting some feedback cycles in.

Finally, a GIF:

waves

This is still proof of concept across the board, but very cool to seeing the full idea come together end to end.

Some issues/ideas, seeing this live for the first time:

  • Adding lots of visual noise to the waves can be messy/distracting, but, the sparse outlines of waves in this specific graphic results in a sort of choppy experience. When comparing to other GIFs in my thread you'll notice I actually increased the density of the wave line dithering to try to have the pixels detect slip by less.
  • I could sample pixels in a small grid (maybe 3x3) and gather directional info based on how the grid is full up, to better simulate pushing over a wave. But, again, with this being tied directly to the background image tuned for visuals - the pixels are too stark/rigid to make this feel natural. I'd want basically a full gradient throughout (much like perlin noise).

My next idea:

Generate an image that is a gradient-only version of the visual waves. This image will never be drawn to the screen, but it will be rotated the same as the background. I'll then sample this gradient image (in a grid) in order to measure the pixels that impact the jetski. By having this image not be visible, I can fill it out with lots of pixels as input to the wave.

Bonus gif of my attempt at using perlin noise to generate a water effect (all pre-baked images made in GIMP)

perlin_water

I could see using this approach to create the pairing: both a visual representation of water and the wave gradient image, just by adjusting the dithering threshold of the visual layer.

3 Likes