Documentation · tutorial

Your first plugin

A typewriter that writes a text letter by letter, from the plugin template to the .zip any game can use.

Starting

Above, the demo game with the plugin in a file beside it (use_typewriter.ts). Change the code and see it at once.

A plugin is a package other people's games import: a dialogue box that types, a health bar, a way to drag things. It is written the way a game is, with the same hooks, and a game uses it the way it uses any library: it imports what the plugin exports and calls it. Nothing in the engine is set up for it.

This page makes one from start to finish: a typewriter that writes a text letter by letter, with a breath after each full stop. It is the game above: index.ts is the game, and use_typewriter.ts is the whole plugin.

What you need

Node.js 20.19 or newer, to build the plugin. The people who use it do not need it, and neither do their players.

1. Start from the template

With npm, in one line. The folder's name is the plugin's name:

npm create nacatamalon@latest typewriter -- --template plugin
cd typewriter
npm install

Without the command line, use the Download the template button at the top of this page. Unzip it, rename the folder if you like, and give the plugin its name once:

npm install
npm run rename -- typewriter

Either way the package is now nacatamalon-typewriter, and its hook is useTypewriter in src/use_typewriter.ts. Here is what is in the folder:

src/index.ts             what a game imports, and nothing else
src/plugin.ts            the plugin's name and the engines it takes, read from package.json
src/use_typewriter.ts    the hook: what the plugin does
view/                    your workbench, to try the plugin while you write it
demo/                    a small game that uses the plugin, its shop window
tests/                   the plugin in a game run with no browser
store/                   the icon and the screenshots
PLUGIN_GUIDE.md          the rules a plugin keeps; it is for you and stays as it is

2. The hook

Replace what src/use_typewriter.ts holds with the typewriter:

import { useUpdate } from 'nacatamalon';
import type { TText } from 'nacatamalon';
import { PLUGIN_NAME, engineFits } from './plugin';
 
export type TTypewriterOptions = {
    /** Letters per second. Default 30. */
    speed?: number;
    /** Extra seconds after these characters. Default a breath after `.`, `!`, `?` and `,`. */
    pauses?: Readonly<Record<string, number>>;
};
 
export type TTypewriter = {
    /** Starts writing this, from nothing. */
    type(text: string): void;
    /** Shows the rest at once. */
    skip(): void;
    /** Whether it is still writing. */
    readonly typing: boolean;
};
 
const PAUSES = { '.': 0.3, '!': 0.3, '?': 0.3, ',': 0.12 };
 
/** Writes a text letter by letter, as a dialogue box does. */
export const useTypewriter = (text: TText, { speed = 30, pauses = PAUSES }: TTypewriterOptions = {}): TTypewriter => {
    if (!(speed > 0)) {
        throw new Error(`${PLUGIN_NAME}: useTypewriter got a speed of ${speed}. It is letters per second, above 0.`);
    }
    const writes = engineFits();
 
    let full = '';
    let shown = 0;
    let wait = 0;
    let typing = false;
 
    useUpdate((delta) => {
        if (!typing) return;
        while (wait <= 0 && shown < full.length) {
            wait += 1 / speed + (pauses[full[shown]] ?? 0);
            shown++;
        }
        wait -= delta;
        text.text = full.slice(0, shown);
        if (shown === full.length) typing = false;
    });
 
    return {
        type(said) {
            full = said;
            shown = writes ? 0 : said.length;
            wait = 0;
            typing = writes;
            text.text = writes ? '' : said;
        },
        skip() {
            shown = full.length;
            text.text = full;
            typing = false;
        },
        get typing() {
            return typing;
        },
    };
};

There is no init, update or destroy to learn: the lifecycle is the hooks, the same ones a game uses. A game calls useTypewriter inside a component. Its body runs once, when that object is made. useUpdate runs every frame for as long as the object lives, stops when the scene is paused, and goes when the object goes. What the hook keeps (full, shown, wait) lives inside it, one set for each text that uses it. Never in a variable at the top of the file: a page can run several games.

Three habits every plugin keeps, all in those lines:

3. What a game gets

src/index.ts is the plugin's front door: what it exports is all a game can import.

export { useTypewriter } from './use_typewriter';
export type { TTypewriter, TTypewriterOptions } from './use_typewriter';

4. Try it

view/ is your workbench: a page of your own to see the plugin at work while you write it. It is never packed. Put in it every case you want to see side by side, the odd ones too. In view/main.ts, three speeds:

import { createGame, createScene, createText, getColor, useSpawn } from 'nacatamalon';
import { useTypewriter } from 'nacatamalon-typewriter';
 
const Case = ({ y, label, speed }: { y: number; label: string; speed: number }) => {
    createText({ text: label, style: { fontSize: 8 }, transform: { x: 8, y } });
    const line = createText({ text: '', style: { fontSize: 8 }, transform: { x: 80, y } });
    useTypewriter(line, { speed }).type('Halt! Who goes there?');
};
 
const View = () => {
    const spawn = useSpawn(Case, 'Case');
    spawn({ y: 40, label: 'speed 10', speed: 10 });
    spawn({ y: 80, label: 'speed 30', speed: 30 });
    spawn({ y: 120, label: 'speed 90', speed: 90 });
    return createScene();
};
 
createGame('#app', { width: 320, height: 180, background: getColor('#12102b'), scaling: 'integer' })({ View });
npm run dev

It imports the plugin by its name, nacatamalon-typewriter, as a game would. It reloads each time you save.

5. Test it

The tests run the plugin in a game with no browser, from nacatamalon/headless: a frame at a time, the same every time. Replace the test in tests/typewriter.test.ts:

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createScene, createText } from 'nacatamalon';
import type { TText } from 'nacatamalon';
import { createHeadlessGame } from 'nacatamalon/headless';
import { useTypewriter } from '../src';
 
test('it writes a letter at a time, then the whole line', async () => {
    let line!: TText;
    const game = createHeadlessGame({
        width: 160,
        height: 90,
        scenes: {
            Level: () => {
                line = createText({ text: '' });
                useTypewriter(line, { speed: 30 }).type('Hello there');
                return createScene();
            },
        },
    });
    await game.loaded();
 
    game.run(1);
    assert.equal(line.text, 'H');
    game.run(10);
    assert.ok(line.text.length > 1 && line.text.length < 11, line.text);
    game.run(60);
    assert.equal(line.text, 'Hello there');
    game.destroy();
});
npm test
npm run typecheck

game.loaded() runs no frame: the first game.run(1) is the game's first frame, and the first letter is there. What a text says now is read from the text itself, line.text.

6. The demo

demo/ is the plugin's shop window: a small game that shows it off. Make demo/main.ts the game at the top of this page, importing the plugin by its name instead of from the file beside it:

import { useTypewriter } from 'nacatamalon-typewriter';

Keep the last lines the template left in it, the ones with dev_shots. Then:

npm run demo

While it runs, F9 saves the game as it is into store/shot-1.png and lists it in package.json. Draw an icon of your own for store/icon.png, 256 by 256.

7. Pack it

Fill in package.json first: description, author, license and homepage, and under nacatamalon the summary, category and tags. Then:

npm run pack

It builds dist/, checks the plugin and leaves two files in release/:

The pack stops only for what would break a game: an import that is not one of the engine's public doors, a dependency left for npm to install (a game that unzips the plugin has no npm, so the build bundles them), a file the package does not carry, or the engine inside dist/ (the game brings its own, and two copies would be two engines that do not see each other). What only a store page needs, such as a screenshot, it reports and lets through.

8. Use it in a game

Unzip the .zip into the game's plugins/ folder, add one line to the game's package.json, and run npm install:

"dependencies": {
    "nacatamalon-typewriter": "file:./plugins/nacatamalon-typewriter"
}

From then on it is imported by its name, like anything else:

import { createScene, createText } from 'nacatamalon';
import { useTypewriter } from 'nacatamalon-typewriter';
 
const Sign = () => {
    const line = createText({ text: '', transform: { x: 16, y: 80 } });
    useTypewriter(line, { speed: 20 }).type('Welcome to the village.');
    return createScene();
};

Without touching package.json, it can also be imported by its path: '../plugins/nacatamalon-typewriter/dist/index.js'. If you published it, npm install nacatamalon-typewriter.

Where to go next

Look at the demo above while it writes the second line: a word that does not fit starts at the end of the first line, and jumps to the next one as it grows. A finished typewriter works out where its lines break before it starts (measureText gives the lines a text with maxWidth is laid out in), so a word starts on the line it ends on. It can also tell the game each letter as it appears, for a blip, and pause and carry on.

The rest of what a plugin can do is in the plugins guide (the whole guide link at the top of this page, and guides/plugins.md inside the engine): an install…() function for what belongs to the whole game, what nacatamalon/extend gives, work of its own on the GPU, and kinds of drawing the engine does not have.