Birdoggydog's Builds

Building a first-person RPG prototype in Godot with agents

Stat breakpoints solved by bisection, one game clock behind every cooldown, shots swept as rays, and a dungeon rasterized from a floor plan onto a grid.

I started Blobber on Sunday evening with no code. By the end of the night it had 17 commits: stats that step up at breakpoints, one game clock that every cooldown counts on, shots swept as rays from the camera, and a dungeon built from a floor plan on a grid.

Blobber is a first-person party RPG in the style of Might and Magic VI, built in Godot 4.7. I build it by directing AI coding agents, and they write the GDScript. Everything below is the code as it stood at the end of that night.

Making stats step up at breakpoints

Every stat runs from 1 to 999 and turns into a bonus through one table. 10 is the baseline, each point below it is -1, and above it the bonus goes up by one at each breakpoint, to +50 at 999.

The party inventory screen with a stats pane on the right for Chuck, a Shank. Muscle is 13 with a bonus of +3 and “next at 14”. Tendons, Fat, Grey Matter and Machine follow in the same form, with derived values such as health and evasion below.
The stats pane shows each stat's bonus and the value where the bonus next goes up.

I wanted diminishing returns, so that 80% of the gains come from 20% of the range. I also wanted the gains to come in steps, so that a player can say “i won’t get to x until the next break point”.

The table is a PackedInt32Array of 51 entries in scripts/stats.gd, built the first time anything asks for it. Entry b is the lowest stat value that gives +b. The entries sit on a geometric curve from 10 to 999:

breakpoint(b) = 10 + 989 * (R^(b/50) - 1) / (R - 1)

R sets how fast the gaps widen, and the 80/20 rule fixes it. Put b = 40 in, and (R^0.8 - 1) / (R - 1) has to equal 0.2. That has no tidy solution, so the script finds ln(R) by bisection between 0 and 40, halving the range 80 times, then fills the table:

var low := 0.0  # ln(R)
var high := 40.0
for i in 80:
	var mid := (low + high) / 2.0
	var fraction := (exp(mid * PARETO_GAIN) - 1.0) / (exp(mid) - 1.0)
	if fraction > PARETO_RANGE:
		low = mid
	else:
		high = mid
var ratio := exp((low + high) / 2.0)

var span := MAX_STAT - BASELINE
_breakpoints.resize(MAX_BONUS + 1)
for b in MAX_BONUS + 1:
	var curve := BASELINE + span * (pow(ratio, float(b) / MAX_BONUS) - 1.0) / (ratio - 1.0)
	# Never more than +1 bonus per stat point.
	_breakpoints[b] = maxi(BASELINE + b, roundi(curve))

R comes out near 3,105, so each gap is about 17% wider than the one before. The maxi on the last line stops two breakpoints landing on one stat point, and it’s why the first 28 are one point apart, 11 to 38. After that the curve takes over: 43, 49, 56, 64, and on up to 208 for +40. 208 is 20% of the way from 10 to 999.

Stats.next_breakpoint(value) returns the next entry, which the pane prints as “next at 14”.

The formulas in scripts/party_member.gd work from bonuses, never from the stat. Each point of Tendons bonus is 4% more actions per second and 1.5% evasion. Each point of Fat bonus is 10 health and soaks 1.5% of every hit, up to 75%. Absorption was a flat amount at first, which made high-Fat characters nearly immune.

A character class is a “cut” of meat, and a cut is one dictionary of starting stats:

{
	"name": "Brisket", "blurb": "Dense and slow. Takes a long time to get through.",
	"muscle": 16, "tendons": 7, "fat": 18, "grey_matter": 8, "machine": 9, "caster": false,
},
The Meat Machine’s culture selection screen. Four rows, called yields, are named Chuck, Flank, Marrow and Gristle. Each has a cut picked from a list (Shank, Brisket, Sweetbread, Offal), a one-line description and its stats.
Picking cuts for the party. Brisket's Tendons of 7 is a bonus of -3, so its 2.0 s melee cooldown shows as 2.24 s.

Counting every cooldown on one game clock

The game has one clock. Attack recovery, MP, monster respawns, trap cycles and the sun all count game seconds on it, and nothing keeps a timer of its own.

I wanted a day and night cycle with months and years, “and all of this should run off the clock”. The turn-based mode I plan can then move the same clock in jumps.

GameClock is an autoload (scripts/game_clock.gd) that holds one float, total_seconds: game seconds since midnight on 1 January, Year 1. In _process it adds delta * time_scale. The normal time_scale is 30, so a game minute passes every two real seconds. The calendar is integer division on that number, with 28-day months and a 336-day year.

Time only moves through advance():

