Crankathlon Postmortem
Hi Y'all.
I've been meaning to do a quick(ish?) post-mortem post on this for the last week, whilst it's still relatively fresh in my mind, but Real Life Stuff got in the way. Still, I figured it's probably still worth going through a few of the implementation specifics on Super High School Sports Day Crankathlon, a few of the lessons learnt out of it, and some of the accidentally smart decisions that meant I could manage to get something that at least looks like it has a bunch of content done in a weekend.
The character sprites were one of the bigger upfront expenses. I had actually been hoping to reuse a sprite sheet from an unreleased desktop PC game I'd been fiddling around with, but it was a little overcomplicated and the use of colour wasn't particularly suitable for turning into monochrome, so I ended up just having to bite the bullet and drew something new. Still, it made for useful pose reference, which helped with cutting down the number of checks I needed to do with it. I pretty much just did the run cycles initially and any additional frames were done later as and when needed, and the character was initially draw bald (the hair I just cut and pasted onto each individual frame later once I'd drawn the character portraits for the Character Select screen and figured out what they looked like). The arguably needless extravagance here is that there are actually four run cycles per character depending on the current running speed, but there's not that significant a difference between them, and I feel like it makes a big difference to the look of the game.
My main implementation thought after that was to get everything for the initial Sprinting event working, as I figured that having everything to do with that working would give me the basis at least several other events, and a couple of the events spun out of that code could be reused for a couple of others. All the events are defined as Classes to make jumping between stuff a little easier in code - my main program loop just calls the Update and Draw functions I have defined in each of the event classes, and I can reassign the event variable to be an instance of whatever event you are doing.
The basic implementation of the Sprinting event is actually pretty straight-forward. Most of the display is comprised of sprites pinned in place using a setIgnoresDrawOffset call. The base is a background image which comprises the striped shading in the lower part of the screen around the track, the darker shadow from the wall, and the light shading of the sky. This stays static throughout.
The next layer on-top of that is the clouds - this is two 1200 pixel wide sprites with differing z-axis values. The first and second 600px of the images are identical so they loop cleanly. These just get shifted by a differing number of pixels per frame (I think it's 0.1px and 0.2px), and once the number of shifted pixels goes over 600, I remove 600px from it's position. Because the image is duplicated twice within the sprite, it results in what appears to be an infinitely scrolling series of clouds. It's mostly just there because I thought the background looked a little too plain otherwise. I'd already done something similar in an as-of-yet unreleased game, so it was a pretty quick implement as a result.
The layer on-top of that is the buildings. Nothing too exciting about that - it's a sprite which doesn't move.
The wall is a 440px sprite. It works similar to the clouds, but it moves at 1/3rd of the player characters current run speed, and because the pattern repeats on such a short cycle, the position only needs to be reset every 12px or so of movement, but having it there creates a nice parallax look.
The track is a series of 400px by 80px sprites. I just set up an image table that had three track pieces in it - the chunk with the start line, the standard track chunk, and the chunk with the finish line. I have a value configured within the Init block that specifies how many sprite chunks the course is, so it just creates a new sprite for each chunk at 400 pixel intervals with the knowledge that chunk 1 will always be a startline chunk, and the last chunk will always be a finish line chunk. This also means I could just calculate the finish point of the race as being at pixel ((number of chunks * 400) - 56), where 56 is the number of pixels from the right hand side of the End Line sprite to the start of the actual end line.
With all that setup in that fashion, I could just slap the competitor sprites on-top of that and call setDrawOffset to move the camera around when the player character wasn't within 200px of the start or end of the track - the setDrawOffset call will only effect the track pieces and the competitor sprites, as the other background elements have setIgnoresDrawOffset applied to keep them in place. This is kind of a lot of words describing what is actually a pretty simple set-up that still somehow ends up looking pretty effective. Was kind of happy that it was still maintaining 50fps with all that going on in Lua, honestly.
I didn't really put in any time investment into smart AI here - the opponents just speed up based on their acceleration stats up to 70% of their maximum speed.
Player mechanics, well, that one ended up being a bit of a headache. My initial basic idea is essentially how the game ended up working, though the implementation ended up being a little interesting. The basic idea was to give each character acceleration, deceleration, minimum speed and maximum speed stat. The player would automatically accelerate up to the minimum speed regardless of player input, but otherwise I'd apply the deceleration value to the players speed, then increment their speed by their acceleration rate altered by the fraction of a 360 degree crank spin performed. At the same time as doing that, I'd decrement the stamina based on the same degree of crank spin - this was basically to dissuade people from going too nuts on the crank and either rendering the screen unviewable, or damaging their console, but I also figured it'd probably add an interesting wrinkle to the game (and also gave me another way to vary the character stats). If you didn't turn the crank, I recover a small chunk of stamina per frame.
Anyway, I got all that working pretty quickly and started testing things in the simulator and tweaking the values until I got something that was feeling pretty good, then deployed it to the Playdate to test performance and... it played completely differently - the players speed shot up way quicker than expected, and the stamina gauge tanked immediately. On a couple of occasions I somehow even managed to get the character running backwards.
I ended up scratching my head over this one for a few hours, adding a whole bunch of debug reporting code, trying it on a different computer and under a different OS, and going for a two hour walk to clear my head, before figuring out that the issue was down to using the Playdate console as input in the simulator. It turns out that, in the simulator, I was getting duplicate crank positions reports every two or three frames - basically it'd report two consecutive frames the with the same crank position, then a larger change accounting for the current position and the missing one on the next frame. There wasn't any consistent pattern to when this would happen, but the result was that in the simulator you'd end up getting the stamina increment every few frames due to it thinking the crank was inactive, which wasn't happening on the actual console where the position reporting was consistent. Dropping the game to 30fps from the 50fps it was running so that it was polling for the crank position less frequently at didn't actually help with this either.
(Right hand here is from the simulator using the PD as a controller, second is direct from the console, both with the crank being turned constantly)
I basically ended up having to put in a frame counter that got initialised every time a crank move was picked up, and delayed the stamina recovery state until the frame counter hit three frames. If any movement in the crank was detected in the interim, I divided the crank movement by the number of frames since the last change in position then applied the speed/stamina changes frames number of times to make up for those frames with accidental duplicate reporting. It wasn't exactly a clean solution, but it does at least force near-enough identical behaviour between the simulator and the device, and I figured I could take a couple of frames hit on recovery for the sake of being able to tune values in the simulator rather than having to deploy to console constantly.
Anyway, once that was all sorted, that was the Sprint event more or less playable. I pivoted to getting the character selection done. Having the character selection at all probably feels like a needless extravagance for a Gamejam game, but I feel like theming (even daft theming) is important, and probably more significantly, I played an awful lot of Athlete Kings on that Saturn back in the day, so it's something I really wanted to include. The big timesink here was the character portraits (it took somewhere in the region of 60-90mins). The implementation on this was otherwise not all that complicated - I have a globals file that sources in all of the game assets at launch, as well as containing all of the character stats. All these are stored in Lua tables. I then have duplicates of all of those tables that I prime from the main tables once the character is selected, only ensuring that the data for the selected character is always in slot 4 of that duplicate table (So if you select character 2, it creates a table with the data in the order [1,3,4,2]). There's probably way smarter ways of doing this for folks with more of a Lua background (I'm kind of a COBOL programmer by trade, believe it or not, so I'm just picking this stuff up on the fly), but well, it just made it easier to pull what I needed for each event, knowing that I just had to reference slot 4 for the selected character. I'd rather have something I know works than something that's smart.
After that, it was just a case of adding some of the character specific stuff to the Sprinting event (things like the finish order profiles that appear when someone crosses the line in the event), and reporting back the finish order upon event conclusion, then throwing together the results screen. I had kind of wanted to have different profile images for each character depending on the position they come in each event (from "pleased" to "grumpy", basically), but, well, Gamejam time constraints. That's one for a theoretical DX edition.
At this point, though, I more or less had a full event loop working - you could select a character, play the event as that character, then get your results back. I was pretty happy that I'd implemented everything I'd wanted for the Sprinting event, so then I moved onto doing the Endurance Run event.
So I copied the Sprinting class then changed the value denoting the number of track chunks. Done.
Well, OK, not quite done, as the fixed 70% run speed that seemed fair for the Sprinting event rendered this one impossible to win due to the player having to worry about stamina. I ended up fudging stamina constraints onto the opponents as well - it was a pretty quick 10min solution so that their speed varied through the race, though it does mostly just end up showing off how unbalanced some of the character stats are. It's interesting enough for a Gamejam game, but I should probably revisit the behaviour there at some point.
Hurdles was done after that. Again, it's 90% the same code as Sprinting, but I add some hurdles sprites in for every track block other than the first and the last. Opponent behaviour is more or less the same as Sprinting, but I trigger a Jump state if they are within 40px of the hurdles, which does mean that opponents will never actually hit them. Occasional accidents are something to consider when doing potential updates, I guess. The actual jumping just involves applying an upward velocity based on the characters Jump state each frame, then decreasing that velocity by a fixed value. Eventually the velocity will invert and send the character in the opposite direction, and we just cancel the jump state once the character gets back to their starting Y point. I'm just doing a basic box overlap check on the player character versus the hurdle, though there's a bug that causes the character to come out of the electrocuted state earlier than I was intending which doesn't really effect the game too much but does mean there's two frames of animation that never play.
I actually did Long Jump next, but to get all the races out the way, Sack Race doesn't actually work that drastically different from the rest - the main difference is that I have a fixed forward movement speed, but I only apply it when the character is in the jump state. The jump code works more or less that same as in the long jump, so the big difference here was just coming up with a unique crank mechanic to trigger the jump state and throwing together some quick additional sprites for the event (though it was only three frames in this instance, so not a big issue).
The first chunk of the Long Jump code actually works more or less identically to Sprinting, only I have a different Track Image table that has four chunk types instead of three - start, standard track, end and sand pit. Instead of defining the length of the course as only the one value, I define it as the number of chunks in the run up portion, and the number of overall chunks. The run up portion is handled in the same way as Sprinting, with the jump point/foul line calculated in the same way as the end line for the races. I then just add repeating sandpit track pieces for the rest of the track length. I'm probably generating more than you'll ever actually see in terms of Sand Box chunks. The actual jump processing works more or less the same as in the Hurdle event, only I'm using some basic Trig to figure out the launch speed, and the velocity decrement is different. The calculation here could do with some work in order to make the results vary a bit more based on the angle, though.
The other difference here is that you have to do this event three times, and it takes your best score out of all your attempts as your result. I basically completely reinitialise the class instance each time you take a go at this - I keep a table of the results that I pass back into the instance, then terminate the event once this result table has three entries.
Triple Jump is basically the Long jump code with three jumps and a trip over state if you don't react quickly enough. Shot-put essentially works the same, but I based the launch velocity on a combination of the run up speed and the amount of strength you manage to apply and use a fixed angle to calculate the velocities. The ball behaves in the same way as a jump, otherwise, and I pin the display offset to the ball once it's thrown.
The second part of the Hammer Throw is pretty similar to the Shot-put event, only the initial launch mechanism is obviously different. This probably needs the most work, to be honest - aside from the lack of sound effects in this event, I think the launch timing is a little too unfair. There is a trade-off between the maximum rotation speed and the hammer flying off at a speed that results in a satisfying arc that I didn't quite get to the bottom of, given the time constraints. Also I'm using in the Rotation transformation provided in the Playdate SDK, but there's a weird jump when the angle is at 90/180/270. Given I'm nowhere near hitting memory limits, I should probably just pregenerate the rotated character images.
Pretty much all the code for the Ball Throw event is unique, though all the player character sprites are reused from other events, cleverly(?) hidden by cropping half the sprite off the bottom of the screen. I wanted to have a few vastly different looking events in there just to break things up, but given the time crunch, this and the hammer throw were the only ones I managed to get in there. I had wanted some kind of catching event in there as well, but that would have required a bunch of additional sprite work I didn't have time for at this point. I think this event is fun but I'm not super-happy with it - at some point I realised that the crank positioning I was originally using for picking up the ball and throwing only made sense in the Simulator where you are probably holding the console parallel to the floor, rather than playing on the console, and I don't particularly like the adjustment I made to try and make it make more sense. Also, the basket collision is kind of terrible. Once you figure out the correct angle and throw strength (69-70 degrees at full strength works, though there are other options) then it basically just becomes a case of how many times you can loop that for the course of the event. Originally, I'd planned to not show the throwing angle, making any degree of consistency difficult, but once I started futzing around with angles I just left that display I put in there for debug reasons there as a crutch. I'm considering either randomising the position of the basket or making it move during the event in a possible future update, but I'll need to have a think about that. There's a few other alternate possibilities I have in mind as well.
At this point I shoehorned in the ending screens (these took approximately the length of time it took the for the movie "Muppets Most Wanted" to play through on my second monitor to draw and code a screen around. Been on a weird Muppets binge recently). This left me about 30mins to get the event description screens working - I had hoped to have a more detailed description with diagrams to explain what you were supposed to be doing, but I basically just had to fall back onto throwing some text from a table onto the screen instead. That certainly needs work.
Sound effects were done using SFXR. Music is all midi tracks created with DanielX.net Paint Composer but with samples of instruments created using the Mega Drive/Genesis FM Synth mode of deflemask applied to the instrument tracks (same as Escapepion and Queen of (Mine) Carts - I do need to figure out some better drum options at some point, though).
The launch animation was generated using a Python script. It basically as loop that generates 20 images by laying five images, altering the Y position of each of those images per frame and slapping on the transparent boarders necessary for this to work properly. I then cropped the first image generated out of that for the games menu card, and the last image from it for the title screen. I'd done something similar when doing the Queen of (Mine) Carts boot animation, after having tried to do a similar effect by hand for Escapepion and hating the process, so that was actually pretty quick to throw together for something I think leaves a good impression.
I am hoping to maybe do an updated version of this to tidy up some of the issues, address some of the feedback you get from the UI, maybe add a couple of extra events and just generally do a bunch of rebalancing work on the character stats. That's probably not happening this side of New Year, though.
Files
Get Super High School Sports Day Crankathlon
Super High School Sports Day Crankathlon
A Sports Game for Playdate made for PlayJam 2
Comments
Log in with itch.io to leave a comment.
Great post. A lot of very interesting information here :D
Fun fact: Super High School Sports Day Crankathlon is currently the only game tagged as a sports game on the Playdate Community Wiki