Shift - a game in 1234 bytes

Shift is my entry for Gynvael Coldwind's compo - "game in 1234 bytes".

I decided to use the browser and do something fun with canvas. I wasn't big on ideas so I went for a clone of SFCave, a game I played back on Palm OS. Somewhere along the line I swapped the control model for something more flappy (for added challenge and arguable originality) but left the original one as an easter egg to find in the source code.

The hardest part was finding an objective for the game. Flying indefinitely while avoiding obstacles is boring and I wanted the game to raise at least some basic emotions in the player, so I looked for a risk/reward system - you can move on and play it safe, or you could do something fun but risky. This was a promising direction and I implemented it dot for dot: there's the objective to catch (yellow ball, reward), but it's surrounded by the obstacle (coral ball, risk). You can catch the objective if you time things well, but you should also pass on some of them because they can be prohibitely difficult to catch safely, especialy with the limited control over your trajectory.

In 1234 bytes there's no time (or space) to explain anything, so I tried to make the game self-explanatory:

  • the first objective is placed so that you catch it if you don't do anything,
  • the game's title tells you everything you need to know about the controls :-)

1234 bytes is a super restrictive limit, but I didn't experiment with any build tools or compression. Instead I took it up as an extra challenge to write readable production code bend and twist Javascript syntax to make the code shorter. It was only one screenful of JS, so I was still easily able to keep a grip on everything during coding despite the lack of indentation and naming. I cannot say the same about writing this summary a few weeks later, though...

