Steamshot 13 – my WebXR entry to the 2024 js13kgames competition

I entered this years js13kgames competition. If you don’t know you have to create a game which when zipped has a file size no bigger than 13kb!! You can’t download online assets like images, sounds, libraries or fonts. Like last year I targeted the WebXR category. This allows an exception to the no libraries rule and allows the developer to use an external library: A-Frame, Babylon.js or Three.js. I chose Three.js.

You need to setup an npm project. Here’s my template. To use it, download a zip. Unzip, open the folder using VSCode. Then use:

npm install.

npm run start to start a test server. and

npm run build to create a distribution version in the dist folder and a zipped version in the zipped folder. It will also give a file size report.

The first challenge is creating a game that fits the theme for the year. In 2024 the theme was Triskaidekaphobia. That is the fear of the number 13. I decided to create a shooting gallery where the user must shoot any ball with the number 13 on it.

Initially I created a proxy of the environment in code.

export class Proxy{
    constructor( scene ){
        this.scene = scene;

        const geo1 = new THREE.CylinderGeometry( 0.25, 0.25, 3 );
        const mat1 = new THREE.MeshStandardMaterial( { color: 0x999999 } );
        const mat2 = new THREE.MeshStandardMaterial( { color: 0x444444, side: THREE.BackSide, wireframe: false } );

        const column = new THREE.Mesh( geo1, mat1 );
        
        for ( let x = -6; x<=6; x+=2 ){
            const columnA = column.clone();
            columnA.position.set( x, 1.5, -20);
            scene.add( columnA );
        }

        const geo2 = new THREE.PlaneGeometry( 15, 25 );
        geo2.rotateX( -Math.PI/2 );
        const floor = new THREE.Mesh( geo2, mat1 );
        floor.position.set( 0, 0, -12.5 );
        //scene.add( floor );

        const geo3 = new THREE.BoxGeometry( 15, 0.6, 0.6 );
        const lintel = new THREE.Mesh( geo3, mat1 );
        lintel.position.set( 0, 3.3, -20 );
        scene.add( lintel );

        const geo4 = new THREE.BoxGeometry( 15, 3.3, 36 );
        const room = new THREE.Mesh( geo4, mat2 );
        room.position.set( 0, 1.65, -10 );
        scene.add( room );
    }
}

I was aiming for a look like this –

A bit of a tall-order but you have to have a goal!!

The next step was creating the balls that move toward the player.

Just a simple class.

export class Ball{
    static states = { DROPPING: 1, ROTATE: 2, FIRED: 3 };
    static canvas = document.createElement('canvas');
    static geometry = new THREE.SphereGeometry( 0.5 );
    
    constructor( scene, num, minus = false, xPos = -1, speed = 0.1 ){
        if (Ball.canvas.width != 256 ){
		    Ball.canvas.width = 256;
            Ball.canvas.height = 128;
        }
        const context = Ball.canvas.getContext('2d');

        if (num == 13){
            context.fillStyle = "#000";
        }else if (minus){
            context.fillStyle = "#f00";
        }else{
            context.fillStyle = "#0f0";
        }

        this.num = num; 
        this.speed = speed;

        context.fillRect(0, 0, 256, 128);

        context.fillStyle = "#fff";
        context.font = "48px Arial";
        context.textAlign = "center";
        context.textBaseline = "middle";
        context.fillText(String(num), 128, 64 );

        const tex = new THREE.CanvasTexture( Ball.canvas );

        const material = new THREE.MeshStandardMaterial( { map: tex, roughness: 0.1 } );

        this.mesh = new THREE.Mesh( Ball.geometry, material );
        this.mesh.position.set( xPos, 4, -20 );
        this.mesh.rotateY( Math.PI/2 );

        this.state = Ball.states.DROPPING;

        scene.add( this.mesh )
    }

    update(game){
        switch(this.state){
            case Ball.states.DROPPING:
                this.mesh.position.y -= 0.1;
                if (this.mesh.position.y <= 1.6){
                    this.state = Ball.states.ROTATE;
                    this.mesh.position.y = 1.6;
                }
                break;
            case Ball.states.ROTATE:
                this.mesh.rotateY( -0.1 );
                console.log( this.mesh.rotation.y );
                if (this.mesh.rotation.y < -Math.PI/2.1){
                    this.state = Ball.states.FIRED;
                }
                break;
            case Ball.states.FIRED:
                this.mesh.position.z += this.speed;
                break;
        }

        if (this.mesh.position.z > 2){
            this.mesh.material.map.dispose();
            if (game) game.removeBall( this );
        }
    }
}

