In peg-drop games (the Peggle and Peglin kind), the fun is in aiming: you line up a shot, watch it bounce through the board, and plan the next one. That only works if the same aim gives the same drop every time. With RigidBody2D it doesn’t quite: results shift with frame timing, and an aim guide drawn from a separate prediction drifts away from what really happens. Here’s how I got exact repeatability in plain GDScript, in case it helps anyone making a physics-y puzzle or roguelite.
1. A tiny fixed-step sim instead of the physics engine
The whole simulation is circles (pegs and balls), two side walls and the bucket dividers. That’s small enough to write by hand. Every step is exactly 1/240 s, with gravity and a speed cap (trimmed slightly here):
const STEP := 1.0 / 240.0
func _step_ball(b: Ball) -> void:
b.vel.y += _gravity * STEP
var sp := b.vel.length()
if sp > MAX_SPEED:
b.vel *= MAX_SPEED / sp
b.pos += b.vel * STEP
# ... walls, then pegs:
for idx in board.near(b.pos, b.radius):
var p := board.pegs[idx]
var d := b.pos - p.pos
var rr := b.radius + p.radius
if p.gone or d.length_squared() >= rr * rr:
continue
var n := d.normalized()
b.pos = p.pos + n * rr # push out of the peg
var vn := b.vel.dot(n)
if vn < 0.0: # reflect, keeping some of the speed
b.vel -= (1.0 + p.kind.bounce) * vn * n
_hit(p, b)
board.near() is a simple spatial grid (48 px cells), so even a dense board only checks a handful of pegs per step. A whole drop takes about 10 ms to simulate.
2. The screen just replays the sim
The sim doesn’t know the screen exists. The board view keeps a time accumulator and runs as many fixed steps as real time allows, then draws the balls where the sim says they are. A slow frame means more steps that frame, never a different result. Speeding up the drop (hold to go faster) is just more steps per second.
3. The aim guide is the real code
The dotted aim line steps the same fixed 1/240 s with the same gravity, speed cap and collision math, on the same board, up to the first peg contact. Bosses that change the rules (heavier gravity, tiny balls, wind) feed into it too, and a “no aim guide” boss simply hides it. A test drops balls at a range of angles under each of those rules and checks the guide ends exactly where the real ball first touches a peg (0.000 px apart). Writing that test is how I found the guide was ignoring the wind boss, so it was worth it.
4. Preview on a copy, so the bot can think
Because the board is plain data, board.copy() is cheap. Anything that wants to “try” a drop (the included bot, the balance script, the tests) runs a full drop on a copy with a preview flag that turns off random rolls and keeps trinkets from growing. The bot tries 12 aims per drop this way and picks the best score.
5. Saving without breaking determinism
This one bit me. A saved run has to play on exactly as if it had never stopped, which means every peg position must come back bit-for-bit. Two things matter: save with full float precision (JSON.stringify(data, "", true, true)), and convert numbers back to their real types on load, because JSON gives everything back as floats. A stress test plays dozens of runs, saving and reloading after every drop, and checks the reloaded run ends identically. That test caught a difference between Godot 4.3 and 4.7 in how whole numbers come back, which would have been invisible otherwise.
I’ve packaged this up as Peg Roguelite Kit, a complete peg-drop roguelite template for Godot 4: 7 kinds of peg, 9 balls, 35 trinkets, 11 bosses, a full 8-ante run with a shop, mouse, keyboard and gamepad, and everything as resources you edit in the Inspector:
There’s a free browser demo if you want to feel the bouncing first: Peg Roguelite Kit - Playable Demo by Spare Parts Studio
Happy to answer questions about any of it.