Add Godot 4.7 physics testbed: Box3D/Jolt/GodotPhysics A/B pinball table

Playable table (hinge-motor flippers, plunger, bumper, drain) plus a headless
probe harness that measured, per engine: tunneling of a fast ball through a
1 cm wall with CCD off/on, and hinge-motor drive. Findings in godot/README.md:
Box3D never tunnels even with CCD off, but its hinge motor sign is inverted
vs Jolt/GodotPhysics, and Jolt blows through angular limits that Box3D holds.
Ships a from-source macOS arm64 libgodot-box3d.dylib (upstream has no
releases). Also gitignore the remaining Microsoft media at repo root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-06 05:33:07 +10:00
parent 43d7e9ac8e
commit 2ca67bb28b
13 changed files with 618 additions and 1 deletions

11
.gitignore vendored
View File

@ -315,3 +315,14 @@ Full_Tilt_Pinball_ISO/
*.ccd
*.sub
*.cue
# More original game media at repo root (same policy as *.DAT/*.MID above)
/SOUND*.WAV
/Pinball.exe
/table.bmp
/wavemix.inf
# Godot testbed transients
godot/.godot/
godot/override.cfg
godot/probe/results/

View File

@ -1,6 +1,14 @@
# macrosoft3dpinball
`3D Pinball for Windows Space Cadet` on macOS. Vendored copy of
`3D Pinball for Windows Space Cadet` on macOS. Two halves:
- The vendored decompilation (below) — the faithful original.
- `godot/` — a Godot 4.7 pinball testbed whose real job is A/B-ing 3D physics
backends (Box3D vs Jolt vs GodotPhysics) on CCD, hinge-motor flippers, and joint
limits. See `godot/README.md` for the probe harness and current findings (Box3D
never tunnels with CCD off but has an inverted hinge-motor sign vs Jolt).
Vendored copy of
[k4zmu2a/SpaceCadetPinball](https://github.com/k4zmu2a/SpaceCadetPinball) (MIT) at upstream
commit `cb9b7b8`, not a submodule — patch it in place, pull upstream by re-vendoring.

92
godot/README.md Normal file
View File

@ -0,0 +1,92 @@
# Godot physics testbed (Box3D / Jolt / GodotPhysics A/B)
A minimal playable pinball table for Godot 4.7 whose real job is comparing 3D physics
backends on the things pinball stresses: fast-small-ball CCD, motorised hinge flippers,
and joint limits. The backend is one project setting, so the same table runs under all
three engines.
## Run it
```bash
/Applications/Godot.app/Contents/MacOS/Godot --path godot
```
Keys: `Z` left flipper, `/` right flipper, `Space` plunger, `R` reset ball.
The project defaults to `Box3D Physics (Extension)` via
[godot-box3d](https://github.com/bearlikelion/godot-box3d) (MIT). `bin/` carries a
macOS arm64 dylib built from source (upstream has no releases; build recipe below).
## A/B probe harness
```bash
godot/run_probe.sh # GODOT_BIN=... to point at a different binary
```
Writes `override.cfg` to switch `physics/3d/physics_engine`, runs `probe/probe.tscn`
headless under each backend, and prints per-engine results (also saved to
`probe/results/`, untracked). Two tests:
1. **Tunneling** — ball fired at a 1 cm static wall at 5160 m/s, CCD off and on.
2. **Hinge motor** — a motorised HingeJoint3D arm; a flipper is a motorised hinge.
There is also a table-level flipper check:
`PINBALL_AUTOTEST=1 godot --headless --path godot` flips both real flippers for 0.5 s
and prints the swing angles.
## Results (2026-08-06, Godot 4.7.stable, M3 Ultra)
Tunneling — ball contained by the 1 cm wall?
| speed m/s | Godot ccd off | Jolt ccd off | Box3D ccd off | all three, ccd on |
|---|---|---|---|---|
| 5 | tunneled* | tunneled* | **contained** | contained |
| 10 | tunneled | contained* | **contained** | contained |
| 20 | contained* | contained* | **contained** | contained |
| 40 | contained* | contained* | **contained** | contained |
| 80 | tunneled | tunneled | **contained** | contained |
| 160 | tunneled | tunneled | **contained** | contained |
\* With a 60 Hz tick, every speed here can step over a 1 cm wall; the discrete-engine
"contained" rows are phase luck (2 m at 20 and 40 m/s lands a tick exactly on the wall
— and both engines produce identical trajectories, so they get lucky identically).
The signal is the pattern, not individual rows: **Box3D never tunnels even with the
CCD flag off** (speculative contacts are always on), the other two need
`continuous_cd = true`, and with it all three hold at 160 m/s.
Hinge motor (target 5 rad/s, 1 s):
| engine | reached rad/s | moved | verdict |
|---|---|---|---|
| GodotPhysics3D | 5.0 | 73.5° (wrapped) | works |
| Jolt | 5.0 | 75.1° (wrapped) | works |
| Box3D | **+5.0** | 73.5° (wrapped) | works |
Flipper autotest on the real table (motors at ±25 rad/s, limits 5°…+50°):
| engine | left swing | right swing |
|---|---|---|
| Box3D | +51.8° — **stops at the 50° limit** | 51.8° — stops at limit |
| Jolt | 106.6° — wrong way, **blows ~100° past the limit** | +104.3° — same |
Two real findings, half a second of headless runtime each:
1. **Box3D's hinge motor sign is inverted** relative to GodotPhysics/Jolt (upstream
drop-in-compatibility bug candidate for godot-box3d). The table's flipper signs are
tuned for Box3D, the project default — flip `FLIP_SPEED`/`REST_SPEED` signs in
`scenes/table.gd` to play under Jolt.
2. With the motor overdriving into them, **Box3D enforces the angular limits hard**
while Jolt lets this setup punch far through them.
## Rebuilding the extension
```bash
git clone --recurse-submodules https://github.com/bearlikelion/godot-box3d
cd godot-box3d && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j
cp bin/libgodot-box3d.dylib <here>/bin/
```
Known Box3D gaps that don't matter for pinball: no Generic6DOF/ConeTwist joints, no
SoftBody3D, Area3D can't detect trimesh/heightmap bodies (the ball is a sphere; fine).
Box3D also ignores Godot's hinge BIAS/LIMIT_SOFTNESS/LIMIT_RELAXATION params (warns at
load; harmless here).

