THEY COME AT DUSK πΊ
A survival horror game for JS1k 2018, where you run away from dead miners as you hear your heart beat. Turn around using the arrow keys until all you hear is beeeeeeeep.
The version above was done within the time and space constraints of JS1k. As I didn't check the timezone for the official deadline, I missed 1 hour and submitted without sound. Using this hour I managed to bring back the sound of the heart beat which adds a lot to the game.
Inspiration
The theme of JS1k 2018 was the "Volatile coin miners". It felt ephemeral, almost futile... like trying to escape in a zombie movie. I wanted to make a tiny survival horror game since a long time and the theme worked well. Stills from old horror movies motivated the used of big and curvy typography.
Technique
If you follow my work, you might remember two projects:
- WOLF1K, my first JS1k entry and attempt to make a first person game. This was a difficult nut to crack and I failed to add any gameplay due to the amount of code needed to store and render sprites.
- ABRACADABRA, my JS1k typing game using emojis, web audio and speech synthesis.
Writing a new first person renderer using emojis as sprites, gave enough room to add fog, audio and some miminalist gameplay.
Emoji sprites
The game counts 1024 elements: fire, ghosts, cars, houses, ... and layers of fog. Even with culling it is far too many visible sprites to render using ctx.fillText(emoji, x, y);
straight in the render loop.
Another issue is that some operating systems fallback to monochrome unicode characters at bigger font sizes.
The work around is to create a list of sprites by first drawing the emojis, and storing them as an image using canvas.toDataURL()
or as a CanvasPattern using ctx.createPattern(canvas, repeat)
. Either approaches are fine. Keep in mind that CanvasPatterns start at the origin of the coordinate system of the Canvas and require to use ctx.setTransform(...)
to update the coordinate system of the canvas which needs to be reset to draw the layers of fog, the score and the ground color
Sound and rendering loop
The sound of the heart beat is done in a ScriptProcessorNode of 1024 samples. Abusing its onaudioprocess
event as a render loop for the audio and visuals.
Beside, since we have same number of audio samples as elements, we use the same for (n = i = 0; i < 1024; i++) { / ... / }
loop to update the audio buffer but also to update and draw the elements.
The heart beat sound itself is a sawtooth oscillator whose frequency is inversely proportional to the health of the player + .001
. This makes the frequency increase exponential as the health of the player decreases. The + .001
prevents a division by 0 and gives a high pitch sound when the player has no more health.
Commented Source code
Here is the commented source code. Regpack compressed it down to 1024 bytes.
// Make b an Array to store our elements: entities ( fire, ghosts, cars, houses ) and layers of fog
b = [];
// We have 1024 entities. 1024 is also the size of our Audio buffer
for (n = i = 0; i < 1024; i++) {
if (i < 10) {
// First render the emoji of an entity, load it into an image to use later as sprite
// We must do this for performance reasons and to make sure the emojis are rendered in color
a.width = 256;
c.font = '900 96px cursive';
c.fillText([...'π₯π»πΉπΊπππππ‘', 'ποΈ'][i], a[i] = 0, 112);
c[i] = new Image;
c[i].src = a.toDataURL()
}
// The first 64 are fog layers and have a sprite index of -1
// After that we use a random value from 0 to 9, with an **4 exponential distribution to have fire & enemies than cars and houses
s = i < 64 ? -1 : (Math.random() ** 4) * 10 | 0;
// Add the element to b
b[i] = {
s, // the sprite index
i, // the distance from the camera/player
x: Math.random() * 64, // random position in x
y: Math.random() * 64, // and y
h: s < 4 ? 1 : 0, // give 1 health point to the first 4 sprites
r: s + 2 - Math.random() - s % 4 // size of the element on screen
}
}
// key inputs are stored in a and use the b.h, the health of the player to control the speed
onkeydown = (e, f) => a[e.which & 2] = b.h;
onkeyup = (e, f) => a[e.which & 2] = 0;
// full size canvas
a.style.width = a.style.height = '100%';
// set up the audio context and audioprocess loop with a 1024 sample buffer
// the x, y, a ( angle), and health of the player
// M the approximate audio time
// and finally S the score aka the distance walked
e = new AudioContext;
n = e.createScriptProcessor(1024, b.x = b.y = b.a = M = S = 0, b.h = 1);
n.connect(e.destination);
n.onaudioprocess = (e, f) => {
// sort the elements by distance to the camera/player
b.sort((e, f) => f.i - e.i);
// get the audio buffer
P = e.outputBuffer.getChannelData(0);
// adjust the direction angle of the player
b.a += a[2] - a[0];
// draw the ground color
c.fillStyle = '#333';
c.fillRect(0, 0, 256, 128);
// set the color of the layers of fog based on the health of the player
c.fillStyle = 'rgba(0,0,64,' + (1.1 - b.h) + ')';
// Loop through each element & sample in the audio buffer
for (n = i = 0; i < 1024; i++) {
// increase M, the audio time
// create a heart beat sound using a sawtooth oscillator ( the % 1 )
// at 1 / 8 volume
// and whose frequency is based on the health of the player
P[i] = (M += 1 / 256 / 64) / (b.h + .001) % 1 / 8;
// get the current element
e = b[i];
if (e.s < 0)
// draw a layer of fog if it has a spriteIndex < 0
c.fillRect(0, 0, 256, 64 + 128 / e.i);
else {
// else it is an element
// Compute the angle relative to the player
R = (
Math.atan2(
// use some modulo magic to tile the city and ghosts to infinity
v = (e.y - b.y + 96) % 64 - 32,
u = (e.x - b.x + 96) % 64 - 32
) * 128 / Math.PI - b.a % 256 + 448) % 256;
// Compute the distance to the camera/player if the element is facing us
e.i = R & 128 ? Math.hypot(u, v) : 64;
// dead miners are special
if (e.s && e.s < 4)
// move towards of the player if it is near, else wander around
e.x -= Math.sin(e.i < M / 8 ? u / 16 : Math.sin(M * e.r + 8) / 64),
e.y -= Math.sin(e.i < M / 8 ? v / 16 : Math.sin(M * e.r) / 64);
// Draw the element if isn't too far
if (128 / e.i > 4) {
// compute the size and horizontal position
s = 128 / e.i * e.r / 2;
R = R * 4 - 768;
// Keep track of the nearest element right in front of the camera/player
if (e.i < 1 && R * R * 4 < s * s)
n = i;
// Draw the correct sprite in place
c.drawImage(c[e.s], R - s / 2 + 128, 64 + 128 / e.i, s * 2, -s)
}
}
}
// Switch to some 70s Movie title style yellow
c.fillStyle = '#ff6';
// Store a fraction of the player's health
z = b.h / 16;
// Check the nearest element
if (n) {
e = b[n];
// factor in the health of the nearest element
z *= e.h;
// update the health of the player accordingly
b.h = Math.max(0, b.h - e.h / 64)
}
// Move the player
b.x = (b.x + 64 + z * Math.sin(b.a * Math.PI / 128 + 8)) % 64;
b.y = (b.y + 64 + z * Math.sin(b.a * Math.PI / 128)) % 64;
// Increase the score by the distance we just walked
S += z;
// Show the score at a matching the heart beat sound
c.fillText(S >> 4, M / (b.h + .001) % 1 * 8, 112)
}
Feedback
Let me know if something about this project remains mysterious, or even if you liked this survival horror game in 1024 bytes. It's always nice to hear from you ;)