41. Developer Console
Back in Lesson 33 I complained about authoring scene data by editing JSON and embedding properties in the asset pipeline. It was miserable. You'd type a coordinate, reload, watch the door appear three metres inside a wall, change the number, reload again, and repeat until your eyes glazed over. Guessing positions in a text editor is no way to build a world.
So let's stop guessing. We'll build a tiny developer console that runs inside the game, reads and writes our existing scene files, and lets us place and link doors while standing right where we want them.
In this lesson we'll:
- Toggle an in-game console with the backtick key
- Add
door,link, andloadcommands that edit scene files in place - Extend the door data model so doors know their position and their paired scene
- Generate scene-change triggers from doors, with a spawn position
- Make scene loading survive a missing model or missing data
As with the last few lessons, gs is the global Game_State pointer set in game_post_window_create. If you see code reach straight for gs without casting from context.user_ptr, that's why (Lesson 36).
The Coordinate-Guessing Problem
A door is just a position and a link to another door. That's barely any data. The hard part isn't storing it, it's deciding the numbers. Where exactly is the cellar entrance? You can't know from a text editor. You know it when you're standing at the door in the running game.
The cheapest possible level editor is a text field in that running game. No separate tool, no extra window, no serialisation format to invent. We already store scenes as SJSON. A console just needs to read those files, change one field, and write them back. The player's own position becomes the coordinate we were guessing at.
Toggling the Console
In input.odin, capture the backtick (grave) key on release:
if rl.IsKeyReleased(.GRAVE) do is.debug_console_released = true
Add the flag to Input_State in game.odin:
Input_State :: struct {
// ... existing fields ...
debug_console_released: bool,
}
We listen for released rather than pressed on purpose. The backtick is the toggle key, but it's also a printable character that GetCharPressed will happily report. Listening on release keeps the toggle and the typing one frame apart, and we'll ignore the backtick character explicitly inside the console anyway.
Console_State
Create a new file console.odin. The state is small:
package main
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:strings"
CONSOLE_LINE_COUNT :: 5
Console_State :: struct {
history: [dynamic]string,
command: [dynamic]byte,
is_active: bool,
}
command is the line you're currently typing, held as raw bytes so we can append and pop characters easily. history is the scrollback of commands you've already run (and their results). is_active is whether the console is open.
Add it to Game_State:
Game_State :: struct {
// ... existing fields ...
console: Console_State,
}
Reading Input
The console reads keyboard input directly each frame it's open, draining Raylib's character queue:
console_handle_input :: proc(cs: ^Console_State, scene_name: string, pos: Vec3) -> (exit: bool) {
for {
r := rl.GetCharPressed()
rk := rl.GetKeyPressed()
if rk == .ESCAPE {
return true
}
if r == '`' {
continue
}
if rk == .BACKSPACE || rl.IsKeyPressedRepeat(.BACKSPACE) {
pop_safe(&cs.command)
}
if rk == .ENTER && len(cs.command) > 1 {
s := string(cs.command[:])
s = strings.clone(s)
msg := console_command_process_and_run(cs, s, scene_name, pos)
if msg != "" {
s = strings.concatenate({s, " : ", msg})
}
append(&cs.history, s)
clear(&cs.command)
break
}
if r == 0 {
break
}
// NOTE: Only accepts ASCII
append(&cs.command, byte(r))
}
return false
}
A few things worth pointing out.
The backtick is skipped. if r == '' do continue` stops the toggle key from landing in the command buffer. Without it, every time you opened the console you'd start with a stray backtick.
Backspace handles key repeat. IsKeyPressedRepeat fires on the OS-level repeat, so holding backspace deletes continuously instead of one character per physical press. pop_safe removes the last byte and does nothing on an empty buffer, so we don't need a length guard.
Enter runs the command. We clone the buffer into a string, run it, and append the command (plus any result message) to the history. The len > 1 check is a cheap guard against firing on a near-empty line. We clone because the byte buffer gets cleared immediately afterwards, and the history needs to own its own copy.
r == 0 ends the frame's drain. GetCharPressed returns zero when the queue is empty, which is our signal to stop looping for this frame.
The procedure returns an exit bool. We'll wire that into the game loop next.
Pausing the Game While Open
In game_update, toggle the console and, while it's open, capture input and skip the rest of the update:
if gs.input.debug_console_released {
gs.console.is_active = !gs.console.is_active
}
if gs.console.is_active {
if !console_handle_input(&gs.console, gs.scene.scene_name, player.position) {
return
}
}
While the console is active we feed it the current scene name and the player's position, then bail out of game_update. The world freezes: no entity updates, no triggers, no physics. That's what you want from a dev console. You don't want enemies wandering off while you're typing.
We pass player.position because that's the coordinate the commands will write. Stand where you want a door, open the console, and that spot is now "here".
Closing the console cleanly is left a little rough here, the Escape path returns control to the game for the frame but the toggle key is the reliable way to close. Tidying this up is a good first exercise.
Rendering the Console
console_render :: proc(cs: ^Console_State, window_width: f32) {
MARGIN_LEFT :: 8
LINE_HEIGHT :: 20
command_line_y: f32 = CONSOLE_LINE_COUNT * LINE_HEIGHT + LINE_HEIGHT
s := string(cs.command[:])
rl.DrawRectangleV(0, {gs.window_width, command_line_y}, {20, 20, 20, 255})
rl.DrawRectangleV({0, command_line_y}, {window_width, LINE_HEIGHT}, {10, 10, 10, 255})
y := command_line_y - LINE_HEIGHT
#reverse for entry in cs.history {
debug_text({MARGIN_LEFT, y}, "%s", entry)
y -= LINE_HEIGHT
if y < 0 {
break
}
}
debug_text({MARGIN_LEFT, command_line_y}, "%s", s)
}
Two rectangles: a dark panel for the scrollback and a slightly darker strip for the active command line beneath it. We draw history bottom-up with #reverse, so the most recent command sits just above the input line and older entries scroll off the top. The current command line renders last, at command_line_y.
We reuse debug_text, the same deferred text drawer from the debug-drawing lessons. That's why game_render flushes it after rendering the console:
if gs.console.is_active {
console_render(&gs.console, gs.window_width)
debug_text_flush()
}
The Door Data Model
In Lesson 33 a door was about as minimal as it gets:
Scene_Door :: struct {
name: string,
pair: string,
}
That was enough to describe a link but not enough to act on one.
Scene_Door :: struct {
pos: Vec3,
name: string,
pair: string,
scene: string,
}
posis where the door sits in the world. This is the coordinate the console writes.nameis the door's local name within its own scene.pairis the name of the door on the other side.sceneis which scene that other door lives in.
The scene-change event needs to carry a spawn position too, so the new scene knows where to place the player. Add it to the payload in events.odin:
Event_Payload_Scene_Change :: struct {
scene_key: string,
scene_kind: Scene_Kind,
spawn_pos: Vec3,
}
And let the scene state hold a copy of its doors, the same way it already holds entity spawners (handy for debug drawing):
Scene_State :: struct {
// ... existing fields ...
doors: [dynamic]Scene_Door,
}
The door Command
Here's the heart of it. The command processor splits the input on spaces and dispatches on the first word. The door case takes a name, finds that door in the current scene's file (updating it in place if it exists, appending it if it doesn't), and writes the file back:
case "door":
if len(parts) == 2 {
door_name := parts[1]
scene, read_err := os.read_entire_file(
fmt.tprintf("data/scenes/%s.json", scene_name),
context.temp_allocator,
)
if read_err != nil {
return fmt.tprintf("read_err: %s", read_err)
}
scene_data: Scene_Data
json_err := json.unmarshal(scene, &scene_data, .SJSON, context.temp_allocator)
if json_err != nil {
return fmt.tprintf("json_err: %s", json_err)
}
doors := make([dynamic]Scene_Door, context.temp_allocator)
door_exists := false
for &door in scene_data.doors {
if door.name == door_name {
door_exists = true
door.pos = pos // update in place
}
}
append(&doors, ..scene_data.doors)
if !door_exists {
append(&doors, Scene_Door{pos = pos, name = door_name})
}
scene_data.doors = doors[:]
data, marshal_err := json.marshal(
scene_data,
{spec = .SJSON, pretty = true},
context.temp_allocator,
)
if marshal_err != nil {
return fmt.tprintf("Marshal err: %s", marshal_err)
}
write_err := os.write_entire_file(fmt.tprintf("data/scenes/%s.json", scene_name), data)
if write_err != nil {
return fmt.tprintf("Write err: %s", write_err)
}
return fmt.tprintf("Created door: '%s' in scene '%s' at [%.2f]", door_name, scene_name, pos)
}
The whole thing runs on context.temp_allocator, since everything is short-lived: we read the file, mutate a struct, write it out, and the bytes can vanish. Notice the pos here is the player's position, passed in from game_update. Stand on the doormat, type door CellarDoor, and the door's coordinate is now exactly where you're standing.
Every error path returns a string. That string flows back into the console history beside the command, so you get immediate feedback in-game without alt-tabbing to a log.
This is also why our scene files now look different. Run door once and the file comes back out as marshalled SJSON, multi-line arrays and all. The cellar.json and flat.json in this lesson are in that format because the console wrote them.
The link Command
door places one end. link connects two ends across two scenes:
link CellarDoor cellar:Exit
The syntax is link <DoorName> <scene>:<OtherDoorName>. The processor validates the parts, then does the same read-mutate-write as door, but for both files:
case "link":
LINK_INVALID_SYNTAX :: "Invalid syntax. Try: link DoorName scene_name:OtherDoorName"
if len(parts) < 3 {
return "ERROR (1): " + LINK_INVALID_SYNTAX
}
door_name := parts[1]
if !strings.contains(parts[2], ":") {
return "ERROR (2): " + LINK_INVALID_SYNTAX
}
other := strings.split(parts[2], ":")
other_scene_name := other[0]
other_door_name := other[1]
// ... read both scene files, unmarshal both ...
// Set this scene's door to point at the other
for &door in scene_data_a.doors {
if door.name == door_name {
door.pair = other_door_name
door.scene = other_scene_name
}
}
// Set the other scene's door to point back
for &door in scene_data_b.doors {
if door.name == other_door_name {
door.pair = door_name
door.scene = scene_name
}
}
// ... marshal both, write both ...
The read, marshal, and write boilerplate is identical to the door command, just done twice, so I've trimmed it. The important part is that the link is symmetric. We set both sides in one command. CellarDoor learns it pairs with cellar:Exit, and Exit learns it pairs back with flat:CellarDoor. A one-way link would let you walk in but never out.
The command refuses to link to a scene that doesn't exist (os.exists) and reports which door it couldn't find on either side, again as a string straight into the console.
The load Command
A quick teleport for testing, it just fires a scene change:
case "load":
if len(parts) != 2 {
return "ERROR (1): Invalid syntax. Try 'load scene_key'"
}
scene_key := parts[1]
emit(Event {
type = .Scene_Change,
payload = Event_Payload_Scene_Change {
scene_key = scene_key,
scene_kind = .FIELD,
},
})
return fmt.tprintf("Loading scene: '%s'", scene_key)
No spawn position, no door, just drop me in that scene. Useful for jumping straight to the area you're editing instead of walking there.
The default case returns the help text:
return "Commands: door, link, entity, load"
entity is advertised but not implemented yet.
Loading Doors Into the Scene
The console writes door data to disk. scene_switch is what turns that data into something the game acts on. It now does three new jobs: survive a missing model or data file, copy doors in for debug drawing, and build a scene-change trigger for every door that has a valid pair.
First, a small but important fix. We clone the scene name before touching the arena:
// Clone scene name as it's about to be cleared from scene memory
scene_name := strings.clone(scene_name, context.temp_allocator)
When a door trigger fires, the scene key it carries (door.scene) lives in the current scene's arena, the one we're about to reset. Read the original scene_name after the reset and you're reading freed memory.
Next, factor the file loading into a helper that doesn't assume the file exists:
scene_data_load :: proc(
scene_name: string,
allocator := context.allocator,
) -> (scene_data: Scene_Data, exists: bool) {
data_path := fmt.tprintf("data/scenes/%s.json", scene_name)
exists = os.exists(data_path)
if exists {
file_data, file_data_err := os.read_entire_file(data_path, context.temp_allocator)
if file_data_err != nil {
log.errorf("Tried to load scene, but JSON file could not be loaded: '%s'", file_data_err)
}
json.unmarshal(file_data, &scene_data, .SJSON, allocator)
}
return scene_data, exists
}
scene_switch returns (has_model, has_data: bool) so callers can react to a half-built scene, and only loads the .glb if it's actually on disk:
model_path := fmt.ctprintf("assets/scenes/%s.glb", scene_name)
has_model = os.exists(string(model_path))
model: raylib.Model
if has_model {
model = rl.LoadModel(model_path)
for i in 0 ..< model.materialCount {
model.materials[i].shader = gs.rendering.shader
}
}
scene_data: Scene_Data
scene_data, has_data = scene_data_load(scene_name, allocator)
Then we copy the doors in for debug drawing and, for each door, build a trigger:
ss.doors = make(type_of(ss.doors), allocator)
append(&ss.doors, ..scene_data.doors)
for door in scene_data.doors {
other_scene_data, _ := scene_data_load(door.scene, context.temp_allocator)
paired_door: Scene_Door
for other_door in other_scene_data.doors {
if other_door.name == door.pair {
paired_door = other_door
break
}
}
paired_door_exists := paired_door.name != ""
if paired_door_exists {
door_trigger := Trigger {
pos = door.pos,
size = 1,
layers = {.Player},
on_enter = Event {
type = .Scene_Change,
payload = Event_Payload_Scene_Change {
scene_key = door.scene,
scene_kind = .FIELD,
spawn_pos = paired_door.pos,
},
},
}
append(&ss.triggers, door_trigger)
} else {
log.warnf(
"Cannot find door '%s:%s' which is paired to '%s:%s'",
door.scene, door.pair, scene_name, door.name,
)
}
}
This is the payoff. A door becomes a Trigger on the .Player layer at the door's position. Its on_enter is a scene change to the paired door's scene, with spawn_pos set to where the paired door sits. We never wrote a single line of "when player walks through door, change scene" logic, the trigger system from Lesson 24 already does that. Doors are just data that transform into triggers.
We have to make ss.triggers now (it wasn't initialised before) because we're appending to it here.
While loading entities, the dialogue field rename from
dialogue_local_idtodialogue_local_id_startlands here too:entity_data.dialogue_local_id_start = entity_def.dialogue[0].local_id. That was a leftover merge conflict inentities.odin, resolve it to the_startversion.
Spawning at the Right Door
The .Scene_Change handler reads the returns and logs what's missing:
events_subscribe(&gs.events, .Scene_Change, proc(event: Event) {
payload := event.payload.(Event_Payload_Scene_Change)
has_model, has_data := scene_switch(&gs.scene_arena, &gs.scene, payload.scene_key)
log.infof("Loading scene: '%s'", payload.scene_key)
if !has_model do log.warnf("Loaded scene has no model: '%s'", payload.scene_key)
if !has_data do log.warnf("Loaded scene has no data: '%s'", payload.scene_key)
gs.scene_kind = payload.scene_kind
if gs.scene_kind == .FIELD {
gs.scene.player_handle = player_init(gs, gs.scene)
player := entity_get(gs.scene, gs.scene.player_handle)
// NOTE: Uncommenting this makes the scene instantly swap
// player.position = payload.spawn_pos
}
})
That commented-out line is the most interesting bug in this lesson, so let's not just leave it sitting there.
We want to spawn the player on the paired door. But the paired door is itself a trigger volume. Drop the player exactly on it and, on the very next frame, triggers_update sees the player standing inside the destination door's trigger and fires its on_enter, sending the player straight back where they came from. You bounce between the two scenes forever, which reads on screen as the scene "instantly swapping". That's why the line is commented out.
There are a few honest fixes, and which you pick is a real design decision:
- Offset the spawn. Place the player a step past the door, outside its trigger. Simple, but you need to know which way "past" is, so the door probably needs a facing direction.
- Fire door triggers on exit, not enter. Then arriving inside the volume does nothing; you only teleport once you walk out the far side. Clean, but changes how every door feels.
- Track recently-teleported entities. Mark the player as "just arrived" and have the destination door ignore them until they've left its volume once.
I've left the line commented so the door system is demonstrably working (walk through, change scene) before we layer the spawn logic on top. Pick a fix and uncomment it.
Surviving Missing Assets
Two render-time guards round this out. render_field now checks the model exists before drawing it:
if model, exists := &gs.assets.models[gs.scene.scene_name]; exists {
for i in 0 ..< model.meshCount {
// ... draw meshes ...
}
}
And physics_compute_next_position wraps its entire collision and ground-snapping body in the same check:
physics_compute_next_position :: proc(ss: ^Scene_State, entity: ^Entity) -> Vec3 {
new_position := entity.position + entity.velocity
if model, exists := &gs.assets.models[gs.scene.scene_name]; exists {
new_position.y += MAX_STEP_HEIGHT
// ... wall passes, ground snap, edge rejection ...
}
return new_position
}
Without the guard, &gs.assets.models[key] for a key that isn't there hands you a nil pointer, and the first model.meshCount deref crashes. Now a scene with no .glb simply renders nothing and skips collision. The player moves freely through it, no walls, no floor, since there's no geometry to test against. That's exactly what you want from a data-only blockout scene you load from the console before you've built any art for it.
A Test Enemy
To make a door worth walking through, the cellar needs something on the other side. Add a Rat entity in data/entities/rat.json:
name = "Rat"
display_name = "Rat"
collider_radius = 0.25
movement_speed = 2
_flags = ["Noclip"]
_battle_entities = ["Rat", "Rat"]
behavior_flags = 1
_layer = "NPC"
hp = 80
atk = 10
def = 5
spd = 7
_battle_entities means walking into a rat on the field starts a battle against two rats. Three of them are spawned in cellar.json.
Try It Yourself
- Implement the
entitycommand. It's already in the help text. Mirror thedoorcommand: spawn an entity of a given type at the player's position and write it into the scene'sspawners. - Add command history recall. Pressing Up should cycle previous commands back into the buffer. There's a hint left in
console_handle_input. - Fix the door loop. Pick one of the three approaches above, then uncomment
player.position = payload.spawn_posand confirm you can walk back and forth without bouncing. - Click to place. The
doorcommand writes the player's position. Add an editor mode where you click a point in the world and set the door there instead. The processor already takes aposargument for exactly this.
The Big Idea
The cheapest level editor is a text field in the running game. Instead of guessing coordinates in a JSON file and reloading to check, you stand where the thing should go and type one command. The console reads the data files you already have, changes one field, and writes them back.
Doors fall out of this as pure data. A door is a position plus the name of another door, and the scene loader turns each one into a trigger you built lessons ago. You never wrote bespoke "walk through door, load scene" code, you described the link and let the trigger system carry it out.
And the moment your loader stops assuming every asset exists, you can author content in any order, blockout scenes with no art, links before the rooms they connect, all without crashing. Tooling that meets you where you're standing pays for itself.