Path to Glory — post mortem (JS13K 2023)

Rémi Vansteelandt
15 min readOct 9, 2023

--

JS13K 2023 is (sadly) over. 163 participants around the world spent a month creating amazing web games that could fit in a 13kb zip file. The competition has been run every year since 2012, and I’ve been participating since 2013. It is my yearly excuse to write unmaintainable and unscalable code, which is a nice change from my daytime job.

The theme for the competition was 13th century, hence we saw tons of knights, kings, and armies.

After being judged by all the other participants, my game Path to Glory ranked first in both mobile and desktop categories. In this article I would like to detail some of the ideas, challenges, and tricks that went into making this entry.

For the full details of the implementation, the source code is available on Github.

For a more condensed version, I also documented my progress day after day on this Twitter thread.

Unused logos

Concept

JS13K starts when the theme is revealed. I usually spend a day trying to come up with ideas, picture what my game could be, whether it is achievable within the constraints, and whether it is something I can pull off.

I usually have a rough idea of the type of game I want to make before the theme is announced. I wanted to make a Hotline Miami-style, 2D top down shooter with fake 3D using parallax. Needless to say I would have never been able to fit the theme — even though firearms were technically invented in the 10th century.

After a day of thinking, sketching, came the idea for Path to Glory: an Arkham-style beat them up, with highly inaccurate historical references.

Graphics would be simplistic and rectangle based to stay within the size constraints.

Rough sketches of the game’s visuals

If you’ve never played the Batman: Arkham series, think of them as beat them up games, in which combat only uses three buttons: attack, dodge (roll), and counter. When an enemy is about to attack, an indicator flashes on top of their head to warn you. You can then either choose to roll and avoid the hit, or press the counter button quickly to stun them and then counter attack. You can also choose to get hit but that’s obviously not as cool.

This would be perfect for a JS13K game where players have limited time to learn the controls.

Another aspect that I really wanted to keep from the Arkham games was how you can hit one enemy, then fly to the other side of the room to hit another. I did not want a realistic game where you’re always focusing on one target. There needed to be numerous enemies, and you had to be able to attack them all in sequence.

Challenges would be the controls, getting the enemy AI right, and having enough depth that it would be interesting and rewarding to play (instead of repetitive and boring).

Lastly, in case I had any room left, I wanted to add upgrades. In the end I didn’t have enough room for such a complex feature and it never made it into the game.

Illustration of how realistic the game would turn out to be

Story

Lately, I’ve been trying to incorporate more story into my games. In 2020 I told the story of a ninja trying to unveil malicious plans from a company called EVILCORP (who could have guessed), and in 2022 I told the story of a lab experiment escaping and murdering everyone in the way.

Even the simplest story can make a game much more interesting. You have an objective, and you know why you’re doing the actions you’re doing.

I also wanted to make sure players could finish the game, so there had to be a conclusion. Even if it takes you five minutes to complete, these five minutes will feel more rewarding than endless waves of enemies. Having a conclusion also gives players something to work towards, and a sense of progression.

Your mission: slay the emperor

With that in mind, naturally came the story of Path to Glory: you’d be a lone soldier, fighting waves of enemies, until the final boss, which would be a king/emperor.

In order to tell this story, I wanted to use a simple fade to black effect, with exposition text. Think about the Star Wars scroll, or all these historical movies that start with “Based on true events”, or give you context on the world they take place in.

In-game introduction

Such an effect would be very easy to achieve, and take very little room.

Animated foliage

The game would take place on some kind of battlefield. I couldn’t just have the player be on a static green grass field. There needed to be trees, bushes, paths, puddles, birds… and it all needed to be random and animated.

One way I could have achieved foliage would be like this:

class Tree {
constructor() {
this.trunkHeight = random(50, 100);
this.trunkWidth = random(2, 5);
// ...
}

render() {
ctx.fillStyle = trunkColor;
ctx.fillRect(0, 0, this.trunkWidth, this.trunkHeight);
// ...
}
}

This works well until it gets more complicated. For every property of the tree (number of leaf blocks, size of each leaf block, …), it gets more bloated.