I created a proxy gun.

export class Gun extends THREE.Group{
    constructor(){
        super();

        this.createProxy();
    }

    createProxy(){
        const mat = new THREE.MeshStandardMaterial( { color: 0xAAAA22 } );
        const geo1 = new THREE.CylinderGeometry( 0.01, 0.01, 0.15, 20 );
        const barrel = new THREE.Mesh( geo1, mat ); 
        barrel.rotation.x = -Math.PI/2;
        barrel.position.z = -0.1;

        const geo2 = new THREE.CylinderGeometry( 0.025, 0.025, 0.06, 20 );
        const body = new THREE.Mesh( geo2, mat ); 
        body.rotation.x = -Math.PI/2;
        body.position.set( 0, -0.015, -0.042 );

        const geo3 = new THREE.BoxGeometry( 0.02, 0.08, 0.04 );
        const handle = new THREE.Mesh( geo3, mat ); 
        handle.position.set( 0, -0.034, 0);

        this.add( barrel );
        this.add( body );
        this.add( handle );
    }
}

and a Bullet

import { Ball } from "./ball.js";

export class Bullet{
    constructor( game, controller ){
        const geo1 = new THREE.CylinderGeometry( 0.008, 0.008, 0.07, 16 );
        geo1.rotateX( -Math.PI/2 );
        const material = new THREE.MeshBasicMaterial( { color: 0xFFAA00  });
        const mesh = new THREE.Mesh( geo1, material );

        const v = new THREE.Vector3();
        const q = new THREE.Quaternion();

        mesh.position.copy( controller.getWorldPosition( v ) );
        mesh.quaternion.copy( controller.getWorldQuaternion( q ) );

        game.scene.add( mesh );
        this.tmpVec = new THREE.Vector3();
        this.tmpVec2 = new THREE.Vector3();

        this.mesh = mesh;
        this.game = game;
    }

    update( dt ){
        let dist = dt * 2;
        let count = 0;

        while(count<1000){

            count++;
            if (dist > 0.5){
                dist -= 0.5;
                this.mesh.translateZ( -0.5 );
            }else{
                this.mesh.translateZ( -dist );
                dist = 0;
            }

            this.mesh.getWorldPosition( this.tmpVec );

            let hit = false;

            this.game.balls.forEach( ball => {
                if (!hit){
                    if (ball.state == Ball.states.FIRED ){
                        ball.mesh.getWorldPosition( this.tmpVec2 );
                        const offset = this.tmpVec.distanceTo( this.tmpVec2 );
                        if ( offset < 0.5 ){
                            hit = true;
                            ball.hit(this.game );
                            this.game.removeBullet( this );
                        }
                    }
                }
            });

            if (dist==0 || hit) break;
        }

        this.mesh.translateZ( dt * -2 );

        if ( this.mesh.position.length() > 20 ) this.game.removeBullet();
    }
}

Now I had a working basic game. Time to create the eye-candy.

First a score and timer display. WebXR does not allow the developer to use the DOM. You can’t simply create a div, position it and update its content using JavaScript. I decided to create a mechanical counter mechanism.

Each segment uses CylinderGeometry. To create the map with the numbers on I used the CanvasTexture class, this creates a Texture from an HTML Canvas, so you can use HTML Canvas drawing commands to ‘paint’ the texture.

export class Counter extends THREE.Group{
    static texture;
    static types = { SCORE: 0, TIMER: 1 };