View File

@ -0,0 +1,14 @@
[configuration]
entry_symbol = "godot_box3d_main"
compatibility_minimum = "4.3"
reloadable = true
[libraries]
linux.debug.x86_64 = "res://bin/libgodot-box3d.so"
linux.release.x86_64 = "res://bin/libgodot-box3d.so"
windows.debug.x86_64 = "res://bin/godot-box3d.dll"
windows.release.x86_64 = "res://bin/godot-box3d.dll"
macos.debug = "res://bin/libgodot-box3d.dylib"
macos.release = "res://bin/libgodot-box3d.dylib"

View File

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

112
godot/probe/probe.gd Normal file
View File

@ -0,0 +1,112 @@
extends Node3D
## Headless physics probe. Run via run_probe.sh, which switches the physics
## engine through override.cfg and launches this scene under each backend.
##
## Test 1 — tunneling: fire a ball at a 1 cm static wall at increasing speeds,
## with CCD off and on, and report whether the wall contains it.
## Test 2 — hinge motor: drive a hinged arm with a joint motor and report
## whether it actually rotates (a flipper is a motorised hinge).
const WALL_THICKNESS := 0.01
const BALL_RADIUS := 0.0135
const START_Z := 2.0
const SPEEDS := [5.0, 10.0, 20.0, 40.0, 80.0, 160.0]
func _ready() -> void:
var engine := str(ProjectSettings.get_setting("physics/3d/physics_engine"))
print("PROBE_START engine=%s tick=%d" % [engine, Engine.physics_ticks_per_second])
_run(engine)
func _run(engine: String) -> void:
var wall := StaticBody3D.new()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(1.0, 1.0, WALL_THICKNESS)
cs.shape = box
wall.add_child(cs)
add_child(wall)
for ccd in [false, true]:
for speed in SPEEDS:
var r := await _fire(float(speed), ccd)
print("PROBE engine=\"%s\" speed=%d ccd=%s result=%s final_z=%.3f drift_y=%.4f" % [
engine, int(speed), "on" if ccd else "off",
"tunneled" if r.tunneled else "contained", r.final_z, r.drift_y])
var m := await _motor_test()
print("PROBE_MOTOR engine=\"%s\" moved_deg=%.1f works=%s" % [
engine, m.moved_deg, "yes" if m.moved_deg > 20.0 else "NO"])
get_tree().quit(0)
func _fire(speed: float, ccd: bool) -> Dictionary:
var ball := RigidBody3D.new()
var cs := CollisionShape3D.new()
var sph := SphereShape3D.new()
sph.radius = BALL_RADIUS
cs.shape = sph
ball.add_child(cs)
ball.mass = 0.08
ball.continuous_cd = ccd
ball.gravity_scale = 0.0
ball.can_sleep = false
ball.position = Vector3(0, 0, START_Z)
add_child(ball)
ball.linear_velocity = Vector3(0, 0, -speed)
# Enough ticks to cover the distance plus settle time.
var ticks := maxi(90, int(ceil((START_Z + 1.0) / speed * 60.0)) + 30)
for i in ticks:
await get_tree().physics_frame
var result := {
"tunneled": ball.position.z < -(WALL_THICKNESS / 2.0 + BALL_RADIUS + 0.05),
"final_z": ball.position.z,
"drift_y": absf(ball.position.y), # nonzero = gravity_scale unsupported
}
ball.queue_free()
await get_tree().physics_frame
return result
func _motor_test() -> Dictionary:
var anchor := StaticBody3D.new()
var acs := CollisionShape3D.new()
var abox := BoxShape3D.new()
abox.size = Vector3(0.05, 0.05, 0.05)
acs.shape = abox
anchor.add_child(acs)
anchor.position = Vector3(3, 0, 0) # far from the wall test
add_child(anchor)
var arm := RigidBody3D.new()
var ccs := CollisionShape3D.new()
var bbox := BoxShape3D.new()
bbox.size = Vector3(0.1, 0.03, 0.022)
ccs.shape = bbox
ccs.position = Vector3(0.05, 0, 0)
arm.add_child(ccs)
arm.mass = 0.15
arm.gravity_scale = 0.0
arm.can_sleep = false
arm.position = Vector3(3.1, 0, 0)
add_child(arm)
var joint := HingeJoint3D.new()
# Local Z (the hinge axis) pointed along world +Y.
joint.transform = Transform3D(Basis(Vector3(1, 0, 0), -PI / 2.0), Vector3(3.1, 0, 0))
joint.set_flag(HingeJoint3D.FLAG_ENABLE_MOTOR, true)
joint.set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, 5.0)
joint.set_param(HingeJoint3D.PARAM_MOTOR_MAX_IMPULSE, 5.0)
add_child(joint)
# Paths only resolve once the joint is inside the tree.
joint.node_a = joint.get_path_to(anchor)
joint.node_b = joint.get_path_to(arm)
var start_q := arm.global_transform.basis.get_rotation_quaternion()
for i in 60:
await get_tree().physics_frame
var end_q := arm.global_transform.basis.get_rotation_quaternion()
var moved := start_q.angle_to(end_q)
print("PROBE_MOTOR_DEBUG rotation=%s ang_vel=%s pos=%s" % [
arm.rotation, arm.angular_velocity, arm.position])
return {"moved_deg": rad_to_deg(moved)}

