Skip to content

2. Core Systems (WIP)

Triangly edited this page May 23, 2026 · 1 revision

Runtime State

Orbinaut uses a runtime state system controlled by obj_game. The current state determines which parts of the framework are allowed to continue updating. The current state is stored in obj_game.state

States are defined through the following enum:

enum GAME_STATE
{
    NORMAL,
    STOP_OBJECTS,
    STOP_ALL
}

State Behaviour

NORMAL

Default gameplay state. All framework systems and gameplay objects update normally

STOP_OBJECTS

Stops gameplay objects while keeping core runtime systems active

STOP_ALL

Effectively disables normal runtime updates besides Input and Fade systems

All systems check state like this:

obj_game.state != STOP_ALL

Object Participation

Each obj_gameobject instance defines the highest state in which it is still allowed to update through max_allowed_game_state

The object remains active while

max_allowed_game_state >= obj_game.state

This allows specific objects and systems to continue running while the rest of the framework is suspended

Input

Input is stored per slot as a struct with boolean fields (up, down, left, right, action1, action2, action3, start), and each slot has two states:

  • input_down, which is held state
  • input_press, which is pressed this frame

Any input struct (global or local) is created via input_create(). You can easily access global input structs with input_check(slot) (input_down) and input_check_pressed(slot) (input_press)

Runtime Update

At the very beginning of each frame obj_game does the following:

  • input_down and input_press are reset via input_reset()
  • gamepad input is read (if assigned to slot)
  • keyboard input is processed (slot 0 only)
  • both sources are merged into the same struct
  • invalid directional states are resolved (left+right, up+down)

Gamepad analog values are converted into digital directions and included in the same struct

Player Input

Players do not read global input directly. Each player stores its own local input struct, which is updated via input_copy() from the global input state

Fade

Fade is a shader-based effect that operates as a timed transition between full screen visibility and a solid colour overlay

While the fade is active, obj_game.game_state is forcibly set to STOP_ALL. This can be overridden via the target_state parameter of fade_perform(). If target_state is STOP_SYSTEM or higher, the calling object may stop executing during the fade. Because of this, any logic that must continue during a fade must be moved into a fade_action callback

Callbacks

fade_action is an optional callback that can be assigned through the corresponding argument of fade_perform(). It is executed during the fade process once the fade completes in either direction. This is useful for logic that must continue even if the object that initiated the fade becomes inactive due to game_state changes

The callback is executed once per frame until it returns true, or until it is cleared by external logic (for example, a room change)

Camera & Views

Orbinaut takes under its control up to VIEW_COUNT (4 by default) simultaneous views, each with its own camera, follow logic, and render surface. Framework-controlled views are iterated with FOR_EACH_VIEW / FOR_EACH_VISIBLE_VIEW

View Data

Each view is backed by two things:

  • view_camera[index]: a native GameMaker camera created via camera_create_view(), padded by CAMERA_HORIZONTAL_BUFFER (8px by default) on each side. The camera is rendered wider than its visible output to prevent artifacts caused by deformation effects; the padding is cropped away during composition
  • view_data_struct[index] (accessed through the view_data macro): a struct holding target for the built-in tracking system, velocity, raw (unclamped) position, camera bounds, screen shake state, movement delay, and coarse culling coordinates. Views are controlled via this struct

Player Reference

Players do not read camera state through obj_game directly. Instead, each player resolves its view_data_ref once, based on its player_index:

var _screen_index = min(player_index, VIEW_COUNT - 1);
view_data_ref = view_data[0];

if player_index > 0 && view_visible[_screen_index]
{
	view_data_ref = view_data[_screen_index];
}

If no view is assigned to that player's screen slot (e.g. single-screen mode), the player falls back to view_data[0] and shares the first view with other players. This reference is used for movement bounds, kill planes, and, since the player controls camera movement themselves rather than relying on the built-in tracking system, manual updates to the view's position

View Rendering

Each view renders to its own surface, view_surface_id[index], rather than directly to application_surface. Per-view surfaces are created automatically during Late Update

Views draw independently, then get composed together in the Post-Draw event, which targets application_surface and copies each visible view's surface onto it with draw_surface_part, cropping away the CAMERA_HORIZONTAL_BUFFER padding on each side

Post-Processing Effects

One of the framework's primary rendering goals is to overcome GameMaker's limitation of only allowing a single shader to be applied at a time. This is primarily driven by the per-sprite palette system, which needs to coexist with other rendering effects while still correctly processing colours behind translucent sprites and overlapping objects. To achieve this, systems such as Deformation Effects and Fade are implemented as reusable Layer Effect prefabs, allowing effects to be applied to already rendered layer output and composed together with minimal effort


Before explaining how post-processing is implemented in the framework, it is worth briefly revisiting how rendering works in GameMaker itself (using simplified GML as an example):

