13 Game

13 Game

#JS13k 2022

I plan to participate in JS13K 2022. A multiplayer fast-action top-down shooter game fitting 13kb (including NodeJS server) is born!

Check out game "13" if you haven't already.

It would be great if you could vote if you like it!

In addition, you could check out the improved 13k version, updated after submission.

Goals

Before the competition theme announcement, I have set a personal goal. I want to implement peer-to-peer real-time multiplayer gameplay. I had not made any multiplayer games before, but very interested in topic and read tons of articles about approach.

Death Theme

I've been thinking about something original in terms of gameplay for a few days. I wish the game would cover the topic better. In the end, I decided that the simpler the better and set focus on the idea of "Bullet-Hell". I realized that if I use Emoji for the faces of the characters, then it could look like a psychopathic killer's mask, so it reminded me of "The Purge" movie series.

Characters

I love platformer games, but like to make game in a different genre, I settled on the top-down shooter. An urban or suburban locations could be more interesting for tactical moves in multiplayer game, but it seemed that it would be too much for a 13kb limit and because of poor art skills. I decided that it would take place in a forest glade, which in principle can cause the right associations with thrillers, where killers hunt down and chasing heroes through the wild night. I was skeptical about the lighting, but by the end I had the will to implement "fog of war" effect, which improved the overall picture.

Inspired by Nuclear Throne for the shooting mechanics, and Hotline Miami for art-style (dirty high-res pixel-art without filtering), as well as the blood jets and smudges on the map.

hlmnt.jpg

I thought if I was lucky, I would give the damn name "13" or "XIII" and make 13 characters and 13 weapons. But time and size limits prevent me from finishing all other weapons.

Networking

I use WebRTC for fast data exchange without confirmation or ordering (like UDP protocol).

I did not use any web-socket for signaling between clients to instantiate peer connections. I decide to implement a simpler solution for data exchange via fetch and EventSource. The server is keeping current connections and managing simple message exchange from client to client. A client sends server a command with destination client identifier, the server sends this message to the recipient through a server-side event. It turned out to be the most compact in comparison with web-sockets solution.

Architecture is simple. I use the deterministic Lock-Step setup: the player waits for all players' input, and only then, the player could simulate and represent this tick state. Full determinism of game logic. To prevent short lags, I use a scheme with overlapping ticks for input. The player assigns input commands execution time a little ahead of time (delayed input), despite the feedback delay on the client of several frames, client smoothly accumulates buffer of inputs to prevent falling down into the cold state (when there is not enough input to simulate a tick).

I didn't do the complex handling of connectivity issues with a purpose. No handling for player connection drops, tracking the lag, kicking a flaky player. Just to save more bytes in the build size! I implemented simple timeouts, in case if some player can't connect in 5 seconds, connection drops. If we start connectivity issues for 5 seconds, we drop connection. In this project, I focused on a successful connection scenario only.

Determinism

All operations should be fully deterministic. Random seeds, game entities list order, position/velocity values should match exactly. I have extra checks in debug mode to verify state correctness across connected clients. At some moment, I have to create an HTML page with four <iframe> game clients to detect synchronization issues much faster.

Graphics

