WebApps in the address bar of the browser

by | 19.12.2019

Desktop hard disks nowadays store several terabytes and can be interconnected in servers to exorbitant memory sizes. The main memory of current systems is in the gigabyte range. Graphics cards for computer games have now reached 12 GB and even mobile devices such as smartphones have gigantic storage possibilities; my iPhone 7 Plus has 128 GB flash memory and 3GB RAM – wow! The times when lack of memory was a big problem for programmers seem to be over.

However, there are still areas where memory consumption plays an important role: Web pages and Web apps on mobile devices. For such systems, the size of the app in terms of Javascript, HTML and CSS plays a role that should not be underestimated. For the loading behavior and the reaction time of an app, it makes a big difference whether an app is 300 KB or 2 MB in size.

Even if you can read that the development of Angular 9 with the new renderer Ivy makes sure that the created Javascript bundles are much smaller than in the previous versions, I have set a task for myself: I want to program an app that is so small that it fits into the address bar of the browser. On the one hand this is only a technical gimmick, which has a small “geek factor” for me personally, but on the other hand this gimmick also has a concrete use case, which I will come back to later.

Effects of an app that fits into the browser’s address bar

An app that is so small that it fits into the address bar of a browser reveals some positive and negative effects. And not just on mobile devices. Here are the positive effects from my point of view:

  • If no external resources such as other files or XHR requests are used, the initial loading time is extremely short and nothing has to be reloaded from the network.
  • The entire app could be saved in the favorites.
  • Data could be stored locally e.g. via the local storage.
  • The app could work 100% offline.

Technically there are some disadvantages besides the positive effects:

  • The URL to open the app is not easy to read or to enter manually into the browser.
  • The executed URL can execute any Javascript code and therefore cannot be protected against manipulation. (It is therefore highly recommended to know the source of the URL AND of course this source must be absolutely trustworthy.)
  • The functionality of the app is limited, because the address bar of the browser is very restricted.

 

The implementation of Pong as an example

For the implementation of an app that fits into the address bar of the browser, I take Pong as an example. The logic of the game is simple, the game doesn’t need any graphics or the like, and it can be completely implemented with browser tools.

I use Visual Studio Code as my development environment. For a little bit of simple Javascript and HTML this is more than enough. Additionally I use a local git repository. Let’s go.

First of all it’s important to get the game running in the browser using an HTML file with directly embedded Javascript:

Pong in the address bar

The rectangles at the top in the middle of the picture represent the goals to be achieved. As soon as a player scores 5 goals, he wins the game and the game can be restarted by pressing the Enter key. The left player is controlled with the W and S keys and the right with the up and down arrow keys.

Here is the corresponding source code:

<!DOCTYPE html>
<html>
    <head>
        <title>Tiny Pong</title>
    </head>
    <body>
        <canvas id="mainCanvas" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);">
    </body>
    <script>
        const game = function() {
            const WIDTH = 640;
            const HEIGHT = 480;
            const PADDLE_WIDTH = 24;
            const PADDLE_HEIGHT = 120;
            const BALL_SIZE = 24;
            const BALL_SPEED = 200;
            const BALL_MAX_SPEED = 400;
            const BALL_SPEED_INCREASE = 20;
            const PADDLE_SPEED = 300;
            const MAX_POINTS = 5;
            const POINT_SIZE = 16;
            const POINT_PADDING = 4;

            const mainCanvas = document.getElementById('mainCanvas');
            mainCanvas.width = WIDTH;
            mainCanvas.height = HEIGHT;
            const context = mainCanvas.getContext('2d');

            let input = {
                left_up : false,
                left_down: false,
                right_up: false,
                right_down: false,
                restart: false
            };

            let ball = {
                x: WIDTH/2-BALL_SIZE/2,
                y: HEIGHT/2-BALL_SIZE/2,
                dirX: 0,
                dirY: 1,
                angle: Math.PI / 6,
                speed: BALL_SPEED
            };

            let paddle_left = {
                x : 10,
                y : HEIGHT/2-PADDLE_HEIGHT/2,
                goals: 0
            };

            let paddle_right = {
                x : WIDTH - 10 - PADDLE_WIDTH,
                y : HEIGHT/2-PADDLE_HEIGHT/2,
                goals: 0
            };

            updateBallDirection(ball);

            window.addEventListener('keydown', event => {
                if (event.key === 'w') {
                    input.left_up = true;
                } else if (event.key === 's') {
                    input.left_down = true;
                } if (event.key === 'ArrowUp') {
                    input.right_up = true;
                } else if (event.key === 'ArrowDown') {
                    input.right_down = true;
                } else if (event.key === 'Enter') {
                    input.restart = true;
                }
            });
            window.addEventListener('keyup', event => {
                if (event.key === 'w') {
                    input.left_up = false;
                } else if (event.key === 's') {
                    input.left_down = false;
                } if (event.key === 'ArrowUp') {
                    input.right_up = false;
                } else if (event.key === 'ArrowDown') {
                    input.right_down = false;
                } else if (event.key === 'Enter') {
                    input.restart = false;
                }
            });

            let start = null;
            function animationFrameRequested(timestamp) {
                if (!start) start = timestamp;
                var elapsedTime = (timestamp - start) * 0.001;
                start = timestamp;
                updateAndRender(context, elapsedTime);
                window.requestAnimationFrame(animationFrameRequested);
            }

            function updateAndRender(context, elapsedTime) {
                context.fillStyle = 'black';
                context.fillRect(0, 0, WIDTH, HEIGHT);
                context.fillStyle = 'white';
                context.strokeStyle = 'white';
                context.lineWidth = 2;

                if (paddle_left.goals === MAX_POINTS) {
                    showWinner(context, 'Left Player Wins');
                } else if (paddle_right.goals === MAX_POINTS) {
                    showWinner(context, 'Right Player Wins');
                } else {
                    moveBall(elapsedTime);
                    updatePaddle(paddle_left, input.left_up, input.left_down, elapsedTime);
                    updatePaddle(paddle_right, input.right_up, input.right_down, elapsedTime);

                    let newAngle = null;
                    if (ball.x <= -BALL_SIZE) {
                        paddle_right.goals += 1;
                        newAngle = -Math.PI/4 + Math.random() * Math.PI/2
                    } else if (ball.x >= WIDTH + BALL_SIZE) {
                        paddle_left.goals += 1;
                        newAngle = Math.PI/4*3 + Math.random() * Math.PI/2
                    }
                    if (newAngle) {
                        respawnBall(ball, newAngle);
                    }

                    drawBall(context, ball);
                    drawPaddle(context, paddle_left);
                    drawPaddle(context, paddle_right);
                    drawPoints(context);
                }
            }

            function respawnBall(ball, newAngle) {
                ball.angle = newAngle;
                ball.speed = BALL_SPEED;
                ball.x = WIDTH/2-BALL_SIZE/2;
                ball.y = HEIGHT/2-BALL_SIZE/2;
                updateBallDirection(ball);
            }

            function showWinner(context, text) {
                context.font = '30px Arial';
                context.fillText(text, WIDTH/2-context.measureText(text).width/2, HEIGHT/2-15);
                const restartText = 'Press <Enter> to Restart Game';
                context.fillText(restartText, WIDTH/2-context.measureText(restartText).width/2, HEIGHT/2+40);

                if (input.restart) {
                    paddle_left.y = HEIGHT/2-PADDLE_HEIGHT/2;
                    paddle_right.y = HEIGHT/2-PADDLE_HEIGHT/2;
                    respawnBall(ball,  Math.PI / 6);
                    paddle_left.goals = 0;
                    paddle_right.goals = 0;
                }
            }

            function moveBall(elapsedTime) {
                ball.x += ball.dirX * elapsedTime * ball.speed;
                ball.y += ball.dirY * elapsedTime * ball.speed;

                if (ball.y > HEIGHT-BALL_SIZE && ball.dirY > 0 || ball.y < 0 && ball.dirY < 0) {
                    ball.dirY = -ball.dirY;
                }

                if (ball.dirX < 0 
                    && ball.x <= paddle_left.x + PADDLE_WIDTH
                    && ball.y + BALL_SIZE > paddle_left.y
                    && ball.y < paddle_left.y + PADDLE_HEIGHT) {
                    ball.dirX = -ball.dirX;
                    ball.speed = Math.min(BALL_MAX_SPEED, ball.speed + BALL_SPEED_INCREASE);
                }
                
                if (ball.dirX > 0 
                    && ball.x + BALL_SIZE > paddle_right.x
                    && ball.y + BALL_SIZE > paddle_right.y
                    && ball.y < paddle_right.y + PADDLE_HEIGHT) {
                    ball.dirX = -ball.dirX;
                    ball.speed = Math.min(BALL_MAX_SPEED, ball.speed + BALL_SPEED_INCREASE);
                }
            }

            function updatePaddle(paddle, up, down, elapsedTime) {
                if (up) {
                    paddle.y -= elapsedTime * PADDLE_SPEED;
                } else if (down) {
                    paddle.y += elapsedTime * PADDLE_SPEED;
                }
                paddle.y = Math.min(Math.max(0, paddle.y), HEIGHT-PADDLE_HEIGHT);
            }

            function updateBallDirection(ball) {
                ball.dirX = Math.cos(ball.angle);
                ball.dirY = Math.sin(ball.angle);
            }

            function drawBall(context, ball) {
                context.fillRect(ball.x, ball.y, BALL_SIZE, BALL_SIZE);
            }

            function drawPaddle(context, paddle) {
                context.fillRect(paddle.x, paddle.y, PADDLE_WIDTH, PADDLE_HEIGHT);
            }

            function drawPoints(context) {
                for (let i = 0; i < MAX_POINTS; i++) {
                    const xleft = WIDTH/2-(i+1)*(POINT_SIZE+POINT_PADDING)-POINT_SIZE;
                    if (i < paddle_left.goals) {
                        context.fillRect(xleft, POINT_PADDING, POINT_SIZE, POINT_SIZE);
                    } else {
                        context.beginPath();
                        context.rect(xleft, POINT_PADDING, POINT_SIZE, POINT_SIZE);
                        context.stroke();
                    }

                    const xright = WIDTH/2+i*(POINT_SIZE+POINT_PADDING)+POINT_SIZE;
                    if (i < paddle_right.goals) {
                        context.fillRect(xright, POINT_PADDING, POINT_SIZE, POINT_SIZE);
                    } else {
                        context.beginPath();
                        context.rect(xright, POINT_PADDING, POINT_SIZE, POINT_SIZE);
                        context.stroke();
                    }
                }
            }

            window.requestAnimationFrame(animationFrameRequested);
        }();
    </script>
