Porting Games from Godot

Much of the acron package grew out of porting finished Godot games – a third-person shooter, a racing game, a 2D tile platformer – to Acron. What those ports had in common was kept: the movers, the fixed-step loop, the coordinate conventions, and readers for Godot’s scene and resource files. This page is about the parts that exist for porting; the mechanisms themselves are described with the rest of the framework (see Physics and Characters, The Game Loop and 2D Games).

How close ported code stays

A port stays close to its source at the statement level, not the framework level. Ported game code can read like the GDScript it came from, so it can be checked line by line against the original:

def _physics_process(self, dt):
   if self.is_on_floor() and self.velocity.z < 0.0:
      self.velocity.z = 0.0
   self.velocity += self.get_gravity() * dt
   if self.input.is_action_just_pressed("jump") and self.is_on_floor():
      self.velocity.z = JUMP_SPEED
   self.move_and_slide(dt)

The vocabulary the game’s own scripts use is kept: move_and_slide, is_on_floor (CharacterBody3D and CharacterBody2D), is_action_pressed and is_action_just_pressed (InputMap), Signal.connect and emit (Signal), and the _physics_process(dt) hook, which GameLoop calls at a fixed 60 Hz for every object passed to loop.register.

Godot’s machinery is not reproduced: there is no scene tree lifecycle, no _ready or _enter_tree, no autoloads, no inspector or @export, no NodePath string addressing. The engine’s scene graph is the scene tree; parenting, transforms, rendering, model loading and vector math are done with the engine directly. A wrapper exists only where it centralises a convention (the axis change, the collision-layer bits) or adds something the engine lacks (the character movers, the fixed step, the spring arm).

Y-up and Z-up

Godot is Y-up, the engine is Z-up; both are right-handed. glTF models are converted to Z-up when they are loaded, so a loaded model must not be rotated again. What still needs converting is every number copied out of a script or a .tscn: spawn points, Vector3.UP, gravity, hand-typed directions. acron.interop holds the conventions:

from acron.interop import UP, GRAVITY, gravity_vec, g2p, p2g

spawn = g2p((4.0, 1.5, -2.0))     # Godot (x, y, z) -> (x, z, y)
gravity = gravity_vec(20.0)       # a project's custom default_gravity

g2p/p2g (and godot_to_acron_vec/acron_to_godot_vec) swap Y and Z. The swap is a reflection: it is right for vectors and positions, never as a rotation basis. For full transforms use acron.gdformat.transform, below.

Dense ported maths – vehicle dynamics, say – can instead run unchanged in a Y-up frame: attach_logic_frame(parent) returns a child node rotated so its local +Y is the parent’s +Z, and g2p_rot/p2g_rot are the matching proper rotation, (x, y, z) -> (x, -z, y). World data used alongside such a frame (track collision, markers) must go through g2p_rot, not g2p: mixing the two mirrors the world against the logic frame.

apply_vertical_fov(lens, degrees) sets a lens from the vertical field of view that Godot cameras are authored with; Lens.set_fov takes the horizontal one. Calling it every frame keeps it right after a window resize.

acron.mathutil has the few global-scope helpers ported scripts call by name, such as move_toward(value, target, delta) (a negative delta moves away, as in Godot).

Reading Godot files

acron.gdformat reads Godot’s text formats – .tscn and .tres, project.godot, res:// and uid:// paths, the TileMap tile_data encoding – into the plain data the runtime modules use. It ships in the wheel so that ported games can keep loading their Godot assets, and is scheduled to be replaced by Acron’s own scene format; new code should not grow a dependency on it. Nothing is imported eagerly: import the submodule you need.

Module

Reads

gdformat.res

res:// and uid:// paths: ResRoot maps them onto a directory (usually the Godot project); set_default_root sets the one every reader falls back to

gdformat.tscn

the section parser (parse_file, parse_sections), value helpers (parse_vector3, parse_color, …), scene_nodes (nodes with their composed transforms, in Z-up) and collision extraction (load_collision_shapes, load_collision_shapes_with_layers, build_static_body), following instanced scenes

gdformat.transform

Transform3D literals: parse_transform3d, godot_transform_to_mat4, godot_transform_to_ts; TO_ZUP is the change of basis

gdformat.materials

StandardMaterial3D .tres files into the ParsedMaterial data that acron.materials applies onto loaded models (parse_material, build_material_index, index_for_model)

gdformat.import_suffixes

Godot’s importer name suffixes (-colonly and the rest): apply_import_suffixes(model) removes the nodes Godot would not draw from a model the port loaded itself

gdformat.tileset, gdformat.tile_map

TileSet resources (parse_tileset) and TileMap layers (parse_tile_map_node, decode_tile_data) for acron.tile_map

gdformat.sprite_frames

SpriteFrames, in their own file, inline or inherited (parse_sprite_frames, scene_sprite_frames)

gdformat.scene2d

compose(scene_path, parent, res) builds a 2D decoration scene (sprites, polygons, nested instances); advance_composed runs its flip-books

gdformat.anim2d

AnimationPlayer tracks (scene_animation) as acron.keyframes tracks; ScenePlayer plays them on a composed scene

gdformat.particles2d

CPUParticles2D and GPUParticles2D nodes (emitter_specs) for acron.particles2d

gdformat.project

project.godot input actions (parse_project_actions) and Godot key codes (godot_keycode_to_acron)

A 3D level’s collision, for instance, is the primitive shapes authored under its StaticBody3D nodes, built as Bullet shapes rather than triangle meshes:

from direct.showbase.ShowBase import ShowBase
from acron.gdformat.res import set_default_root
from acron.gdformat.tscn import build_static_body
from acron.world import PhysicsWorld, layer_bit

set_default_root("path/to/godot_project")

base = ShowBase()
physics = PhysicsWorld()
level = build_static_body("res://level/scenes/structure.tscn")
base.render.attach_new_node(level).set_collide_mask(layer_bit(1))
physics.attach(level)

and the project’s key bindings become an input map:

from acron.gdformat.project import parse_project_actions
from acron.input_map import InputMap

actions = parse_project_actions("path/to/godot_project/project.godot")
inputs = InputMap(base, held=actions, edges={"jump": actions["jump"]})

parse_project_actions maps keyboard events only; joypad bindings are skipped.

3D particles

acron.particles3d replays Godot CPUParticles3D emitters from their numbers. Burst is a one-shot emitter (fired by an animation track or a line of script; it removes itself when its particles die) and Loop re-emits for as long as it exists. Both take the emitter’s parameters as keyword arguments – geom, amount, lifetime, direction, spread, velocity, gravity, scale and scale_curve, color and color_ramp, the emission shape, and more – with directions in the engine’s frame (godot_dir(x, y, z) converts one). card_geom, sphere_geom, cone_geom, ray_geom and glb_geom make the particle geometry.

from acron.particles3d import Burst, Curve, card_geom, godot_dir

Burst(base, base.render, geom=card_geom(0.2, 0.2), amount=24,
      lifetime=0.6, pos=(0, 5, 1), direction=godot_dir(0, 1, 0),
      spread=45.0, velocity=(2.0, 4.0), gravity=(0, 0, -9.8),
      scale_curve=Curve((0.0, 1.0), (1.0, 0.0)),
      billboard=True, additive=True)

Each particle is a node integrated in Python on a render task: enough for effect-sized emitters, tens to low hundreds of particles, not for thousands. clear_all() destroys every live emitter, for a scene teardown.