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.

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,
},

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.

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.

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)
- Rasterize. The plan is drawn onto a grid of 0.5 m cells, 92 by 153. A
PackedInt32Arrayrecords which rectangle owns each cell, or -1. The first rectangle to claim a cell keeps it. - 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.
- 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.

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, sounequip()refuses them. - Sound: 135 free clips from Kenney, plus ambience loops made by a Python script.