PVG
44. Selection and Translation — Program Video Games

44. Selection and Translation

%% In %% our first pass of the editor lsat lesson, we built a simple fly-camera that let us click a point in the 3D world and use console commands to spawn things. After using it even briefly, you've probably come to realise what I did: it doesn't feel great. Placing objects by clicking a point and typing door TownDoor is imprecise and tedious.

Today we're taking a step back to build a bespoke tool tailored for our needs. We are going to implement a proper 3D Translation Gizmo—the classic 3-axis arrows you see in engines like Unity or Blender—so we can select objects, click an axis, and drag them around the scene with grid snapping.

In this lesson we'll:

  1. Update our input state and change our exit hotkey (so we stop accidentally closing the game).
  2. Expand the Editor_State to track selected objects and translation axes.
  3. Build and render a 3D translation gizmo.
  4. Implement raycasting to select doors, triggers, and spawners.
  5. Add click-and-drag logic with discrete snapping increments.

Quality of Life and Input

First, let's get a small annoyance out of the way. Raylib defaults to closing the window when you press Escape. When you're tabbing through menus or cancelling an editor action, hitting Escape is muscle memory. Closing the game accidentally is infuriating.

In main.odin, tell Raylib to clear the exit key, and implement a custom combination (Ctrl+Q) to break the main loop:

    defer rl.CloseAudioDevice()
    rl.SetMasterVolume(0.5)

    rl.SetExitKey(.KEY_NULL)

    // ... inside the main loop:
        if rl.IsKeyDown(.LEFT_CONTROL) && rl.IsKeyPressed(.Q) {
            break
        }

Next, our editor is going to need to know when we let go of the mouse button so it can stop dragging the gizmo. In input.odin and game.odin, add a releasing boolean:

// game.odin
Input_State :: struct {
    // ...
    clicking:         bool,
    releasing:        bool,
    holding:          bool,
    // ...
}

// input.odin
    if rl.IsMouseButtonDown(.LEFT) do is.holding = true
    if rl.IsMouseButtonPressed(.LEFT) do is.clicking = true
    is.releasing = rl.IsMouseButtonReleased(.LEFT)

Expanding the Editor State

Our old Editor_State just held a selected_point. We need to track a lot more now: what type of object we have selected, exactly where it is, which axis we are currently dragging, and an accumulator to handle our discrete grid snapping.

In editor.odin, redefine the state and add the enums:

Editor_State :: struct {
    is_active:        bool,
    free_mouse:       bool,
    selection_mode:   Editor_Selection_Mode,
    selection_pos:    Vec3,
    translate_acc:    f32,
    translate_dir:    Editor_Dir,
    translate_index:  int,
}

Editor_Selection_Mode :: enum {
    NONE,
    POINT,
    DOOR,
    TRIGGER,
    SPAWNER,
}

// Make sure NONE is last so that int(Y) == 1 and Editor_Dir(1) == Y
Editor_Dir :: enum {
    X, Y, Z,
    NONE,
}

We also need to define the physical dimensions of our gizmo and our snapping rules. Add these constants to the top of editor.odin:

SNAP_INCREMENT :: 0.25
SNAP_INCREMENT_MULT :: 1.0 / SNAP_INCREMENT
SNAP_THRESHOLD_2D :: 5

TG_SIDES :: 12
TG_SHAFT_RADIUS :: 0.05
TG_ARROW_RADIUS :: 0.15
TG_SHAFT_LENGTH :: 1.00
TG_ARROW_LENGTH :: 0.30

Drawing the Translation Gizmo

A translation gizmo is just visual feedback. It's composed of three cylinders pointing along the X, Y, and Z axes, usually coloured Red, Green, and Blue.

To make the gizmo interactable, we generate Axis-Aligned Bounding Boxes (AABBs) for each arrow. This allows us to cast a ray from the mouse cursor and determine if the user clicked the X, Y, or Z axis.

Add these procedures to the bottom of editor.odin:

