Categories
Uncategorised

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

By niklever

Started coding in 1983 using Sinclair Basic. Recently developed HTML5 games and THREE.js web apps. Course developer on the Udemy and Packt platforms.

Leave a Reply

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