</html>

The minifier in action

The next step is to make all the code as small as possible. So-called minifiers are used for this. These are programs that remove all unnecessary characters and shorten all identifiers, especially with Javascript, so that the program becomes much slimmer. This procedure is also used with Single Page Applications to reduce the size of the files delivered to the browser. Here there are often much more extensive improvements such as tree shaking to remove unused code from used libraries. For us, this is just a matter of shortening the content. You can find a corresponding tool, for example, at https://www.toptal.com/developers/javascript-minifier.

And this is what the result looks like after using a minifier:

<!DOCTYPE html><html><head><title>Tiny Pong</title></head><body><canvas id="mainCanvas" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);"></body><script>const game=function(){const e=640,t=480,n=24,i=120,r=24,l=200,o=400,a=20,s=300,d=5,y=16,f=4,h=document.getElementById("mainCanvas");h.width=e,h.height=t;const w=h.getContext("2d");let g={left_up:!1,left_down:!1,right_up:!1,right_down:!1,restart:!1},u={x:e/2-r/2,y:t/2-r/2,dirX:0,dirY:1,angle:Math.PI/6,speed:l},c={x:10,y:t/2-i/2,goals:0},p={x:e-10-n,y:t/2-i/2,goals:0};M(u),window.addEventListener("keydown",e=>{"w"===e.key?g.left_up=!0:"s"===e.key&&(g.left_down=!0),"ArrowUp"===e.key?g.right_up=!0:"ArrowDown"===e.key?g.right_down=!0:"Enter"===e.key&&(g.restart=!0)}),window.addEventListener("keyup",e=>{"w"===e.key?g.left_up=!1:"s"===e.key&&(g.left_down=!1),"ArrowUp"===e.key?g.right_up=!1:"ArrowDown"===e.key?g.right_down=!1:"Enter"===e.key&&(g.restart=!1)});let x=null;function m(n,i){n.angle=i,n.speed=l,n.x=e/2-r/2,n.y=t/2-r/2,M(n)}function k(n,r){n.font="30px Arial",n.fillText(r,e/2-n.measureText(r).width/2,t/2-15);n.fillText("Press <Enter> to Restart Game",e/2-n.measureText("Press <Enter> to Restart Game").width/2,t/2+40),g.restart&&(c.y=t/2-i/2,p.y=t/2-i/2,m(u,Math.PI/6),c.goals=0,p.goals=0)}function _(e,n,r,l){n?e.y-=l*s:r&&(e.y+=l*s),e.y=Math.min(Math.max(0,e.y),t-i)}function M(e){e.dirX=Math.cos(e.angle),e.dirY=Math.sin(e.angle)}function P(e,t){e.fillRect(t.x,t.y,n,i)}window.requestAnimationFrame(function l(s){x||(x=s);var h=.001*(s-x);x=s,function(l,s){if(l.fillStyle="black",l.fillRect(0,0,e,t),l.fillStyle="white",l.strokeStyle="white",l.lineWidth=2,c.goals===d)k(l,"Left Player Wins");else if(p.goals===d)k(l,"Right Player Wins");else{!function(e){u.x+=u.dirX*e*u.speed,u.y+=u.dirY*e*u.speed,(u.y>t-r&&u.dirY>0||u.y<0&&u.dirY<0)&&(u.dirY=-u.dirY),u.dirX<0&&u.x<=c.x+n&&u.y+r>c.y&&u.y<c.y+i&&(u.dirX=-u.dirX,u.speed=Math.min(o,u.speed+a)),u.dirX>0&&u.x+r>p.x&&u.y+r>p.y&&u.y<p.y+i&&(u.dirX=-u.dirX,u.speed=Math.min(o,u.speed+a))}(s),_(c,g.left_up,g.left_down,s),_(p,g.right_up,g.right_down,s);let h=null;u.x<=-r?(p.goals+=1,h=-Math.PI/4+Math.random()*Math.PI/2):u.x>=e+r&&(c.goals+=1,h=Math.PI/4*3+Math.random()*Math.PI/2),h&&m(u,h),function(e,t){e.fillRect(t.x,t.y,r,r)}(l,u),P(l,c),P(l,p),function(t){for(let n=0;n<d;n++){const i=e/2-(n+1)*(y+f)-y;n<c.goals?t.fillRect(i,f,y,y):(t.beginPath(),t.rect(i,f,y,y),t.stroke());const r=e/2+n*(y+f)+y;n<p.goals?t.fillRect(r,f,y,y):(t.beginPath(),t.rect(r,f,y,y),t.stroke())}}(l)}}(w,h),window.requestAnimationFrame(l)})}();</script></html>

