Engine v0: Godot 4.6 data-driven arcade racer scaffold

Drop-in content: cars/<id>/stats.tres (+optional car.glb), levels/<id>/level.glb
or .tscn with a Spawn marker. Raycast arcade car (boost, drift, drift-earns-boost),
chase cam, HUD, menu built from registry scan. Placeholder Woolies carpark with
shuntable parked cars. Headless smoke test + windowed screenshot harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-27 21:03:45 +10:00
commit 1c0b7efb32
25 changed files with 502 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.godot/
.DS_Store
*.blend1
screenshot.png

54
README.md Normal file
View File

@ -0,0 +1,54 @@
# Burnout Infinity: Shitbox
Burnout 3-style arcade racer, Aussie shitboxes, Woolies carparks. Godot 4.6 + Blender, native Apple Silicon.
## Run
```
/Applications/Godot.app/Contents/MacOS/Godot --path .
```
Controls: WASD/arrows drive, Shift drift, Space boost (earn it by drifting), R reset, Esc menu.
## Add a car
Make a folder `cars/<id>/` containing `stats.tres`:
```
[gd_resource type="Resource" script_class="CarStats" load_steps=2 format=3]
[ext_resource type="Script" path="res://src/car_stats.gd" id="1"]
[resource]
script = ExtResource("1")
display_name = "Datto 120Y"
model_path = "res://cars/datto_120y/car.glb"
engine_power = 12.0
mass = 900.0
grip = 6.5
```
Model is optional — no `car.glb` means you drive the placeholder box. Export the GLB from
Blender with the car facing **-Y** (Blender forward), roughly 1.8 x 1.4 x 4.2 m, origin at the
centre, wheels-bottom around z = -0.5.
## Add a level
Make a folder `levels/<id>/` containing `level.glb` (or `level.tscn`). Blender convention:
- Objects that should be solid: suffix the object name with `-col` (Godot generates collision on import).
- Add an Empty named `Spawn` where the car starts (its -Y/forward axis = drive direction).
- Export scene as glTF Binary to `levels/<id>/level.glb`. Done — it appears in the menu.
## Tests
```
/Applications/Godot.app/Contents/MacOS/Godot --headless --path . --import
/Applications/Godot.app/Contents/MacOS/Godot --headless --path . --script tests/smoke.gd
```
## Design doc
`docs/BurnoutInfinityShitbox.pdf` — research report on the original game. The build order the
report implies: 1) handling + boost loop (done, tune forever), 2) crash/damage + takedowns,
3) traffic, 4) Crash Mode in the carpark, 5) race modes + AI.

11
cars/gemmy_tx/stats.tres Normal file
View File

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="CarStats" load_steps=2 format=3]
[ext_resource type="Script" path="res://src/car_stats.gd" id="1"]
[resource]
script = ExtResource("1")
display_name = "Gemmy TX"
model_path = ""
engine_power = 14.0
mass = 950.0
grip = 7.0

11
cars/vl_terbo/stats.tres Normal file
View File

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="CarStats" load_steps=2 format=3]
[ext_resource type="Script" path="res://src/car_stats.gd" id="1"]
[resource]
script = ExtResource("1")
display_name = "VL Terbo"
model_path = ""
engine_power = 18.0
mass = 1300.0
grip = 5.0

Binary file not shown.

View File

