Birdoggydog's Builds

Running turn-based combat on a real-time clock in Godot

One clock and one set of recovery timers serve both modes in Blobber. Also seeded tests, a 155-action input table and chained save migrations.

Blobber has a turn-based mode now, and it has no rules of its own: it stops the game’s one clock and steps it by hand. It was one of nine branches that landed by 10:27, built overnight and through the morning by AI agents working three at a time.

Running turn-based mode on the real-time clock

Enter switches a fight to turn-based. The clock stops while anyone in the party is ready to act, and a strip at the top shows who goes next.

Twelve captioned stills from one fight against two blocky orange monsters. It starts in real time, then switches to turn-based with an order strip along the top of the screen. The spell book opens on a caster’s turn, green spell clouds land, and a record screen lists each blow.
The same fight in both modes. The strip at the top is the order, soonest first.

I wanted real time with a turn-based mode from the first message of the project, and I didn’t want a second set of combat rules to keep in step with the first.

The autoload GameClock (scripts/game_clock.gd) is the only clock. Its advance(game_seconds) adds to play_seconds and emits the signal advanced(game_seconds). Every gameplay timer counts down in a handler of that signal. A party member has cooldown_remaining. A monster has cooldown_remaining, spell_cooldown_remaining and cast_remaining. “Ready” means cooldown_remaining <= 0.0, and acting sets it back to the action’s recovery time. In real time the clock’s _process() calls advance(delta * time_scale) every frame.

TurnMode (scripts/turns/turn_mode.gd, a plain Node the level makes) sets GameClock.running = false and calls advance() itself, once per physics step. This is the whole decision, from its _physics_process():

	# How much game time this step is worth, never past the next thing due.
	var rate := 0.0
	if _party_moving():
		rate = MOVE_RATE
	elif acting() < 0:
		rate = playback_rate
	var step := minf(rate * delta, next_event_in())
	GameClock.stepped_rate = step / delta
	Projectile.flush_rate = playback_rate if step <= 0.0 else 0.0
	_step = step
	_everybody = everybody
	if step > 0.0:
		GameClock.advance(step)

The rate is 0 while a member is ready, so the game waits. It is MOVE_RATE (1.0) while a movement key is down or the party is in the air. Turning and looking are free. With nobody ready it is playback_rate, 3.0 game seconds a real second.

next_event_in() returns the smallest timer still running: a member’s recovery, a queued second hit, a hunting monster’s recovery or its spell wind-up. Cutting the step to that stops the clock exactly on a ready time, never past it.

Monsters don’t know the mode exists. They already scaled their movement by GameClock.rate(), which now reads:

func rate() -> float:
	return time_scale if running else stepped_rate

So whatever moves by rate() moves exactly as far as the step. Shots flew on real time before, and now use rate() in both modes. Traps sense only while rate() > 0. One exception: Projectile.flush_rate lets shots already in the air fly on while the clock is held, so each lands in the turn it was fired in.

The order inside one physics step is set with process_physics_priority. TurnMode runs at -100 (keys, then the clock), shots at -50, a small child node at -25 (members the step made ready act), then the party and monsters at 0.

acting() picks whose turn it is: the member who is up, ready, and has been ready longest (_due_at, the play_seconds when their recovery ended). F calls Party.attack(i) for that member, the same function hold-F runs for everyone in real time. Q waits: it sets their recovery to end 0.05 seconds after the next actor is ready. G lets every member act as they recover until each has gone once. The strip is order(): members who are up and monsters hunting the party, each with the game seconds until ready, sorted soonest first. TurnHud, a CanvasLayer, shows the first five.

At 3x a monster’s 4 second recovery takes about 1.3 real seconds. Whether that’s the right pace is still open on the work list. It’s a setting, gameplay/playback_rate.

Why did one test miss its shot 2 times in 60?

Every test now runs on seeded dice, takes at most one physics step per frame, and aims at its target. Nothing the player sees changed, so there’s no picture.

Every test scene extends tests/harness_base.gd, and its _ready() now runs this before the test:

