Skip to content

Getting Started with Banjo🔗

In this guide, you'll go from a new directory to the quick start example Banjo project

Project Setup🔗

bun init
bun add @studiokeywi/banjo

Here are some boilerplate files to get the project started -- a simple HTML page, our "game" logic, and a "server" file using Bun:

Changed lines for each file will be highlighted in each step

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Banjo Project</title>
    <style>
      body,
      html {
        height: 100%;
        margin: 0px;
      }
    </style>
    <script type="module" defer src="./game.ts"></script>
  </head>
  <body></body>
</html>
1
console.log('Hello world!');
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const server = Bun.serve({
  async fetch({ url }) {
    if (new URL(url).pathname === '/game.ts') {
      const [body] = (await Bun.build({ entrypoints: ['./game.ts'] })).outputs;
      return new Response(body);
    }
    return new Response(Bun.file('./index.html'));
  },
});

console.log(`Now listening on http://${server.hostname}:${server.port}\n${'-'.repeat(25 + server.hostname.length + server.port.toString().length)}`);

You should now be able to run:

bun .

And see something similar to

Now listening on http://localhost:3000
--------------------------------------

Pointing your browser to that address, you should see a blank page with your "Hello world!" printed in the DevTools console.

createEngine🔗

Now that you have a working skeleton, you can start using Banjo features. Let's start with creating a simple engine by updating your project files:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Banjo Project</title>
    <style>
      body,
      html {
        height: 100%;
        margin: 0px;
      }
      #debugHUD {
        left: 8px;
        font-size: 3rem;
        position: absolute;
        top: 8px;
      }
    </style>
    <script type="module" defer src="./game.ts"></script>
  </head>
  <body>
    <div id="debugHUD"></div>
  </body>
</html>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { createEngine } from '@studiokeywi/banjo/engine';

const hud = document.querySelector<HTMLDivElement>('#debugHUD')!;
let FPS: number;
let TPS: number;

const engine = createEngine({
  TPS: 60, // (1)!
  render: () => { // (2)!
    hud.innerText = `Engine TPS: ${TPS} | Engine FPS: ${FPS}`;
  },
  update: () => { // (3)!
    ({ FPS, TPS } = engine); // (4)!
  },
});
  1. The Ticks Per Second (or TPS) is a measure of how often you want your game logic to run
  2. The render function will get called approximately as many times per second as the user's monitor refresh rate
  3. The update function will get called approximately as many times per second as defined in TPS
  4. The Engine interface exposes the approximate FPS and TPS for quick access like this
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const server = Bun.serve({
  async fetch({ url }) {
    const path = new URL(url).pathname;
    if (path === '/game.ts') {
      const [body] = (await Bun.build({ entrypoints: ['./game.ts'] })).outputs;
      return new Response(body);
    }
    if (path.startsWith('/src:')) {
      return new Response(Bun.file(path.slice(5)));
    }
    return new Response(Bun.file('./index.html'));
  },
});

console.log(`Now listening on http://${server.hostname}:${server.port}\n${'-'.repeat(25 + server.hostname.length + server.port.toString().length)}`);

IntelliSense doesn't recognize DOM types like document?

Try adding the "DOM" value to your tsconfig.json in the lib property under compilerOptions:

{
  "compilerOptions": {
    "lib": ["DOM"] // (1)!
  }
}
  1. While not required, we suggest a minimum lib setting of ["DOM", "DOM.AsyncIterable", "DOM.Iterable", "ESNext"]

Under the hood, Banjo's game loop utilizes requestAnimationFrame. This means we can attempt to target the user's monitor refresh rate for maximum FPS, and provide developers with a customizable target for game ticks (or updates) per second. Both the render and update functions are passed a delta value in milliseconds.

The render Function🔗

The render function should handle whatever logic your game requires to display the game. This could involve using the <canvas> element, manipulating DOM elements, or more based on your project structure. For now, we'll create a simple HUD using a <div> to render engine data.

render is passed a delta value representing the elapsed time since the last frame

The update Function🔗

The update function should handle whatever logic is required to change the state of the game. For now, we'll use it to grab the current engine state.

update is passed a delta value equal to 1 / (TPS * 1_000) (the duration of one "tick" based on the provided TPS value, converted to milliseconds)

Running the Engine🔗

