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.
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.
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.
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.
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
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.
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
).
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!
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.
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. Onlyinterface
types and arrow functions. Array
andObject
lookups with arrow-functions instead ofif
/switch
{}
and[]
literals, clone by deconstruction syntax.
- 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.
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
- 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.
- 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).
- 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.
- 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.
- 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