Devlog #1: Screenplay driven development


This fortnight's been about getting back into game development in earnest, building an engine that takes in the screenplay in markdown format and breathes life into it.


Along the way, I've also been doing what could only be described as frontend development for UI of the game world's ubiquitous electronic device - the octahedrally shaped Pendant.

Finally, I skirt around some tooling rabbit-holes.

Read on for the low-down!

1.1 Screenplay Driven Development

For the longest time, the kernel of the game existed only in text form; it was originally conceived as a scene - a doctor gives an expectant family the bad news that their child is going to be born introverted - which turned into short story, which turned into a short screenplay, which turned into novella-length screenplay, and which is now in the process of being turned into a game.

Other voice: So, this is where you girls are. I should have suspected!
Sarah: Oh no, it's the true villain behind it all: the evil archwizard!
Daphne: That's just my mum. Let's jack out and see what she wants.
*Portal appears. When you walk through, you enter a new scene in Daphne's room. Daphne and Sarah have VR headsets on.*
*Daphne's bedroom consists of a single bed, a bedside drawer, a shuttered window, a desk with a workstation PC on it.*
*Helen, Daphne's mum, has just entered the room.*
Helen: I see you're burning through all our credits on the workstation again.

Listing 1: Excerpt from an opening scene.

I wanted to continue writing and editing in markdown (I'm about 90% happy with act I's structure and the other acts still need redrafting) so the question was how to get this text into the game without doing too much manual importing which might need to be repeated later.

In the end I settled on annotating the screenplay using markdown-flavoured links:

Other voice: So, this is where you girls are. I should have suspected!
Sarah: Oh no, it's the true villain behind it all: the evil archwizard!
Daphne: That's just my mum. Let's jack out and see what she wants.
[action](portal-appears)
*Portal appears. When you walk through, you enter a new scene in Daphne's room. Daphne and Sarah have VR headsets on.*
[await](leaves-skara-daphne)
[scene](daphnes-bunker)
*Daphne's bedroom consists of a single bed, a bedside drawer, a shuttered window, a desk with a workstation PC on it.*
*Helen, Daphne's mum, has just entered the room.*
[action](helen-enters)
Helen: I see you're burning through all our credits on the workstation again.

Listing 2: The action label triggers methods or animations on the current scene, await waits for the specified signal to be emitted (e.g. for after the player finished a mini-game) before continuing with the dialogue, and scene loads up a new scene.

Parsing through the JSONified markdown hasn't been too bad; dialogue lines detected using the presence of colons; stage directions (in italics) are ignored in favour of actions, which call out to the corresponding method in-code according to link the type and name, e.g. for the action [action](helen-approaches-daphne), the corresponding code is called:

func _helen_approaches_daphne(fastforward: bool) -> void:
    if fastforward:
        player.global_position = _get_cue("daphne-back-away").global_position
        _helen.global_position = _get_cue("helen-approach-daphne").global_position
    else:
        player.move_to(_get_cue("daphne-back-away").global_position, true)
        _helen.stride_to(_get_cue("helen-approach-daphne").global_position)
        await player.move_finished

Listing 3: Example of programmatic animation; fastforward flag is for when we want to skip past this particular action (but still have its outcome).

So far, a couple of bits have felt hacky; I have a section at the end of every act called 'interactables', which described flavour text from interacting with objects, NPCs, posters etc. for more world-building; that whole section needs to be treated separately to the main script. There's also a bunch of ancillary text, e.g. confirmation or reminder prompts, but I hadn't written but needs to be in the game; in the end I've just been dumping all of that into a script_extras.gd file.

I've gone through 40% of the Act I using this approach and am cautiously optimistic about being able to shoehorn everything in. When I've finished annotating Act I like this, I'll update on how it held up!

And the overall result for the previously discussed snippet looks like:

Figure 2: NPCs are using NavigationAgent to move to stage marks (the coloured x's).

1.2 Pendant UI

I had way too much fun implementing SPA-style clone for the Pendant UI, complete with breadcrumb navigation, inbox, chat and notification dots:

Figure 3: Is it me or does it smell like WhatsApp in here?

Figure 3: Is it me or does it smell like WhatsApp in here?

Despite (or by virtue of?) not using any modern frontend frameworks, Godot's control nodes, signal feature and the editor's interactive development made it straightforward to get a prototype off the ground; I mainly used dumb data objects, assigned to UI nodes and made observable to them by emitting signals.

Borrowing from web development however, the idea of URLs and routing to them was useful - I had a route_path(path: String) method would delegate the handling of fragments to other routing methods (e.g. to route_chats which then later called route_chat_thread) that would ultimately render the UI. Route transitions can also be handled at the end - for e.g. marking 'reading' chat messages as read if we navigated off a chat thread page.

2 Craft

2.1 Gamedev

Getting back to using Godot in earnest has been a pleasure; I've also discovered a few techniques and workflow improvements I had missed from back when I was using it in for game-jams (i.e. the 'stick to the first thing that works in the mad scrabble to get something remotely playable out' work-mode).

2.1.1 Unique nodes

Using unique nodes over get_node or its $ sugar seems to be an outright win; %UnreadMessages is much preferable to maintain over something like $UI/Control/PanelContainer/ScrollContainer/VBoxContainer/HBoxContainer/UnreadMessages, with the latter susceptible to frequent path movements in and out of various containers over the course of UI development.

2.1.2 Running code in-editor

I'm trying not to go crazy over this and put @tool on everything, but the idea of interactively running code in the editor is intoxicating. Right now, I'm just using it to quell my OCD on characters facing the right way:

Figure 4: Hey, look over there!

The official docs explain the use of pattern well; I think one example that could be useful is accessing child nodes; these are only available after @onready so you should do a check on is_inside_tree() to avoid error spam in the Output panel:

@onready var anim_player: AnimationPlayer = $AnimationPlayer
@export var initial_facing = Vector2(0, 1):
    set(new_face):
        initial_facing = new_face
        if is_inside_tree():
            _render_facing(initial_facing)
func _render_facing(facing: Vector2) -> void:
    if facing.x > 0:
        anim_player.play("idle-east")
    # ...
Listing 4: Implementation of the above character facing functionality (truncated).


2.1.3 Setters and getters

The above was also an example of using a setter; I've been hesitant to use getters and setters in general as syntax sugar, preferring the more boring way of using clearly named methods - but tried them out in two cases while implementing the Pendant UI:

  • Emitting signals in the setters of data objects to update UI nodes, e.g. text labels
  • Caching the number of unread messages in a chat thread using a getter

While the latter is currently a premature optimisation indulgence, the former feels like a succinct way to get some reactive programming going.

On the topic of signals, I admittedly got trigger-happy emitting these signals and eventually had to tone it down (i.e. stop emitting similarly named events as a data change propagates down through child objects). I think the advice this GDQuest article gives is sound (I've yet to try out a global event bus).

Other minor workflow tips and tricks I've found are:

  • Using theme resources to avoid putting the same font file (or other stylings) on labels/buttons over and over again - just plop one of these guys on the root node and all its descendents get styled appropriately.
  • Using <Ctrl-Shift-c> over Scene nodes and Inspector properties to copy their paths as strings.
    • Although I'm using unique nodes more than their fully specified node paths, it's nice having a shortcut to find what property value to change for the longer ones, e.g. set("theme_override_font_sizes/font_size", 32)
  • Sizing ColorRects - previously to get them to fill the entire viewport, I was going in and manually inputting the viewport width and height; turns out if you have them as children of Node or Control, you can just set both of their anchors to Full Rect.
    • I found that PanelContainers are generally more appropriate to use for providing a background to UI than ColorRects.

3 Tooling

3.1 Dev console

As this is an interactive fiction game consisting of a lot of dialogue and cutscenes, having a way to navigate and skip through them while testing is essential for sanity.

Keeping the parsed screenplay data structure around was handy for knocking up a UI to jump to scenes. I also bound the > key to skip dialogue (and half-thinking about keeping it in as a general feature too).

Figure 5: Scene (or label) selection in the dev console.

3.2 Shortcuts

On Windows, I had moved my Godot development from Emacs onto Visual Studio Code; I didn't have the appetite to get GDScript mode working while strafing across WSL or getting debugging set up (I was also curious to see what kids these days are into with their graphical text editors).

I ran into difficulty getting the GDScript extension to execute a run-and-build with a single key-binding (F5) working with a Godot 4 project. I eventually worked this out by following https://github.com/godotengine/godot-vscode-plugin/issues/389 (but haven't been able to get debugging working) but at the time, the quickest bodge I thought of was an AutoHotKey script:


SetTitleMatchMode, 2 ; Match Windows title anywhere (not 100% precise, but hey)
; Another option: ahk_exe Godot_v4.1.1-stable_win64.exe
$F5:: ; $ Prevents hotkey from triggered itself
if WinExist("Godot Engine")
        WinActivate ; Use the window found by WinExist.
        WinWaitActive
        Send, {F5}

Listing 5: Comes with the occasional Easter egg of occasionally refreshing the browser when it's on a Godot documentation tab - utility over correctness!

3.3 Spellchecking with hunspell

After experiencing a moment of blind facepalm when a friend mentioned they had thought 'extravert' was a misspelling of 'extrovert' (both are correct, we later discovered but extravert is more academic), I figured I should get a proper spellcheck set up soon to avoid a Business Secrets of the Pharoahs-type situation.

Emacs' flyspell/hunspell/ispell config was stubborn to co-operate, however, at first flagging contractions like 'isn't' and 'shouldn't'. This was solved after rubber-ducking on Emacs StackExchange and I now enjoy proper British English spellchecking and the capability to add words into personal dictionaries (plaintext word lists AFAICT) - one for writing the screenplay and one for blogging.

4 Coming Up

Eyeballing how long it might take to finish the first pass in annotating the screenplay (40% through according to line count), realistically the next two weeks are looking gamedev-heavy too.

Although optimistically much of the engine and markdown labelling functionality has been developed, so fingers crossed I've have moved to working on some art and music!

Leave a comment

Log in with itch.io to leave a comment.