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 installWithout 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 -- typewriterEither 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:
- It checks the engine.
engineFits(), fromsrc/plugin.ts, asks whether the engine running the game is one this plugin was made for, from thepeerDependenciesrange inpackage.json(>=0.7.0 <1). When it is not, the engine says so once, in the plugin's name, and the plugin decides what to do: this one still shows the text, all at once. - Its messages start with its name.
PLUGIN_NAMEisnacatamalon-typewriter, so whoever reads the console knows who is speaking. - Every option has a default, and a doc comment: it is what the game's author reads when their code editor shows the option.
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 devIt 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 typecheckgame.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 demoWhile 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 packIt builds dist/, checks the plugin and leaves two files in release/:
nacatamalon-typewriter-1.0.0.zip, to send by hand or put up on itch.io, a release page or your own site.nacatamalon-typewriter-1.0.0.tgz, the same for npm, if you want to publish it there.
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.