Skip to main content

A 2D game

2D in Balaur is not a separate engine: it is a second set of components over the same scene tree. This tutorial walks through the concepts using examples/angrynerds, a small slingshot game that ships with the repository:

cargo run -p balaur_cli --features window -- run examples/angrynerds
cargo run -p balaur_cli --features window -- edit examples/angrynerds

2D nodes

A 2D node uses the regular Transform: x/y translate, the z-axis rotation spins it, x/y scale. The 2D components are shape2d (rendering), body2d and collider2d (physics), and widget (HUD elements):

[[nodes]]
name = "Ground"
position = [0, -0.5, 0]
body2d = "static"
shape2d = { kind = "rect", half_extents = [14, 0.5] }
collider2d = { kind = "rect", half_extents = [14, 0.5], friction = 0.9 }
color = [0.45, 0.63, 0.35]

[[nodes]]
name = "Score"

[nodes.widget]
kind = "label"
text = "Score 0"
anchor = "top_left"
x = 20
y = 16
font_size = 22
text_color = "#2b2b2b"

Shapes are rect and circle. Widgets are label, button, and panel, anchored to a screen corner or the center; they live as scene-tree nodes and render through the widget layer.

The camera

The 2D camera is orthographic with pan and zoom. Zoom is expressed in logical pixels per world unit:

render.set_camera_2d(0, 3, 52) -- center x, center y, zoom
local cx, cy, zoom = render.camera_2d()

render.mouse_world_2d() converts the cursor to world coordinates for picking, and render.draw_line_2d(...) draws debug or aiming overlays — the example uses both for the slingshot band.

2D physics

body2d and collider2d run in a rapier2d world with the same determinism guarantees as 3D: the enhanced-determinism build, a fixed 60 Hz accumulator, and ordered collections. The physics2d module drives it from scripts:

physics2d.set_gravity(0, -9.81)
physics2d.add_body(node, physics2d.BODY_DYNAMIC)
physics2d.add_collider(node, { kind = physics2d.SHAPE_CIRCLE, radius = 0.4 })
physics2d.apply_impulse(node, dx * POWER, dy * POWER)

physics2d.max_contact_impulse(node) returns the strongest impact a body took this frame — the example uses it to decide when a pig is popped.

Components from scripts

Scripts read and write the same components the scene file declares. Updating the score label is a component write:

local w = node:get_component("widget")
w.text = "Score " .. self.score
node:set_component("widget", w)

Removing a node's 2D components (remove_component("body2d"), ...) takes it out of both the renderer and the physics world without freeing it, which is how the example implements restarts: capture every node's initial components in init, re-apply them on R.

The editor

The editor detects a 2D scene from its components and switches automatically: 2D grid, pan/zoom camera, a 2D selection gizmo, click-picking in world space, and 2D collider overlays. Because the whole level is scene data, it is editable in the editor without touching the script.