Reit Games
  • News
  • Games
  • About
  • Cover image

    Toki - Post Mortem

    Toki is a game where you control time. Stop objects with your stasis gun and collect time capsules in this 2D platformer. I used Phaser and Spine2D to create a minimalistic, smooth looking game with vector graphics. The game won the decentralized challenge, featured in the GitHub blog post, and placed 33 overall out of 200 entries during the Gamedev.js gamejam in 2023.

    Toki

    Toki, which means "time" in Japanese, is the title of the game I made for the Gamedev.js gamejam in 2023. The theme was, surprise surprise, "Time". The first thing that came to mind when the theme was announced was the stasis ability from Zelda: Breath of the Wild.

    Stasis Mechanic

    When the theme was announced, I knew right away what I wanted to create. I also wanted to focus on only 1 mechanic in the game, keeping the scope as small as possible. The mechanic is fairly simple. You can stop time of objects by shooting a "stasis ray" at them. Shoot the same object again to unlock the stasis.

    blog post image

    Instead of creating lots of mechanics, I wanted to see how much I was able to create with the "stasis gun." I created some moving platforms, rotating platforms (with and without spikes,) and a hook you can grab onto. I also created some boxes, which doesn't serve a purpose in the game besides being a debug object in production. I used it to test the stasis gun. Once I was satisfied with the functionality of the stasis ability, it was a matter of adding the same behavior on other game objects.

    When we shoot a "stasis ray", we create a line, and find the closest object in the world/scene that intersects with that object.

    getClosestObject.ts

    export function getClosestBody(scene, startPos, endPos) {
      const line = new Phaser.Geom.Line(
        startPos.x, startPos.y, endPos.x, endPos.y
      ); // stasis ray
      const bodies = scene.matter.world
        .getAllBodies()
         // TODO ignore certain type of bodies
        .filter((b) => true) 
         // TODO sort based on proximity to startPos
        .sort((a, b) => 1);
    
      for (var i = 0; i < bodies.length; i++) {
        const body = bodies[i];
        const vertices = body.vertices;
    
        // loop through all edges of the body 
        // check if the line intersects with any edge
        for (var j = 0; j < vertices.length; j++) {
          const v1 = vertices[j];
          const v2 = vertices[(j + 1) % vertices.length];
          const edge = new Phaser.Geom.Line(v1.x, v1.y, v2.x, v2.y)
          const intersection = Phaser.Geom.Intersects.LineToLine(
            line, edge
          );
    
          // return the first body that intersects with the ray
          if (intersection) {
            return body;
          }
        }
      }
      return null;
    };

    When we have such a body, we emit an event, timeLock, with the object we found (e.g. emit(GameEvent.timeLock, { body: closestBody });)

    With this technique, we can decide to listen to the event and react to it, or ignore it entirely. I don't know if this pattern has a name, but it's similar to the composite pattern.

    Our infamous box can then listen for the timeLock event like this

    box.ts

    class Box {
      constructor() {
         on(GameEvent.timeLock, this.onTimeLock);
         // Remember to also remove the event listener 
         // when the object is removed from the game
      }
      
      // Check if the box was hit, and then do something
      onTimeLock({ body }) {
        if (body && body === this.body) {
          // Util function available for all objects in the game
          commonTimeLock(this.scene, this.body);
        }
      }
    }
      
    

    Other objects. such as ground, walls, the store, or doors, can ignore the timeLock - event completely, but still be a game object that is able to be hit by the ray (preventing the ray from passing through them.)

    Vector graphics and animations

    Most participants in game jams focus on pixel art. I think the main reason is aesthetics, but also a good choice to limit the scope, and don't go overboard with effects and animations. It's also fairly easy to find asset packs with pixel art, and they don't cost a fortune. It's a popular art form, and people love it.

    Then why did I chose vector art instead of pixel art for my game!? I used vector graphics as an experiment. I didn't want hard corners. I didn't want the look that most are going for. I wanted to try something different. I also like to create the artwork myself, and I enjoy working with vector art. I'm pretty bad at making pixel art, and it's a lot more difficult than what you would think. Putting a pixel in the "wrong" place on a 8x8 pixel canvas can make it or break it. Trying to make pixel art animate well is even more difficult, and you most likely have to create animations frame by frame. I don't have time to do that myself, and it looks horrible. Don't get me wrong, I like pixel art games, and people who are good at it are really good. I just ain't.

    I find vector art a lot more satisfying and fun to work with. It's also more forgiving when it comes to animation. I can use a key-frame animation tool, such as Spine2D, to create animations. It's almost effortless to make something that looks good enough, and I can even re-use the same animation for different graphics (skins!)

    But there's more! And the next part is kind of a secret, but I want to share it with you.

    Secret about how I created all stages

    All the stages in my game are made in Affinity Designer, a vector application similar to Adobe Illustrator. Since I use the application to create vector art to begin with, why not use it as my map editor too? Sound's crazy, doesn't it? Instead of shipping my own map editor, I use what I already have available and save a ton of time. It's like getting a map editor for free.

    Here's a screenshot from level 1 in my "map editor." On the left side, we have the level itself. On the right side, we have the layers panel. By using a naming convention on the layer panel, we're able to know what the object represents in the code too. For example, a spinning bar is represented as a red circle, with the name {spinningBar}. Add {safe} in the name to remove the spikes. The time capsules are green circles with the name {timeCapsule}.

    The circles represent what and where in the game we create the game objects.

    blog post image

    Taking a closer look at level1, I have highlighted the ground. It's a path, a series of Bezier curves, and this path can be represented exactly the same in Phaser by copying the path from the exported svg.

    blog post image

    The beauty of svg is that it can be read and parsed like HTML, making it possible to use the DOM API in the browser to query what we are interested in. Here's an example on how the level may look like

    level1.svg

    <svg>
        <rect id="level1" x="0" y="0" width="8268.22" height="2310.37" style="fill:#565a75;"/>
        <clipPath id="_clip1">
            <rect id="level11" serif:id="level1" x="0" y="0" width="8268.22" height="2310.37"/>
        </clipPath>
        <g clip-path="url(#_clip1)">
            <rect id="_-box-" serif:id="{box}" x="3241.55" y="967.793" width="79.317" height="79.317" style="fill:none;stroke:#0f0f1b;stroke-width:5px;"/>
            <rect id="_-box-1" serif:id="{box}" x="3241.55" y="882.542" width="79.317" height="79.317" style="fill:none;stroke:#0f0f1b;stroke-width:5px;"/>
            <rect id="_-box-2" serif:id="{box}" x="1793.48" y="804.776" width="79.317" height="79.317" style="fill:none;stroke:#0f0f1b;stroke-width:5px;"/>
            <rect id="_-box-3" serif:id="{box}" x="1793.48" y="719.525" width="79.317" height="79.317" style="fill:none;stroke:#0f0f1b;stroke-width:5px;"/>
            <path id="ground--collision-" serif:id="ground {collision}" d="M1614.2,-247.057C2187.82,329.174 1147.15,-160.627 982.123,159.038C922.895,273.763 1163.56,550.582 894.61,671.036C664.817,773.954 1168.69,1140.68 1385.54,1109.38C1592.64,1079.49 2037.75,751.354 2126.34,985.516C2171.86,1105.84 2117.84,1333.14 2139.69,1516.21C2165.35,1731.11 2295.54,1885.07 2813.99,1733.48C2950.46,1693.59 2786.29,1381.86 2884.41,1155.18C2988.72,914.221 4006.38,1332.3 4001.99,1155.18C3988.8,622.953 4499.47,994.198 4929.21,1008.36C5145.93,1015.5 5297.99,1020.32 5371.86,967.979C5511.17,869.277 5538.64,350.906 5046.63,160.34C4811.08,69.105 5522.23,-28.646 5477,-144.614C5380.17,-392.887 5603.09,-761.627 5917.18,-811.408C6659.48,-929.057 6997.14,-332.457 7204.99,-17.878C7500.79,429.809 8026.39,2403.58 7170.27,2694.37C5791.92,3162.53 288.832,3241.15 -1065.11,2791.12C-1950.53,2496.83 -1205.44,576.817 -953.37,-5.788C-707.894,-573.15 1327.02,-535.543 1614.2,-247.057Z" style="fill:#565a75;stroke:#000;stroke-width:15px;stroke-miterlimit:1.5;"/>
            <circle id="_-door---to-level0---goal-" serif:id="{door} {to-level0} {goal}" cx="5169.45" cy="934.764" r="30.661" style="fill:#f0e;"/>
            <circle id="_-timeCapsule-" serif:id="{timeCapsule}" cx="4384.23" cy="763.83" r="30.661" style="fill:#00f683;"/>
            <circle id="start" cx="1364.94" cy="930.425" r="30.661" style="fill:#f0e;"/>
            <circle id="_-spinningBar---safe-" serif:id="{spinningBar} {safe}" cx="2727.95" cy="1055.15" r="30.661" style="fill:#ff1717;"/>
            <circle id="_-spinningBar-" serif:id="{spinningBar}" cx="2647.72" cy="1366.46" r="30.661" style="fill:#ff1717;"/>
            <circle id="_-spinningBar-1" serif:id="{spinningBar}" cx="2395.96" cy="1646.64" r="30.661" style="fill:#ff1717;"/>
            <circle id="_-spinningBar---safe-1" serif:id="{spinningBar} {safe}" cx="2334.64" cy="919.848" r="30.661" style="fill:#ff1717;"/>
            <circle id="_-timeCapsule-1" serif:id="{timeCapsule}" cx="1461.77" cy="327.902" r="30.661" style="fill:#00f683;"/>
        </g>
    </svg>
    

    The "path"-element contains a path that we can parse directly in Phaser, and draw it exactly the same way as we did in Affinity Designer. We can also use "stroke" and "fill" from the "style"-attribute to use the same colors too. In the game, we can have functions that create the walls, ground, and game-objects by querying the svg-file. Here's an example on how we would create boxes from the svg-file:

    createBoxes.js

    function createBoxesFromSvg(scene, svgDoc) {
      const rectElements = svgDoc.querySelectorAll('rect');
      const boxes = [];
      for (let el of rectElements) {
        if (!el.getAttribute('serif:id')?.match('{box}')) {
          continue;
        }
        const pos = getPosFromSvgRect(el);
        const height = getHeightFromSvgRect(el);
        boxes.push(new Box(scene, { pos, height, width: height }));
      }
      return boxes;
    };

    I used an open source project, svg-to-phaser-path, to convert the path in the svg-file to a JSON-path, that we can feed into a function in Phaser.

    createPathFromSvg.js

    import svgToPhaserPath from 'svg-to-phaser-path';
    
    // Simple example to render a path in Phaser from an SVG-file
    // Takes a Scene and an svgElement as its arguments
    function createPath(scene, el) {  
      const jsonPath = svgToPhaserPath(el.getAttribute('d'));
      const path = new Phaser.Curves.Path(jsonPath);
      // rgbToHEx is a helper function to get the color in the right format
      const color: number = rgbTohex(el.style.stroke);
      const fill: number = rgbTohex(el.style.fill);
      const strokeWidth = 4;
    
      const graphics = scene.add.graphics();
      graphics.lineStyle(strokeWidth, color, 1);
      graphics.fillStyle(fill, 1);
      graphics.fillPoints(path.getPoints());
      path.draw(graphics);
    }

    With this technique, we're able to use Affinity Designer to create levels exactly how they would like in the game. It's flexible, fast, and easy to create many different levels.

    Animations with Spine 2D

    Making animations with Spine 2D is a breeze. They have good tutorials on their web page, and we can find runtimes for the most popular game engines (e.g. Unity and Godot.) Phaser, the game engine I used, also released a new version that supports the latest version of Spine2D, just in time for the game jam. It may take some time to get into the software itself, but once we understand the concept of bones, slots, attachments, and keyframes, creating animations becomes second to nature. Creating a convincing idle animation is probably the easiest to start with. We only need one bone, and we can either stretch it or move it up and down. That's it. I've done slightly more on my own idle animation.

    blog post image

    When it comes to skins, we can reuse the same animation by changing the images. With that, we save a lot of time! If you wanted to do the same with sprite-sheet animations, you'd have to copy each frame for each skin, ending up with a large sprite sheet for each skin.

    blog post image

    The power of Spine 2D becomes more and more evident the longer you play around with it. Not only that, but you learn new techniques that can be applied to game objects without using Spine 2D. I learned more about meshes, path restrictions, inverse kinematics and tweens. All of these concepts are useful to know about in game development, and we can apply the same techniques in games without using animation tools.

    Music and sound effects

    I'm a bit stubborn, and I like to do everything myself when I'm working on games, not only art, but music and sound effects as well. I always try to reduce time spent on the game, so it's a bit strange that I work on stuff that I could have found in asset packs instead. However, I either have to look online for several hours trying to find something I'm satisfied with, or pay money. I therefore try to make something myself instead. I made the music in Ableton Live, and I spent 1 day creating the music and sound effects. Some sound effects are drum samples with some additional effects applied to them, such as reverb, echo, and compression. I also recorded some funny noises with my mouth and added a ton of effects to the samples. The "dead" sound and the "door opened" sounds are made that way.

    Playing sounds in Phaser is as simple as loading the samples (I got best results with mp3 files,) and play them. One important thing to remember is to load multiple instances if you plan on playing the same sound multiple times in a row, otherwise you may abruptly stop a playing sound.

    Playing the same sound with different volume each time can also help add some variation. Here's an example

    soundUtils.js

    const shootSounds = []; // all of our sound variations
    let shootCounter = 0; // used to play different sounds
    
    function loadSounds(scene) {
      scene.load.audio('shoot', 'shoot.mp3', { instances: 3 });
    }
    
    function initSounds(scene) {
      shootSounds.push(scene.sound.add('shoot', { volume: 0.1 }));
      shootSounds.push(scene.sound.add('shoot', { volume: 0.15 }));
      shootSounds.push(scene.sound.add('shoot', { volume: 0.2 }));
    }
    
    function playShootSound() {
      // Select the next sound to play
      const shoot = shootSounds[++shootCounter % shootSounds.length];
      shoot?.play();
    }

    The challenges

    The game jam offered several challanges, and I opted in on all of them except one. The challenges I embraced including my placement were:

    I have to admit that the decentralized categories (web3, Arcadians, Overlord, and Interoperability) didn't have that many participants, but I'm still proud of the placements I got.

    Web3 challenge and Arcadian

    The challenge I put the most effort into winning was web3, and I'm thrilled with my first place. I focused on using the NEAR blockchain, letting players buy additional skins for the main character. The player is actually buying a non fungible token (NFT.) With this, players are buying skins as though they were actual clothes (in a physical store.) I find this really cool, because players can then use the NFT in other games (which supports it,) or players can re-sell it on an marketplace (e.g. Paras.id,) or even transfer or trade it with someone else.

    blog post image

    The other decentralized challenges were more about integrating lore and characters from their universe. I took a spin on the Arcadian challange by letting the protagonist be an Arcadian trapped in a smooth vector like world. The Arcadian is the Sage, and he only appears in dialogs in "pixel-art" style. Usually, in pixel art games, the character in the dialog features a high resolution version of the character speaking. I did the opposite.

    blog post image

    The Arcadian challenge helped me shape the lore of my game. The Sage needs to collect time capsules in each stage, and the capsules will later become the key to travel back to the Arcadian world.

    Interoperability and Overlord challenge

    The interoperability challenge was mostly a combination of all the decentralized challenges. If you used lore and art from different worlds, and tried to tie them together, you are automatically eligible to join the challenge. I did add an Overlord character to the game, the shop keeper, but it was a bit farfetched. I took one of the Overlord artworks, did some mesh transformations in Spine 2D, and made him the shop-keeper. I felt the art-work blended well in with the rest of the game.

    Final thoughts

    Toki is currently the game I've made which I'm the most proud of. I feel I was able to create something that is fun, looks unique, and has potential to become something more. Maybe I can create a complete release with monitization and more web3 integrations.

    Embrace the challenges

    If I made the Overlord and an actual Arcadian playable characters in the game, maybe I could have a shot at winning all decentralized challenges. Next time, I will try to focus on the most important part of the challenges in order to score more points.

    Look back at the feedback

    Looking back at all the feedback I got, I was able to better understand what to fix, and what to improve. The game felt rather slippery, which made it difficult to control the player. I was able to fix that in a post version, but it required a lot of tweaking with the physics I used. Phaser has two main physics engines: Matter.js, and Arcade Physics. I went with Matter.js, but I wonder if maybe Arcade Physics would have been a better choice for the platformer, I'm not really sure until I try.

    I also got a comment that the jump was not high enough, and after playing the game a few times, you forget about these minor details. It's important to have people test the game to discover these things. It will make the game play better when you take it into consideration.

    I keep getting feedback about my games being difficult, and Toki was not an exception. I actually did try my best to keep the game easy this time, because I've got comments in past game jams about the same thing. Maybe I need to let my son be a play tester next year, just to make sure the game isn't too difficult, at least for the first couple of levels.

    I learned a lot during this game jam, as I have done with other game jams in the past, and I cannot recommend participating in game jams enough. I was able to convince a co-worker to join the same game jam as well, and he was really glad I introduced it to him. I think you'll be surprised about how much you actually learn by participating in a game jam. Not only will you become a better programmer, but you'll become better at managing time as well. You may even look at games differently when you play them, and maybe appreciate them even more. With that said, go ahead and join the 2024 Gamedev.js gamejam right now!

    If you liked this blog post, or want me to elaborate on other topics, consider following me on Twitter, @ReitGames, and mention me in a Tweet. Happy coding!