@ -0,0 +1,39 @@
extends Node3D
## ponytail: placeholder carpark built in code -- real levels arrive as level.glb from Blender
## and this whole folder becomes just the GLB + nothing else.
func _ready() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = 8
_box(Vector3(0, -0.5, 0), Vector3(240, 1, 160), Color(0.35, 0.35, 0.37)) # tarmac
for side in [-1, 1]: # kerb walls
_box(Vector3(0, 0.4, side * 79.0), Vector3(240, 0.8, 2), Color(0.6, 0.6, 0.6))
_box(Vector3(side * 119.0, 0.4, 0), Vector3(2, 0.8, 160), Color(0.6, 0.6, 0.6))
for row in 4: # rows of parked shitboxes, shuntable
for bay in 10:
if rng.randf() < 0.35:
continue
var pos := Vector3(-27.0 + bay * 6.0, 0.8, -30.0 + row * 18.0)
_box(pos, Vector3(1.9, 1.4, 4.4), Color.from_hsv(rng.randf(), 0.5, rng.randf_range(0.4, 0.9)), true)
for i in 6: # light poles
_box(Vector3(-30.0 + i * 12.0, 3.0, 8.0), Vector3(0.4, 6.0, 0.4), Color(0.3, 0.3, 0.3))
func _box(pos: Vector3, size: Vector3, color: Color, dynamic := false) -> void:
var body: PhysicsBody3D = RigidBody3D.new() if dynamic else StaticBody3D.new()
if dynamic:
body.mass = 300.0
var shape := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = size
shape.shape = bs
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = size
var mat := StandardMaterial3D.new()
mat.albedo_color = color
bm.material = mat
mi.mesh = bm
body.add_child(shape)
body.add_child(mi)
body.position = pos
add_child(body)

View File

@ -0,0 +1 @@
uid://baogh32opqndt

View File

@ -0,0 +1,9 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://levels/woolies_carpark/carpark.gd" id="1"]
[node name="WooliesCarpark" type="Node3D"]
script = ExtResource("1")
[node name="Spawn" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 55)

43
project.godot Normal file
View File

@ -0,0 +1,43 @@
config_version=5
[application]
config/name="Burnout Infinity: Shitbox"
run/main_scene="res://src/main.tscn"
config/features=PackedStringArray("4.6", "Forward Plus")
[display]
window/size/viewport_width=1600
window/size/viewport_height=900
[input]
throttle={
"deadzone": 0.2,
"events": [Object(InputEventKey,"physical_keycode":87,"device":-1), Object(InputEventKey,"physical_keycode":4194320,"device":-1)]
}
brake={
"deadzone": 0.2,
"events": [Object(InputEventKey,"physical_keycode":83,"device":-1), Object(InputEventKey,"physical_keycode":4194322,"device":-1)]
}
steer_left={
"deadzone": 0.2,
"events": [Object(InputEventKey,"physical_keycode":65,"device":-1), Object(InputEventKey,"physical_keycode":4194319,"device":-1)]
}
steer_right={
"deadzone": 0.2,
"events": [Object(InputEventKey,"physical_keycode":68,"device":-1), Object(InputEventKey,"physical_keycode":4194321,"device":-1)]
}
drift={
"deadzone": 0.2,
"events": [Object(InputEventKey,"physical_keycode":4194325,"device":-1)]
}
boost={
"deadzone": 0.2,
"events": [Object(InputEventKey,"physical_keycode":32,"device":-1)]
}
reset={
"deadzone": 0.2,
"events": [Object(InputEventKey,"physical_keycode":82,"device":-1)]
}

78
src/car.gd Normal file
View File