translation_gizmo_positions :: proc(pos: Vec3) -> (ends: [3]Vec3, tips: [3]Vec3) {
    end_x := pos + {TG_SHAFT_LENGTH, 0, 0}
    tip_x := end_x + {TG_ARROW_LENGTH, 0, 0}
    end_y := pos + {0, TG_SHAFT_LENGTH, 0}
    tip_y := end_y + {0, TG_ARROW_LENGTH, 0}
    end_z := pos + {0, 0, TG_SHAFT_LENGTH}
    tip_z := end_z + {0, 0, TG_ARROW_LENGTH}

    return {end_x, end_y, end_z}, {tip_x, tip_y, tip_z}
}

translation_gizmo_aabbs :: proc(pos: Vec3) -> (boxes: [3]raylib.BoundingBox) {
        total_len: f32 = TG_SHAFT_LENGTH + TG_ARROW_LENGTH
        thickness: f32 = TG_ARROW_RADIUS * 2

        x_center := pos + {total_len / 2, 0, 0}
        x_box := aabb_from_pos_size(x_center, {total_len, thickness, thickness})
        y_center := pos + {0, total_len / 2.0, 0}
        y_box := aabb_from_pos_size(y_center, {thickness, total_len, thickness})
        z_center := pos + {0, 0, total_len / 2.0}
        z_box := aabb_from_pos_size(z_center, {thickness, thickness, total_len})

        return {x_box, y_box, z_box}
}

Now for drawing. We use rl.DrawCylinderEx to draw both the shaft and the arrowhead (by setting the top radius to 0). We also highlight the axis in white if it's currently being hovered or dragged.

translation_gizmo_draw :: proc(pos: Vec3, hovered_side: Editor_Dir = .NONE) {
    ends, tips := translation_gizmo_positions(pos)

    x_color := rl.RED
    y_color := rl.GREEN
    z_color := rl.BLUE

    if hovered_side == .X do x_color = rl.WHITE
    if hovered_side == .Y do y_color = rl.WHITE
    if hovered_side == .Z do z_color = rl.WHITE

    rl.DrawCylinderEx(pos, ends.x, TG_SHAFT_RADIUS, TG_SHAFT_RADIUS, TG_SIDES, x_color)
    rl.DrawCylinderEx(ends.x, tips.x, TG_ARROW_RADIUS, 0, TG_SIDES, x_color)

    rl.DrawCylinderEx(pos, ends.y, TG_SHAFT_RADIUS, TG_SHAFT_RADIUS, TG_SIDES, y_color)
    rl.DrawCylinderEx(ends.y, tips.y, TG_ARROW_RADIUS, 0, TG_SIDES, y_color)

    rl.DrawCylinderEx(pos, ends.z, TG_SHAFT_RADIUS, TG_SHAFT_RADIUS, TG_SIDES, z_color)
    rl.DrawCylinderEx(ends.z, tips.z, TG_ARROW_RADIUS, 0, TG_SIDES, z_color)

    rl.DrawSphere(pos, TG_SHAFT_RADIUS * 1.5, rl.WHITE)
}

Raycasting and Selection Logic

When the user clicks the screen, we cast a ray into the 3D world. But the order of operations is critical. If a gizmo overlaps a door, we want to grab the gizmo, not select the door again.

The priority is:

  1. Detect clicking a gizmo axis.
  2. Detect clicking an object (door, spawner, trigger).
  3. Detect clicking the raw level geometry (fallback point selection).

Update the signature of editor_update to take cursor: Vec2 and completely replace the is.clicking block:

ray := rl.GetScreenToWorldRay(cursor, camera^)