1
godot/probe/probe.gd.uid Normal file
View File

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

6
godot/probe/probe.tscn Normal file
View File

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

17
godot/project.godot Normal file
View File

@ -0,0 +1,17 @@
; Godot 4.7 physics testbed for macrosoft3dpinball.
; Physics engine is switched via this setting (or an override.cfg written by run_probe.sh):
; "GodotPhysics3D" | "Jolt Physics" | "Box3D Physics (Extension)"
config_version=5
[application]
config/name="macrosoft3dpinball testbed"
run/main_scene="res://scenes/table.tscn"
config/features=PackedStringArray("4.7")
[physics]
3d/physics_engine="Box3D Physics (Extension)"
3d/default_gravity=9.81
3d/default_gravity_vector=Vector3(0, -0.99357, 0.1132)

20
godot/run_probe.sh Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# A/B/C physics probe: runs probe/probe.tscn headless under each backend by
# switching physics/3d/physics_engine through override.cfg.
set -euo pipefail
cd "$(dirname "$0")"
GODOT="${GODOT_BIN:-/Applications/Godot.app/Contents/MacOS/Godot}"
ENGINES=("GodotPhysics3D" "Jolt Physics" "Box3D Physics (Extension)")
mkdir -p probe/results
"$GODOT" --headless --path . --import > /dev/null 2>&1 || true
for e in "${ENGINES[@]}"; do
printf '[physics]\n3d/physics_engine="%s"\n' "$e" > override.cfg
slug=$(echo "$e" | tr ' ()' '_' | tr -s '_')
echo "=== $e ==="
"$GODOT" --headless --path . res://probe/probe.tscn 2>/dev/null \
| grep '^PROBE' | tee "probe/results/$slug.txt"
done
rm -f override.cfg

