How to make a 3D game in only 2KB of JavaScript

Months ago, when I heard that the legendary JS1k game jam would not be continuing, I talked it over with some other devs and decided to help fill the void we would host a 2k game jam on itch called 2kPlus Jam. The primary goal of this comp was to create a game that fits entirely in a 2 kilobyte zip file. That is incredibly small, for point of reference a 3.5 floppy disk could hold over 700 of these games.

My entry, Hue Jumper, is an homage to 80’s racing game rendering technology. The 3D graphics and physics engine was implemented from scratch in pure JavaScript. I also spent ungodly hours tweaking the gameplay and visuals.

The theme for the game jam was “Shift” which I incorporated by shifting the hue for the world’s color when the player crosses a checkpoint. I imagined that crossing a checkpoint was fictionally like shifting or jumping into a new dimension with a different hue, which is how I came up with the name “Hue Jumper”.

This post is going to be a bit long because it contains the JavaScript code for my game in it’s entirety. The code is already well commented so I’m not going to explain every line of it nor are you expected to read through all the code now. Instead my goal is to explain how it works, why I made it this way, and walk you through the overall structure. This same code is on CodePen for you to play around with live. So please continue reading, buckle up, and hold onto your butts!

Inspiration

Out Run by Sega

My primary inspiration comes from nostalgia of classic 80’s style racing games like Out Run. Using a similar technique, they were able to push real time 3D graphics on very early hardware. Also I have recently been playing some modern racing games like Distance and Lonely Mountains: Downhill which helped inform the visual design and feel.

Jake Gordon’s project to create a pseudo 3D racer in JavaScript was a big help. He wrote a fantastic multi post blog series that explains how it works. Though I started from scratch, seeing his code helped me work through some of the math and other problems I encountered.

I also looked at a JS1k game called Moto1kross by Chris Glover. This small one kilobyte racing game helped give me a point of reference for what was possible, and with an extra kilobyte of space available I knew I had to far surpass it.

High Level Strategy

Because of the strict size limitation, I needed to be very careful about how my program is structured. My general strategy was to keep everything as simple as possible in serving the ultimate goal of making a game that looks and feels solid.

To help compress the code, I ran it through Google Closure Compiler which removes all white space, renames variables to 1 letter characters, and performs some light optimization. You can use this site to run your code through Closure Compiler Service online. Unfortunately Closure does some other stuff that doesn’t help, like replacing template strings, default parameters and other ES6 features that help save space. So I needed to manually undo some of that and perform a few more ‘risky’ minification techniques to squeeze out every last byte. It’s not a huge win though, the bulk of the savings comes from the structure of the code itself.

The code needs to be zipped to fit into a 2 kilobytes. If that was not an option there is a similar yet less powerful tool called RegPack which can make self uncompromising JavaScript. Either way the strategy is the same, to repeat code wherever possible and let the compressor deflate it. For example there are certain strings which appear often so their compression ratio is large. Some of the best examples are c.width, c.height, and Math, but there are many other smaller ones that add up. So, when reading through this code, keep in mind that you will often see things purposefully repeated to take advantage of the compression.

CodePen

Here’s the game running live on CodePen. You can actually play it in the iframe, but for best results I recommend opening it in a new tab, where you can edit or fork the code.

See the Pen HUE JUMPER – 2 kilobyte 3D racing game in JavaScript by Frank Force (@KilledByAPixel) on CodePen.

HTML

There is very little html used by my game, as it is mostly JavaScript. This is the smallest way to create a full screen canvas, combined with code that later sets the canvas size to the window inner size. I’m not sure why on CodePen it was necessary to add overflow:hidden to the body, but this should work fine when opened directly.

The final minified version uses an even smaller setup by wrapping the JavaScript in an onload call… <body style=margin:0 onload=”code_goes_here”><canvas id=c> However, during development I prefer not to use that condensed setup because the code is stored in a string so editors can’t properly highlight the syntax.

<body style=margin:0>
<canvas id=c>
<script>

Constants