If you have been running your code after each change (or using Bun's --watch mode to restart automatically), you'll notice that nothing is actually updating on the page. This is because the engine needs to be started first. We advise using patterns to start, stop, or pause/unpause the engine based on browser ready state and whether it has focus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Banjo Project</title>
    <style>
      body,
      html {
        height: 100%;
        margin: 0px;
      }
      #debugHUD {
        left: 8px;
        font-size: 3rem;
        position: absolute;
        top: 8px;
      }
    </style>
    <script type="module" defer src="./game.ts"></script>
  </head>
  <body>
    <div id="debugHUD"></div>
  </body>
</html>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { createEngine } from '@studiokeywi/banjo/engine';

const pause = () => { // (1)!
  if (!engine.paused) {
    engine.pause();
  }
};
const unpause = () => {
  if (engine.paused) {
    engine.pause();
  }
};

const hud = document.querySelector<HTMLDivElement>('#debugHUD')!;
let FPS: number;
let TPS: number;

const engine = createEngine({
  TPS: 60,
  render: () => {
    hud.innerText = `Engine TPS: ${TPS} | Engine FPS: ${FPS}`;
  },
  update: () => {
    ({ FPS, TPS } = engine);
  },
});

addEventListener('beforeunload', () => {
  engine.stop();
  removeEventListener('blur', pause);
  removeEventListener('focus', unpause);
});
addEventListener('blur', pause);
addEventListener('focus', unpause);

if (document.readyState === 'complete') {
  engine.start();
} else {
  addEventListener('load', () => {
    engine.start();
  });
}
  1. The pause and unpause helpers here exist to make sure that we don't alter the pause state incorrectly (although that shouldn't happen in this example)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const server = Bun.serve({
  async fetch({ url }) {
    const path = new URL(url).pathname;
    if (path === '/game.ts') {
      const [body] = (await Bun.build({ entrypoints: ['./game.ts'] })).outputs;
      return new Response(body);
    }
    if (path.startsWith('/src:')) {
      return new Response(Bun.file(path.slice(5)));
    }
    return new Response(Bun.file('./index.html'));
  },
});

console.log(`Now listening on http://${server.hostname}:${server.port}\n${'-'.repeat(25 + server.hostname.length + server.port.toString().length)}`);

What About Pausing?🔗

Pausing the engine does not stop its internal loop; it only prevents the update and render callbacks from being executed. So you may be wondering how you can detect/display the paused state of the engine from outside of the render and update loops? This seems like a good use case for a Watcher:

This usage of a Watcher operates outside of the normal engine update cycle. This should not be used for logic that affects the game state directly

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Banjo Project</title>
    <style>
      body,
      html {
        height: 100%;
        margin: 0px;
      }
      #debugHUD {
        left: 8px;
        font-size: 3rem;
        position: absolute;
        top: 8px;
      }
    </style>
    <script type="module" defer src="./game.ts"></script>
  </head>
  <body>
    <div id="debugHUD"></div>
  </body>
</html>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { createEngine } from '@studiokeywi/banjo/engine';
import { createWatcher } from '@studiokeywi/banjo/watcher';

const pause = () => {
  if (!engine.paused) {
    engine.pause();
  }
};
const unpause = () => {
  if (engine.paused) {
    engine.pause();
  }
};

const hud = document.querySelector<HTMLDivElement>('#debugHUD')!;
const isPaused = createWatcher({
  do: () => { // (1)!
    if (engine.paused && !hud.innerText.includes('Paused')) {
      hud.innerText += ' | Paused';
    }
  }
});
let FPS: number;
let TPS: number;

const engine = createEngine({
  TPS: 60,
  render: () => {
    hud.innerText = `Engine TPS: ${TPS} | Engine FPS: ${FPS}`;
  },
  update: () => {
    ({ FPS, TPS } = engine);
    if (!isPaused.running) {
      isPaused.start(); // (2)!
    }
  },
});

addEventListener('beforeunload', () => {
  engine.stop();
  isPaused.stop();
  removeEventListener('blur', pause);
  removeEventListener('focus', unpause);
});
addEventListener('blur', pause);
addEventListener('focus', unpause);

if (document.readyState === 'complete') {
  engine.start();
} else {
  addEventListener('load', () => {
    engine.start();
  });
}
  1. The Watcher object performs behaviors based on configurable conditions and timings. Here, we want the Watcher to do our update for the pause display.
  2. Watcher objects run for a set period of time before stopping, and so this will restart the watcher automatically as needed.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const server = Bun.serve({
  async fetch({ url }) {
    const path = new URL(url).pathname;
    if (path === '/game.ts') {
      const [body] = (await Bun.build({ entrypoints: ['./game.ts'] })).outputs;
      return new Response(body);
    }
    if (path.startsWith('/src:')) {
      return new Response(Bun.file(path.slice(5)));
    }
    return new Response(Bun.file('./index.html'));
  },
});