Now once it’s animated, it gets even worse:

class Tree {
constructor() {
this.trunkHeight = random(50, 100);
this.trunkWidth = random(2, 5);
this.rotation = 0;
this.rotationDirection = 1;
this.rotationSpeed = random(Math.PI / 128, Math.PI / 64);
// ...
}

update(elapsed) {
this.rotation += this.rotationDirection * elapsed * this.rotationSpeed;
if (!isBetween(-Math.PI / 32, this.rotation, Math.PI / 32)) {
this.rotationDirection = this.rotation < 0 ? 1 : -1;
this.rotation = this.rotation < 0 ? -Math.PI / 32 : Math.PI / 32;
}
}

render() {
ctx.rotate(this.rotation);
ctx.fillStyle = trunkColor;
ctx.fillRect(0, 0, this.trunkWidth, this.trunkHeight);
// ...
}
}

Instead, I wanted to be able to combine all three functions into one.

In order to achieve this, I needed to be able to calculate animations based on the age of the tree, as well as generate random values repeatedly, so I created this super simple RNG class:

class RNG {
constructor() {
this.index = 0;
this.elements = Array.apply(null, Array(50)).map(() => random());
}

next(min = 0, max = 1) {
return this.elements[this.index++ % this.elements.length] * (max - min) + min;
}

reset() {
this.index = 0;
}
}

All it does is store an array of random elements, which can then be queried. Every time you query a random element, index is incremented by one, so the next time you query, you’ll get a different random value. As long as you call next() in the same order every time, you will get repeatable results.

My tree then becomes:

class Tree {
constructor() {
this.rng = new RNG();
this.age = 0;
}

update(elapsed) {
this.age += elapsed;
}

render() {
this.rng.reset();

const trunkHeight = this.rng.next(50, 100);
const trunkWidth = this.rng.next(2, 5);
const rotationSpeed = this.rng.next(Math.PI / 128, Math.PI / 64);

const rotation = Math.sin(this.age * Math.PI * 2 / rotationSpeed) * Math.PI / 32;

ctx.rotate(rotation);
ctx.fillStyle = trunkColor;
ctx.fillRect(0, 0, trunkWidth, trunkHeight);
// ...
}
}

With a small class like this, it doesn’t look much better, but as the number of properties and animation parameters increase, only the render() function grows. And it can also be optimized like this:

class Tree {
// ...
render() {
this.rng.reset();

const rotation = Math.sin(this.age * Math.PI * 2 / rotationSpeed) * Math.PI / 32;

ctx.rotate(Math.sin(this.age * Math.PI * 2 / this.rng.next(Math.PI / 128, Math.PI / 64)) * Math.PI / 32);
ctx.fillStyle = trunkColor;
ctx.fillRect(0, 0, this.rng.next(2, 5), this.rng.next(50, 100));
// ...
}
}

Shadows

The game now had tree, grass, and bushes. However it still felt flat. I wanted to solve this by adding shadows.

Shadows can be easy to achieve: render the exact same shapes in transparent black, with a transformation.

In order to achieve “convincing” shadow shapes, I opted to shear the canvas context. This would allow me to make the shadows point diagonally while preserving a realistic shape.

Source: https://en.wikipedia.org/wiki/Shear_mapping

Obviously I didn’t want to write rendering code for both entities and their shadows, so I opted to create a utility function, to which I would provide the render calls. The function would first shear the context, then render the object as sheared (and black), then render the object again without any alterations:

function renderWithShadow(ctx, renderObject) {
// Render the object shadow
ctx.save();
ctx.resolveColor = () => 'rgba(0,0,0,.2)';
ctx.scale(1, 0.5);
ctx.transform(1, 0, 0.5, 1, 0, 0); // shear the context
renderObject(ctx);
ctx.restore();

// Render the object
ctx.save();
renderObject(ctx);
ctx.restore();
};

function renderBushWithShadow() {
renderWithShadow(ctx, function() {
ctx.fillColor = ctx.resolveColor('green');
ctx.fillRect(0, 0, 20, 20);
});
}