There are many constants that control different aspects of the game. When the code is minified with a tool like Google Closure, these constants will all be replaced much like a #define in C++. Putting them first makes it faster to tweak gameplay.

// draw settings
const context = c.getContext`2d`; // canvas context
const drawDistance = 800;         // how far ahead to draw
const cameraDepth = 1;            // FOV of camera
const segmentLength = 100;        // length of each road segment
const roadWidth = 500;            // how wide is road
const curbWidth = 150;            // with of warning track
const dashLineWidth = 9;          // width of the dashed line
const maxPlayerX = 2e3;           // limit player offset
const mountainCount = 30;         // how many mountains are there
const timeDelta = 1/60;           // inverse frame rate
const PI = Math.PI;               // shorthand for Math.PI

// player settings
const height = 150;               // high of player above ground
const maxSpeed = 300;             // limit max player speed
const playerAccel = 1;            // player forward acceleration
const playerBrake = -3;           // player breaking acceleration
const turnControl = .2;           // player turning rate
const jumpAccel = 25;             // z speed added for jump
const springConstant = .01;       // spring players pitch
const collisionSlow = .1;         // slow down from collisions
const pitchLerp = .1;             // rate camera pitch changes
const pitchSpringDamp = .9;       // dampen the pitch spring
const elasticity = 1.2;           // bounce elasticity
const centrifugal = .002;         // how much turns pull player
const forwardDamp = .999;         // dampen player z speed
const lateralDamp = .7;           // dampen player x speed
const offRoadDamp = .98;          // more damping when off road
const gravity = -1;               // gravity to apply in y axis
const cameraTurnScale = 2;        // how much to rotate camera
const worldRotateScale = .00005;  // how much to rotate world
    
// level settings
const maxTime = 20;               // time to start
const checkPointTime = 10;        // add time at checkpoints
const checkPointDistance = 1e5;   // how far between checkpoints
const maxDifficultySegment = 9e3; // how far until max difficulty
const roadEnd = 1e4;              // how far until end of road

Mouse Control

The input system uses only the mouse. With this bit of code we can track mouse clicks and the horizontal cursor position expressed as a value between -1 and 1. Double clicking is implemented via mouseUpFrames. The mousePressed variable is only used once, to start the game when the player clicks for the first time.

mouseDown     =
mousePressed  =
mouseUpFrames =
mouseX        = 0;
    
onmouseup   =e=> mouseDown = 0;
onmousedown =e=> mousePressed ? mouseDown = 1 : mousePressed = 1;
onmousemove =e=> mouseX = e.x/window.innerWidth*2 - 1;

Math Functions

There are a few functions used by this game to simplify code and reduce repetition. Some standard math functions to Clamp and Lerp values. ClampAngle is useful because it wraps angles between -PI and PI, something many games require.

Random Test Pattern

The R function works almost like magic because it generates seeded random numbers. This is done by taking the sine of the current random seed, multiplying it by a high number, and then looking at the fractional part. There are many ways to do it but this is one of the smallest. I wouldn’t recommend using this for gambling software but it’s random enough for our purposes. We will be using this random generator to create variety procedurally without needing to save any data. For example the variation in mountains, rocks and trees is not stored anywhere in memory. The goal isn’t to reduce memory in this case though, but to eliminate the code that would be needed to store and retrieve that data.

As this is a “True 3D” game it helps greatly to have a 3D vector class which also makes the code smaller. This class contains only the bare essentials necessary for this game, a constructor with add and multiply functions can take either scalar or vector parameter. To determine if a scalar is passed in, we just check if it is less then a large number. A more correct way would be to use isNan or check if it’s type is a Vec3, but that would require more space.

Clamp     =(v, a, b)  => Math.min(Math.max(v, a), b);
ClampAngle=(a)        => (a+PI) % (2*PI) + (a+PI<0? PI : -PI);
Lerp      =(p, a, b)  => a + Clamp(p, 0, 1) * (b-a);
R         =(a=1, b=0) => Lerp((Math.sin(++randSeed)+1)*1e5%1,a,b);
   
