
Visual novel
You read a story that moves forward line by line in a text box, over character art and backgrounds, and every so often you choose what to do or say. Your decisions take the story down different branches to one of several endings.
Also known as: VN, sound novel
Defining mechanics
- The text moves forward at your pace (with a click or a key) and a label shows the name of whoever is speaking.
- Choices at key moments that branch the story, sometimes with variables that remember what you chose earlier.
- Several endings that invite you to play again to discover the other branches.
- Reading aids such as saving at any point, going back or skipping what you have already read.
Gameplay loop
You read a scene, reach a decision, choose and see its consequences. When you reach an ending, you start again to try another branch.
Our demo
Controls. Click, tap or press space to advance the text (the first click finishes writing the line). At decisions, click an option or press 1 or 2.
Open full screenA short visual novel set in an ordinary neighbourhood. Ana looks for Whiskers, her cat, before the storm arrives. It has three decisions and four endings, and the whole script lives in one data structure you can rewrite without touching the engine.
Made with Kaplay, in about 186 lines of JavaScript.
How it's made
The script
The whole story is here, separate from the engine. Each scene has a background, the characters on screen and a list of lines of four kinds: someone speaks, the player chooses, the story jumps to another scene, or it reaches an ending. Every text carries an es and an en version, so translating means filling in those keys. To write your own story you only change this region.
// ✏️ To write your own story, change ONLY this region. // Each scene has a background, the characters on screen and a list of lines: // { who, es, en } someone speaks (who: "narrator" shows no name) // { choose: [...] } the player chooses; each option says which scene to go to // { goto: "scene" } jumps to another scene without asking // { end: {es, en} } the story ends with that ending const CHARACTERS = { ana: { name: "Ana", shape: "girl", color: [235, 120, 90] }, leo: { name: "Leo", shape: "boy", color: [120, 150, 220] }, sam: { name: "Sam", shape: "adult", color: [190, 110, 200] }, cat: { name: { es: "Bigotes", en: "Whiskers" }, shape: "cat", color: [200, 200, 210] }, }; const SCRIPT = { start: { sky: [250, 190, 140], ground: [150, 110, 90], characters: ["ana", "leo"], lines: [ { who: "narrator", es: "Amanece en el barrio. Unas nubes oscuras se acercan.", en: "Morning in the neighbourhood. Dark clouds are gathering." }, { who: "ana", es: "Leo, ¿has visto a Bigotes? Anoche no volvió a casa.", en: "Leo, have you seen Whiskers? She didn't come home last night." }, { who: "leo", es: "No... y viene tormenta. Hay que encontrarla antes del mediodía.", en: "No... and a storm is coming. We must find her before noon." }, { who: "ana", es: "¡Pero si nunca se aleja!", en: "But she never wanders off!" }, { choose: [ { es: "Seguir las huellas hacia el parque", en: "Follow the paw prints to the park", goto: "park" }, { es: "Preguntar en la tienda de la esquina", en: "Ask at the corner shop", goto: "shop" }, ] }, ] }, park: { sky: [170, 160, 190], ground: [80, 120, 90], characters: ["ana"], lines: [ { who: "narrator", es: "Las huellas cruzan el césped mojado y de pronto se separan.", en: "The paw prints cross the wet grass and suddenly split." }, { who: "ana", es: "Unas van hacia el estanque; las otras, hacia el cobertizo viejo.", en: "Some go to the pond; the others, to the old shed." }, { choose: [ { es: "Ir al estanque", en: "Go to the pond", goto: "pond" }, { es: "Ir al cobertizo", en: "Go to the shed", goto: "shed" }, ] }, ] }, shop: { sky: [140, 200, 240], ground: [190, 150, 100], characters: ["ana", "sam"], lines: [ { who: "narrator", es: "La tienda huele a pan recién hecho y suena la radio.", en: "The shop smells of fresh bread and the radio is on." }, { who: "sam", es: "¿Una gata gris con collar rojo? La vi pasar hacia el estanque.", en: "A grey cat with a red collar? I saw her heading to the pond." }, { who: "ana", es: "¡Es Bigotes! Gracias, Sam.", en: "That's Whiskers! Thank you, Sam." }, { choose: [ { es: "Correr sola al estanque", en: "Run to the pond alone", goto: "pond" }, { es: "Volver a avisar a Leo", en: "Go back and tell Leo", goto: "together" }, ] }, ] }, pond: { sky: [150, 180, 200], ground: [80, 120, 110], characters: ["ana", "cat"], lines: [ { who: "narrator", es: "Junto al estanque, Bigotes mira a los patos como si nada.", en: "By the pond, Whiskers watches the ducks as if nothing happened." }, { who: "cat", es: "Miau.", en: "Meow." }, { choose: [ { es: "Agacharte y llamarla en voz baja", en: "Kneel down and call her softly", goto: "calm" }, { es: "Correr para atraparla", en: "Run to catch her", goto: "soaked" }, ] }, ] }, calm: { sky: [250, 200, 120], ground: [150, 110, 90], characters: ["ana", "cat"], lines: [ { who: "narrator", es: "Te agachas y la llamas despacio. Bigotes se frota contra tu pierna y la llevas a casa.", en: "You kneel and call her softly. Whiskers rubs against your leg and you carry her home." }, { end: { es: "En casa antes de la lluvia", en: "Home before the rain" } }, ] }, soaked: { sky: [90, 100, 120], ground: [60, 70, 80], characters: ["ana"], lines: [ { who: "narrator", es: "Bigotes se asusta y escapa. Empieza a llover fuerte.", en: "Whiskers gets scared and runs off. It starts to pour." }, { who: "ana", es: "Vuelvo a casa empapada... ¡y Bigotes ya duerme en el sofá!", en: "I get home soaked... and Whiskers is already asleep on the sofa!" }, { end: { es: "Empapada", en: "Soaked" } }, ] }, shed: { sky: [60, 55, 80], ground: [40, 35, 50], characters: ["ana"], lines: [ { who: "narrator", es: "El cobertizo está vacío. Afuera cae la tormenta y esperas a que pase.", en: "The shed is empty. The storm breaks outside and you wait it out." }, { end: { es: "Refugio en el cobertizo", en: "Shelter in the shed" } }, ] }, together: { sky: [250, 210, 150], ground: [150, 110, 90], characters: ["leo", "cat"], lines: [ { who: "narrator", es: "Vas con Leo al estanque y entre los dos traen a Bigotes a casa.", en: "You go to the pond with Leo and together you bring Whiskers home." }, { who: "leo", es: "Bien hecho, Ana. Preguntar también es buscar.", en: "Well done, Ana. Asking is also searching." }, { end: { es: "En equipo", en: "Teamwork" } }, ] }, };Setup
The game is created and the advance button (space or Enter) is defined. The total number of endings is counted automatically by reading the script, and a set keeps the endings you have already seen across playthroughs.
kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [20, 18, 30], buttons: { advance: { keyboard: ["space", "enter"] } }, }); const TEXT_SPEED = 45; // letters per second const TOTAL_ENDINGS = Object.values(SCRIPT).filter((s) => s.lines.some((l) => l.end)).length; const seenEndings = new Set(); // survives between playthroughs: an invitation to replaySilhouettes
Each character is a silhouette made of rectangles, circles and polygons. Whoever is not speaking is drawn darker, a simple way to show at a glance who is talking.
// Geometric silhouettes: each shape is a list of simple pieces. // Whoever is not speaking is drawn darker, so you can tell who has the floor. function drawCharacter(id, x, speaking) { const ch = CHARACTERS[id]; const c = speaking ? rgb(...ch.color) : rgb(...ch.color).darken(80); const d = (comps) => add([...comps, color(c), "scene"]); const floor = 240; if (ch.shape === "cat") { d([rect(90, 40), pos(x - 45, floor - 70)]); // body for (const dx of [-40, -22, 22, 36]) d([rect(8, 32), pos(x + dx, floor - 32)]); // legs d([circle(22), pos(x + 50, floor - 84)]); // head for (const dx of [36, 54]) d([polygon([vec2(0, 0), vec2(6, -16), vec2(12, 0)]), pos(x + dx, floor - 100)]); // ears d([rect(8, 60), pos(x - 52, floor - 120)]); // tail return; } const halfWidth = ch.shape === "adult" ? 50 : 40; if (ch.shape === "boy") d([rect(70, 120), pos(x - 35, floor - 120)]); else d([polygon([vec2(-halfWidth, 0), vec2(halfWidth, 0), vec2(22, -120), vec2(-22, -120)]), pos(x, floor)]); d([circle(26), pos(x, floor - 148)]); // head if (ch.shape === "boy") d([rect(56, 8), pos(x - 10, floor - 166)]), d([rect(50, 18), pos(x - 25, floor - 180)]); // cap if (ch.shape === "adult") d([rect(60, 10), pos(x - 30, floor - 100)]); // apron strings if (ch.shape === "girl") d([rect(8, 70), pos(x - 36, floor - 140)]), d([rect(8, 70), pos(x + 28, floor - 140)]); // pigtails }The dialogue box
The semi-transparent box with the speaker's name on top is the genre's visual signature. This layout, with the box at the bottom and the picture visible, is called ADV, and the one that covers almost the whole screen with text is called NVL.
// The dialogue box with the speaker's name: the visual heart of the genre. add([rect(608, 100, { radius: 8 }), pos(16, 246), color(0, 0, 0), opacity(0.75), z(10)]); const nameBox = add([rect(170, 28, { radius: 6 }), pos(28, 232), color(80, 80, 80), opacity(1), z(11)]); const nameText = add([text("", { size: 16, font: FONT }), pos(40, 237), z(12)]); const body = add([text("", { size: 17, font: FONT, width: 580, lineSpacing: 4 }), pos(30, 268), z(12)]); const prompt = add([text("▼", { size: 14, font: FONT }), pos(604, 326), z(12), opacity(0)]);The engine
The engine only remembers which scene and line you are on, and decides what to do with each line. The text appears letter by letter; the first click completes it and the second advances. That detail makes reading feel comfortable.
// The engine only knows how to read the script: where we are (scene + line) and what to do with each line. let current = "start"; let index = 0; let fullText = ""; let letters = 0; let options = []; let finished = false; // go() changes scene on the next frame: ignore clicks until then function showScene() { destroyAll("scene"); const s = SCRIPT[current]; add([rect(640, 360), color(...s.sky), "scene"]); // The horizon: a row of buildings of different heights ([x, top, width]). for (const [x, top, w] of [[0, 150, 120], [120, 100, 140], [260, 130, 160], [420, 80, 120], [540, 140, 100]]) add([rect(w, 360 - top), pos(x, top), color(...s.ground), "scene"]); const line = s.lines[index]; const xs = s.characters.length === 1 ? [320] : [190, 450]; s.characters.forEach((id, i) => drawCharacter(id, xs[i], !line.who || line.who === id)); } function showLine() { const line = SCRIPT[current].lines[index]; if (line.goto) return goTo(line.goto); if (line.end) return (finished = true), go("end", line.end); showScene(); if (line.choose) return showOptions(line.choose); const ch = CHARACTERS[line.who]; nameText.text = ch ? t(typeof ch.name === "string" ? { es: ch.name } : ch.name) : ""; nameBox.opacity = ch ? 1 : 0; if (ch) nameBox.color = rgb(...ch.color).darken(60); fullText = t(line); letters = 0; } function goTo(target) { [current, index] = [target, 0]; showLine(); } function advance() { if (options.length || finished) return; if (letters < fullText.length) letters = fullText.length; // first click: finish writing else index++, showLine(); } // Typewriter effect: the text appears letter by letter. onUpdate(() => { letters = Math.min(fullText.length, letters + TEXT_SPEED * dt()); body.text = fullText.slice(0, Math.floor(letters)); prompt.opacity = !options.length && letters >= fullText.length ? 0.5 + 0.5 * Math.sin(time() * 6) : 0; });Choices and branches
A decision draws one button per option, and choosing jumps to the scene named in the script. A single click handler chooses when there are options and advances when there are none, so one click never does both.
// A choice draws one button per option; choosing jumps to another scene of the script. function showOptions(list) { [fullText, letters, nameText.text, nameBox.opacity] = ["", 0, "", 0]; options = list.map((op, i) => { const b = add([rect(440, 40, { radius: 8 }), pos(320, 110 + i * 54), anchor("center"), color(60, 50, 90), outline(2, rgb(255, 255, 255)), area(), z(20), "option", { target: op.goto }]); b.add([text(`${i + 1}. ${t(op)}`, { size: 16, font: FONT }), anchor("center")]); return b; }); } function pick(button) { if (!button) return; destroyAll("option"); options = []; goTo(button.target); } // A single click handler: if there are options, it picks; otherwise it advances. onClick(() => (options.length ? pick(options.find((b) => b.isHovering())) : advance())); onButtonPress("advance", advance); onKeyPress(["1", "2", "3"], (key) => pick(options[Number(key) - 1])); onUpdate(() => options.forEach((b) => (b.color = b.isHovering() ? rgb(100, 80, 150) : rgb(60, 50, 90)))); showLine();Scenes
Title, game and end. The end screen tells you how many of the endings you have found, an invitation to play again, which is key to the genre. It waits half a second before accepting a click so you do not skip it by accident.
function screen(title, subtitle, next) { add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]); add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 34)), anchor("center"), color(200, 210, 255)]); // Half a second of waiting so a hasty click does not skip the screen. let ready = false; wait(0.5, () => (ready = true)); const goNext = () => ready && next(); onButtonPress("advance", goNext); onClick(goNext); } scene("title", () => screen(t({ es: "¿Dónde está Bigotes?", en: "Where is Whiskers?" }), t({ es: "Una novela visual corta. Haz clic o pulsa espacio para avanzar; elige con clic o con las teclas 1 y 2.\nHaz clic o pulsa espacio para empezar.", en: "A short visual novel. Click or press space to advance; choose by clicking or with keys 1 and 2.\nClick or press space to start.", }), () => go("game"))); scene("end", (ending) => { seenEndings.add(ending.es); screen(t({ es: "Final: ", en: "Ending: " }) + t(ending), t({ es: `Encontraste ${seenEndings.size} de ${TOTAL_ENDINGS} finales. Haz clic o pulsa espacio para empezar de nuevo.`, en: `You found ${seenEndings.size} of ${TOTAL_ENDINGS} endings. Click or press space to start again.` }), () => go("game")); }); go("title");
What we simplified
- The characters are geometric silhouettes with no expressions, and the backgrounds are just a coloured sky and a row of buildings.
- There is no music, voice, backlog or saving. There are also no variables remembering earlier choices; each option just jumps to another scene.
To modify it
- Write your own story in the script region, with two decisions and three endings. Draw the tree on paper first.
- Make the story bilingual, in Spanish and an Indigenous language. Add a key (for example ay for Aymara) to every line and change the t function so it uses it.
- Add a variable that remembers a decision (for example, whether you went through the shop) and use it later to show a different line.
Full code
// Visual novel demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";
// #region script
// ✏️ To write your own story, change ONLY this region.
// Each scene has a background, the characters on screen and a list of lines:
// { who, es, en } someone speaks (who: "narrator" shows no name)
// { choose: [...] } the player chooses; each option says which scene to go to
// { goto: "scene" } jumps to another scene without asking
// { end: {es, en} } the story ends with that ending
const CHARACTERS = {
ana: { name: "Ana", shape: "girl", color: [235, 120, 90] },
leo: { name: "Leo", shape: "boy", color: [120, 150, 220] },
sam: { name: "Sam", shape: "adult", color: [190, 110, 200] },
cat: { name: { es: "Bigotes", en: "Whiskers" }, shape: "cat", color: [200, 200, 210] },
};
const SCRIPT = {
start: { sky: [250, 190, 140], ground: [150, 110, 90], characters: ["ana", "leo"], lines: [
{ who: "narrator", es: "Amanece en el barrio. Unas nubes oscuras se acercan.", en: "Morning in the neighbourhood. Dark clouds are gathering." },
{ who: "ana", es: "Leo, ¿has visto a Bigotes? Anoche no volvió a casa.", en: "Leo, have you seen Whiskers? She didn't come home last night." },
{ who: "leo", es: "No... y viene tormenta. Hay que encontrarla antes del mediodía.", en: "No... and a storm is coming. We must find her before noon." },
{ who: "ana", es: "¡Pero si nunca se aleja!", en: "But she never wanders off!" },
{ choose: [
{ es: "Seguir las huellas hacia el parque", en: "Follow the paw prints to the park", goto: "park" },
{ es: "Preguntar en la tienda de la esquina", en: "Ask at the corner shop", goto: "shop" },
] },
] },
park: { sky: [170, 160, 190], ground: [80, 120, 90], characters: ["ana"], lines: [
{ who: "narrator", es: "Las huellas cruzan el césped mojado y de pronto se separan.", en: "The paw prints cross the wet grass and suddenly split." },
{ who: "ana", es: "Unas van hacia el estanque; las otras, hacia el cobertizo viejo.", en: "Some go to the pond; the others, to the old shed." },
{ choose: [
{ es: "Ir al estanque", en: "Go to the pond", goto: "pond" },
{ es: "Ir al cobertizo", en: "Go to the shed", goto: "shed" },
] },
] },
shop: { sky: [140, 200, 240], ground: [190, 150, 100], characters: ["ana", "sam"], lines: [
{ who: "narrator", es: "La tienda huele a pan recién hecho y suena la radio.", en: "The shop smells of fresh bread and the radio is on." },
{ who: "sam", es: "¿Una gata gris con collar rojo? La vi pasar hacia el estanque.", en: "A grey cat with a red collar? I saw her heading to the pond." },
{ who: "ana", es: "¡Es Bigotes! Gracias, Sam.", en: "That's Whiskers! Thank you, Sam." },
{ choose: [
{ es: "Correr sola al estanque", en: "Run to the pond alone", goto: "pond" },
{ es: "Volver a avisar a Leo", en: "Go back and tell Leo", goto: "together" },
] },
] },
pond: { sky: [150, 180, 200], ground: [80, 120, 110], characters: ["ana", "cat"], lines: [
{ who: "narrator", es: "Junto al estanque, Bigotes mira a los patos como si nada.", en: "By the pond, Whiskers watches the ducks as if nothing happened." },
{ who: "cat", es: "Miau.", en: "Meow." },
{ choose: [
{ es: "Agacharte y llamarla en voz baja", en: "Kneel down and call her softly", goto: "calm" },
{ es: "Correr para atraparla", en: "Run to catch her", goto: "soaked" },
] },
] },
calm: { sky: [250, 200, 120], ground: [150, 110, 90], characters: ["ana", "cat"], lines: [
{ who: "narrator", es: "Te agachas y la llamas despacio. Bigotes se frota contra tu pierna y la llevas a casa.", en: "You kneel and call her softly. Whiskers rubs against your leg and you carry her home." },
{ end: { es: "En casa antes de la lluvia", en: "Home before the rain" } },
] },
soaked: { sky: [90, 100, 120], ground: [60, 70, 80], characters: ["ana"], lines: [
{ who: "narrator", es: "Bigotes se asusta y escapa. Empieza a llover fuerte.", en: "Whiskers gets scared and runs off. It starts to pour." },
{ who: "ana", es: "Vuelvo a casa empapada... ¡y Bigotes ya duerme en el sofá!", en: "I get home soaked... and Whiskers is already asleep on the sofa!" },
{ end: { es: "Empapada", en: "Soaked" } },
] },
shed: { sky: [60, 55, 80], ground: [40, 35, 50], characters: ["ana"], lines: [
{ who: "narrator", es: "El cobertizo está vacío. Afuera cae la tormenta y esperas a que pase.", en: "The shed is empty. The storm breaks outside and you wait it out." },
{ end: { es: "Refugio en el cobertizo", en: "Shelter in the shed" } },
] },
together: { sky: [250, 210, 150], ground: [150, 110, 90], characters: ["leo", "cat"], lines: [
{ who: "narrator", es: "Vas con Leo al estanque y entre los dos traen a Bigotes a casa.", en: "You go to the pond with Leo and together you bring Whiskers home." },
{ who: "leo", es: "Bien hecho, Ana. Preguntar también es buscar.", en: "Well done, Ana. Asking is also searching." },
{ end: { es: "En equipo", en: "Teamwork" } },
] },
};
// #endregion
// #region setup
kaplay({
width: 640,
height: 360,
letterbox: true, pixelDensity,
background: [20, 18, 30],
buttons: { advance: { keyboard: ["space", "enter"] } },
});
const TEXT_SPEED = 45; // letters per second
const TOTAL_ENDINGS = Object.values(SCRIPT).filter((s) => s.lines.some((l) => l.end)).length;
const seenEndings = new Set(); // survives between playthroughs: an invitation to replay
// #endregion
// #region characters
// Geometric silhouettes: each shape is a list of simple pieces.
// Whoever is not speaking is drawn darker, so you can tell who has the floor.
function drawCharacter(id, x, speaking) {
const ch = CHARACTERS[id];
const c = speaking ? rgb(...ch.color) : rgb(...ch.color).darken(80);
const d = (comps) => add([...comps, color(c), "scene"]);
const floor = 240;
if (ch.shape === "cat") {
d([rect(90, 40), pos(x - 45, floor - 70)]); // body
for (const dx of [-40, -22, 22, 36]) d([rect(8, 32), pos(x + dx, floor - 32)]); // legs
d([circle(22), pos(x + 50, floor - 84)]); // head
for (const dx of [36, 54]) d([polygon([vec2(0, 0), vec2(6, -16), vec2(12, 0)]), pos(x + dx, floor - 100)]); // ears
d([rect(8, 60), pos(x - 52, floor - 120)]); // tail
return;
}
const halfWidth = ch.shape === "adult" ? 50 : 40;
if (ch.shape === "boy") d([rect(70, 120), pos(x - 35, floor - 120)]);
else d([polygon([vec2(-halfWidth, 0), vec2(halfWidth, 0), vec2(22, -120), vec2(-22, -120)]), pos(x, floor)]);
d([circle(26), pos(x, floor - 148)]); // head
if (ch.shape === "boy") d([rect(56, 8), pos(x - 10, floor - 166)]), d([rect(50, 18), pos(x - 25, floor - 180)]); // cap
if (ch.shape === "adult") d([rect(60, 10), pos(x - 30, floor - 100)]); // apron strings
if (ch.shape === "girl") d([rect(8, 70), pos(x - 36, floor - 140)]), d([rect(8, 70), pos(x + 28, floor - 140)]); // pigtails
}
// #endregion
scene("game", () => {
// #region dialogue-box
// The dialogue box with the speaker's name: the visual heart of the genre.
add([rect(608, 100, { radius: 8 }), pos(16, 246), color(0, 0, 0), opacity(0.75), z(10)]);
const nameBox = add([rect(170, 28, { radius: 6 }), pos(28, 232), color(80, 80, 80), opacity(1), z(11)]);
const nameText = add([text("", { size: 16, font: FONT }), pos(40, 237), z(12)]);
const body = add([text("", { size: 17, font: FONT, width: 580, lineSpacing: 4 }), pos(30, 268), z(12)]);
const prompt = add([text("▼", { size: 14, font: FONT }), pos(604, 326), z(12), opacity(0)]);
// #endregion
// #region engine
// The engine only knows how to read the script: where we are (scene + line) and what to do with each line.
let current = "start";
let index = 0;
let fullText = "";
let letters = 0;
let options = [];
let finished = false; // go() changes scene on the next frame: ignore clicks until then
function showScene() {
destroyAll("scene");
const s = SCRIPT[current];
add([rect(640, 360), color(...s.sky), "scene"]);
// The horizon: a row of buildings of different heights ([x, top, width]).
for (const [x, top, w] of [[0, 150, 120], [120, 100, 140], [260, 130, 160], [420, 80, 120], [540, 140, 100]])
add([rect(w, 360 - top), pos(x, top), color(...s.ground), "scene"]);
const line = s.lines[index];
const xs = s.characters.length === 1 ? [320] : [190, 450];
s.characters.forEach((id, i) => drawCharacter(id, xs[i], !line.who || line.who === id));
}
function showLine() {
const line = SCRIPT[current].lines[index];
if (line.goto) return goTo(line.goto);
if (line.end) return (finished = true), go("end", line.end);
showScene();
if (line.choose) return showOptions(line.choose);
const ch = CHARACTERS[line.who];
nameText.text = ch ? t(typeof ch.name === "string" ? { es: ch.name } : ch.name) : "";
nameBox.opacity = ch ? 1 : 0;
if (ch) nameBox.color = rgb(...ch.color).darken(60);
fullText = t(line);
letters = 0;
}
function goTo(target) {
[current, index] = [target, 0];
showLine();
}
function advance() {
if (options.length || finished) return;
if (letters < fullText.length) letters = fullText.length; // first click: finish writing
else index++, showLine();
}
// Typewriter effect: the text appears letter by letter.
onUpdate(() => {
letters = Math.min(fullText.length, letters + TEXT_SPEED * dt());
body.text = fullText.slice(0, Math.floor(letters));
prompt.opacity = !options.length && letters >= fullText.length ? 0.5 + 0.5 * Math.sin(time() * 6) : 0;
});
// #endregion
// #region choices
// A choice draws one button per option; choosing jumps to another scene of the script.
function showOptions(list) {
[fullText, letters, nameText.text, nameBox.opacity] = ["", 0, "", 0];
options = list.map((op, i) => {
const b = add([rect(440, 40, { radius: 8 }), pos(320, 110 + i * 54), anchor("center"), color(60, 50, 90), outline(2, rgb(255, 255, 255)), area(), z(20), "option", { target: op.goto }]);
b.add([text(`${i + 1}. ${t(op)}`, { size: 16, font: FONT }), anchor("center")]);
return b;
});
}
function pick(button) {
if (!button) return;
destroyAll("option");
options = [];
goTo(button.target);
}
// A single click handler: if there are options, it picks; otherwise it advances.
onClick(() => (options.length ? pick(options.find((b) => b.isHovering())) : advance()));
onButtonPress("advance", advance);
onKeyPress(["1", "2", "3"], (key) => pick(options[Number(key) - 1]));
onUpdate(() => options.forEach((b) => (b.color = b.isHovering() ? rgb(100, 80, 150) : rgb(60, 50, 90))));
showLine();
// #endregion
});
// #region scenes
function screen(title, subtitle, next) {
add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 34)), anchor("center"), color(200, 210, 255)]);
// Half a second of waiting so a hasty click does not skip the screen.
let ready = false;
wait(0.5, () => (ready = true));
const goNext = () => ready && next();
onButtonPress("advance", goNext);
onClick(goNext);
}
scene("title", () => screen(t({ es: "¿Dónde está Bigotes?", en: "Where is Whiskers?" }),
t({
es: "Una novela visual corta. Haz clic o pulsa espacio para avanzar; elige con clic o con las teclas 1 y 2.\nHaz clic o pulsa espacio para empezar.",
en: "A short visual novel. Click or press space to advance; choose by clicking or with keys 1 and 2.\nClick or press space to start.",
}),
() => go("game")));
scene("end", (ending) => {
seenEndings.add(ending.es);
screen(t({ es: "Final: ", en: "Ending: " }) + t(ending),
t({ es: `Encontraste ${seenEndings.size} de ${TOTAL_ENDINGS} finales. Haz clic o pulsa espacio para empezar de nuevo.`,
en: `You found ${seenEndings.size} of ${TOTAL_ENDINGS} endings. Click or press space to start again.` }),
() => go("game"));
});
go("title");
// #endregionDesign challenges
- Keeping branching under control, because it grows very fast. Branches usually merge back into shared scenes so the script does not multiply endlessly.
- Making every decision matter, or at least feel like it matters, and letting the player see its effect.
- Keeping the reading pace by alternating dialogue, scene changes and choices, so that blocks of text do not become tiring.
- Keeping the script separate from the engine. That way writers do not need to program, and translating the story into another language means changing only the text.
- Testing every branch, because an unreachable ending or a scene with no way out are typical bugs.
History
- 1983OriginThe Portopia Serial Murder Case
Portopia defined the Japanese adventure game the genre grew out of. The name "visual novel" has been used since the mid-1990s.
Visual novel (Wikipedia) - 1985The Portopia Serial Murder Case
Portopia's Famicom version replaces the text parser with a menu of commands chosen with the gamepad.
The Portopia Serial Murder Case (Wikipedia) - 1992Otogirisō
Chunsoft's Otogirisō reduces play to reading and choosing between story branches, with sound and several endings. It is called a "sound novel".
Otogirisō (Wikipedia) - 1996
Leaf's Shizuku is the first commercial game presented as a "visual novel".
Visual novel (Wikipedia) - 2001Phoenix Wright: Ace Attorney
Capcom's Phoenix Wright mixes reading with investigation and with trials where you look for contradictions. Its 2005 English release brings the genre to Western audiences.
Phoenix Wright: Ace Attorney (Wikipedia) - 2002Higurashi When They Cry
Higurashi, by an amateur (dōjin) group, is released in chapters and has no choices at all. It shows that a visual novel can be completely linear.
Visual novel (Wikipedia) - 2004
Ren'Py is released, a free and open-source visual novel engine whose scripts are written in a screenplay-like syntax.
Ren'Py (Wikipedia) - 2017Butterfly Soup
Brianna Lei's Butterfly Soup is released as pay-what-you-want on itch.io, and PC Gamer names it the year's best visual novel. It is an example of independent visual novels made outside Japan.
Butterfly Soup (Wikipedia)
Reference games
1983
The Portopia Serial Murder Case
You investigate a murder in Kobe with your assistant Yasu, questioning suspects and visiting places. In the Famicom version (1985) the text parser was replaced by a menu of commands, the template for Japanese adventures and the visual novel.
1992
Otogirisō
A couple takes shelter in a haunted mansion. You read the story over illustrations with music and sound effects and, at key moments, choose what to do; each ending unlocks new options for the next playthrough. Chunsoft called it a "sound novel".
2001
Phoenix Wright: Ace Attorney
You are a rookie defence attorney. You alternate between investigations, where you gather evidence, and trials, where you cross-examine witnesses and point out where their testimony contradicts a piece of evidence.
2002
Higurashi When They Cry
A murder mystery in the Japanese village of Hinamizawa, released in chapters by an amateur (dōjin) group. It has no choices; the challenge is reading and deducing what is going on before the answer chapters.
2015
Syrup and the Ultimate Sweet
Syrup, a candy alchemist, finds a candy golem in her basement. A short jam visual novel made with Ren'Py, with ten endings depending on your choices and translations into many languages, including Spanish.
2017
Butterfly Soup
You follow four Asian-American girls in a school baseball club in California. It is an independent comedy-romance visual novel; its author offers it at a price you choose, including free.
2019
Adventures with Anxiety!
You play as a person's anxiety, shown as a wolf, and choose which fear to whisper to them at each moment. A short interactive story about mental health, released into the public domain.
Exercise
Before programming, draw your story's tree on paper, with two decisions and at least three endings. Then write it in the demo's «script» region without touching the engine. You can make it bilingual, in Spanish and an Indigenous language, because every line of the script already stores its text in more than one language.