March Project: A Visual Novel System for Godot

The Team DogPit 2023 Jam kicked off this month! It remains my favorite annual jam, though this year I had to scale back the planned game because I was out on a trip for the first half. I ended up submitted a game called “Odd Soul” which you can play here: https://xoana.itch.io/odd-soul

The theme was, “The Odds are Good,” and while I had initially planned a game where you have to DM for a bunch of players who keep rolling terribly, the game seemed too far removed and complicated to execute.

I pivoted to something of a visual novel, planning to make a game with multiple endings whose story junctions depended not just on text but also games of Rock, Paper, Scissors. The mechanics for Rock, Paper, Scissors I stole from someone I saw on Twitter:

The gameplay turned out to be too hard to manage. There’s too much visual noise for the player to really figure out what they’re doing and the action can either be too fast-paced or too boring. There wasn’t really an in-between I found.

Nonetheless, I kept with the visual novel system and, after struggling with a story that was ENTIRELY too complicated and serious for a fun game, I stumbled upon the idea of playing Death for your eternal soul. Mild spoiler: Death plays perfectly for most rounds of the game, but every so often there’s a small chance that it will make a mistake. One-shot, your odds of winning a game are in the tens of thousands, but play often enough and you’re basically guaranteed to win.

When I got tired of working on minigames I went back to writing story and bumbling through possible endings. It’s far from the finest bit of writing, but it works well enough for the game and expected playtime.

I am slightly pleased with how robust the visual novel format is. A user can create an arbitrary story as a JSON file and the application will detect and run that, so new stories can be made (with new visual assets) with no code.

The story JSON is fairly simple: { "scenes": { ... all scenes ... }, "assets": { ... all assets ...}

With the average scene looking like this:

"goto_apartment": {
	"background": "apartment",
	"actors": ["dog", "cat", "death"],
	"text": "[death:eyes_closed]One moment.  [death]There we are.\n[dog]Dog: *bark*\n[cat]Cat: *meow*\n[death]Oh!\nOh wow. <truncated>\n",
	"prompt": "Yeah.",
	"options": {
		"They're hungry.": "hungry",
		"They're my reason for living.  Or they were.": "reason_for_living"
	},
	"next": "",
	"on_enter": "fade_in",
	"minigame": "",
	"minigame_outcome": {

	}
},

And the average asset looking something like this:

"assets": {
	"characters": {
		"reaper": {
			"default": "png:<a base64 encoded PNG>",
			"angry": "file:path/to/a/png/alternative.png",
		}, // More characters
	},
	"backgrounds": { ... like above ... }
}

Let’s break down a scene:

The “actors” array determines the order of the portraits to be shown. In the above example, we have a scene like this:

Is it a good idea to show Death your apartment? Maybe!

The ‘text’ area then can/will highlight actors. When an actor is not speaking, they’re faded slightly and made transparent. An actor can have different emotes inline. [death:happy] will, for example, look for an image in the Death asset named “happy” and will display that where Death is. All actors need a “default” image. Convention over configuration. One additional key about the text is that switching expressions will not pause the dialog for user input. User input is only required when a newline appears in the text, so in theory we could chain several emotes together for an animation (this happens once in the story) or several scenes together to trigger actions (this also happens in the story).

The last piece to consider is how to branch. The branching will trigger after all the text is finished displaying. “options” specifies an associative array (dictionary) with the text of the choice mapping to the name of the scene. In the above, the user will see two buttons, “They’re hungry.” and “They’re my reason for living…”. Clicking “They’re hungry” will bring us to the scene named “hungry”. Another possible way to branch is to trigger a minigame by setting the “minigame” field. I have four implemented (“tictactoe”, “pong”, “random”, and “rockpaperscissors”), but only two appear in the story. (I’d meant to make more, but timing is hard.) Each minigame has to emit a signal on completion with the status: GameOutcome.WIN, GameOutcome.LOSE, or GameOutcome.DRAW. And the “minigame_outcome” has to map “win”, “lose”, and “draw” to the scenes.

I am slightly proud of the “random” game, since that starts up a minigame which immediately returns one of the outcomes and is a way to make a story branch randomly. If there’s no “text”, the scene starts up, finishes, and branches in a single frame.

If there’s no “prompt” and no “minigame”, then “next” is the scene to which we jump when the dialog is complete.

Finally, “on_enter”. This is the part I’m least fond of. “on_enter” is the name of a function to be called as soon as the scene is done loading. I’ve used it to fade_in and trigger returns to the menu, but I think these uses could be better served by inlining them. That way we don’t need separate scenes for returning to the menu or jumping to endings, and opens the possibility of setting multiple flags in the same scene.

All things told, I’m not proud of the gameplay or the art or any of it, but it’s done and I find relief in this and satisfaction in having completed another jam. The entire project is open source and available for review here: https://github.com/JosephCatrambone/DogpitJam2023

Comments are closed.