This is a post-mortem writeup for Colors of Your World.
This was my 20th (!) time competing in Ludum Dare, and my 12th time working together with xellaya. The theme was "Sacrifices must be made", and we ended up making a platformer called "Colors of Your World", where you sacrifice colors from your world to gain abilities to progress through the game. It's a bit reminiscent of Celeste in its gameplay.
Overall: 27rd (4.190 average from 81 ratings) Fun: 30th (4.127 average from 81 ratings) Innovation: 573rd (3.076 average from 81 ratings) Theme: 762nd (3.089 average from 81 ratings) Graphics: 81th (4.367 average from 81 ratings) Audio: 6th (4.423 average from 80 ratings) Mood: 39th (4.196 average from 81 ratings) Average Score: 3.92
At this point I'm not really sure what to make of scores or rankings anymore, as they're a bit of a crapshoot and we've been trending pretty consistently. Our innovation and theme scores were not particularly high this time, as our gameplay was not really novel, and our incorporation of the theme was hit-or-miss. Some people really appreciated the take we had on the theme, while others were disappointed that the theme tied into the narrative but not the gameplay mechanics themselves (which we opted not to do), so it was more of a personal perference thing. Regardless, these scores are really nothing to complain about.
I don't want to launch into an extended treatise on platformer level design, but I do want to point out a few things:
Matt Thorson's GDC talk on Level Design in Celeste is an amazing runthrough of some really key aspects of good design, particularly the idea that each screen (and each "world" or "level") should be focused around a certain "story". This was a guiding principle for me as I designed the screens and worlds for Colors of Your World.
Screen 3-2, for instance, is trying to teach the player the different ways to combine their new wall climbing ability with their previously acquired dash ability:
The gap in the very beginning of this screen is there to remind the player to think about dashing. (The previous screen was focused completely on the wall climbing, so dashing might not be in the forefront of their mind.)
Next we have a dash jump into a wall climb, followed by an opportunity to wall jump:
And then the final part of the screen is an opportunity for the player to put it all together and learn the practice of dash jumping after climbing up a wall:
You'll also notice that each of the three worlds in the game have a pretty formulaic structure that goes something like this:
First, a couple of screens that teach you how to use a newly gained ability:
Second, a couple of screens introducing and then featuring a new obstacle:
Third, a couple of screens introducing and then featuring a different obstacle:
And finally, a couple of screens combining the two types of obstacles in an interesting and challenging way:
This is essentially a take on "4 step level design", and not only did it give each world a nice sense of progression, but it also made it much easier for me to figure out what kinds of screens I needed to come up with.
2D platforming has been around for a long, long time, yet I still routinely see very mediocre platforming physics, especially among games made using Unity. In Colors of Your World I think we were able to create an engine that is both responsive and fun to move around in, and most of that is due to some strong guiding principles for programming platformer physics.
Using a variable timestep leads to variations and nonreproducibility, especially when applying gravity and jump height on a per-frame basis (numerical integration/Euler approximation instead of calculating the exact integral). That means each time you jump, your character could end up at a different height depending on the exact frame update durations! Fortunately, Unity makes this easy to do -- just put all of your physics code in FixedUpdate() functions and they'll be called using fixed timesteps.
There is one tricky thing to keep in mind when using this approach: Input gets polled in Update() and not FixedUpdate() timing. For checking whether keys are held down, this is fine, but it makes handling keypress events (GetButtonDown) tricky -- do NOT check for GetButtonDown() in FixedUpdate() as you may lose inputs depending on framerate. Instead, you need to poll for input in Update() and then handle the results of that input in FixedUpdate().
For more of a general intro to using fixed timesteps, you can read this very classic article by Gaffer On Games.
I've seen too many games that try to implement platformer physics by slapping on rigidbodies and forces and hoping that generic 2D physics simulation will do the trick. This simply doesn't work -- most platforming gameplay simply doesn't arise out of a normal physics sim, so you'll want to handle movement and velocity manually. To do this you will need to either write or find a framework for basic platforming physics. A good framework should ideally be able to do the following things:
This can be tricky code to write and I absolutely wouldn't recommend trying to do it in the middle of a game jam as there are some edge cases and weird things to solve that might not be immediately obvious without really sitting down with it. If you're ever planning on making any sort of 2D platformer though, this is good code to work through and will serve you well.
For more advanced coders, try implementing some various features on top of this such as one-way platforms, custom collision callbacks, slopes, ladders, etc. The sky is the limit, but also be careful not to overdesign too many detailed features, since each game will have different requirements.
Some games have fixed-height jumps, but for Colors of Your World we wanted variable-height jumps that are controllable by the player. Typically this is done by altering the downwards acceleration force that is applied to the player based on whether the jump button is held down or not (the initial jump velocity is always the same). The pseudocode for that would look something like this:
// Called every frame somewhere from a FixedUpdate() block, whenever gravity should be applied void ApplyGravity() { // Normal gravity. float gravity = kNormalGravityAcceleration; // Apply less gravity if we are holding jump and still moving upwards. if (isJumping and userIsPressingJump) { gravity = kLowerGravityAcceleration; } // Apply gravity acceleration to our velocity. _myYVelocity += gravity * Time.deltaTime; }
Of course, this will take some tweaking before it "feels" right. Of course, you could using some serialized curves for even more flexibility.
This isn't important for every game, but for platformers where you are going to be falling a considerable distance it can often be important to cap the player's maximum vertical velocity so they don't start dropping like a bullet if they fall more than a couple tiles. This is as simple as it sounds, just put a hard cap on the player's downward vertical velocity when applying gravity!
There are a whole bunch of extra little things that you can put in to make things feel a bit better. A lot of these seem more or less like edge cases but you'd be surprised at how many people rely on them without even realizing it.
I won't delve too deeply into the actual code we used here, but I should note that I used a simple finite state machine architecture for handling the different player states. This is a pretty common approach!
Using a single finite state machine is not always the perfect fit for structuring your player-character game code. For example, you might have a game where player movement (running, jumping, climbing) might be handled separate from player actions (shooting, reloading, using a special move). If you're going down the line of making "StandingIdleState", "StandingWhileShootingState", "ClimbingIdleState", "ClimbingWhileShootingState", ... you might want to rethink your approach. Remember to think about how you will hook into the animation system as you plan your architecture!
At the end of the day a lot of this stuff comes with experience, so get down into the trenches and start coding! =D