    constructor( scene, pos = new THREE.Vector3(), rot = new THREE.Euler ){
        super();

        this.scale.set( 1.5, 1.5, 1.5 );

        scene.add( this );
        this.position.copy( pos );
        this.rotation.copy( rot );

        if ( Counter.texture == null ){
            const canvas = document.createElement('canvas');

            canvas.width = 1024;
            canvas.height = 64;

            const context = canvas.getContext( '2d' );

            context.fillStyle = "#000";
            context.fillRect( 0, 0, 1024, 64 );

            context.textAlign = "center";
            context.textBaseline = "middle";
            
            context.font = "48px Arial";

            context.fillStyle = "#fff";

            const inc = 1024/12;
            const chars = [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ":", " " ];
            let x = inc/2;

            chars.forEach( char => {
                context.setTransform(1, 0, 0, 1, 0, 0);
                context.rotate( -Math.PI/2 );
                context.translate( 0, x );
                context.fillText( char, -32, 34 );
                x += inc;
            });

            Counter.texture = new THREE.CanvasTexture( canvas );
            Counter.texture.needsUpdate = true;
        }

        const r = 1;
        const h = Math.PI * 2 * r;
        const w = h/12;

        const geometry = new THREE.CylinderGeometry( r, r, w );
        geometry.rotateZ( -Math.PI/2 );
        const material = new THREE.MeshStandardMaterial( { map: Counter.texture } );

        const inc = w * 1.1;
        const xPos = -inc * 2.5;
        const zPos = -r * 0.8;

        for( let i=0; i<5; i++ ){
            const mesh = new THREE.Mesh( geometry, material );
            mesh.position.set( xPos + inc*i, 0, zPos );
            this.add( mesh );
        }

        this.type = Counter.types.SCORE;

        this.displayValue = 0;
        this.targetValue = 0;
    }

    set score(value){
        if ( this.type != Counter.types.SCORE ) this.type = Counter.type.SCORE;
        this.targetValue = value;
    }

    updateScore( ){
        const inc = Math.PI/6;
        let str = String( this.displayValue );
        while ( str.length < 5 ) str = "0" + str;
        const arr = str.split( "" );
        
        this.children.forEach( child => {
            const num = Number(arr.shift());
            if (!isNaN(num)){
                child.rotation.x = -inc*num - 0.4;
            }
        });
    }

    updateTime( ){
        const inc = Math.PI/6;
        let secs = this.displayValue;
        let mins = Math.floor( secs/60 );
        secs -= mins*60;
        let secsStr = String( secs );
        while( secsStr.length < 2 ) secsStr = "0" + secsStr;
        let minsStr = String( mins );
        while( minsStr.length < 2 ) minsStr = "0" + minsStr;
        let timeStr = minsStr + ":" + secsStr;
        let arr = timeStr.split( "" );
        
        this.children.forEach( child => {
            const num = Number(arr.shift());
            if (isNaN(num)){
                child.rotation.x = -inc*10 - 0.4;
            }else{
                child.rotation.x = -inc*num - 0.4;
            }
        });
    }

    set seconds(value){
        if ( this.type != Counter.types.TIMER ) this.type = Counter.types.TIMER;
        this.targetValue = value;
        this.update( 0 );
    }

    get time(){
        let secs = this.targetValue;
        let mins = Math.floor( secs/60 );
        secs -= mins*60;
        let secsStr = String( secs );
        while( secsStr.length < 2 ) secsStr = "0" + secsStr;
        let minsStr = String( mins );
        while( minsStr.length < 2 ) minsStr = "0" + minsStr;
        return minsStr + ":" + secsStr;
    }

    update( dt ){
        if ( this.targetValue != this.displayValue ){
            if ( this.targetValue > this.displayValue ){
                this.displayValue++;
            }else{
                this.displayValue--;
            }
        }

        switch( this.type ){
            case Counter.types.SCORE:
               this.updateScore();
                break;
            case Counter.types.TIMER:
                this.updateTime();
                break
        }
    }
}

Another challenge was creating an environment map. Which is essential when using MeshStandardMaterial with a roughness less than 1. As soon as it is smooth it reflects a map which by default is black. Resulting in very dark renders of shiny objects. With only 13k to play with you can’t simply load a bitmap texture. Instead you have to generate an environment map at runtime. A WebGLRenderer does not have to write to the screen. If you set a WenGLRenderTarget you can render to that. For a environment map you need it to be compiled in a special way. You can use a PMREMGenerator and the compileEquirectangularShader method.

if (this.scene.environment == null){
            this.renderTarget = new THREE.WebGLRenderTarget(1024, 512);
            this.renderer.setSize( 1024, 512 );
            this.renderer.setRenderTarget( this.renderTarget );
            this.renderer.render( this.scene, this.camera );

            const pmremGenerator = new THREE.PMREMGenerator( this.renderer );
            pmremGenerator.compileEquirectangularShader();
            const envmap = pmremGenerator.fromEquirectangular( this.renderTarget.texture ).texture;
            pmremGenerator.dispose();

            this.scene.environment = envmap;
            this.renderer.setRenderTarget( null );
            this.resize();
        }

I also need a way of rendering wood. I decided to use noise like this CodePen example.

The NoiseMaterial class does the heavy lifting.