console.log(`Now listening on http://${server.hostname}:${server.port}\n${'-'.repeat(25 + server.hostname.length + server.port.toString().length)}`);

Final Touches🔗

While technically a full Banjo project, this is still a little... basic. Our final step for this guide will be introducing a "bouncing ball" animation. It's still pretty simple, but it shows how easily Banjo features can be combined to obtain desired behavior:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Banjo Project</title>
    <style>
      body,
      html {
        height: 100%;
        margin: 0px;
      }
      #debugHUD {
        left: 8px;
        font-size: 3rem;
        position: absolute;
        top: 8px;
      }
      #ball {
        background: blue;
        border: solid 0px transparent;
        border-radius: 1rem;
        height: 16px;
        position: absolute;
        width: 16px;
      }
    </style>
    <script type="module" defer src="./game.ts"></script>
  </head>
  <body>
    <div id="ball"></div>
    <div id="debugHUD"></div>
  </body>
</html>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import { createEngine } from '@studiokeywi/banjo/engine';
import { clamp } from '@studiokeywi/banjo/math/conversions';
import { native } from '@studiokeywi/banjo/math/random';
import { add, vector2 } from '@studiokeywi/banjo/math/v2';
import { createWatcher } from '@studiokeywi/banjo/watcher';

const pause = () => {
  if (!engine.paused) {
    engine.pause();
  }
};
const unpause = () => {
  if (engine.paused) {
    engine.pause();
  }
};

const hud = document.querySelector<HTMLDivElement>('#debugHUD')!;
const isPaused = createWatcher({
  do: () => {
    if (engine.paused && !hud.innerText.includes('Paused')) {
      hud.innerText += ' | Paused';
    }
  },
});
let FPS: number;
let TPS: number;

const ball = document.querySelector<HTMLDivElement>('#ball')!;
const rng = native();
const speedX = rng.randRange(1, 5, true) * (rng.randFloat() < 0.5 ? 1 : -1);
const speedY = rng.randRange(1, 5, true) * (rng.randFloat() < 0.5 ? 1 : -1);
const ballSpeed = vector2(speedX, speedY);
const startX = rng.randRange(0, innerWidth - 16, true);
const startY = rng.randRange(0, innerHeight - 16, true);
const ballPosition = vector2(startX, startY);

const engine = createEngine({
  TPS: 60,
  render: delta => {
    hud.innerText = `Engine TPS: ${TPS} | Engine FPS: ${FPS}`;
    ball.style.left = `${ballPosition.x + delta * ballSpeed.x}px`;
    ball.style.top = `${ballPosition.y + delta * ballSpeed.y}px`;
  },
  update: () => {
    ({ FPS, TPS } = engine);
    if (!isPaused.running) {
      isPaused.start();
    }
    add(ballPosition, ballSpeed, ballPosition);
    if (ballPosition.x < 0 || ballPosition.x >= innerWidth - 16) {
      ballPosition.x = clamp(ballPosition.x, 0, innerWidth - 16);
      ballSpeed.x *= -1;
    }
    if (ballPosition.y < 0 || ballPosition.y >= innerHeight - 16) {
      ballPosition.y = clamp(ballPosition.y, 0, innerHeight - 16);
      ballSpeed.y *= -1;
    }
  },
});

addEventListener('beforeunload', () => {
  engine.stop();
  isPaused.stop();
  removeEventListener('blur', pause);
  removeEventListener('focus', unpause);
});
addEventListener('blur', pause);
addEventListener('focus', unpause);

if (document.readyState === 'complete') {
  engine.start();
} else {
  addEventListener('load', () => {
    engine.start();
  });
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const server = Bun.serve({
  async fetch({ url }) {
    const path = new URL(url).pathname;
    if (path === '/game.ts') {
      const [body] = (await Bun.build({ entrypoints: ['./game.ts'] })).outputs;
      return new Response(body);
    }
    if (path.startsWith('/src:')) {
      return new Response(Bun.file(path.slice(5)));
    }
    return new Response(Bun.file('./index.html'));
  },
});

console.log(`Now listening on http://${server.hostname}:${server.port}\n${'-'.repeat(25 + server.hostname.length + server.port.toString().length)}`);

Live Demo🔗