I use WebGL with instanced arrays rendering. I took js13k-2d as a basis and gradually mixed the code in and out. Removing everything related to discard-alpha and z-buffer. For more efficient compression, I have moved all GL constants to manually created const enum GL which will be inlined into the source code by transpiler. Then I add "color offset" feature for the hit effect. I use color-offset alpha component as a factor-parameter, which is smoothly change blend mode from normal to additive (it's a classic technique which pre-multiplied alpha allows).

Finally, I minify the shader with GLSLX.

Minified shader

At game startup, I pre-render all game sprites on a canvas: simple squares, circles, elements of a virtual joystick, emoji sprites. And then create a single atlas for all game objects. I pre-draw all emoji icons on a temporary canvas and then scale it down into an atlas cell. That achieves a greater pixelization effect of the emoji glyph. To reach the object's hard pixel boundaries, I cut semi-transparent values on alpha channel for nearly each sprite.

Initially, blood particles and shells lay on the map and accumulated continuously. That's slowed down the rendering.

At some point, I decide to turn the map background into the FBO and started drawing the accumulated particles on the surface for completed tics.

I thought of lighting through the grid originally, but then quickly realized that there would be a lot of calculation code. I have no possibility to add another shader for lighting. So I decide to draw light sources into a texture. I draw the light source image on the canvas using a gradient filled circle and upload it into the separated texture with linear filtering enabled. We render that texture for each light source with special blend mode, that subtracts alpha from the receiver.

gl.blendFunc(GL.ZERO, GL.ONE_MINUS_SRC_ALPHA);

I draw this final texture without filtering on top of everything else. The effect of the "fog of war". Hard squares are notable, which adds the feeling that we are actually calculating the mesh-grid on CPU, as a tile grid.

Fog of War

Audio

Initially, I took what was always in front of my eyes - SFXR for sound effects and SoundBox for music. Having quickly ported the code to TypeScript and add some sounds and the example music track, I realized that size is relatively big. And I also had to add a panoramic audio emulation: calculate sound volume for left-right channels and falloff depends on camera position.

And right away I threw my energy into optimizing the size of the code and the efficiency of sound data. Did a preprocessing of the parameters for SFXR to remove some calculations in the sound generation code, which gave me an excellent result in terms of code-size, but then I had to go back to the size problem again.

Anyway, music and sounds prevented me from adding new features, and I tried the library ZZFX and ZZFXM. Main idea is to use ZZFX generated sound as instrument samples, that removes some code duplication. For a long time, I tested with Depp track from the ZZFXM examples.

By the end of the project, when I had over-size about ~200 bytes, I plan to write a short powerful track myself, in the tracker, or at least CUT the patterns from the Depp music. As a result, I stuck in the tracker for hours, I could not realize any musical ideas. I understood that the music player and the track itself would take about 500 bytes. I didn't have enough space...

It was a panic, and I try another approach - procedural music generation using the same ZZFX samples. I took a minor scale and began with a bass playing cycle, and as always, help and inspiration came from my beloved wife, at some launch iteration she recognized the bass line as one of the Justice track, after that everything fell into place with a rhythmic pattern, then drum snare, kick, hats comes in with variability and delays, then add an Q-filter to the bass line and automated some parameters randomly. The result is less than 100 lines of TypeScript code!

I am very pleased how result sounds. I would add a separated second track for a splash screen, if I had more time.

Then, at the last moment, I added a simple Welcome message and game process commenter with speech-synthesis API. It also added lovely audio variety.

Build compression pipeline

1. Bundle TypeScript

Build TypeScript directly into a JavaScript bundle using esbuild. This includes syntax minification, which, for example, will change const to let (terser will not do it). Also, there we substitute all defines (as process.env.NODE_ENV), and drop out all console.* calls. Double check your tsconfig.json to be sure you use esnext, don't preserve const enum, don't include any tslib polyfill, don't use decorators or any TS code-gen features.

In addition, I minify index.html file with html-minifier

index.html

Size: 80933 bytes

2. Merge properties between non-overlapped types

TypeScript could set some strict rules about type's properties. We could consider that any object will implement only a concrete type and will not mix properties from multiple types. If we assign the same ordered names across types, terser will choose the same identifiers for them and will reduce the overall vocabulary used in the code. To my surprise, in 2022, there is still no tool that would minify code at the TypeScript level.

merge-props

For example, r_ and startX_ renamed to $0_ identifier. pointer_ and downEvent_ renamed to $5_ identifier.

Size: 78481 bytes

3. Use terser to compress and minify

Main option is --mangle-props. I use standard RegExp /._$/ to rename properties. Also enable module mode, top-level, ECMAScript version is 2020, mark pure functions, enable pure getters, unsafe arrow-functions, boolean as integer, etc...

Size: 32968 bytes

4. Web API hashing

We can't rename Web API methods and functions, but we can create alias with shorter names for them before using them. This is a common technique for js13k games. If understood right, you could implement it from scratch easily. All we need to do for this:

  • Get all the properties that we will use for specific classes
  • Calculate SEED, in which the abbreviated HASH values will not intersect within each type.
  • Generate a symbol-dictionary and sort them by identifier usage count in the minified code (after terser step).
  • We substitute the dictionary in the final build and rename all encountered usages (it's simple because another props already mangled by terser).

api hashing

Size: 31457 bytes

5. Crash JavaScript

I use Roadroller to crash JavaScript code to save around 1 kb in the final ZIP archive. I can't use unsafe option to pollute global scope, because it collides with my global HTML identifiers: c, b, l. Output size may vary because of roadroller parameters selection algorithm. So I use -O2 option to minimize differences in size from build to build.

Now the whole c.js content fits my screen!

crash

Size: 17794 bytes

6. Create a ZIP archive.

I use AdvanceCOMP advzip command to compress client and server with options --shrink-insane --iter=1000. I use short filenames c.js, s.js to save some bytes in zip entries table.

zip

Size: 13229 bytes

Conclusion

Keep in mind you will need to enable all compression levels to the max for the final build and hopefully analyze your code changes with exactly this setup. Code changes could decrease size before Roadroller, but could increase ZIP size.

Code style

I code with rules based on modern ES syntax and cool modern syntax features. Plain types without unnecessary nesting, global variables, plain functions.

  • No class/function/constructor keyword. Only interface types and arrow functions.
  • Array and Object lookups with arrow-functions instead of if/switch

image.png

  • {} and [] literals, clone by deconstruction syntax.

image.png

  • Optional chaining for properties, methods, indexed access. arr[i]?.[j].?(args)
  • Re-exports, for example, export const {sin, cos, ...} = Math;
  • Using of undefined array elements [,,,,,3]

Arrow-function arguments as variables

Use arrow-function optional arguments instead of declaring let in a local scope. I want to demostrate this technique on my map background pre-render function. Notice, I don't call closePath() already. Please check the code we want to compress with optional arguments:

export const generateMapBackground = (): void => {
    const map = createCanvas(BOUNDS_SIZE);
    const detailsColor = ["#080", "#572"];
    map.fillStyle = "#060";
    map.fillRect(0, 0, BOUNDS_SIZE, BOUNDS_SIZE);
    const sc = 4;
    map.scale(1, 1 / sc);
    for (let i = 0; i < BOUNDS_SIZE; ++i) {
        map.fillStyle = detailsColor[rand(2)];
        map.beginPath()
        map.arc(rand(BOUNDS_SIZE), rand(BOUNDS_SIZE) * sc, 1 + rand(16), 0, PI2);
        map.fill();
        map.fillRect(rand(BOUNDS_SIZE), rand(BOUNDS_SIZE) * sc, 1, 4 + rand(8));
    }
    uploadTexture(mapTexture, map.canvas);
}

()=>{let _=r_(1024),$=["#080","#572"];_.p="#060",_.de(0,0,1024,1024),
_.ee(1,1/4);for(let e=0;e<1024;++e)_.p=$[B(2)],_.yo(),_.arc(B(1024),
4*B(1024),1+B(16),0,v),_.fill(),_.de(B(1024),4*B(1024),1,4+B(8));
U(f_,_.c$)}

// BUILD: 80946
// MANGLE: 78494
// TERSER: 32999
// REHASH: 31488
// ROADROLL: 17783
// LZMA: 13217

I move for counter declaration and initialization to function argument, so we remove let word. Then I move canvas declaration and initialization to arguments as well. There are inlined 1024 literal everywhere in function, let's move it also as function argument to be sure we will use 1-char identifier. We don't need to do all 1024 iterations there. That's fine to do only 1023 iterations. So I move increment to for condition expression and I don't need to change < to <=. Terser won't inline our color constant array. So let's do it manually. In result, we save 31 bytes in minified code, and 10 extra bytes in the final ZIP archive.

export const generateMapBackground = (_i: number = 0, 
_size: number = BOUNDS_SIZE, 
_map = createCanvas(_size)): void => {
    _map.fillStyle = "#060";
    _map.fillRect(0, 0, _size, _size);
    const sc = 4;
    _map.scale(1, 1 / sc);
    for(; _i++ < _size;) {
        _map.fillStyle = ["#080", "#572"][rand(2)];
        _map.beginPath()
        _map.arc(rand(_size), rand(_size) * sc, 1 + rand(16), 0, PI2);
        _map.fill();
        _map.fillRect(rand(_size), rand(_size) * sc, 1, 4 + rand(8));
    }
    uploadTexture(mapTexture, _map.canvas);
}

(_=0,$=1024,e=r_($))=>{for(e.p="#060",e.de(0,0,$,$),e.ee(1,1/4);_++<$;)
e.p=["#080","#572"][B(2)],e.yo(),e.arc(B($),4*B($),1+B(16),0,v),e.fill(),
e.de(B($),4*B($),1,4+B(8));U(f_,e.c$)}

// BUILD: 80933 (-13)
// MANGLE: 78481 (-13)
// TERSER: 32968 (-31)
// REHASH: 31457 (-31)
// ROADROLL: 17773 (-10)
// LZMA: 13207 (-10)

Performance

To reduce the build size, I almost do not optimize the use of memory and allocations. I create and clone objects massively on every simulation tick. Although this should cause a heavy load on the garbage collector and frame execution time in general, but final game runs at 60 FPS even on modern mobile devices. In the future, I think this can be easily optimized by adding functions that do all this stuff in place object, I could place another stuff inside object pools, and so on.

What really needed to be optimized in the first place was to find only visible objects, then sort only visible objects to determine the draw-order.

Unfortunately, collision checking had to be optimized at the very beginning. Instead of naive implementation with a complete enumeration of the object archetype pairs, it was necessary to use spatial hashing. This technique reduces the number of pairs for checking as much as possible. Because we run the simulation one or more ticks per frame - it helped a lot. It also showed that the version with loose-grid is pretty same size comparing with naive variant. Again, unfortunately, the final build did not include this optimization.

loose-grid broad-phase

Do all compression tricks for not performance critical code first, because many of these techniques could lead to performance degradation while running each frame.

Things I would start from next time

  1. Would use js13k Game Server from the start because of the Server category. I just didn't read the rules at the very beginning and I did not think there was a Special rule for Multiplayer games. However, my server turned out to be so simple and tiny that I didn’t see the point of moving everything to the server sandbox 12 hours before submitting the final project build. Another thing I was worried about was that the game wouldn't work on js13kgames.com site or submit will not use a link to Heroku deployed nodes.
  2. I would immediately do a fixed time-step game loop (remove code for multiplying on delta-time). I would immediately start the game physics in integer coordinate-system (just better for networking packing and determinism normalization flow).
  3. It was necessary from the very beginning of the project to integrate the prompt "Use Emoji font?". When everything was ready for me, I was already struggling with the build size in a hurry, because of 3 extra bytes oversize. At such a moment, I don’t want to touch any tested game code, so I had to revise the initialization code and add additional state to remove one conditional branch.
  4. After submitting the project, I changed the collision checking system from naive object-to-object checking (n^2) to a loose-grid spatial hashing broad-phase, which turned out to be a very serious optimization for simulating additional predicted tics (when we don’t have enough events from all opponents). In the original project, unfortunately, running too far ahead can lead to low FPS.
  5. As you work on the project, it would be necessary to make drafts for the report.

Final words

The competition was excited and pretty challenged. I didn't expect I finish anything during the first 2 weeks. Realtime networking is the most hard feature for "13" game. I gain the invaluable experience during the "13" game development. See you all next year!

Bonus: Unfinished Feature List

  • Collect experience points and quick level-up skills
  • Ammo mechanics (finite ammo quantity)
  • Reload mechanics (reload time between clips)
  • Second weapon slot (to switch to active)
  • Rocket launcher, grenades, barrel explosions
  • Character footprints and more particles
  • Laser machine-gun
  • Scissors weapon! Like Boomerang with multiple reflections.
  • Generate tiled map topology for better tactical aspect of the gameplay
  • Game rooms. Public and private games. Join to random game room.
  • Gamepad support