In the end, shadows make a huge difference visually:

Left: game without shadows. Right: finished game

Path

A key element of Path to Glory would be said path. I didn’t want the player to stray away from it, so it had to be visible, but I also needed to be able to tell how far the player was from it, so I could teleport them back to it.

One way to achieve this would have been to store segments:

const path = [
new Segment(pt1, pt2),
new Segment(pt2, pt3),
];

However, this would have caused numerous issues:

  • it isn’t infinite and needs to be extended if the player goes beyond the last point
  • it requires a lot of code to render (which segments to render)
  • it requires a lot of code to determine how far the player is from it

Instead, I opted for my favorite function: Math.sin() .

The sine wave has many uses. It can be used to animate an oscillation (like how the trees and grass alternate back and forth), to calculate positions, or, in my case, to render wiggly lines.

The path players have to follow happens to be a wiggly line, so I could just render it as a sine curve:

Single sine wave

But it gets boring fast. It goes up a certain amount of pixels, then goes down the same amount of pixels.

But it gets less boring as soon as I sum two sine waves together:

Sum of two sine waves with different parameters

The implementation is extremely simple:

function pathCurve(x) {
const main = sin(x * Math.PI * 2 / 2000) * 200; // Main curve
const wiggle = sin(x * Math.PI * 2 / 1000) * 100; // Smaller curve, adds some noise
return main + wiggle;
}

function distanceFromPath(x, y) {
const yOnCurve = pathCurve(x);
return Math.abs(y - yOnCurve);
}

function teleportBackToPath(player) {
const yOnCurve = pathCurve(x);
player.y = yOnCurve;
}

function renderPath(fromX, toX) {
ctx.beginPath();
for (let x = fromX ; x < toX ; x += STEP_SIZE) {
ctx.lineTo(x, pathCurve(x));
}
ctx.stroke();
}

No arrays, (almost) no loops, and the path is inherently infinite.

By simply having a large STEP_SIZE, I can make the path blocky to match the rest of the visuals:

Left: large STEP_SIZE. Right: small STEP_SIZE

Character state

One of the first thing I started working on was the player itself. I needed to keep track of a ton of properties. Are they walking? Are they exhausted? Are they dodging? Are they charging an attack?

The first approach was to keep track of everything through properties:

class Player {
constructor() {
this.isChargingAttack = false;
this.attackChargeRatio = 0;
this.isDodging = false;
this.dodgingTimeLeft = 0;
this.stamina = 1;
this.isRecoveringStamina = false;
// ...
}

dodge() {
if (this.isDodging || this.isRecoveringStamina) {
return;
}

actuallyDodge()
}
}

Even with this simple example, it feels bloated. It doesn’t even have many of the properties I would need, and is going to be very hard to maintain. It’s going to have bugs.

Instead, I decided to go for a less optimized solution and implement a state machine. The idea is to have a state class for each state that the player can be in, and let it decide when to transition to other states.

Conceptualizing the state machine on paper

States could be:

  • Idle: player isn’t doing anything
  • Charging: player is charging an attack
  • Dodging: player is currently rolling
  • Exhausted: player is out of stamina and currently recovering

For instance, the idle state would look like this:

class IdleState extends State {
update() {
if (this.player.controls.attack) {
this.transitionTo(new AttackState());
}

if (this.player.controls.dodge) {
this.transitionTo(new DodgingState());
}

// ... more transitions ...
}
}

This allowed me to polish the states that the player could be in, and in general felt like a success.

Choosing targets

Like I mentioned in the concept, I wanted the player to be able to switch between targets. I should be able to click to attack an enemy that is to my left, then click to the right to attack an enemy that is behind me.

This actually turned out to be quite difficult, as players would often attack targets by mistake: they would click the enemy directly in front of them, but the character would dash towards another enemy.

The solution I opted for was to give each potential target a ranking:

  • enemies in the middle of the player’s cone of vision would have a higher rank
  • enemies closer to the player would also have a higher rank