In the end I still had some obvious options to save me a dozen bytes, but the game felt complete to me, so I trimmed it to exactly 1234b (in line with the compo's spirit) and called it a day.

I did most of the coding in a train. I swear, I'm going to get a train simulator in my basement someday - these things give me a huge productivity boost!

Code walkthrough

I used the first 3 bytes for the UTF-8 byte order mark. It was necessary because I used a unicode string later on and I wanted the browser encoding detection to guess the encoding correclty. Turns out 0xEF,0xBB,0xBF is much shorter than a <meta charset="utf-8"> and seemed to work just as well on the bunch of browsers I tested.

<body bgcolor=#121><canvas id=C width=600 height=300 style="width:100%;border:solid lime"><script>

That's all HTML I needed for this game. id=C makes a global variable C that points to the canvas - no need for document.getElementById. The CSS border was important - I felt the gameplay was way too unfair when the player didn't have an indication where the game area ends.

Initially I skipped the canvas width and height completely in order to use its default size (300x150). This aspect ratio is quite nice - it fills up the screen well on a typical widescreen computer. Initially I actually tested the game without width: 100% and just relied on browser zoom. However it looked too pixelated for my taste, so I doubled the canvas resolution. I also added the width: 100% so that the user can immediately play in full screen without any mingling. Not a big price to pay for good UX.

M=Math,j=300,T=P=0,wf=requestAnimationFrame,c=C.getContext('2d'),

Every variable is a global variable! Long live globals. Ain't gonna spend precious bytes on the var or const thing.

N=B=>new Date();

This was used in 3 places. This was a borderline - if it was used 2 times I wouldn't save a byte on a helper function.

ES6 lambdas are THE thing! B was my go-to character for the unused first parameter - shorter than ()=>new Date(). Also learned that Date() returns a string for some reason. We're going to subtract these, so no go. There's Date.now() but it is same length.

I could have done N=Date.now instead of the lambda, but I didn't bother to check if this method works everywhere when called without a proper this.

B=n=>Array.from(Array(140).keys()).map(x=>n)

This is my allocator for a fixed-length array filled with a given value. WHY IS IT SO DIFFICULT TO DO IT IN JS? In Python it's literally [n]*140 and here I need ES6 and hacks on top of hacks because even Array(140).map() doesn't do anything useful. I still wasted space, though - I later found out that plain ES5 Array.fill would to the trick quicker.

A=(x,y)=>c.beginPath()|c.arc(x,y,20,0,6.3)|c.stroke()

This function renders a circle around a center point. I used | instead of semicolons so that I could keep the function boy to 1 expression and avoid the braces {}. Comma wouldn't work here. Note that bitwise |, unlike ||, always evaluates all arguments. What's with the 6.3? I could have said 7, it would approximate 2*PI well enough and is 2 bytes shorter...

Z=5
ks=B(20);bs=B(0)
v=0,Vx=500,Vy=150

Oh, this is the gamestate! Z is how fast the objective moves (units per frame), ks is the array of control points of the player's "tail", bs is an array that for each X position has either 0 or the Date object when the player pressed space. This is for animating the "platforms" that appear when you jump. Now that I think of it, the dates are completely redundant, bools would suffice - the scroll speed is constant so for each X position if I know the player jumped there, I also can calculate when exactly.

v is the player's vertical velocity (units per frame), Vx and Vy are the objective's coordinates. My naming knows no rules - I was just picking a random letter...

T and P were initialised earlier. T is the angle of the obstacle around the objective and P is, well, the points counter.

S=D=false;onkeyup=e=>S=e.shiftKey,onkeydown=(e=>{S=D=e.shiftKey})

This is all of the input handling! Yeah, these are window.onkeyup and window.onkeydown which is much shorter than all that addEventListener circus. D says if the Shift key was pressed this frame (is cleared at the end of each frame), S just says if it's pressed right now. S only serves the "bonus mode" where the player is controlled by holding Shift, not tapping it.

c.fillStyle='lime'

c is the canvas context. Uhh, I think this is the text colour?

G=c.createLinearGradient(0,0,0,j);["coral","lime","peru"].map((x,i)=>G.addColorStop(i*.4,x))

Oh, this is where I thought that a gradient might be an easy cheat to make the player's trail appear neater. I defined a vertical gradient that is more red near the edges of the screens. I picked the reddish-pink HTML colours with the shortest names.

f=B=>{
    ...
    wf(f)
    D=0
}
wf(f)

This is the game's main loop! I didn't do any timers and just assumed 60Hz for window.requestAnimationFrame. I hope the game won't run like crazy for people with 144Hz screens...

c.clearRect(0,0,600,j)
c.strokeStyle=G
c.beginPath()
c.moveTo(0,ks.shift()*2);bs.shift()
ks.map((y,x)=>c.lineTo(x*2,y*2))

This code iterates over ks and renders the player's trail. This marks the start of abusing map to substitute for forEach, which is also why you can't really see any loops in my code.

Here's also where I do ks.shift() and bs.shift() that drops the leftmost element of the array and moves the rest 1 element to the left. This is where the scrolling happens. The arrays are now 139 elements long. Later on, the next position is appended so the array length stays the same.

This is a good time to mention how the coordinate system works:

  • The canvas size is 600x300, x grows right, y grows up
  • The array ks is half size, back from when the virtual screen was only 300x150. That's why during rendering the player's trail I multiply both x and y by 2.
  • The array only goes right to index 140 which is a bit less than half the (old) width of the screen - that's how long the player's trail extends from the left side of the screen.
  • So if we have ks[20] = 30, this means that the player's trail has a control point at [202, 302] = [40, 60].
  • The last control point (index 139) points where the player's "head" is: X is always 139*2, Y varies. This is the important point for collision detection later.

    bs.map((b,x)=>{if(b){ dt=N()-b; Q=M.log((N()-b)/20)2M.max(0,(2-M.pow(2, dt/1000)))2 c.moveTo(x2-Q,ks[x-1]2) c.lineTo(x2+Q,ks[x-1]*2) }}) c.stroke()

Oh, this is where I iterate over bs and draw a small platform for each X that has a non-zero value. The platform width (Q) depends on dt - how long ago the player has bounced there. Again I could have made it just a function of x. The exact width is calculated using a stupid easing equation that I ended up with when I decided I cannot figure out anything prettier. Oh, I forgot to substitute one dt.

I spend quite a few bytes and here and fine-tuned the equation many times. I felt it's stupid when the player bounces off thin air, so I thought the "vanishing platform" metaphor is an important part of the experience. Frankly I barely notice it when I play, but still I have the impression that it improves the perceived graphics a lot.

c.strokeStyle='yellow'
A(Vx,Vy)

Here the objective is rendered!

T+=.05
Q=B=>{Vx=650,Vy=M.random()*j,Z=M.random()*5+2}

This helper function resets the objective at the right side the screen and randomises its height and speed. I also decided it's a good place to increment the obstacle rotation angle.v

if((Vx-=Z)<-100)Q()

Reset the objective if it goes offscreen on the left

c.strokeStyle='coral'
Wx=Vx+M.cos(T)*50,Wy=Vy+M.sin(T)*50
A(Wx,Wy)

Render the obstacle. Yay, I didn't forget middle school trigonometry!

//Y=ks[138]+(v+=.05-S*.1) // BONUS MODE
Y=ks[138]+(v+=.05-D*2)
ks.push(Y);bs.push(D?N():0);

Oh, remember when the ks array lost its first item? Here a new item at index 139 (player head's position) is calculated and appended back. In the meantime I change the speed - add the gravity, subtract the jump's acceleration. Bonus mode uses shift holding and a small coefficient, normal mode uses shift keypress and a much larger coefficient.

Oh, and if you pressed shift, the current timestamp is stored in bs so that the tiny platform animation is triggered.

z=ks[139]
a=Vx-280,b=Vy-z*2
if(a*a+b*b<400)Q(P++)

Collision detection with circles... Could I make this even easier on myself? If the distance between player's head (140*2, ks[139]*2) and objective position (Vx, Vy) is less than 20 (objective radius), score is incremented and objective is reset.

(The parameter to Q is unused, of course; I just glued two statements into one expression.)

c.fillText(P,Vx,Vy)

Oh, HERE is the score counter. I almost forgot about it. I placed it in each objective because that's where you end up looking anyway.

a=Wx-280,b=Wy-z*2
;(Y<0||Y>150||a*a+b*b<400)
?c.fillText(P+"(ง'̀-'́)ง",j,Y*1.8+18)
:wf(f)

Again, collision detection. If player's head is out of bounds or if it collides with the obstacle, display the score and the ending emoticon (including some guesswork where the message shuold go). Otherwise, continue the game - schedule the next frame.

Learnings

TODO TODO

That's all, folks!

Comments

Comments powered by Disqus