@ -0,0 +1,78 @@
class_name Car
extends RigidBody3D
## Arcade raycast car: 4 suspension rays, body-level tyre model.
## ponytail: body-level grip/drive forces -- go per-wheel only if the feel demands it.
@export var stats: CarStats
const REST := 0.55 # suspension rest length, m
const STEER_ANGLE := 0.6
const STEER_SPEED := 10.0
const DRIFT_GRIP_MULT := 0.35
const BOOST_MULT := 1.8
const BOOST_DRAIN := 0.35 # meter/sec while boosting
const DRIFT_EARN := 0.3 # meter/sec while sliding
const DRAG := 0.012 # quadratic drag -> top speed = sqrt(power/DRAG)
var steer := 0.0
var boost := 0.0 # 0..1
var boosting := false
var slip := 0.0 # sideways m/s
var spawn_xform: Transform3D
@onready var wheels: Array[Node] = [$FL, $FR, $RL, $RR]
func _ready() -> void:
mass = stats.mass
center_of_mass_mode = RigidBody3D.CENTER_OF_MASS_MODE_CUSTOM
center_of_mass = Vector3(0, -0.3, 0)
angular_damp = 3.0
spawn_xform = global_transform
if stats.model_path != "" and ResourceLoader.exists(stats.model_path):
$Body.visible = false
add_child((load(stats.model_path) as PackedScene).instantiate())
func _physics_process(delta: float) -> void:
if Input.is_action_just_pressed("reset"):
global_transform = spawn_xform
linear_velocity = Vector3.ZERO
angular_velocity = Vector3.ZERO
var throttle := Input.get_axis("brake", "throttle")
steer = lerpf(steer, Input.get_axis("steer_right", "steer_left") * STEER_ANGLE, STEER_SPEED * delta)
var drifting := Input.is_action_pressed("drift")
boosting = Input.is_action_pressed("boost") and boost > 0.0
var space := get_world_3d().direct_space_state
var grounded := 0
for w: Node3D in wheels:
var from := w.global_position
var q := PhysicsRayQueryParameters3D.create(from, from - global_basis.y * REST)
q.exclude = [get_rid()]
var hit := space.intersect_ray(q)
if hit:
grounded += 1
var offset := REST - from.distance_to(hit.position)
var point_vel := linear_velocity + angular_velocity.cross(from - global_position)
var spring := offset * mass * 25.0 - global_basis.y.dot(point_vel) * mass * 2.0
if spring > 0.0:
apply_force(global_basis.y * spring, from - global_position)
if grounded >= 2:
var fwd := -global_basis.z
var speed := fwd.dot(linear_velocity)
var power := stats.engine_power * (BOOST_MULT if boosting else 1.0)
apply_central_force(fwd * throttle * power * mass)
apply_torque(global_basis.y * steer * clampf(speed / 12.0, -1.0, 1.0) * mass * 14.0)
slip = global_basis.x.dot(linear_velocity)
var grip := stats.grip * (DRIFT_GRIP_MULT if drifting else 1.0)
apply_central_force(-global_basis.x * slip * grip * mass)
apply_central_force(-linear_velocity * linear_velocity.length() * mass * DRAG)
if absf(slip) > 4.0 and speed > 8.0:
boost = minf(boost + DRIFT_EARN * delta, 1.0)
if boosting:
boost = maxf(boost - BOOST_DRAIN * delta, 0.0)
func speed_kmh() -> float:
return linear_velocity.length() * 3.6

1
src/car.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://brt5bxt4u8k23

30
src/car.tscn Normal file
View File

@ -0,0 +1,30 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://src/car.gd" id="1"]
[sub_resource type="BoxShape3D" id="shape"]
size = Vector3(1.8, 1.0, 4.2)
[sub_resource type="BoxMesh" id="mesh"]
size = Vector3(1.8, 1.0, 4.2)
[node name="Car" type="RigidBody3D"]
script = ExtResource("1")
[node name="Shape" type="CollisionShape3D" parent="."]
shape = SubResource("shape")
[node name="Body" type="MeshInstance3D" parent="."]
mesh = SubResource("mesh")
[node name="FL" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.8, -0.3, -1.5)
[node name="FR" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.8, -0.3, -1.5)
[node name="RL" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.8, -0.3, 1.5)
[node name="RR" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.8, -0.3, 1.5)

10
src/car_stats.gd Normal file
View File

@ -0,0 +1,10 @@
class_name CarStats
extends Resource
## One car = one folder under res://cars/ with a stats.tres (this resource).
## Optional car.glb next to it, referenced via model_path. No model = placeholder box.
@export var display_name := "Unnamed Shitbox"
@export var model_path := ""
@export var engine_power := 14.0 # forward accel, m/s^2-ish
@export var mass := 1100.0
@export var grip := 6.0 # lateral grip; lower = slidier