func _make_repeatable() -> void:
	var dice := int(arg("seed", "1"))
	if arg("seed") != "":
		print("seed: ", dice)
	seed(dice)
	MonsterSize.rng.seed = dice
	WeaponRules.rng.seed = dice
	MonsterLoot.rng.seed = dice
	Afflictions.rng.seed = dice
	# Never more than one physics step to a frame. A frame that took a long time (a busy
	# machine) would otherwise be made up with up to eight steps at once, and the clock with
	# them: a wait of three frames could be anything from none to 0.4 s of play. This way a
	# slow machine runs the game slowly instead of in jumps. (Recording a film steps the game
	# at a fixed rate and is not affected.)
	Engine.max_physics_steps_per_frame = 1

seed() covers Godot’s global generator. Four rules roll on a RandomNumberGenerator of their own, kept as a static var rng, and each is seeded by name. Running a test with -- --seed=N gives it other dice, which finds a check that only passes on luck.

The failing check was “the party’s shot lands”, and three faults were stacked under it.

  • The test read the floor height as party.position.y after boot. The party wakes on a platform 0.8 m up, so every later placement left it standing in the air.
  • It called queue_free() on one monster and spawned the next on the same spot in the same frame. A freed node stays solid until the frame ends, and the new monster was pushed onto its head, about 1.6 m up. The fix is to wait two frames.
  • It turned the party toward the monster and set party.pitch = 0.0. Monster heights had been random since the day before (this type from 2.2 to 3.6 m), and a level shot from the air went over a short one. Both misses in 60 runs had a monster under 2.4 m.

Tests now aim with this helper, which tilts the view from the party’s eye height (1.6 m):

func aim_at(target: Vector3) -> void:
	face(target)
	var to := target - (party.global_position + Vector3.UP * party.eye_height)
	party.pitch = atan2(to.y, Vector2(to.x, to.z).length())

The quest builder had a real race. Every time it opens, _sweep() in scripts/builder/builder_model.gd deletes any other process’s scratch folder that has no stamp.txt. But _write_scratch() makes the directories first and writes the stamp after. Open the builder twice at once and one copy deletes the other’s folder. Eight copies of the builder tests at once failed 11 of 24 runs. The tests now build their folder to one side with the stamp already in it, then move it into place with DirAccess.rename_absolute(), so it never exists without a stamp. The builder itself still makes the folder first.

The agent on this ran the suite ten times in a row and three times with two copies at once: 608 scenario runs, 0 failures, by its own count. One failure it could never reproduce, so its cause is unknown.

Putting every key and button behind one input table

The game opens on a main menu now, and Esc or Start pauses it. There are settings, key rebinding, and a controller layout for every screen.

Twelve stills: an opening screen reading VAT 07 NUTRIENT FEED FAILURE, a save menu with four slots, a settings page with volume sliders, the spell book and map with a small box naming the controller button being pressed, a pause menu titled PROCESS HELD, and a main menu titled BLOBBER.
The box at the top right names each controller button the film pressed.

Rebinding and on-screen hints both need one place that knows what every key does. That is the autoload Controls (scripts/input/controls.gd), built round a constant table, ACTIONS, of 155 rows. A row is an id, its name on the rebinding screen, a context, the default keys and the default controller buttons:

	["jump", "Jump", WORLD, "Space,X", "Y"],
	["interact", "Use / talk", WORLD, "E", "A"],
	["attack", "Attack / act", WORLD, "F,mouse:1", "RT"],
	["next_spell", "Next readied spell", WORLD, "C", "X"],
	["next_caster", "Next caster", WORLD, "Shift+C", ""],

A binding is a string: key:Shift+C, mouse:1, pad:A, or pad:LX- for a stick direction. _register() turns each into an InputEventKey, InputEventJoypadButton or InputEventJoypadMotion and writes it into Godot’s InputMap under the action’s id, so Input.is_action_pressed() still works underneath. A stick counts as one press when it crosses 0.5. Only bindings that differ from the defaults are saved, in the bindings section of user://settings.cfg (a ConfigFile), never in a save.

The context says which actions are listened for at the same moment. The world and a screen never are, so R can be Rest in the world and Ready in the spell book. rebind() takes a binding away from any action that would clash and returns the losers for the screen to name.

Hints are templates. Controls.format("[{interact}] Open") gives [E] Open, or [A] Open once the last input came from a controller. Controls.live(label, template) keeps a weak reference to the label and refills it on every rebind or change of device.