if is.clicking {
    is_clicking_gizmo := false
    is_clicking_object := false

    boxes := translation_gizmo_aabbs(es.selection_pos)

    es.translate_dir = .NONE
    closest_dist := max(f32)
    
    // 1. Check Gizmo
    for i in 0..<3 {
        collision := rl.GetRayCollisionBox(ray, boxes[i])
        if collision.hit && collision.distance < closest_dist {
            es.translate_dir = Editor_Dir(i)
            closest_dist = collision.distance
            is_clicking_gizmo = true
        }
    }

    if is_clicking_gizmo {
        // do nothing atm
    } else {
        // 2. Check Objects
        closest_dist := max(f32)
        closest_index := -1
        es.selection_mode = .NONE

        // Check Spawners
        for spawner, i in gs.scene.entity_spawners {
            box := aabb_from_pos_size(spawner.pos, 1)
            collision := rl.GetRayCollisionBox(ray, box)
            if collision.hit && collision.distance < closest_dist {
                closest_index = i
                closest_dist = collision.distance
                is_clicking_object = true
                es.selection_mode = .SPAWNER
                es.selection_pos = spawner.pos
            }
        }

        // NOTE: Repeat the same loop for doors and triggers

        if closest_index >= 0 {
            es.translate_index = closest_index
        }
    }

    if is_clicking_object {
        // do nothing as we modifed state in-place
        // if hit no object nor gizmo... 
    } else if !is_clicking_gizmo {
        model := gs.assets.models[gs.scene.scene_name]
        mesh_count := model.meshCount
        for mesh_index in 0..<mesh_count {
            mesh := model.meshes[mesh_index]
            collision := rl.GetRayCollisionMesh(ray, mesh, raylib.Matrix(1))
            if collision.hit {
                es.selection_mode = .POINT
                es.selection_pos = collision.point
            }
        }
    }
}

Dragging and Snapping

We don't want objects sliding smoothly along floating point values like 1.23419. We want them snapping cleanly to a grid (e.g., 0.25 increments) so that tiles and objects align perfectly.

For now we don't actually snap to the grid, so if an object starts misaligned, it'll remain misaligned. We'll fix this soon.

To do this, we accumulate the 2D mouse movement. Once the mouse moves past our SNAP_THRESHOLD_2D, we apply the SNAP_INCREMENT to the target axis, reset the accumulator, and write the new position back to the actual object array.

Directly below the is.clicking block, add the dragging code:

if is.holding {
    if es.translate_dir != .NONE {
                // Rough conversion of 2D screen mouse movement into 1D drag magnitude
        dist := (is.move.x - is.move.y) / 2
        es.translate_acc += dist

        // translations are strictly orthogonal
        side_index := int(es.translate_dir)
        v := es.selection_pos[side_index]

        if abs(es.translate_acc) > SNAP_THRESHOLD_2D {
            v += clamp(es.translate_acc, -SNAP_INCREMENT, SNAP_INCREMENT)
            es.selection_pos[side_index] = v
            es.translate_acc = 0

            // Move the actual object data in the scene
            switch es.selection_mode {
            case .NONE, .POINT:
            case .DOOR:
                                gs.scene.doors[es.translate_index].pos = es.selection_pos
            case .SPAWNER:
                    gs.scene.entity_spawners[es.translate_index].pos = es.selection_pos
            case .TRIGGER:
                                gs.scene.triggers[es.translate_index].pos = es.selection_pos
            }
        }
    }
}

if is.releasing {
    es.translate_acc = 0
    es.translate_dir = .NONE
}

Finally, hook up the rendering in editor_render. You'll want to draw bounding boxes around all the doors, spawners, and triggers so you can see what you are clicking, and then call translation_gizmo_draw(es.selection_pos) if an object is selected. Check the diff for the exact rendering loops.

The Big Idea

Tools are force multipliers. The time you spend building a functional editor inside your engine pays for itself tenfold the moment you start building real levels.

By projecting a 2D screen ray into our 3D space, interacting with AABBs, and manipulating discrete, snapped values, we have bypassed the nightmare of manually typing coordinates into JSON files. The editor state now correctly holds references to our in-memory scene data and mutates it live. In the future, we could add rotation, scaling, or turn off the snap increment with a hotkey, but for now, we have a robust foundation for authoring content visually.