With a little manual work, the size of the code can be further reduced. To get the whole thing into the address bar of your browser, you only have to specify the correct MIME type using a Data URI. Simply take the minified code above and add the following text to the front:

data:text/html,

You’ve already got it all together! If you want to play a round of Pong with a colleague in the browser, you can simply copy the following text and paste it into your browser address bar. Have fun playing.

data:text/html,<!DOCTYPE html><html><head><title>Tiny Pong</title></head><body><canvas id="mainCanvas" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);"></body><script>const game=function(){const e=640,t=480,n=24,i=120,r=24,l=200,o=400,a=20,s=300,d=5,y=16,f=4,h=document.getElementById("mainCanvas");h.width=e,h.height=t;const w=h.getContext("2d");let g={left_up:!1,left_down:!1,right_up:!1,right_down:!1,restart:!1},u={x:e/2-r/2,y:t/2-r/2,dirX:0,dirY:1,angle:Math.PI/6,speed:l},c={x:10,y:t/2-i/2,goals:0},p={x:e-10-n,y:t/2-i/2,goals:0};M(u),window.addEventListener("keydown",e=>{"w"===e.key?g.left_up=!0:"s"===e.key&&(g.left_down=!0),"ArrowUp"===e.key?g.right_up=!0:"ArrowDown"===e.key?g.right_down=!0:"Enter"===e.key&&(g.restart=!0)}),window.addEventListener("keyup",e=>{"w"===e.key?g.left_up=!1:"s"===e.key&&(g.left_down=!1),"ArrowUp"===e.key?g.right_up=!1:"ArrowDown"===e.key?g.right_down=!1:"Enter"===e.key&&(g.restart=!1)});let x=null;function m(n,i){n.angle=i,n.speed=l,n.x=e/2-r/2,n.y=t/2-r/2,M(n)}function k(n,r){n.font="30px Arial",n.fillText(r,e/2-n.measureText(r).width/2,t/2-15);n.fillText("Press <Enter> to Restart Game",e/2-n.measureText("Press <Enter> to Restart Game").width/2,t/2+40),g.restart&&(c.y=t/2-i/2,p.y=t/2-i/2,m(u,Math.PI/6),c.goals=0,p.goals=0)}function _(e,n,r,l){n?e.y-=l*s:r&&(e.y+=l*s),e.y=Math.min(Math.max(0,e.y),t-i)}function M(e){e.dirX=Math.cos(e.angle),e.dirY=Math.sin(e.angle)}function P(e,t){e.fillRect(t.x,t.y,n,i)}window.requestAnimationFrame(function l(s){x||(x=s);var h=.001*(s-x);x=s,function(l,s){if(l.fillStyle="black",l.fillRect(0,0,e,t),l.fillStyle="white",l.strokeStyle="white",l.lineWidth=2,c.goals===d)k(l,"Left Player Wins");else if(p.goals===d)k(l,"Right Player Wins");else{!function(e){u.x+=u.dirX*e*u.speed,u.y+=u.dirY*e*u.speed,(u.y>t-r&&u.dirY>0||u.y<0&&u.dirY<0)&&(u.dirY=-u.dirY),u.dirX<0&&u.x<=c.x+n&&u.y+r>c.y&&u.y<c.y+i&&(u.dirX=-u.dirX,u.speed=Math.min(o,u.speed+a)),u.dirX>0&&u.x+r>p.x&&u.y+r>p.y&&u.y<p.y+i&&(u.dirX=-u.dirX,u.speed=Math.min(o,u.speed+a))}(s),_(c,g.left_up,g.left_down,s),_(p,g.right_up,g.right_down,s);let h=null;u.x<=-r?(p.goals+=1,h=-Math.PI/4+Math.random()*Math.PI/2):u.x>=e+r&&(c.goals+=1,h=Math.PI/4*3+Math.random()*Math.PI/2),h&&m(u,h),function(e,t){e.fillRect(t.x,t.y,r,r)}(l,u),P(l,c),P(l,p),function(t){for(let n=0;n<d;n++){const i=e/2-(n+1)*(y+f)-y;n<c.goals?t.fillRect(i,f,y,y):(t.beginPath(),t.rect(i,f,y,y),t.stroke());const r=e/2+n*(y+f)+y;n<p.goals?t.fillRect(r,f,y,y):(t.beginPath(),t.rect(r,f,y,y),t.stroke())}}(l)}}(w,h),window.requestAnimationFrame(l)})}();</script></html>

The practical use

Of course, my example is literally a gimmick. Could the whole thing also have a practical use? In my opinion: yes. For example, I could imagine that you could create a small, persistent to-do list app with it. This stores its data in the local storage and can be quickly and easily saved and accessed in my browser via a favorite entry. I also think the idea that you can send the app directly in an e-mail via a link is pretty cool. If one could still compress and sign such URLs, then perhaps larger apps would also be conceivable and the security of the application could be increased. Let’s see, maybe I will think of something else in the next few years. Or maybe you have an idea … 😉

 

Notes:

Peter Friedland has published some more articles in the t2informatik Blog, including:

t2informatik Blog: Performance Optimisation for WPF Applications

Performance Optimisation for WPF Applications

t2informatik Blog: CI/CD pipeline on a Raspberry Pi

CI/CD pipeline on a Raspberry Pi

t2informatik Blog: Avalonia UI - Cross-Platform WPF application development with .NET Core

Avalonia UI

Peter Friedland
Peter Friedland

Software Consultant at t2informatik GmbH

Peter Friedland works at t2informatik GmbH as a software consultant. In various customer projects he develops innovative solutions in close cooperation with partners and local contact persons. And from time to time he also blogs.