For each attack I would calculate the score, and dash towards the target with the highest score:

strikability(victim, radiusX, radiusY, fov) {
if (victim === this || !radiusX || !radiusY) return 0;

const angleToVictim = angleBetween(this, victim);
const aimAngle = angleBetween(this, this.controls.aim);
const angleScore = 1 - abs(normalize(angleToVictim - aimAngle)) / (fov / 2);

const dX = abs(this.x - victim.x);
const adjustedDY = abs(this.y - victim.y) / (radiusY / radiusX);

const adjustedDistance = hypot(dX, adjustedDY);
const distanceScore = 1 - adjustedDistance / radiusX;

return distanceScore < 0 || angleScore < 0
? 0
: (distanceScore + pow(angleScore, 3)); // angleScore to the power of three to follow a cubic curve
}

In the end, the system worked but received a lot of tweaking throughout all the testing sessions.

Visual representation of each target’s strikability. Thicker yellow line = higher strikability.

AI

Another critical aspect of the game would be the AI. They had to be smart enough so that their behavior could be believable.

If enemies just walk towards you and spam their attack, it won’t feel interesting. I needed them to attack the player, use their shields, and retreat.

The first thing I did was to build simple blocks of AI:

class WalkTowardsPlayerAI extends AI {
update() {
controls.angle = angleToPlayer; // face the player
controls.force = 1; // walk
}
}

class WalkAwayFromPlayerAI extends AI {
update() {
controls.angle = angleToPlayer + Math.PI; // face away from the player
controls.force = 1; // walk
}
}

class AttackAI extends AI {
update() {
controls.attack = attackRatio < 1; // hold the attack until it's fully charged, then release
}
}

With a few of these simple AI blocks, I could combine them, for instance:

class ContinuouslyAttackPlayer extends AI {
constructor() {
this.attack = new AttackAI();
this.walkTowardsPlayer = new WalkTowardsPlayerAI();
}

update() {
this.attack.update();
this.walkTowardsPlayer.update();
}
}

However, there needed to be some kind of sequence: I want the enemy to walk towards the player, THEN attack, THEN wait for a second, THEN retreat, THEN repeat.

I achieved that by leveraging JavaScripts async/await syntax, which allowed me to write mostly intuitive code.

Each AI type would have a completion promise, that would only resolve when the behavior is complete. For instance:

class WalkTowardsPlayerAI extends AI {
waitForCompletion() {
return new Promise((resolve) => this.resolve = resolve);
}

update() {
controls.angle = angleToPlayer; // face the player
controls.force = 1; // walk

if (distance(player, enemy) < 50) {
this.resolve();
}
}
}

I was then able to write a more complex AI by leveraging those promises:

class CompleteAI extends AI {
update() {
for (const childAI of this.childrenAIs) {
childAI.update();
}
}

start() {
while (true) {
await this.startChildAI(new IdleAI(1)); // Idle for 1 second
await this.startChildAI(new WalkTowardsPlayerAI());

// Attack three times
for (let i = 0 ; i < 3 ; i++) {
await this.startChildAI(new AttackAI());
}

await this.startChildAI(new IdleAI(1)); // Wait a bit after striking
await this.startChildAI(new WalkAwayFromPlayerAI()); // Retreat

// Rinse and repeat!
}
}

async startChildAI(childAI) {
this.childrenAIs.add(childAI);
try {
childAI.waitForCompletion();
} finally {
this.childrenAIs.delete(childAI);
}
}
}

Balancing enemies

Now that I had good AI for one enemy, I needed AI for multiple enemies. With the AI described above, if there are five enemies on the screen, all five of them are going to constantly chase the player, attack, retreat, then attack again.

This does not make for a fun, nor balanced gameplay loop.

I decided to implement a token based aggression system, inspired by DOOM 2016. The idea is that each enemy will wait for its turn to attack. When they’re ready to attack, they request a token. Once they’re done attacking, they give their token back, allowing other enemies to attack.