1
src/car_stats.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://dtrpw74k6hge1

64
src/game.gd Normal file
View File

@ -0,0 +1,64 @@
class_name Game
extends Node3D
## Loads the chosen level, spawns the chosen car at the level's "Spawn" node,
## runs chase cam + HUD. Everything built in code -- game.tscn is just this script.
static var car_path := ""
static var level_path := ""
var car: Car
var cam: Camera3D
var speed_label: Label
var boost_bar: ProgressBar
func _ready() -> void:
var level: Node = (load(level_path) as PackedScene).instantiate()
add_child(level)
car = (load("res://src/car.tscn") as PackedScene).instantiate()
car.stats = load(car_path)
add_child(car)
var spawn := level.find_child("Spawn", true, false)
if spawn is Node3D:
car.global_transform = spawn.global_transform
car.global_position += Vector3.UP * 0.6
cam = Camera3D.new()
cam.fov = 72.0
add_child(cam)
cam.global_position = car.global_position + Vector3(0, 3, 8)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-55, 30, 0)
sun.shadow_enabled = true
add_child(sun)
var env := WorldEnvironment.new()
var e := Environment.new()
e.background_mode = Environment.BG_SKY
e.sky = Sky.new()
e.sky.sky_material = ProceduralSkyMaterial.new()
env.environment = e
add_child(env)
var hud := CanvasLayer.new()
add_child(hud)
speed_label = Label.new()
speed_label.position = Vector2(24, 24)
speed_label.add_theme_font_size_override("font_size", 32)
hud.add_child(speed_label)
boost_bar = ProgressBar.new()
boost_bar.position = Vector2(24, 72)
boost_bar.size = Vector2(220, 18)
boost_bar.show_percentage = false
hud.add_child(boost_bar)
func _process(delta: float) -> void:
if Input.is_action_just_pressed("ui_cancel"):
get_tree().change_scene_to_file("res://src/main.tscn")
return
var target := car.global_position + car.global_basis * Vector3(0, 2.4, 6.0)
cam.global_position = cam.global_position.lerp(target, 1.0 - exp(-5.0 * delta))
cam.look_at(car.global_position + Vector3.UP * 1.2)
cam.fov = lerpf(cam.fov, 84.0 if car.boosting else 72.0, 6.0 * delta)
speed_label.text = "%d km/h" % roundf(car.speed_kmh())
boost_bar.value = car.boost * 100.0

1
src/game.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://ci8smv5mvb4fb

6
src/game.tscn Normal file
View File

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://src/game.gd" id="1"]
[node name="Game" type="Node3D"]
script = ExtResource("1")

31
src/main.gd Normal file
View File

@ -0,0 +1,31 @@
extends Control
## Menu: pick a car and a level from whatever folders exist, hit RACE.
@onready var car_list: ItemList = $VBox/HBox/Cars
@onready var level_list: ItemList = $VBox/HBox/Levels
var car_paths: Array = []
var level_paths: Array = []
func _ready() -> void:
var cars := Registry.cars()
for id in cars:
var stats: CarStats = load(cars[id])
car_list.add_item(stats.display_name)
car_paths.append(cars[id])
var levels := Registry.levels()
for id in levels:
level_list.add_item(String(id).capitalize())
level_paths.append(levels[id])
if not car_paths.is_empty():
car_list.select(0)
if not level_paths.is_empty():
level_list.select(0)
$VBox/Start.pressed.connect(_start)
func _start() -> void:
if car_list.get_selected_items().is_empty() or level_list.get_selected_items().is_empty():
return
Game.car_path = car_paths[car_list.get_selected_items()[0]]
Game.level_path = level_paths[level_list.get_selected_items()[0]]
get_tree().change_scene_to_file("res://src/game.tscn")

1
src/main.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://fsusisaqg5nu