export class NoiseMaterial extends THREE.MeshStandardMaterial{
    constructor( type, options ){
		super( options );

		if ( this.noise == null ) this.initNoise();

		switch( type ){
			case 'oak':
				this.wood( 0x261308, 0x110302 );
				break;
			case 'darkwood':
				this.wood( 0x563308, 0x211302 );
				break;
			case 'wood':
			default:
        		this.wood();
				break;
		}

		this.onBeforeCompile = shader => {
			for (const [key, value] of Object.entries(this.userData.uniforms)) {
				shader.uniforms[key] = value;
			}
			shader.vertexShader = shader.vertexShader.replace( '#include <common>', `
				varying vec3 vModelPosition;
				#include <common>
				`);
			shader.vertexShader = shader.vertexShader.replace( '#include <begin_vertex>', `
				vModelPosition = vec3(position);
				#include <begin_vertex>
				`);
			shader.fragmentShader = shader.fragmentShader.replace( 
				'#include <common>', 
				this.userData.colorVars ) ;		
		  	shader.fragmentShader = shader.fragmentShader.replace( 
				'#include <color_fragment>', 
				this.userData.colorFrag ) 	
		  }
    }

	wood( light = 0x735735, dark = 0x3f2d17 ){
		this.userData.colorVars = `
			uniform vec3 u_LightColor;
			uniform vec3 u_DarkColor;
			uniform float u_Frequency;
			uniform float u_NoiseScale;
			uniform float u_RingScale;
			uniform float u_Contrast;

			varying vec3 vModelPosition;

			${this.noise}

			#include <common>
		`

		this.userData.colorFrag = `
			float n = snoise( vModelPosition.xxx )/3.0 + 0.5;
			float ring = fract( u_Frequency * vModelPosition.x + u_NoiseScale * n );
			ring *= u_Contrast * ( 1.0 - ring );

			// Adjust ring smoothness and shape, and add some noise
			float lerp = pow( ring, u_RingScale ) + n;
			diffuseColor.xyz *= mix(u_DarkColor, u_LightColor, lerp);
		`

		const uniforms = {};
		uniforms.u_time = { value: 0.0 };
		uniforms.u_resolution = { value: new THREE.Vector2() };
		uniforms.u_LightColor = { value: new THREE.Color(light) };
		uniforms.u_DarkColor = { value: new THREE.Color(dark) };
		uniforms.u_Frequency = { value: 55.0 };
		uniforms.u_NoiseScale = { value: 2.0 };
		uniforms.u_RingScale = { value: 0.26 };
		uniforms.u_Contrast = { value: 10.0 };

		this.userData.uniforms = uniforms;
	}