[SPOILER]
// Pre-Draw
for (var depth = 16000; depth >= 0; depth--)
{
    var layer = layer_at_depth(depth);

    if layer_is_instance_layer(layer)
    {
        var instances = layer.instances;

        for (var n = 0; n < array_length(instances); n++)
        {
            instances[n].run_pre_draw();
        }
    }
}

// i is a viewport index, total 8 viewports
for (var i = 0; i < 8; i++)
{
    var draw_begin_buffer = surface_create(...);
    var draw_buffer = surface_create(...);
    var draw_end_buffer = surface_create(...);

    // Draw Begin
    draw_set_target(draw_begin_buffer);

    for (var depth = 16000; depth >= 0; depth--)
    {
        var layer = layer_at_depth(depth);

        layer.run_begin_draw_script();

        if layer_is_instance_layer(layer)
        {
            var instances = layer.instances;

            for (var n = 0; n < array_length(instances); n++)
            {
                instances[n].run_draw_begin();
            }
        }
        else
        {
            layer.run_draw_begin();
        }

        layer.run_end_draw_script();
    }

    // Draw
    draw_set_target(draw_buffer);

    for (var depth = 16000; depth >= 0; depth--)
    {
        var layer = layer_at_depth(depth);

        layer.run_begin_draw_script();

        if layer_is_instance_layer(layer)
        {
            var instances = layer.instances;

            for (var n = 0; n < array_length(instances); n++)
            {
                instances[n].run_draw();
            }
        }
        else
        {
            layer.run_draw();
        }

        layer.run_end_draw_script();
    }

    // Layer effects only affect the Draw buffer.
    for (var depth = 16000; depth >= 0; depth--)
    {
        apply_layer_effects(layer_at_depth(depth), draw_buffer);
    }

    // Draw End
    draw_set_target(draw_end_buffer);

    for (var depth = 16000; depth >= 0; depth--)
    {
        var layer = layer_at_depth(depth);

        layer.run_begin_draw_script();

        if layer_is_instance_layer(layer)
        {
            var instances = layer.instances;

            for (var n = 0; n < array_length(instances); n++)
            {
                instances[n].run_draw_end();
            }
        }
        else
        {
            layer.run_draw_end();
        }

        layer.run_end_draw_script();
    }

    // Final composition
    draw_set_target(views[i]);

    draw_surface(draw_begin_buffer, 0, 0);
    draw_surface(draw_buffer, 0, 0);
    draw_surface(draw_end_buffer, 0, 0);

    draw_reset_target();
}

// Post-Draw
for (var depth = 16000; depth >= 0; depth--)
{
    var layer = layer_at_depth(depth);

    if (layer_is_instance_layer(layer))
    {
        var instances = layer.instances;

        for (var n = 0; n < array_length(instances); n++)
        {
            instances[n].run_post_draw();
        }
    }
}

obj_game has its depth changed to 16000 during creation, causing all of its draw events to execute before every other object in the room. This allows it to configure the rendering pipeline before any subsequent draw calls are performed

Pre-Draw

The Pre-Draw event executes before GameMaker begins rendering any views. At this stage, the framework configures rendering state that remains constant for the entire frame

Specifically, it:

  • updates the fade parameters for both the Layer Effect and shader implementation
  • uploads the fade shader uniforms
  • resets the palette tracking state used by the palette system

Although the fade shader uniforms are initialised here, the shader itself is immediately disabled, as the framework uses the Layer Effect implementation by default. Since the fade Layer Effect is attached at the initial depth (0), it automatically affects every subsequent draw call executed below that depth. This also makes it trivial to exclude specific objects from the fade by placing them above the effect (using a depth lower than 0), as is done by obj_gui_title_card

The shader implementation exists primarily for objects that are rendered outside of the standard Draw event (such as obj_gui_pause), where Layer Effects are not applied

Draw

Unlike Pre-Draw, the Draw event is executed once for every rendered view. Since GameMaker has already selected the active viewport, the framework can now configure rendering state using the current camera and render surface

For each view, Orbinaut:

  • retrieves the active camera and render surface properties
  • uploads camera-dependent parameters to every registered deformation effect
  • rebuilds the palette context mask, binds it and uploads the palette shader parameters

The palette system generates a per-view mask surface where black pixels represent the primary palette context and white pixels represent the secondary one. The palette shader samples this mask to determine which palette map should be used for each pixel

Because this setup occurs before any other object's Draw event, every subsequent draw call automatically renders using the correct parameters for the currently active view

Draw End

Once the main Draw pass has finished, the framework simply clears all active shader state by calling shader_reset(). This prevents any shader configured during the Draw pass from affecting the remainder of the rendering pipeline

Clone this wiki locally