class Vec3 // 3d vector class
{
  constructor(x=0, y=0, z=0) {this.x = x; this.y = y; this.z = z;}
  
  Add=(v)=>(
    v = v < 1e5 ? new Vec3(v,v,v) : v, 
    new Vec3( this.x + v.x, this.y + v.y, this.z + v.z ));
    
  Multiply=(v)=>(
    v = v < 1e5 ? new Vec3(v,v,v) : v, 
    new Vec3( this.x * v.x, this.y * v.y, this.z * v.z ));
}

Render Functions

LSHA generates a standard HSLA (hue, saturation, luminosity, alpha) color using a template string, and has just been reordered so more often used components come first. The global hue shift that occurs at checkpoints is also applied here.

DrawPoly draws a trapezoid shape and is used to render absolutely everything in the scene. The Y component is converted to an integer using |0 to ensure that the road polys are fully connected. Without this, there would be a thin line between road segments. For this same reason this rendering tech can’t handle applying roll to the camera without causing graphical artifacts from diagonal lines.

DrawText just renders outlined text used to display time, distance, and the game’s title.

LSHA=(l,s=0,h=0,a=1)=>`hsl(${h+hueShift},${s}%,${l}%,${a})`;

// draw a trapazoid shaped poly
DrawPoly=(x1, y1, w1, x2, y2, w2, fillStyle)=>
{
    context.beginPath(context.fillStyle = fillStyle);
    context.lineTo(x1-w1, y1|0);
    context.lineTo(x1+w1, y1|0);
    context.lineTo(x2+w2, y2|0);
    context.lineTo(x2-w2, y2|0);
    context.fill();
}

// draw outlined hud text
DrawText=(text, posX)=>
{
    context.font = '9em impact';         // set font size
    context.fillStyle = LSHA(99,0,0,.5); // set font color
    context.fillText(text, posX, 129);   // fill text
    context.lineWidth = 3;               // line width
    context.strokeText(text, posX, 129); // outline text
}

Build Track with Procedural Generation

Before the game starts we must first generate the entire track which is will be different for every play through. To do this we build a list of road segments that store the position and width of the road at each point along the track.

The track generator is pretty basic, it just tapers between sections of varying frequency, amplitude, and width. The distance along the track determines how difficult that section can be.

The road pitch angle is calculated here using the atan2 function to be used for physics and lighting.

Example result of the procedural track generator.
roadGenLengthMax =                     // end of section
roadGenLength =                        // distance left
roadGenTaper =                         // length of taper
roadGenFreqX =                         // X wave frequency 
roadGenFreqY =                         // Y wave frequency
roadGenScaleX =                        // X wave amplitude
roadGenScaleY = 0;                     // Y wave amplitude
roadGenWidth = roadWidth;              // starting road width
startRandSeed = randSeed = Date.now(); // set random seed
road = [];                             // clear road

// generate the road
for( i = 0; i < roadEnd*2; ++i )          // build road past end
{
  if (roadGenLength++ > roadGenLengthMax) // is end of section?
  {
    // calculate difficulty percent
    d = Math.min(1, i/maxDifficultySegment);
  
    // randomize road settings
    roadGenWidth = roadWidth*R(1-d*.7,3-2*d);        // road width
    roadGenFreqX = R(Lerp(d,.01,.02));               // X curves
    roadGenFreqY = R(Lerp(d,.01,.03));               // Y bumps
    roadGenScaleX = i>roadEnd ? 0 : R(Lerp(d,.2,.6));// X scale
    roadGenScaleY = R(Lerp(d,1e3,2e3));              // Y scale
  
    // apply taper and move back
    roadGenTaper = R(99, 1e3)|0;                 // random taper
    roadGenLengthMax = roadGenTaper + R(99,1e3); // random length
    roadGenLength = 0;                           // reset length
    i -= roadGenTaper;                           // subtract taper
  }
  
  // make a wavy road
  x = Math.sin(i*roadGenFreqX) * roadGenScaleX;
  y = Math.sin(i*roadGenFreqY) * roadGenScaleY;
  road[i] = road[i]? road[i] : {x:x, y:y, w:roadGenWidth};
  
  // apply taper from last section and lerp values
  p = Clamp(roadGenLength / roadGenTaper, 0, 1);
  road[i].x = Lerp(p, road[i].x, x);
  road[i].y = Lerp(p, road[i].y, y);
  road[i].w = i > roadEnd ? 0 : Lerp(p, road[i].w, roadGenWidth);
    
  // calculate road pitch angle
  road[i].a = road[i-1] ? 
    Math.atan2(road[i-1].y-road[i].y, segmentLength) : 0;
}