A test keeps it that way. tests/controls.gd reads every game script outside scripts/input/ with a regular expression for KEY_, JOY_BUTTON_, Input.is_action_pressed and the like, and fails on any match that isn’t one of five listed exceptions.

A controller has too few buttons for this game. The D-pad opens four screens, and holding LB shows a wheel of eight actions, each fired as if its key had been pressed.

Nobody has held a controller to it. The film and the tests build controller events and feed them to the engine, from tests/pad_base.gd:

func _pad_button(button: JoyButton, pressed: bool) -> void:
	var event := InputEventJoypadButton.new()
	event.device = 0
	event.button_index = button
	event.pressed = pressed
	event.pressure = 1.0 if pressed else 0.0
	Input.parse_input_event(event)

Chaining two save migrations that both claimed version 4

Two branches each raised the save file from version 3 to 4: the one for learning spells one at a time, and the one for XP dropped on a party wipe. Landed, they are versions 4 and 5.

Stills of the spell book with unlearned spells stamped NOT ON FILE, a chest holding procedure sheets, a vendor called the Registrar listing spells to buy, a spell named Recall raising a dead party member, and a dead monster standing back up as an ally.
Spells are learned one at a time now. Unknown ones show as blanks.
Stills of a party wipe screen reading 2400 XP UNSPENT, RENDERED DOWN AND LEFT AT THE YARD, the party back at the vat, a second wipe that loses the first drop, a load menu with three autosaves, and a fight log listing each blow by fight.
A wipe leaves your unspent XP where you fell. Wipe again first and it is gone.

A save is user://saves/<slot>.json with a version and a dictionary of sections. SaveGame._read_file() refuses a file newer than FILE_VERSION and brings an older one forward one version at a time:

	while version < FILE_VERSION:
		if migrations.has(version):
			file = migrations[version].call(file)
		version += 1

migrations maps the version a file was written with to a function that takes the whole decoded file and returns it as the next version would have written it. A migration never asks the running game anything. The 3 to 4 one carries a frozen table of the spells each school had at version 3, and gives a member all of them so nobody loses their spell book.

Both branches were cut from version 3, so both added an entry under key 3. The merge moved the second to key 4 and set FILE_VERSION to 5. This is scripts/save/save_migrations.gd after it:

static func all() -> Dictionary:
	return {1: v1_items_get_ids, 2: v2_one_list_and_lore_by_type, 3: v3_spells_known_one_by_one,
			4: v4_keys_by_id}

The order was free here. One migration adds known_spells to each member in the party section. The other renames keys in the doors, chests, traps, keycards and monsters sections, from an id and a position (cell_door@-5.3,0.0,-38.5) to the id alone. A third branch made it version 6 the same morning.

Why did the run sit stopped for four hours?

One main session briefs the agents and lands what they finish. Each agent works on its own branch in its own git worktree. The main session only acts when an agent reports to it or I type.

At 2:05 the main session hit its usage limit. Two agents finished at 2:05 and 2:15, and nothing was running to land them. The limit reset at 2:50, but nothing sends the session its next message. I typed “Continue” at 6:21, so finished work waited 4 hours 16 minutes.

One agent had started a shell loop at 23:49 that read a test output file every ten seconds, waiting for one result line. The line was never written to that file. The agent finished and reported without it, and the loop ran for about seven hours with no time limit. It only slept and read a file, so it changed nothing. I found it, not the session.

A watchdog for this has to run outside the session, so a limit doesn’t stop it too. It needs to know which branches are finished and unlanded, and to put a time limit on every wait. None of that existed that night.

Also done

  • Items, chests, loot, recipes and prices left the code tables for JSON under content/ (content/items/weapons.json and eight more, stocks.json, loot.json, costs.json), read at start by ItemDb. A recorded baseline of every item, chest, loot roll, recipe and cost was identical before and after. Another branch was adding items to the tables this one deleted, so seven files conflicted in the merge.
  • Classes and XP cost curves are content files too, with a class editor.
  • Spells no longer take damage from the caster’s weapon. Staves carry a spell bonus of their own.
  • A spear in both hands does 1.25 times its damage.
  • Monsters chasing the party are still chasing it after a load.
  • I looked into an art pipeline and put it on hold.

Next: how dungeons and an overworld get made.