Each enemy is more or less dangerous, therefore not all are equal. For instance, having 3 enemies with sticks and no armor attack is manageable, but having 3 enemies with axes, heavy armor and shield before much trickier.

In order to address that, each enemy type is given an aggression score:

function aggressionScore(enemy) {
if (enemy.hasAxe) return 3;
if (enemy.hasSword) return 2;
return 1; // score cannot be 0
}

Enemies can then follow the following algorithm:

while (true) {
// Do nothing until the token is granted
const token = await aggressionTracker.requestToken(agressionScore);

// Token is granted, attack
await this.startChildAI(new AttackAI());

// Done attacking, give the token back
await aggressionTracker.returnToken(token);
}

While the system is very obvious once you know it, it makes for much more balanced combat:

Enemies waiting for their turn to attack

Script

I wanted the game to have a script:

  • when the player is far from the path, fade to black, teleport the player to the path, fade back in
  • when the player reaches a certain location, spawn a wave of enemies. Once all the enemies in the wave are eliminated, repeat
  • when the player dies, fade to black, then restart the script

While the main script is relatively simple, it got more complicated once in the tutorial. I wanted the player to perform certain actions multiple times (hit this dummy 3 times, roll 3 times, walk around…).

Similar to the AI structure I decided to leverage JavaScript async/await syntax again, which would allow me to easily come up with a complex script.

For instance, this is how the attack section of the tutorial was achieved:

// Spawn the dummies
for (let r = 0 ; r < 1 ; r += 1 / 5) {
const enemy = scene.add(new DummyEnemy());
enemy.x = Math.cos(r * TWO_PI) * 200;
enemy.y = Math.sin(r * TWO_PI) * 200;
enemy.poof();
}

// Repeat the same thing three times
for (let i = 1; i <= 3 ; i++) {
showMessage('[LEFT CLICK] to strike a dummy ' + i + '/3');
const initial = totalAttackCount(); // record how many times the player attacked
await waitFor(() => totalAttackCount() > initial); // wait for the attack count to increase
// Repeat
}

showMessage('Yay!');

Sound/music

Being the absolute n00b that I am when it comes to audio, Frank Force’s ZzFX came to the rescue yet again. The tool allowed me to create sound effects fairly easily.

The trickier part was the song. With JS13K’s overall skill level increasing every year, I couldn’t get away without having a soundtrack.

I had used Sonant-X in the past, however I have absolutely no musical background, therefore coming up with my own composition felt like an impossible task.

Luckily, I was able to leverage ChatGPT to do the heavy lifting for me. It was relatively easy to get it to generate a few patterns, and then all I had to do was to recreate and rearrange them in sonant-x live.

Asking ChatGPT to generate musical patterns

Playtesting

This year I was able to get about 5 people to test my game before submitting. I would ask people to jump on a Discord call, share their screen, then play the game so I could take notes of everything they were doing.

I cannot emphasize enough how valuable those testing sessions were. The game would have looked similar, yet felt very different if it wasn’t for the people who gave me feedback.

Thank you to everyone who took the time to try the game while it was still a work in progress.

Some of the things that were changed following feedback sessions include:

  • being able to hit multiple enemies with one strike
  • game balance (damage, stamina loss, enemy damage…)
  • controls (issues with attacking the wrong target)
  • camera zoom
  • controls (reminders of the controls in game over screen)
  • game duration

Conclusion

This concludes my post mortem for 2023’s JS13K.

I would highly recommend participating in this competition to anyone. It has a lovely community, and it allows us, once a year, to get creative both technically and artistically. You don’t need to spend the entire month on your entry, nor be an experienced game developer.

Thank you to the whole JS13K community, especially Andrzej Mazur who runs the competition every year, and thanks again to everyone who tested my game and helped shape into what it ended up being.

If you’re interested in this type of content, you can follow me on Twitter, Mastodon, where I post updates to my upcoming game project, Earth’s Greatest Defender, or add me on LinkedIn.

--

--

Rémi Vansteelandt
Rémi Vansteelandt

Written by Rémi Vansteelandt

iOS/Android developer during the day, game development hobbyist at night.

No responses yet