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.

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.yafter 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.

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.


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.jsonand eight more,stocks.json,loot.json,costs.json), read at start byItemDb. 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.