Startup the Game

Now the the track exists, we need only initialize a few variables to start the game.

// reset everything
velocity = new Vec3
  ( pitchSpring =  pitchSpringSpeed =  pitchRoad = hueShift = 0 );
  
position = new Vec3(0, height);      // set player start pos
nextCheckPoint = checkPointDistance; // init next checkpoint
time = maxTime;                      // set the start time
heading = randSeed;                  // random world heading

Update Player

This is the main update function that handles updating and rendering everything in the game! Normally it is not good practice to have a giant function in your code and this would be split up into sub functions. So to help explain things, it is broken up into several parts below.

First we need to get some info about the road at the player’s location. To make the physics  and rendering feel smooth, the values are interpolated between current and next road segments.

The player’s position and velocity are 3D vectors, and are updated with kinematics to apply gravity, dampening and other factors. If the player is below the road, the position is clamped to the ground plane and velocity is reflected against the normal. Also, while on ground the acceleration is applied and the camera will shake when off road. After play testing I decided to allow the player to still tun while airborne.

Input is handled here to control acceleration, braking, jumping, and turning. Double clicks are detected is via mouseUpFrames. There is also some code to track how many frames the player has been in the air to allow a short grace period when the player can still jump.

The camera’s pitch angle uses a spring system to give a dynamic feel as the player accelerates, brakes, and jumps. Also the camera tilts to match the road angle as the player drives over hills and jumps.