72
godot/scenes/table.gd Normal file
View File

@ -0,0 +1,72 @@
extends Node3D
## Playable Box3D/Jolt/GodotPhysics pinball testbed.
## Z = left flipper, / = right flipper, Space = plunger, R = reset ball.
const FLIP_SPEED := 25.0
const REST_SPEED := 8.0
const PLUNGE_IMPULSE := 0.32 # ~4 m/s on the 80 g ball
const BUMPER_IMPULSE := 0.12
@onready var ball: RigidBody3D = $Ball
@onready var left_joint: HingeJoint3D = $LeftFlipperJoint
@onready var right_joint: HingeJoint3D = $RightFlipperJoint
var _ball_start: Transform3D
func _ready() -> void:
_ball_start = ball.transform
print("physics engine: ", ProjectSettings.get_setting("physics/3d/physics_engine"))
$Drain.body_entered.connect(_on_drain)
$BumperArea.body_entered.connect(_on_bumper)
if OS.get_environment("PINBALL_AUTOTEST") == "1":
_autotest()
## Headless check that the real table flippers flip: hold both motors at flip
## speed for half a second and report how far each flipper swung.
func _autotest() -> void:
set_physics_process(false)
var lf: RigidBody3D = $LeftFlipper
var rf: RigidBody3D = $RightFlipper
var l0 := lf.rotation.y
var r0 := rf.rotation.y
left_joint.set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, FLIP_SPEED)
right_joint.set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, -FLIP_SPEED)
for i in 30:
await get_tree().physics_frame
print("AUTOTEST left_swing_deg=%.1f right_swing_deg=%.1f" % [
rad_to_deg(angle_difference(l0, lf.rotation.y)),
rad_to_deg(angle_difference(r0, rf.rotation.y))])
get_tree().quit(0)
func _physics_process(_delta: float) -> void:
var left := Input.is_physical_key_pressed(KEY_Z)
var right := Input.is_physical_key_pressed(KEY_SLASH)
left_joint.set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, FLIP_SPEED if left else -REST_SPEED)
right_joint.set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, -FLIP_SPEED if right else REST_SPEED)
if Input.is_physical_key_pressed(KEY_SPACE) and _ball_in_lane():
ball.apply_central_impulse(Vector3(0, 0, -PLUNGE_IMPULSE))
if Input.is_physical_key_pressed(KEY_R):
_reset_ball()
func _ball_in_lane() -> bool:
return ball.position.x > 0.21 and ball.position.z > 0.30 \
and ball.linear_velocity.length() < 0.05
func _on_drain(body: Node3D) -> void:
if body == ball:
_reset_ball.call_deferred()
func _on_bumper(body: Node3D) -> void:
if body != ball:
return
var bumper: StaticBody3D = $Bumper
var dir := ball.global_position - bumper.global_position
dir.y = 0.0
if dir.length() < 0.001:
dir = Vector3(0, 0, 1)
ball.apply_central_impulse(dir.normalized() * BUMPER_IMPULSE)
func _reset_ball() -> void:
ball.linear_velocity = Vector3.ZERO
ball.angular_velocity = Vector3.ZERO
ball.transform = _ball_start