36
src/main.tscn Normal file
View File

@ -0,0 +1,36 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://src/main.gd" id="1"]
[node name="Main" type="Control"]
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1")
[node name="VBox" type="VBoxContainer" parent="."]
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 40.0
offset_top = 40.0
offset_right = -40.0
offset_bottom = -40.0
theme_override_constants/separation = 20
[node name="Title" type="Label" parent="VBox"]
text = "BURNOUT INFINITY: SHITBOX"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 44
[node name="HBox" type="HBoxContainer" parent="VBox"]
size_flags_vertical = 3
theme_override_constants/separation = 20
[node name="Cars" type="ItemList" parent="VBox/HBox"]
size_flags_horizontal = 3
[node name="Levels" type="ItemList" parent="VBox/HBox"]
size_flags_horizontal = 3
[node name="Start" type="Button" parent="VBox"]
text = "RACE"
theme_override_font_sizes/font_size = 32

21
src/registry.gd Normal file
View File

@ -0,0 +1,21 @@
class_name Registry
## Content discovery: a car is cars/<id>/stats.tres, a level is levels/<id>/level.glb (or .tscn).
## ponytail: DirAccess scan works in dev; revisit when we ship an exported .app (pck listing differs).
static func cars() -> Dictionary:
var out := {}
for dir in DirAccess.get_directories_at("res://cars"):
var p := "res://cars/%s/stats.tres" % dir
if ResourceLoader.exists(p):
out[dir] = p
return out
static func levels() -> Dictionary:
var out := {}
for dir in DirAccess.get_directories_at("res://levels"):
for f in ["level.tscn", "level.glb"]:
var p := "res://levels/%s/%s" % [dir, f]
if ResourceLoader.exists(p):
out[dir] = p
break
return out

1
src/registry.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://dmhb80pfe8b1e

19
tests/screenshot.gd Normal file
View File

@ -0,0 +1,19 @@
extends SceneTree
## Boot straight into the game and save a viewport screenshot (needs a window, not --headless):
## /Applications/Godot.app/Contents/MacOS/Godot --path . --script tests/screenshot.gd
## Writes screenshot.png to the project dir. Used to eyeball the build without a human.
func _initialize() -> void:
_run()
func _run() -> void:
Game.car_path = Registry.cars().values()[0]
Game.level_path = Registry.levels().values()[0]
root.add_child((load("res://src/game.tscn") as PackedScene).instantiate())
await process_frame
Input.action_press("throttle")
for i in 90:
await physics_frame
root.get_texture().get_image().save_png("res://screenshot.png")
print("screenshot saved")
quit(0)

29
tests/smoke.gd Normal file
View File

@ -0,0 +1,29 @@
extends SceneTree
## Headless smoke test:
## /Applications/Godot.app/Contents/MacOS/Godot --headless --path . --script tests/smoke.gd
## Registry finds content, game boots, car drives forward under throttle.
func _initialize() -> void:
_run()
func _run() -> void:
var cars := Registry.cars()
var levels := Registry.levels()
assert(not cars.is_empty(), "no cars found")
assert(not levels.is_empty(), "no levels found")
for p in cars.values():
var s: CarStats = load(p)
assert(s != null and s.engine_power > 0.0, "bad stats: %s" % p)
Game.car_path = cars.values()[0]
Game.level_path = levels.values()[0]
var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate()
root.add_child(game)
await process_frame # _ready doesn't run until the main loop starts
var start: Vector3 = game.car.global_position
Input.action_press("throttle")
for i in 180:
await physics_frame
var dist := start.distance_to(game.car.global_position)
assert(dist > 5.0, "car didn't move (%.1f m)" % dist)
print("smoke ok: %d cars, %d levels, drove %.1f m, %.0f km/h" % [cars.size(), levels.size(), dist, game.car.speed_kmh()])
quit(0)

1
tests/smoke.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://cgx3wtstk4snq