
Godot Game Loop Time Trial
- 115 installs
- 454 repo stars
- Updated July 28, 2026
- thedivergentai/gd-agentic-skills
Helps with ai & agent building tasks during AI-assisted development.
About
godot-game-loop-time-trial is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- godot-game-loop-time-trial
- AI & Agent Building
- AI-coding skill
Godot Game Loop Time Trial by the numbers
- 115 all-time installs (skills.sh)
- +15 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #3,942 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/thedivergentai/gd-agentic-skills --skill godot-game-loop-time-trialAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 115 |
|---|---|
| repo stars | ★ 454 |
| Last updated | July 28, 2026 |
| Repository | thedivergentai/gd-agentic-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Time Trial Loop: Arcade Precision
[!NOTE]
Resource Context: This module provides expert patterns for Time Trial Loops. Accessed via Godot Master.
Architectural Thinking: The "Validation-Chain" Pattern
A Master implementation treats Time Trials as a State-Validated Sequence. Recording a time is easy; ensuring the player didn't cheat via shortcuts requires a strictly ordered CheckpointManager.
Core Responsibilities
- TimeTrialManager: The central clock. Validates checkpoint order and handles "Best Lap" logic.
- GhostRecorder: Captures high-frequency transform data. Uses delta-time timestamps for frame-independent playback.
- Checkpoint: Spatial triggers that notify the Manager.
Expert Code Patterns
1. Robust Checkpoint Validation
Prevent "Shortcut Cheating" by requiring checkpoints to be cleared in numerical order.
# time_trial_manager.gd snippet
func pass_checkpoint(index):
if index == current_checkpoint_index + 1:
current_checkpoint_index = index
_emit_split_time()2. Space-Efficient Ghosting
Avoid recording every frame. Sample the player's position at a fixed rate (e.g., 10Hz) and use Linear Interpolation (lerp) during playback to fill the gaps.
# ghost_replayer.gd (Conceptual)
func _process(delta):
# Uses linear interpolation for smooth 60fps+ playback from 10hz data
var target_pos = frame_a.p.lerp(frame_b.p, weight)Master Decision Matrix: Data Storage
| Format | Best For | Implementation |
|---|---|---|
| Dictionary Array | Prototyping | Simple [{t: 0.1, p: pos}, ...] |
| Typed Array | Performance | PackedVector3Array for positions. |
| JSON/Binary | Saving | FileAccess.get_var() to save ghost files. |
NEVER Do
- NEVER use OS.get_ticks_msec() for ultra-precise race timing — Millisecond resolution is too coarse for high-end racing games. Use
Time.get_ticks_usec()for microsecond precision. - NEVER rely exclusively on _process() for finish line triggers — Visual frames can skip during lag. Always evaluate physical overlaps in
_physics_process()to guarantee detection within the fixed physics step. - NEVER evaluate Area3D overlaps immediately after instantiation — The physics server requires at least one physics frame to synchronize.
await get_tree().physics_framebefore checking for players. - NEVER scale a CollisionShape3D on a checkpoint non-uniformly — This breaks the underlying SAT collision math. Always scale the internal shape resource (e.g.,
BoxShape3D.size) instead. - NEVER use TCP (reliable) for syncing positions in multiplayer racing — Congestion algorithms cause huge spikes. Use
ENetMultiplayerPeerwithTRANSFER_MODE_UNRELIABLEfor high-frequency position updates. - NEVER trust client-side finish line/lap crossing — Always validate triggers on the authoritative server using
multiplayer.is_server()to prevent cheating. - NEVER use standard float equality (==) for record lap times — Use
is_equal_approx()to account for precision loss in accumulated time variables. - NEVER hardcode input checks without flushing the buffer — For frame-perfect boost/stop responses, call
Input.flush_buffered_events()to ensure the engine has processed the latest raw input. - NEVER allocate new Vector3 arrays inside fast path-following loops — This triggers the garbage collector. Use
PackedVector3Arrayto maintain a contiguous memory block. - NEVER use dynamic string paths ($"../Checkpoint") in tight loops — Lookups are slow. Use
@onreadyto cache node references during initialization. - NEVER record the whole player object for ghosts — Only record core transforms (position/rotation). Recording the whole object is memory-intensive and unnecessary for visual ghosts.
- NEVER give the ghost collision — It should be a purely visual indicator (e.g., semi-transparent) to avoid disrupting the player's line.
- NEVER neglect checkpoint sequencing — Don't just check if the player hit the finish line. Verify they passed every intermediate checkpoint in the correct order.
- NEVER use Area3D without monitoring optimization — Checkpoints should only look for the
Playerphysics layer to minimize the number of physics overlap calculations. - NEVER use standard lerp for ghost rotation — Use
slerp()orQuaternion.slerp()to avoid gimbal lock and ensure smooth rotation interpolation.
---
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
time_trial_patterns.gd
10 Expert patterns: Microsecond timing, server-authoritative validation, rubber-banding AI, and frame-perfect input flushing.
time_trial_manager.gd
The central clock. Validates checkpoint order and handles "Best Lap" logic.
ghost_recorder.gd
Captures high-frequency transform data for playback.
time_trial_playback_buffer.gd
Jitter-buffer for smooth ghost playback during network streaming.
time_trial_leaderboard_bridge.gd
Formatting utility for converting raw time data to human-readable strings.
---
Expert Time Trial Patterns
1. Delta-Compression for Ghosts
Instead of recording every frame, only store a new "keyframe" if the player's position or rotation has changed beyond a threshold.
- Storage: Use
FileAccess.open_compressed()withFileAccess.COMPRESSION_ZSTDfor maximum efficiency. - Binary: Save as raw floats/integers rather than JSON to reduce file size by ~80%.
2. The Leaderboard Bridge
Standardize time formatting across your game to avoid precision issues.
- Precision: Store records in
msec(int) orusec(int) to avoid float rounding errors. - Formatting: Use
%02d:%02d.%03dformat strings for consistent UI display (e.g.,01:24.450).
Reference
- Master Skill: godot-master
# ghost_recorder.gd
# [GDSKILLS] godot-game-loop-time-trial
# EXPORT_REFERENCE: ghost_recorder.gd
extends Node
@export var target_node: Node3D
@export var sample_rate: float = 0.1 # Seconds between samples
var recording: Array = []
var is_recording: bool = false
var time_elapsed: float = 0.0
var last_sample_time: float = 0.0
func start_recording() -> void:
recording.clear()
is_recording = true
time_elapsed = 0.0
last_sample_time = 0.0
func stop_recording() -> void:
is_recording = false
func _physics_process(delta: float) -> void:
if not is_recording or not target_node:
return
time_elapsed += delta
if time_elapsed >= last_sample_time + sample_rate:
_capture_sample()
last_sample_time = time_elapsed
func _capture_sample() -> void:
recording.append({
"t": time_elapsed,
"p": target_node.global_position,
"r": target_node.global_rotation
})
func get_data() -> Array:
return recording
# ghost_replayer.gd
# [GDSKILLS] godot-game-loop-time-trial
# EXPORT_REFERENCE: ghost_replayer.gd
extends Node3D
@export var ghost_visual: Node3D
@export var interpolation_enabled: bool = true
var recording_data: Array = []
var is_playing: bool = false
var playback_time: float = 0.0
var _data_size: int = 0
func start_playback(data: Array) -> void:
if data.is_empty():
return
recording_data = data
_data_size = recording_data.size()
playback_time = 0.0
is_playing = true
# Initial placement
_apply_transform(recording_data[0])
# Ensure visual is ready
if ghost_visual:
ghost_visual.show()
func stop_playback() -> void:
is_playing = false
if ghost_visual:
ghost_visual.hide()
func _process(delta: float) -> void:
if not is_playing:
return
playback_time += delta
# Check for end of recording
if playback_time >= recording_data[_data_size - 1].t:
_apply_transform(recording_data[_data_size - 1])
is_playing = false
return
_update_transform()
func _update_transform() -> void:
# Find the current frame (Binary search could be grander, but linear is fine for <10 min runs)
# Optimization: We assume sequential access, so we can track the last index.
# For robustness, we'll just scan or use a simple look-ahead since samples are ordered.
var idx = _find_keyframe_index(playback_time)
if idx == -1: return
var frame_a = recording_data[idx]
var frame_b = recording_data[idx + 1]
if not interpolation_enabled:
_apply_transform(frame_a)
return
# Calculate t (0.0 to 1.0) between frames
var duration = frame_b.t - frame_a.t
if duration <= 0.0001:
_apply_transform(frame_a)
return
var weight = (playback_time - frame_a.t) / duration
var target_pos = frame_a.p.lerp(frame_b.p, weight)
# Slerp for rotation requires Basis or Quat. Assuming 'r' is Vector3 (Euler) or Basis.
# ghost_recorder saves global_rotation (Vector3 Euler) usually, but Quat is safer.
# Let's assume recorder saves Vector3 for simplicity, but converting to Basis for slerp is better.
var rot_a = Quaternion.from_euler(frame_a.r)
var rot_b = Quaternion.from_euler(frame_b.r)
var target_rot = rot_a.slerp(rot_b, weight).get_euler()
if ghost_visual:
ghost_visual.global_position = target_pos
ghost_visual.global_rotation = target_rot
func _apply_transform(frame: Dictionary) -> void:
if ghost_visual:
ghost_visual.global_position = frame.p
ghost_visual.global_rotation = frame.r
func _find_keyframe_index(time: float) -> int:
# Returns index such that data[index].t <= time < data[index+1].t
# Simple linear scan suitable for short replays.
# For long runs, store 'last_index' state to resume search.
for i in range(0, _data_size - 1):
if time < recording_data[i+1].t:
return i
return -1
class_name TimeTrialLeaderboardBridge
extends Node
## Helper for formatting time trial results and bridging to leaderboards.
## Converts raw milliseconds/ticks into human-readable MM:SS.mmm format.
## Formats milliseconds into "MM:SS.mmm"
static func format_msec(msec_total: int) -> String:
var msec := msec_total % 1000
var seconds := (msec_total / 1000) % 60
var minutes := (msec_total / 60000)
return "%02d:%02d.%03d" % [minutes, seconds, msec]
## Formats physics frames into "MM:SS.mmm" based on engine tick rate
static func format_frames(frames: int) -> String:
var ticks_per_sec = Engine.physics_ticks_per_second
var msec_total = int((float(frames) / ticks_per_sec) * 1000.0)
return format_msec(msec_total)
## Utility to calculate current session time
var start_tick: int = 0
func start_session() -> void:
start_tick = Engine.get_physics_frames()
func get_elapsed_formatted() -> String:
var current = Engine.get_physics_frames()
return format_frames(current - start_tick)
# time_trial_manager.gd
# [GDSKILLS] godot-game-loop-time-trial
# EXPORT_REFERENCE: time_trial_manager.gd
extends Node
signal lap_started()
signal lap_finished(time: float, is_new_best: bool)
signal checkpoint_passed(index: int, split_time: float)
var best_time: float = INF
var current_lap_time: float = 0.0
var is_racing: bool = false
var current_checkpoint_index: int = -1
var total_checkpoints: int = 0
func setup_track(checkpoints_count: int) -> void:
total_checkpoints = checkpoints_count
current_checkpoint_index = -1
func start_lap() -> void:
current_lap_time = 0.0
is_racing = true
current_checkpoint_index = -1
lap_started.emit()
func pass_checkpoint(index: int) -> void:
if not is_racing: return
# Linear progression check
if index == current_checkpoint_index + 1:
current_checkpoint_index = index
checkpoint_passed.emit(index, current_lap_time)
if index == total_checkpoints - 1:
_finish_lap()
func _finish_lap() -> void:
is_racing = false
var is_best = current_lap_time < best_time
if is_best:
best_time = current_lap_time
lap_finished.emit(current_lap_time, is_best)
func _process(delta: float) -> void:
if is_racing:
current_lap_time += delta
# time_trial_patterns.gd
extends Node
# 1. Microsecond Precision Timing
# EXPERT NOTE: Tracks lap times using the CPU's high-resolution microsecond counter.
var _lap_start_usec := 0
func start_lap() -> void:
_lap_start_usec = Time.get_ticks_usec()
func get_elapsed_seconds() -> float:
return (Time.get_ticks_usec() - _lap_start_usec) / 1_000_000.0
# 2. Server-Authoritative Lap Validation
# EXPERT NOTE: Clients trigger the RPC, but only the host evaluates the win.
@rpc("any_peer", "call_local", "reliable")
func validate_checkpoint(id: int) -> void:
if multiplayer.is_server():
var sender_id := multiplayer.get_remote_sender_id()
_server_check_sequence(sender_id, id)
func _server_check_sequence(_peer: int, _cp_id: int) -> void:
# Validate that checkpoint ID follows the previous one in sequence
pass
# 3. Area3D Checkpoint Detection
# EXPERT NOTE: Securely limits signal triggering to specific vehicle/player classes using safe casting.
func _on_checkpoint_entered(body: Node3D) -> void:
var car := body as CharacterBody3D # Or VehicleBody3D
if car:
_trigger_checkpoint_logic()
func _trigger_checkpoint_logic() -> void:
pass
# 4. Rubber-Banding Speed Adjustments
# EXPERT NOTE: Dynamically alters maximum agent speed based on distance to the leader.
func update_ai_speed(agent: RID, player_distance: float) -> void:
var base_speed := 50.0
var factor := 0.5
var speed: float = base_speed + (player_distance * factor)
NavigationServer3D.agent_set_max_speed(agent, speed)
# 5. Fast Distance Checking (Squared)
# EXPERT NOTE: Uses distance_squared_to to skip the expensive square-root calculation.
func is_near_finish_line(pos: Vector3, finish_pos: Vector3) -> bool:
var threshold_sq := 10.0 * 10.0 # 10 meters
return pos.distance_squared_to(finish_pos) < threshold_sq
# 6. Optimized Tag Comparisons with StringName
# EXPERT NOTE: Uses StringName for instant hash comparisons in checkpoint logic.
var checkpoint_tag := &"checkpoint_sector_3"
func check_gate_tag(tag: StringName) -> void:
if tag == checkpoint_tag:
# Proceed logic
pass
# 7. Unreliable High-Frequency Packet Sync
# EXPERT NOTE: Sends highly-volatile racer positions quickly via raw bytes/unreliable.
func sync_transform(data: PackedByteArray) -> void:
multiplayer.send_bytes(data, 0, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
# 8. Dynamic Physics Material Overrides
# EXPERT NOTE: Dynamically adjusts bounce/friction when surface changes (ice, mud, road).
func set_surface_friction(body: RigidBody3D, friction_val: float) -> void:
var mat := PhysicsMaterial.new()
mat.friction = friction_val
body.physics_material_override = mat
# 9. Frame-Perfect Input Buffering
# EXPERT NOTE: Forces the engine to parse immediate inputs before physics calculations.
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed(&"boost"):
Input.flush_buffered_events()
_apply_boost()
func _apply_boost() -> void:
pass
# 10. Curve-Based Steering Interpretation
# EXPERT NOTE: Uses visual Curve resources to evaluate complex steering dampening based on speed.
@export var steering_damp_curve: Curve
func get_steer_damp(speed_ratio: float) -> float:
return steering_damp_curve.sample_baked(speed_ratio)
class_name TimeTrialPlaybackBuffer
extends Node
## Handles ghost playback with a jitter buffer for network-streamed data.
## Ensures smooth playback even if packets arrive out of order or in bursts.
@export var buffer_time: float = 0.5 # Seconds to buffer before starting playback
@export var ghost_visual: Node3D
var _incoming_buffer: Array[Dictionary] = []
var _is_playing: bool = false
var _playback_time: float = 0.0
var _buffer_filled: bool = false
## Call this whenever a new ghost frame arrives from the network.
func push_frame(time: float, transform: Transform3D) -> void:
_incoming_buffer.append({"t": time, "xform": transform})
# Keep buffer sorted by time
_incoming_buffer.sort_custom(func(a, b): return a.t < b.t)
if not _is_playing and _get_buffer_duration() >= buffer_time:
_buffer_filled = true
_start_playback()
func _process(delta: float) -> void:
if not _is_playing:
return
_playback_time += delta
_update_ghost_transform()
func _start_playback() -> void:
if _incoming_buffer.is_empty(): return
_playback_time = _incoming_buffer[0].t
_is_playing = true
func _update_ghost_transform() -> void:
if _incoming_buffer.size() < 2: return
# Find interpolation frames
var frame_a = _incoming_buffer[0]
var frame_b = _incoming_buffer[1]
# Remove old frames that are behind the playback head
while _incoming_buffer.size() > 2 and _incoming_buffer[1].t < _playback_time:
_incoming_buffer.remove_at(0)
frame_a = _incoming_buffer[0]
frame_b = _incoming_buffer[1]
if ghost_visual and frame_a.t <= _playback_time:
var span = frame_b.t - frame_a.t
var weight = (_playback_time - frame_a.t) / span if span > 0 else 0.0
ghost_visual.global_transform = frame_a.xform.interpolate_with(frame_b.xform, weight)
func _get_buffer_duration() -> float:
if _incoming_buffer.size() < 2: return 0.0
return _incoming_buffer[-1].t - _incoming_buffer[0].t