View File

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

262
godot/scenes/table.tscn Normal file
View File

@ -0,0 +1,262 @@
[gd_scene load_steps=28 format=3]
[ext_resource type="Script" path="res://scenes/table.gd" id="1"]
[sub_resource type="BoxShape3D" id="floor_shape"]
size = Vector3(0.54, 0.02, 1.04)
[sub_resource type="BoxMesh" id="floor_mesh"]
size = Vector3(0.54, 0.02, 1.04)
[sub_resource type="StandardMaterial3D" id="mat_floor"]
albedo_color = Color(0.13, 0.28, 0.16, 1)
[sub_resource type="BoxShape3D" id="wall_long_shape"]
size = Vector3(0.02, 0.06, 1.04)
[sub_resource type="BoxMesh" id="wall_long_mesh"]
size = Vector3(0.02, 0.06, 1.04)
[sub_resource type="BoxShape3D" id="wall_short_shape"]
size = Vector3(0.54, 0.06, 0.02)
[sub_resource type="BoxMesh" id="wall_short_mesh"]
size = Vector3(0.54, 0.06, 0.02)
[sub_resource type="StandardMaterial3D" id="mat_wall"]
albedo_color = Color(0.45, 0.42, 0.38, 1)
[sub_resource type="BoxShape3D" id="lane_shape"]
size = Vector3(0.01, 0.06, 0.79)
[sub_resource type="BoxMesh" id="lane_mesh"]
size = Vector3(0.01, 0.06, 0.79)
[sub_resource type="BoxShape3D" id="guide_l_shape"]
size = Vector3(0.16, 0.06, 0.01)
[sub_resource type="BoxMesh" id="guide_l_mesh"]
size = Vector3(0.16, 0.06, 0.01)
[sub_resource type="BoxShape3D" id="guide_r_shape"]
size = Vector3(0.13, 0.06, 0.01)
[sub_resource type="BoxMesh" id="guide_r_mesh"]
size = Vector3(0.13, 0.06, 0.01)
[sub_resource type="BoxShape3D" id="flipper_shape"]
size = Vector3(0.1, 0.03, 0.022)
[sub_resource type="BoxMesh" id="flipper_mesh"]
size = Vector3(0.1, 0.03, 0.022)
[sub_resource type="StandardMaterial3D" id="mat_flipper"]
albedo_color = Color(0.85, 0.15, 0.12, 1)
[sub_resource type="SphereShape3D" id="ball_shape"]
radius = 0.0135
[sub_resource type="SphereMesh" id="ball_mesh"]
radius = 0.0135
height = 0.027
[sub_resource type="StandardMaterial3D" id="mat_ball"]
albedo_color = Color(0.75, 0.75, 0.8, 1)
metallic = 0.9
roughness = 0.25
[sub_resource type="PhysicsMaterial" id="ball_phys"]
friction = 0.25
bounce = 0.3
[sub_resource type="CylinderShape3D" id="bumper_shape"]
height = 0.05
radius = 0.035
[sub_resource type="CylinderMesh" id="bumper_mesh"]
top_radius = 0.035
bottom_radius = 0.035
height = 0.05
[sub_resource type="StandardMaterial3D" id="mat_bumper"]
albedo_color = Color(0.95, 0.55, 0.1, 1)
[sub_resource type="CylinderShape3D" id="bumper_area_shape"]
height = 0.08
radius = 0.055
[sub_resource type="BoxShape3D" id="drain_shape"]
size = Vector3(0.2, 0.06, 0.04)
[node name="Table" type="Node3D"]
script = ExtResource("1")
[node name="Playfield" type="StaticBody3D" parent="."]
[node name="FloorShape" type="CollisionShape3D" parent="Playfield"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.01, 0)
shape = SubResource("floor_shape")
[node name="FloorMesh" type="MeshInstance3D" parent="Playfield"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.01, 0)
mesh = SubResource("floor_mesh")
surface_material_override/0 = SubResource("mat_floor")
[node name="WallLeftShape" type="CollisionShape3D" parent="Playfield"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.26, 0.03, 0)
shape = SubResource("wall_long_shape")
[node name="WallLeftMesh" type="MeshInstance3D" parent="Playfield"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.26, 0.03, 0)
mesh = SubResource("wall_long_mesh")
surface_material_override/0 = SubResource("mat_wall")
[node name="WallRightShape" type="CollisionShape3D" parent="Playfield"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.26, 0.03, 0)
shape = SubResource("wall_long_shape")
[node name="WallRightMesh" type="MeshInstance3D" parent="Playfield"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.26, 0.03, 0)
mesh = SubResource("wall_long_mesh")
surface_material_override/0 = SubResource("mat_wall")
[node name="WallFarShape" type="CollisionShape3D" parent="Playfield"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.03, -0.51)
shape = SubResource("wall_short_shape")
[node name="WallFarMesh" type="MeshInstance3D" parent="Playfield"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.03, -0.51)
mesh = SubResource("wall_short_mesh")
surface_material_override/0 = SubResource("mat_wall")
[node name="WallNearShape" type="CollisionShape3D" parent="Playfield"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.03, 0.51)
shape = SubResource("wall_short_shape")
[node name="WallNearMesh" type="MeshInstance3D" parent="Playfield"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.03, 0.51)
mesh = SubResource("wall_short_mesh")
surface_material_override/0 = SubResource("mat_wall")
[node name="LaneWallShape" type="CollisionShape3D" parent="Playfield"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.21, 0.03, 0.075)
shape = SubResource("lane_shape")
[node name="LaneWallMesh" type="MeshInstance3D" parent="Playfield"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.21, 0.03, 0.075)
mesh = SubResource("lane_mesh")
surface_material_override/0 = SubResource("mat_wall")
[node name="GuideLeftShape" type="CollisionShape3D" parent="Playfield"]
transform = Transform3D(0.768, 0, -0.64, 0, 1, 0, 0.64, 0, 0.768, -0.19, 0.03, 0.35)
shape = SubResource("guide_l_shape")
[node name="GuideLeftMesh" type="MeshInstance3D" parent="Playfield"]
transform = Transform3D(0.768, 0, -0.64, 0, 1, 0, 0.64, 0, 0.768, -0.19, 0.03, 0.35)
mesh = SubResource("guide_l_mesh")
surface_material_override/0 = SubResource("mat_wall")
[node name="GuideRightShape" type="CollisionShape3D" parent="Playfield"]
transform = Transform3D(-0.768, 0, -0.64, 0, 1, 0, 0.64, 0, -0.768, 0.167, 0.03, 0.35)
shape = SubResource("guide_r_shape")
[node name="GuideRightMesh" type="MeshInstance3D" parent="Playfield"]
transform = Transform3D(-0.768, 0, -0.64, 0, 1, 0, 0.64, 0, -0.768, 0.167, 0.03, 0.35)
mesh = SubResource("guide_r_mesh")
surface_material_override/0 = SubResource("mat_wall")
[node name="LeftFlipper" type="RigidBody3D" parent="."]
transform = Transform3D(0.906, 0, -0.423, 0, 1, 0, 0.423, 0, 0.906, -0.115, 0.02, 0.42)
mass = 0.15
continuous_cd = true
can_sleep = false
[node name="Shape" type="CollisionShape3D" parent="LeftFlipper"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.05, 0, 0)
shape = SubResource("flipper_shape")
[node name="Mesh" type="MeshInstance3D" parent="LeftFlipper"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.05, 0, 0)
mesh = SubResource("flipper_mesh")
surface_material_override/0 = SubResource("mat_flipper")
[node name="RightFlipper" type="RigidBody3D" parent="."]
transform = Transform3D(0.906, 0, 0.423, 0, 1, 0, -0.423, 0, 0.906, 0.115, 0.02, 0.42)
mass = 0.15
continuous_cd = true
can_sleep = false
[node name="Shape" type="CollisionShape3D" parent="RightFlipper"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.05, 0, 0)
shape = SubResource("flipper_shape")
[node name="Mesh" type="MeshInstance3D" parent="RightFlipper"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.05, 0, 0)
mesh = SubResource("flipper_mesh")
surface_material_override/0 = SubResource("mat_flipper")
[node name="LeftFlipperJoint" type="HingeJoint3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 0, 1, 0, -1, 0, -0.115, 0.02, 0.42)
node_a = NodePath("../Playfield")
node_b = NodePath("../LeftFlipper")
angular_limit/enable = true
angular_limit/upper = 0.873
angular_limit/lower = -0.087
motor/enable = true
motor/target_velocity = -8.0
motor/max_impulse = 0.6
[node name="RightFlipperJoint" type="HingeJoint3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 0, 1, 0, -1, 0, 0.115, 0.02, 0.42)
node_a = NodePath("../Playfield")
node_b = NodePath("../RightFlipper")
angular_limit/enable = true
angular_limit/upper = 0.087
angular_limit/lower = -0.873
motor/enable = true
motor/target_velocity = 8.0
motor/max_impulse = 0.6
[node name="Ball" type="RigidBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.2325, 0.0135, 0.44)
mass = 0.08
physics_material_override = SubResource("ball_phys")
continuous_cd = true
can_sleep = false
[node name="Shape" type="CollisionShape3D" parent="Ball"]
shape = SubResource("ball_shape")
[node name="Mesh" type="MeshInstance3D" parent="Ball"]
mesh = SubResource("ball_mesh")
surface_material_override/0 = SubResource("mat_ball")
[node name="Bumper" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.025, -0.1)
[node name="Shape" type="CollisionShape3D" parent="Bumper"]
shape = SubResource("bumper_shape")
[node name="Mesh" type="MeshInstance3D" parent="Bumper"]
mesh = SubResource("bumper_mesh")
surface_material_override/0 = SubResource("mat_bumper")
[node name="BumperArea" type="Area3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.025, -0.1)
[node name="Shape" type="CollisionShape3D" parent="BumperArea"]
shape = SubResource("bumper_area_shape")
[node name="Drain" type="Area3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.03, 0.485)
[node name="Shape" type="CollisionShape3D" parent="Drain"]
shape = SubResource("drain_shape")
[node name="Camera3D" type="Camera3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 0.417, 0.909, 0, -0.909, 0.417, 0, 1.2, 0.55)
fov = 30.0
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 0.5, 0.866, 0, -0.866, 0.5, 0, 1, 0)
shadow_enabled = true