you know what, screw uppercase. not like that many people are gonna be reading this anyway.

creating a speech

the basics

do this, basically.
  • ts
import type { SpeechFunction } from "$lib/speech/Speech";
export const speech: SpeechFunction = async function* (s) {
	// horray
congrats! you have a speech that does nothing.
the s argument is important. it's your speech goodies, which are your gateway to doing anything, really.
and that's the basics. let's review:
  • export default an async generator
  • the s argument is important!!!!!!!!!!!!!!!

cleaning up after yourself

before i get onto the actual speech part, i should touch on cleaning up after yourself.
this is a website, so we kinda have to deal with the issue of that the user might just ditch us halfway through via "links".
luckily it isn't so bad due to the generator functions.


whenever you want to check if it's okay to keep on going, you yield.
yielding is done best whenever there's a gap in the code to wait on, like when waiting for a promise to resolve. so yield also acts like await.
  • ts
yield delayedResolve(1000);
however, i couldn't type it nicely, so you'll just get an any back when dealing with a value. a workaround in cases like those would be to just use await and put yield on a line on its own afterwards:
  • ts
const response = await fetch("/");
or just type the variable:
  • ts
const response: Response = yield fetch("/");
it's not the prettiest, but who cares. it's just me :)
some functions that return values, like character.prompt() , must be followed by a yield. this is because functions like these have a mechanism to resolve early with null incase the user clicks off.
why not yield* await?
do you want to type that out every single time?
anyway, the yielding lets you do some weird things while letting you clean up easily. in this example, the user can click off without having to respond to the dialog and the ticker won't linger.
  • ts
const ticker = new Ticker().start();
ticker.onTick.add(() => {
	// some code to do some animation, idk

try {
	yield dabric.say("I'm animating!!!!");
} finally {

methods to help cleaning up in the goodies

they make life easier for how you're likely to use them in speeches, and they also automatically clean up after themselves so you don't have to do anything.
if you want to make your own, the event to listen to is s.onDestroy. just like svelte.
lets you start the ticker and subscribe to it with a single function.
the above example could be shortened down to:
  • ts
const ticker = s.ticker(
		/* options */
	() => {
		// some code to do animation, idk

yield dabric.say("I'm animating!!!");

note that the try finally isn't needed anymore as the ticker helper is cleaning up for us.
lets you subscribe to something with a similar nature to svelte stores; the usual object with a subscribe function that returns another function that unsubscribes...
  • ts
s.subscribeTo(thing, () => {
	/* blah */


now we can get to characters and making them talk.
first, you'll need to get a character. you can just import one and then add it with s.addCharacter()
  • ts
const dabric = s.addCharacter(DabricCharacter);
making them talk is pretty simple:
  • ts
yield dabric.say("hello");
you can also prompt for something like so:
  • ts
// 0 if "idk", 1 if "you tell me"...
const result = await dabric.prompt(["idk", "you tell me"]);
and combine say and prompt at the same time with ask:
  • ts
const result = await evilMan.ask("What's your favorite color >:)", [
you can also directly mutate some properties for full control:
  • ts
// makes the dialog appear
// changes the text
// waits for click

character states

characters have states. like what's their facial expression? or whatever?
there's three properties that govern it:
  • defaultState: the default state, which you should normally leave alone.
  • state: applied over defaultState, it's literally just the state.
  • lineState: applied over state, it resets once a line is spoken.
you can set a lineState using .say like so:
  • ts
yield dabric.say("I'm winking!!!", { face: "wink" });
yield dabric.ask("example", ["blah", "blah"], { face: "straight" });
for setting the state, you can just directly set it:
  • ts
dabric.state.face = "wink";
yield dabric.say("I'm winking!!!!");
when you directly set the state, it won't update. you have to manually update the state using .updateState(). .say and friends automatically do this, so it's usually not needed, but you may need to do it when using something like animations.
by default a character has no states. oops


a helper function that removes a lot of the boilerplate by using template literals. it's meant to be similar to the old yaml speech system.
so why didn't we just stick with the yaml system?
sure it's elegant to write characters saying stuff, but its weaknesses start to appear when you have them react based on what you say. that's best to do with code, thus this.
  • typescript
const dabric = s.addCharacter(DabricCharacter);

yield *
	hello, i'm dabric saying words
	this is another dialog
	this is a dialog\nwith a line break! aren't template literal whatevers cool?
	Other guy talkin here
	yes, you can just give it an unconstructed character.
	it'll add it on its own and also remember the instance it made.
	Great for being lazy
	now i'm talking again
	${{ face: "wink" }}
	that means i'm not winking anymore
			whitespace is trimmed

			and blank lines are ignored
	how nice.
	${`you can also do this!! now you can use ${variables}!!!!!`}

yield *
	characters are preserved across calls, so i'm still dabric talking
	same with me!!!!!!!

the stage

characters talking is fun, but sometimes they are represented as something actually on the screen, and you need some way to go like focus between them. stages are a system that handles some of this for you.
you can access the stage through s.stage.
thinking, would it be good to support multiple stages? like two characters in different rooms? how would that work? that would already be somewhat complex camera-wise lmao
the stage manages which character(s) have focus. by default, it'll give the current talking character focus and have it linger until another one talks. this is auto mode.
you can set focus with a character or the stage itself. this will override auto mode.
  • ts
// or
you can also give multiple characters focus:
  • ts
stage.focus(dabric, coolGuy);
to return to auto mode handling it, just set the focus to null.
  • ts
this is really all stage does. it gives some standardization between renderers in terms of where to point the camera at.
onFocus lets you listen to when the focus changes.

running another speech

sometimes you want to pass the torch onto another speech to run. to do this just use runSpeech(). it will inherit any context with it.
  • ts
// supports relative paths
yield s.runSpeech("../hell");

starting a speech

  • ts
const speech = new Speech();
await speech.loadSpeech("/path/to/speech");
// or
await speech.loadSpeech(aSpeechYouSomehowHaveOnHand);
you can then run the loaded speech like so, setting up beforehand if needed:
  • ts
speech.context[SomeSymbol] = someThing;
// resolves once this specific speech stops running
await speech.run();
alternatively, you can also provide the path/speech directly to .run:
  • ts
await speech.run("/path/to/speech");


when you make dabric wink, which DynamicDabricFaceManager will you use? that's what contexts help with.
you can access it with context on the Speech itself, or on the SpeechGoodies. it's just an object.
when setting it, you should use symbols to avoid conflicts.
here's an idea. a function like so:
  • js
function getFaceContext(speech) {
	if (speech[FaceContextSymbol] == null) {
		speech[FaceContextSymbol] = new FaceWhatever();
	return speech[FaceContextSymbol];

making a character

  • ts
class DabricCharacter extends Character {
	constructor(data: CharacterData) {
		this.name.$ = "Dabric";
		this.icon.$ = "/url/to/icon.png";
		this.color.$ = {
			foreground: "hsl(56, 100%, 50%)",
			background: "hsl(56, 100%, 10%)",

		this.onStateChange.add(() => {
			// do something with the state

	override defaultState = {
		face: "default",

	override async *typeMessage(message: string) {
		let currentMessage = "";
		for (let i = 0; i < message.length; i++) {
			currentMessage += message[i];
			await timeoutPromise(100);
			yield currentMessage;