func advance(game_seconds: float) -> void:
	if game_seconds <= 0.0:
		return
	var before := _whole_seconds()
	total_seconds += game_seconds
	var after := _whole_seconds()

	advanced.emit(game_seconds)
	if after / SECONDS_PER_MINUTE != before / SECONDS_PER_MINUTE:
		minute_changed.emit()
	if after / SECONDS_PER_HOUR != before / SECONDS_PER_HOUR:
		hour_changed.emit()
	if after / SECONDS_PER_DAY != before / SECONDS_PER_DAY:
		day_changed.emit()
	if after / SECONDS_PER_MONTH != before / SECONDS_PER_MONTH:
		month_changed.emit()
	if after / SECONDS_PER_YEAR != before / SECONDS_PER_YEAR:
		year_changed.emit()

It compares before and after, so a big jump fires each signal once. Skipping a month emits day_changed once, not 28 times.

Cooldowns are written in real seconds at the normal rate and converted once. GameClock.real_seconds(2.0) is 60 game seconds. A party member’s commit(action) stores that in cooldown_remaining. The party listens to advanced and calls each member’s tick(game_seconds), which subtracts.

Movement follows the clock too. GameClock.rate() is time_scale / 30, or 0 while the clock is stopped, and a monster multiplies its horizontal velocity by it before move_and_slide(). A laser grid has no timer at all. Its place in its on/off cycle is fposmod(GameClock.total_seconds / 30 + phase, on_time + off_time).

The sky (scripts/day_night_cycle.gd) reads GameClock.day_fraction every frame. The sun’s angle is (day_fraction - 0.25) * TAU, which puts sunrise at 6:00 and sunset at 18:00. That angle turns a DirectionalLight3D along an arc tilted 25 degrees. smoothstep on the sine of the angle blends the ProceduralSkyMaterial colors, the ambient light and the fog between day and night.

Aiming shots with the camera, not a target lock

Ranged attacks and spells fly to the point under the crosshair, and I aim them by turning and looking up or down.

A walled concrete yard under a brown sky. A green blocky monster stands beside a rusty post, with a health plate reading “Spoiled Yield 1000 / 1000”. A doorway behind leads into the dungeon. Debug text fills the top left corner.
The practice monster in the yard, with 1000 health and a small crosshair on the post beside it.

The first version locked onto the target for me. I didn’t like it: “Shots should fire straight in front of you.” So the lock came out.

Every physics tick, scripts/party.gd casts one ray from the camera along its forward axis for 20 m with intersect_ray, leaving out the party’s own body. Where it hits is aim_point, or the point 20 m out if it hits nothing. If the collider is a Monster, that monster is target.

Holding F calls attack() on every tick. Each living member whose cooldown has run out picks for themselves: the spell if they’re a caster with 10 MP, otherwise melee if target is within 4 m, otherwise a ranged shot. Melee calls target.take_damage() directly. The other two spawn a Projectile. The four members’ shots start spread across 0.9 m of the party’s front and each is pointed at aim_point, so they converge on the crosshair. They start up to 0.8 m ahead of the camera, because they filled the bottom of the screen on their first frame.

A Projectile is a Node3D with no collision shape. Before each move it sweeps a ray over the stretch it’s about to cover (scripts/projectile.gd):

func _physics_process(delta: float) -> void:
	var step := minf(speed * delta, max_distance - _travelled)
	var next := global_position + direction * step
	var query := PhysicsRayQueryParameters3D.create(global_position, next)
	var excluded := passes_through.duplicate()
	if shooter:
		excluded.append(shooter.get_rid())
	query.exclude = excluded
	var hit := get_world_3d().direct_space_state.intersect_ray(query)
	if not hit.is_empty():
		_impact(hit["position"], hit["collider"])
		return
	global_position = next
	_travelled += step
	if _travelled >= max_distance:
		queue_free()

Physics runs at 120 ticks a second and a party shot flies at 28 m/s, so each sweep is about 23 cm. The ray covers the whole step, so a shot can’t skip through a thin wall between two ticks. Whatever it touches first ends it, and takes the damage if it has a take_damage method.

Building the dungeon from a floor plan on a grid

The dungeon is nine rooms beyond the yard, with two loops and two ramps, opening onto a 200 m empty field. scripts/dungeon.gd builds all of it from a list of rectangles when the scene starts.

A rusted green corridor with three red laser beams across it, one above another. Pressure plates lie on the floor beyond. Four party cards run along the bottom of the screen.
A laser grid in the gallery. Its beams are at 0.5, 1.1 and 1.7 m, on for 2.2 s and dark for 1.8 s.

The first room was built one hand-placed box at a time. The dungeon is generated so that walls, floors and roofs meet edge to edge, with no gaps and no overlapping faces.

The plan is 14 room rectangles and 12 doorways, in meters, each with a ceiling height and materials:

# 2. Gallery: long, narrow and tall. x -2..2, n 44..68.
_room(-2, 2, 44, 68, 6.0, Mat.PAINT, Mat.FLOOR)
  1. Rasterize. The plan is drawn onto a grid of 0.5 m cells, 92 by 153. A PackedInt32Array records which rectangle owns each cell, or -1. The first rectangle to claim a cell keeps it.
  2. Mark layers. An owned cell adds a floor slab below it and a roof slab above its ceiling. A doorway cell adds a hazard-striped band over the opening. An empty cell with an owned cell among its eight neighbors is wall, as tall as the tallest thing it touches. Each distinct (bottom, top, material) gets its own byte mask of the grid.
  3. Merge. Each mask is covered greedily with as few boxes as possible:
func _emit_layer(mask: PackedByteArray, y0: float, y1: float, material: Material) -> void:
	for row in _rows:
		for column in _columns:
			if mask[row * _columns + column] == 0:
				continue
			var width := 1
			while column + width < _columns and mask[row * _columns + column + width] == 1:
				width += 1
			var depth := 1
			while row + depth < _rows and _row_is_set(mask, row + depth, column, width):
				depth += 1
			for r in range(row, row + depth):
				for c in range(column, column + width):
					mask[r * _columns + c] = 0
			var size := Vector3(width * CELL, y1 - y0, depth * CELL)
			var center := Vector3(
				GRID_X0 + column * CELL + size.x / 2,
				(y0 + y1) / 2,
				-(GRID_N0 + row * CELL + size.z / 2))
			_add_box(center, size, material)

Each box becomes a StaticBody3D holding a BoxMesh and a BoxShape3D of the same size. The materials are StandardMaterial3D with uv1_triplanar and uv1_world_triplanar on, so a texture lines up across every seam and no box needs UVs.

The party can’t step up a ledge, so the two 1.5 m height changes are ramps, 6 m and 8 m long. Each is a wedge built with SurfaceTool, with a ConvexPolygonShape3D of its six corners to walk on.

Eleven doors and fourteen traps sit on top, placed by world coordinates in scripts/dungeon/dungeon_content.gd. A trap has no Area3D. Every physics tick it turns the party’s position into its own local space with to_local() and compares numbers.

Splitting the work across git worktrees

For the first 99 minutes there was no version control. Then the project became a local git repository with no remote, with main in one folder and each branch checked out in a folder of its own beside it.

Five blocky monsters in a row on an empty field, each a different color and size, with name plates: Gristle Spitter 200, Rind Stalker 280, Bile Seer 180, Marrow Mender 150 and Slab Warden 480.
Five monster types built in one worktree while doors and traps were built in another. There is only one monster model, so they differ by tint and height.

I wanted to work on the map while features were being built. A git worktree folder is a whole Godot project that opens separately. Godot’s import cache, .godot/, is in .gitignore, so each folder imports the assets once and keeps its own cache. A .gitattributes line, * text=auto eol=lf, keeps line endings the way Godot writes them.

The bigger pieces each went to a background agent in its own worktree. An agent leaves its changes uncommitted. Nothing is merged into main until I say “merge”, and then the clean worktrees are fast-forwarded to the new main.

The first room, the opening’s story, the dialog and the interface wiring were all in one script, scripts/vat_bay.gd, so two branches would collide there. The rule was that each piece goes in a new script and touches existing files as little as it can. The dungeon’s whole footprint in vat_bay.gd was a doorway cut in the yard’s north wall and one line, add_child(Dungeon.new()).

Monster behaviour got the same shape. Monster is a CharacterBody3D that supplies verbs (move_toward_point, face, can_see, try_melee, try_ranged), and a behaviour is a small script in scripts/ai/ that only decides which verbs to call:

func fight(monster: Monster, party: Party) -> void:
	var distance := monster.distance_to(party)
	if distance > monster.melee_range * 0.7:
		monster.move_toward_point(party.global_position)
	else:
		monster.stop()
	monster.face(party.global_position)

	if distance <= monster.melee_range:
		monster.try_melee(party)
	elif _sees and distance <= monster.ranged_range:
		monster.try_ranged(party)

That’s the whole hybrid type (scripts/ai/hybrid_ai.gd). The five types share ChaseAI, a state machine with three states: idle, chase and return. A monster notices the party within 18 m in plain sight and gives up beyond 32 m, or after 8 s without sight. There’s no pathfinding, so a monster that hasn’t walked home in 45 s is put there.

The night had one merge conflict, where two branches each added a line at the same spot in scripts/party.gd. Both lines were kept. It also had one duplicate. My request for the monster agent reached the doors-and-traps agent too while that one was being resumed, and it started an agent of its own for the same job in a new worktree. The duplicate was stopped, but its worktree is still there.

Nothing was played by hand that night. Every check was a scripted run with simulated key presses and screenshots.

Also done

  • A setting. The brick test room became Culture Bay 7, a worn-down sci-fi meat plant where a party of four is grown at the Meat Machine.
  • An NPC I can talk to with E. A chat bubble by his head lists numbered options, grey until I’m within 3.5 m.
  • One shared party inventory on the I key. Everyone starts with a stock heart, lungs and liver that change nothing and are flagged requires_surgery, so unequip() refuses them.
  • Sound: 135 free clips from Kenney, plus ambience loops made by a Python script.