Update=()=>
{

// get player road segment
s = position.z / segmentLength | 0; // current road segment
p = position.z / segmentLength % 1; // percent along segment

// get lerped values between last and current road segment
roadX = Lerp(p, road[s].x, road[s+1].x);
roadY = Lerp(p, road[s].y, road[s+1].y) + height;
roadA = Lerp(p, road[s].a, road[s+1].a);

// update player velocity
lastVelocity = velocity.Add(0);
velocity.y += gravity;
velocity.x *= lateralDamp;
velocity.z = Math.max(0, time?forwardDamp*velocity.z:0);

// add velocity to position
position = position.Add(velocity);
  
// limit player x position (how far off road)
position.x = Clamp(position.x, -maxPlayerX, maxPlayerX); 

// check if on ground
if (position.y < roadY)
{
  position.y = roadY; // match y to ground plane
  airFrame = 0;       // reset air frames
  
  // get the dot product of the ground normal and the velocity
  dp = Math.cos(roadA)*velocity.y + Math.sin(roadA)*velocity.z;
  
  // bounce velocity against ground normal
  velocity = new Vec3(0, Math.cos(roadA), Math.sin(roadA))
    .Multiply(-elasticity * dp).Add(velocity);
    
  // apply player brake and accel
  velocity.z += 
    mouseDown? playerBrake :
    Lerp(velocity.z/maxSpeed, mousePressed*playerAccel, 0);
  
  // check if off road
  if (Math.abs(position.x) > road[s].w)
  {
    velocity.z *= offRoadDamp;                    // slow down
    pitchSpring += Math.sin(position.z/99)**4/99; // rumble
  }
}

// update player turning and apply centrifugal force
turn = Lerp(velocity.z/maxSpeed, mouseX * turnControl, 0);
velocity.x +=
  velocity.z * turn -
  velocity.z ** 2 * centrifugal * roadX;

// update jump
if (airFrame++<6 && time 
  && mouseDown && mouseUpFrames && mouseUpFrames<9)
{
  velocity.y += jumpAccel; // apply jump velocity
  airFrame = 9;            // prevent jumping again
}
mouseUpFrames = mouseDown? 0 : mouseUpFrames+1;

// pitch down with vertical velocity when in air
airPercent = (position.y-roadY) / 99;
pitchSpringSpeed += Lerp(airPercent, 0, velocity.y/4e4);

// update player pitch spring
pitchSpringSpeed += (velocity.z - lastVelocity.z)/2e3;
pitchSpringSpeed -= pitchSpring * springConstant;
pitchSpringSpeed *= pitchSpringDamp;
pitchSpring += pitchSpringSpeed; 
pitchRoad = Lerp(pitchLerp, pitchRoad, Lerp(airPercent,-roadA,0));
playerPitch = pitchSpring + pitchRoad;

// update heading
heading = ClampAngle(heading + velocity.z*roadX*worldRotateScale);
cameraHeading = turn * cameraTurnScale;

// was checkpoint crossed?
if (position.z > nextCheckPoint)
{
  time += checkPointTime;               // add more time
  nextCheckPoint += checkPointDistance; // set next checkpoint
  hueShift += 36;                       // shift hue
}

Pre-Render

Before rendering, the canvas is cleared by setting it’s width and height. This also fits the canvas to fill the window.

We also calculate the projection scale used to transform world points to canvas space. The cameraDepth value represents the field of view (FOV) of the camera which is to 90 degrees for this game. The calculation is 1/Math.tan((fovRadians/2), which works out to be exactly 1 for an FOV of 90 degrees. To preserve aspect ratio, the projection is scaled by c.width.

// clear the screen and set size
c.width = window.innerWidth, c.height = window.innerHeight;

// calculate projection scale, flip y
projectScale = (new Vec3(1,-1,1)).Multiply(c.width/2/cameraDepth);

Draw Sky, Sun and Moon

The background atmosphere is drawn with a full screen linear gradient that changes color based on the sun’s direction.

To conserve space, both the sun and moon are drawn in the same for loop using a full screen radial gradient with transparency.

The linear and radial gradients combine to make a sky box that fully wraps around the scene.

// get horizon, offset, and light amount
horizon = c.height/2 - Math.tan(playerPitch)*projectScale.y;
backgroundOffset = Math.sin(cameraHeading)/2;
light = Math.cos(heading);

// create linear gradient for sky
g = context.createLinearGradient(0,horizon-c.height/2,0,horizon);
g.addColorStop(0,LSHA(39+light*25,49+light*19,230-light*19));
g.addColorStop(1,LSHA(5,79,250-light*9));

// draw sky as full screen poly
DrawPoly(c.width/2,0,c.width/2,c.width/2,c.height,c.width/2,g);

// draw sun and moon (0=sun, 1=moon)
for( i = 2 ; i--; )
{
  // create radial gradient
  g = context.createRadialGradient(
    x = c.width*(.5+Lerp(
      (heading/PI/2+.5+i/2)%1,
      4, -4)-backgroundOffset),
    y = horizon - c.width/5,
    c.width/25,
    x, y, i?c.width/23:c.width);
  g.addColorStop(0, LSHA(i?70:99));
  g.addColorStop(1, LSHA(0,0,0,0));
  
  // draw full screen poly
  DrawPoly(c.width/2,0,c.width/2,c.width/2,c.height,c.width/2,g);
}

Draw Mountains and Horizon

The mountains are procedurally generated, by drawing 50 triangles on the horizon. Lighting is applied so that mountains are darker when facing towards the sun because they are in shadow. Also, closer mountains are darker to simulate fog. The real trick here was tweaking the random values for the size and color to give good results.

The final part of drawing the background is to draw the horizon line and below to fill the bottom of the canvas with solid green.

// set random seed for mountains
randSeed = startRandSeed;

// draw mountains
for( i = mountainCount; i--; )
{
  angle = ClampAngle(heading+R(19));
  light = Math.cos(angle-heading); 
  DrawPoly(
    x = c.width*(.5+Lerp(angle/PI/2+.5,4,-4)-backgroundOffset),
    y = horizon,
    w = R(.2,.8)**2*c.width/2,
    x + w*R(-.5,.5),
    y - R(.5,.8)*w, 0,
    LSHA(R(15,25)+i/3-light*9, i/2+R(19), R(220,230)));
}

// draw horizon
DrawPoly(
  c.width/2, horizon, c.width/2, c.width/2, c.height, c.width/2,
  LSHA(25, 30, 95));

Project Road Segments to Canvas Space

Before the road can be rendered, we must first get the projected road points. The first part of this is a bit tricky because our road’s x value needs to be converted to a world space position. To make the roads appear to curve, we will apply the x value as a second order derivative. This is why there is the strange bit of code “x+=w+=”. Because of the way this works, the road segments don’t have persistent world space positions, but instead are recomputed every frame based on the player’s location.

Once we have the world space position, we can subtract the player position from the road position to get the local camera space position. The rest of the code applies a transform by first rotating heading, pitch, then the projection transform to make farther things appear smaller, and finally moving it to canvas space.

for( x = w = i = 0; i < drawDistance+1; )
{
  p = new Vec3(x+=w+=road[s+i].x,     // sum local road offsets
    road[s+i].y, (s+i)*segmentLength) // road y and z pos
      .Add(position.Multiply(-1));    // get local camera space

  // apply camera heading
  p.x = p.x*Math.cos(cameraHeading) - p.z*Math.sin(cameraHeading);
  
  // tilt camera pitch and invert z
  z = 1/(p.z*Math.cos(playerPitch) - p.y*Math.sin(playerPitch));
  p.y = p.y*Math.cos(playerPitch) - p.z*Math.sin(playerPitch);
  p.z = z;
  
  // project road segment to canvas space
  road[s+i++].p =                         // projected road point
    p.Multiply(new Vec3(z, z, 1))         // projection
    .Multiply(projectScale)               // scale
    .Add(new Vec3(c.width/2,c.height/2)); // center on canvas
}

Draw Road Segments

Now that we have the canvas space points for each road segment, the rendering is fairly straightforward. We need to draw each of the road segments from back to front, or more specifically the trapezoid shaped polys that connect road segments.

To create the road there are 4 layers rendered on top of each other: the ground, the striped curb, the road itself and the dashed white line. Each is shaded based on the pitch and heading of the road segment with some extra logic depending on that layer’s appearance.

It is necessary to check if the segment is in the near / far clip range to prevent weird rendering artifacts. Also, there is a nice optimization to scale down the resolution of the road by distance when they become very thin. This cuts the draw count by over half with no noticeable loss in quality for a massive performance win.

Wireframe outline showing every poly that is rendered.
let segment2 = road[s+drawDistance]; // store the last segment
for( i = drawDistance; i--; )        // iterate in reverse
{
  // get projected road points
  segment1 = road[s+i];
  p1 = segment1.p;
  p2 = segment2.p;
  
  // random seed and lighting
  randSeed = startRandSeed + s + i;
  light = Math.sin(segment1.a) * Math.cos(heading) * 99;
  
  // check near and far clip
  if (p1.z < 1e5 && p1.z > 0)
  {
    // fade in road resolution over distance
    if (i % (Lerp(i/drawDistance,1,9)|0) == 0)
    {
      // ground
      DrawPoly(c.width/2, p1.y, c.width/2,
        c.width/2, p2.y, c.width/2,
        LSHA(25 + light, 30, 95));

      // curb if wide enough
      if (segment1.w > 400)
        DrawPoly(p1.x, p1.y, p1.z*(segment1.w+curbWidth),
          p2.x, p2.y, p2.z*(segment2.w+curbWidth),
          LSHA(((s+i)%19<9? 50: 20) + light));
      
      // road and checkpoint marker
      DrawPoly(p1.x, p1.y, p1.z*segment1.w,
        p2.x, p2.y, p2.z*segment2.w,
        LSHA(((s+i)*segmentLength%checkPointDistance < 300 ?
          70 : 7) + light));
        
      // dashed lines if wide and close enough
      if ((segment1.w > 300) && (s+i)%9==0 && i < drawDistance/3)
          DrawPoly(p1.x, p1.y, p1.z*dashLineWidth,
          p2.x, p2.y, p2.z*dashLineWidth,
          LSHA(70 + light));

      // save this segment
      segment2 = segment1;
    }

Draw Road Trees and Rocks

This game has only two different types of objects: trees and rocks, which are rendered on top of the road. First we determine if there is an object by using the R() function. This is one of those places where the magic of the seeded random numbers shines. We will also use R() to add random shape and color variation to the objects.

Originally I wanted to have other vehicles, but that would not fit in the size constraints without making major cuts so I comprised by using the scenery as obstacles. The positions is randomized and biased to be closer to the road, I found them too sparse and easy to navigate otherwise. To save space, the object height also determines the type of object.

This is where collisions are checked between the player and objects by comparing their position in 3D space. When an object is hit, the player is slowed down and that object is marked as hit so it can be passed through safely.

To prevent objects popping on the horizon, the transparency fades in over distance. The shape and color of the objects uses our trapezoid drawing function with variation thanks to the magical seeded random function I mentioned earlier.

    if (R()<.2 && s+i>29)                  // is there an object?
    {
      // player object collision check
      x = 2*roadWidth * R(10,-10) * R(9);  // choose object pos
      const objectHeight = (R(2)|0) * 400; // choose tree or rock
      if (!segment1.h                      // dont hit same object
        && Math.abs(position.x-x)<200                      // X
        && Math.abs(position.z-(s+i)*segmentLength)<200    // Z
        && position.y-height<segment1.y+objectHeight+200)  // Y
      {
        // slow player and mark object as hit
        velocity = velocity.Multiply(segment1.h = collisionSlow);
      }

      // draw road object
      const alpha = Lerp(i/drawDistance, 4, 0);  // fade in object
      if (objectHeight) 
      {
        // tree trunk
        DrawPoly(x = p1.x+p1.z * x, p1.y, p1.z*29,
          x, p1.y-99*p1.z, p1.z*29,
          LSHA(5+R(9), 50+R(9), 29+R(9), alpha));
          
        // tree leaves
        DrawPoly(x, p1.y-R(50,99)*p1.z, p1.z*R(199,250),
          x, p1.y-R(600,800)*p1.z, 0,
          LSHA(25+R(9), 80+R(9), 9+R(29), alpha));
      }
      else
      {
        // rock
        DrawPoly(x = p1.x+p1.z*x, p1.y, p1.z*R(200,250),
          x+p1.z*(R(99,-99)), p1.y-R(200,250)*p1.z, p1.z*R(99),
          LSHA(50+R(19), 25+R(19), 209+R(9), alpha));
      }
    }
  }
}

Draw HUD, Update Time, Request Next Update

The game title, time, and distance are displayed with a very basic font rendering system using the DrawText function we set up earlier. Before the player clicks the mouse, it displays the title centered on the screen. I am very proud to fit the luxury of showing the game title and using bold outlined impact font. Had I been tighter on space, some of that would have been the first to go.

Once the mouse has been pressed, the game starts and the HUD displays the time left and current distance. The time is also updated in this conditional block because it should only decrease after the race has started.

At the very end of this massive Update function, it calls requestAnimationFrame(Update) to trigger the next update.

if (mousePressed)
{
  time = Clamp(time - timeDelta, 0, maxTime); // update time
  DrawText(Math.ceil(time), 9);               // show time
  context.textAlign = 'right';                // right alignment
  DrawText(0|position.z/1e3, c.width-9);      // show distance
}
else
{
  context.textAlign = 'center';      // center alignment
  DrawText('HUE JUMPER', c.width/2); // draw title text
}

requestAnimationFrame(Update); // kick off next frame

} // end of update function

Final Bit of Code

The giant update function above needs to be called once to set the update loop in motion.

Also, the HTML needs a closing script tag to make all that code actually run.

Update(); // kick off update loop
</script>

Minification

That’s the entire game! Here’s a look at the final result after it has been minified with color coding to show the different parts. After all that work, you can only imagine how satisfying it is seeing my entire game in a small patch of code like this. This is also before the zip which cuts the size almost in half by eliminating repetitious code.

  • HTML – Red
  • Functions – Orange
  • Setup – Yellow
  • Player Update – Green
  • Background Render – Cyan
  • Road Render – Purple
  • Object Render – Pink
  • HUD Render – Brown

Caveats

There are other ways to achieve 3D rendering that provide both performance and visual benefits. If I had more space available, I would have preferred to use a WebGL API like three.js which I used for Bogus Roads, a slightly similar game I made last year. Also, because it is using requestAnimationFrame, there really needs to be some extra code to ensure the framerate is capped at 60 fps, which I have added to the enhanced version. I prefer using requestAnimationFrame over setInterval though because it results in smoother rendering due it to being vsynced. One major benefit to this code is it is extremely compatible and should work on any device, though it is a bit sluggish on my aging iPhone.

Wrapping It Up

Thank you for reading all of this, or at least scrolling to the bottom! I hope you learned something new. If you liked this post, follow me on Twitter for more stuff like this. Also, there are more mind blowing games made for 2kPlus Jam, you check them all out on itch!

The code for this game is open source on GitHub under the GPL-3.0 so feel free to use it in your own projects. That repository also contains the 2k version which at the time of posting is only 2031 bytes! You can also play the “plus” version with some bonus features like music and sound effects.

I will leave you with the first tweet I posted about this game with an early video…

And of course, I made a promotional dweet to celebrate the game’s release…

This entry was posted in Game Dev, JavaScript and tagged , , , . Bookmark the permalink.

26 Responses to How to make a 3D game in only 2KB of JavaScript

  1. Pingback: === popurls.com === popular today

  2. Pingback: How I made a 3D game in only 2KB of JavaScript - Proxap

  3. Pingback: How I made a 3D game in only 2KB of JavaScript – Digital Marketing Directory

  4. Anon says:

    How did you fit a whole javascript engine it 2KiB? It’s cheating if you can arbitrarily ignore the size of your dependencies else you can make your whole game as a dependency and have a small wrapper under 2Jib.

    • Frank says:

      Most small demos require some sort of environment in which to run, even if it is an executable. In this case that is the JavaScript runtime environment, there are no other dependencies.

      Because JavaScript is interpreted, you can also say that the source code when zipped is under 2k.

    • mff002 says:

      I do not think this is cheating,

      JS is an interpreted language, you cannot compare it to a compiling language.

    • h8ters gonna h8 says:

      It’s not like JS runtime provides any 3D capabilities. It’s a general purpose programming language.

    • Titov Dmitry says:

      What?)) In this way if you are creating this game for Windows, you must put windows source code in your archive))) yeah?)))

  5. Ofer says:

    This is all so beautiful. Thanks for sharing all this JS knowledge.

  6. Pingback: Vienen curvas – LOCOS POR ANDROID

  7. Catherine says:

    This is black magic you wizard!

  8. Pingback: CodePen Home HUE JUMPER – A 2 kilobyte 3D racing game in JavaScript | Texto casi Diario

  9. Pingback: Collective #596 – Find Right Software

  10. Pingback: Tech roundup 66: a journal published by a bot - Javi Lσpez G.

  11. FluxHunter says:

    what a breathtaking!
    I got so impressed because
    I used to make games only using framework
    I’m just curious what’s ur major framework or engine?
    Thanks for sharing it

    • Frank says:

      I have used many engines on AAA games, recently spent 3 years working in UE4. I also wrote my own C++ game engine. Right now I am mostly using JavaScript, I have used three.js a bit.

  12. Pingback: 16+ Articles of March to Learn JavaScript - Web Design Tips

  13. John says:

    Hey, wanted to let you know that Closure Compiler translates to ES5 by default but you can set the output language to anything up to ES2019 so it doesn’t need to remove template strings etc.

  14. Pingback: Collective #596 – Grew Design

This site uses Akismet to reduce spam. Learn how your comment data is processed.