	initNoise(){
		this.noise = `
			//
			// Description : Array and textureless GLSL 2D/3D/4D simplex 
			//               noise functions.
			//      Author : Ian McEwan, Ashima Arts.
			//  Maintainer : stegu
			//     Lastmod : 20110822 (ijm)
			//     License : Copyright (C) 2011 Ashima Arts. All rights reserved.
			//               Distributed under the MIT License. See LICENSE file.
			//               https://github.com/ashima/webgl-noise
			//               https://github.com/stegu/webgl-noise
			// 

			vec3 mod289(vec3 x) {
			return x - floor(x * (1.0 / 289.0)) * 289.0;
			}

			vec4 mod289(vec4 x) {
			return x - floor(x * (1.0 / 289.0)) * 289.0;
			}

			vec4 permute(vec4 x) {
				return mod289(((x*34.0)+1.0)*x);
			}

			// Permutation polynomial (ring size 289 = 17*17)
			vec3 permute(vec3 x) {
			return mod289(((x*34.0)+1.0)*x);
			}
			
			float permute(float x){
				return x - floor(x * (1.0 / 289.0)) * 289.0;;
			}

			vec4 taylorInvSqrt(vec4 r){
			return 1.79284291400159 - 0.85373472095314 * r;
			}

			float snoise(vec3 v){ 
			const vec2  C = vec2(1.0/6.0, 1.0/3.0) ;
			const vec4  D = vec4(0.0, 0.5, 1.0, 2.0);

			// First corner
			vec3 i  = floor(v + dot(v, C.yyy) );
			vec3 x0 =   v - i + dot(i, C.xxx) ;

			// Other corners
			vec3 g = step(x0.yzx, x0.xyz);
			vec3 l = 1.0 - g;
			vec3 i1 = min( g.xyz, l.zxy );
			vec3 i2 = max( g.xyz, l.zxy );

			//   x0 = x0 - 0.0 + 0.0 * C.xxx;
			//   x1 = x0 - i1  + 1.0 * C.xxx;
			//   x2 = x0 - i2  + 2.0 * C.xxx;
			//   x3 = x0 - 1.0 + 3.0 * C.xxx;
			vec3 x1 = x0 - i1 + C.xxx;
			vec3 x2 = x0 - i2 + C.yyy; // 2.0*C.x = 1/3 = C.y
			vec3 x3 = x0 - D.yyy;      // -1.0+3.0*C.x = -0.5 = -D.y

			// Permutations
			i = mod289(i); 
			vec4 p = permute( permute( permute( 
						i.z + vec4(0.0, i1.z, i2.z, 1.0 ))
					+ i.y + vec4(0.0, i1.y, i2.y, 1.0 )) 
					+ i.x + vec4(0.0, i1.x, i2.x, 1.0 ));

			// Gradients: 7x7 points over a square, mapped onto an octahedron.
			// The ring size 17*17 = 289 is close to a multiple of 49 (49*6 = 294)
			float n_ = 0.142857142857; // 1.0/7.0
			vec3  ns = n_ * D.wyz - D.xzx;

			vec4 j = p - 49.0 * floor(p * ns.z * ns.z);  //  mod(p,7*7)

			vec4 x_ = floor(j * ns.z);
			vec4 y_ = floor(j - 7.0 * x_ );    // mod(j,N)

			vec4 x = x_ *ns.x + ns.yyyy;
			vec4 y = y_ *ns.x + ns.yyyy;
			vec4 h = 1.0 - abs(x) - abs(y);

			vec4 b0 = vec4( x.xy, y.xy );
			vec4 b1 = vec4( x.zw, y.zw );

			//vec4 s0 = vec4(lessThan(b0,0.0))*2.0 - 1.0;
			//vec4 s1 = vec4(lessThan(b1,0.0))*2.0 - 1.0;
			vec4 s0 = floor(b0)*2.0 + 1.0;
			vec4 s1 = floor(b1)*2.0 + 1.0;
			vec4 sh = -step(h, vec4(0.0));

			vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ;
			vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ;

			vec3 p0 = vec3(a0.xy,h.x);
			vec3 p1 = vec3(a0.zw,h.y);
			vec3 p2 = vec3(a1.xy,h.z);
			vec3 p3 = vec3(a1.zw,h.w);

			//Normalise gradients
			vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));
			p0 *= norm.x;
			p1 *= norm.y;
			p2 *= norm.z;
			p3 *= norm.w;

			// Mix final noise value
			vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
			m = m * m;
			return 42.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1), 
											dot(p2,x2), dot(p3,x3) ) );
			}
		`
	}
}

I created panelling, a gun and a ceiling with lights and air conditioning pipes.

When creating the panelling and the gun I made use of the ThreeJS Shape class and ExtrudeGeometry.

I did some play testing and the frame rate was suffering on my Quest2. I decided to swap the panelling for a texture. Which I created by using a WebGLRenderTarget.

            const panelling = new Panelling( 1.5, 1.5, true );
            panelling.position.set( 0.12, 1.34, -1 );
            this.scene.add( panelling );

            const renderTarget = new THREE.WebGLRenderTarget(128, 128, {
                generateMipmaps: true, 
                wrapS: THREE.RepeatWrapping,
                wrapT: THREE.RepeatWrapping,
                minFilter: THREE.LinearMipmapLinearFilter
            });
            this.camera.aspect = 1.2;
            this.camera.updateProjectionMatrix();
            this.renderer.setRenderTarget( renderTarget );
            this.renderer.render( this.scene, this.camera );
            
            const map = renderTarget.texture;
            map.repeat.set( 30, 6 );
            this.panels[0].panelling.material.map = map;
            this.panels[1].panelling.material.map = map;
            this.scene.remove( panelling );

With some play testing I adjusted the bullet speed to be slow enough that balls to the left or right were quite difficult to hit. Then I added varying ways for the balls move. Starting just straight and ending up going up and down on a swivel. I also added a leaderboard and styled the various panels using css.

I submitted the game.

Having submitted the game I started work on a ThreeJS Path Editor. The paths I used to create complex shapes using ExtrudeGeometry involved drawing on graph paper there had to be a better way.

It’s all ready for next years competition. Source code here.

Nik Lever September 2024


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *