Skip to content

Writing a game

magmacrunchmedia edited this page Aug 28, 2026 · 2 revisions

Writing a game

A game is three callbacks and a scene. There is no library to link and no package to install: games compile the engine's sources directly, magnolia-style.

The smallest thing that renders

#include <stdio.h>
#include <stdlib.h>
#include "../../external/sokol/sokol_app.h"
#include "../../hologram.h"

static HoloGpuScene gpu;
static HoloScene scene;
static char shader_src[65536];

sapp_desc sokol_main(int argc, char *argv[]) {
    scene = (HoloScene){
        .spheres = { { .center = {0, 1, 0}, .radius = 1,
                       .albedo = {0.85f, 0.25f, 0.35f} } },
        .sphere_count = 1,
        .has_floor = 1,
        .floor_a = {0.85f, 0.85f, 0.85f}, .floor_b = {0.25f, 0.3f, 0.35f},
        .sun_dir = {-3.0f/7, 6.0f/7, 2.0f/7},
        .horizon = {1.0f, 0.9f, 0.8f}, .zenith = {0.25f, 0.45f, 0.9f},
    };

    HoloCamera cam = holo_camera_make(hv3(0, 1.6f, 5), hv3(0, 1, 0),
                                      hv3(0, 1, 0), 55.0f, 1.0f);
    holo_gpu_scene_fill(&gpu, &scene, &cam, 0);   /* 0 = RGB, 1 = spectral */

    FILE *f = fopen("shaders\\trace.hlsl", "rb");
    size_t n = fread(shader_src, 1, sizeof shader_src - 1, f);
    shader_src[n] = 0;
    fclose(f);

    return holo_display_app(&(HoloDisplayDesc){
        .title = "my game",
        .fs_source = shader_src,
        .uniforms = &gpu,
        .uniforms_size = sizeof gpu,
    });
}

sokol owns main(), so a game's entry point is sokol_main() returning holo_display_app(). The shader is read from disk at startup, which means you can edit the tracer and relaunch without recompiling.

The camera's aspect argument is a placeholder here (1.0f). The shader derives the real aspect from the framebuffer size, so the image stays correct when the window is resized. It matters only when you build a camera for the oracle, which renders at a known size.

Adding movement

Three callbacks, filled in as needed:

before_frame Simulate, then write the camera into your uniform block.
after_frame Runs after the draw: readback, diffing, quitting.
event_cb Every sokol event; hand it to holo_input_event.
static HoloWalkWorld world;
static HoloWalker walker;
static HoloInput input;
static float yaw = 3.14159f, pitch;

static HoloCamera camera(void) {
    HoloV3 eye = hv3(walker.pos.x, walker.pos.y + 1.55f, walker.pos.z);
    HoloV3 fwd = hv3(sinf(yaw) * cosf(pitch), sinf(pitch),
                     cosf(yaw) * cosf(pitch));
    return holo_camera_make(eye, hv3_add(eye, fwd), hv3(0, 1, 0), 70.0f, 1.0f);
}

static void before_frame(void) {
    float dx, dy;
    holo_input_look(&input, &dx, &dy);
    yaw   -= dx * 0.003f;
    pitch -= dy * 0.003f;
    if (pitch >  1.5f) pitch =  1.5f;
    if (pitch < -1.5f) pitch = -1.5f;

    timestep_advance((float)sapp_frame_duration());
    for (int s = 0; s < timestep_steps(); s++) {
        float fx = sinf(yaw), fz = cosf(yaw);
        float f = (float)(holo_input_held(&input, SAPP_KEYCODE_W)
                        - holo_input_held(&input, SAPP_KEYCODE_S));
        float r = (float)(holo_input_held(&input, SAPP_KEYCODE_D)
                        - holo_input_held(&input, SAPP_KEYCODE_A));
        walker.vel.x = (fx * f - fz * r) * 3.5f;
        walker.vel.z = (fz * f + fx * r) * 3.5f;
        if (walker.grounded && holo_input_held(&input, SAPP_KEYCODE_SPACE)) {
            walker.vel.y = 6.0f;
        }
        holo_walk_step(&walker, &world, timestep_dt());
    }

    HoloCamera cam = camera();
    holo_gpu_scene_fill(&gpu, &scene, &cam, spectral_on);
}

static void event_cb(const struct sapp_event *ev) {
    holo_input_event(&input, ev);
}

Call timestep_set_hz(120) once at startup. Mouse look is per frame; walking is per step. The input module handles capture itself (click to lock the mouse, Escape to release) and clears held keys on focus loss so nothing sticks.

Build it by adding a line to build.bat alongside the examples; the pattern is %PURE% plus display.c, oracle.c and input.c.

Wiring up the oracle

Any game can hold its own frames to the CPU tracer, and it costs about ten lines. Add a --diff mode: render a few frames, then compare and exit with the verdict.

static void after_frame(void) {
    frames_drawn++;
    if (diff_mode && frames_drawn == 5) {
        /* The oracle camera needs the REAL aspect. */
        HoloCamera c = camera();
        c = holo_camera_make(c.pos, hv3_add(c.pos, c.forward), hv3(0, 1, 0),
                             70.0f,
                             (float)sapp_width() / (float)sapp_height());
        HoloOracleStats st;
        int ok = holo_oracle_diff(&scene, &c, spectral_on, &st);
        printf("DIFF %s: mean %.4f/255, %.3f%% outliers\n",
               ok ? "OK" : "FAIL", st.mean, st.outlier_pct);
        exit(ok ? 0 : 1);
    }
}

Wait five frames or so, past the swapchain's first-present wrinkles. Spectral CPU rendering takes a few seconds at 640×480, which is expected; the oracle is allowed to be slow.

If it fails, both frames land in build\diff_gpu.ppm and build\diff_cpu.ppm.

Choosing a pipeline

holo_gpu_scene_fill(&gpu, &scene, &cam, spectral) picks per frame:

  • 0, RGB. Three-channel throughput, glass at its D-line index. Correct for any scene with no dispersive or polarizing element, and much cheaper.
  • 1, spectral. Twelve wavelengths, Stokes vectors, Mueller matrices. Required for dispersion, polarizers, waveplates and gratings.

The interactive examples bind this to T, which is a good habit: toggling it is the fastest way to see which of your effects are real optics and which are just materials.

Practical notes

Design the scene in physical units. Metres and real indices of refraction. A dish's focal length really is curv_r / 2; if you want the focus at eye height on a walk line, solve for the apex position rather than nudging.

The sun disk is a rendering choice, not a light. It changes what a ray sees when it escapes; it does not change Lambert shading, which always uses sun_dir. Turn it on when reflections should be able to find the sun.

Neutral albedos are exact in both pipelines; saturated ones are not. A gray scene renders identically through RGB and spectral. Strongly coloured albedos survive the wavelength round trip only approximately, which is fine for art but not for colorimetry.

Collision is a second, simpler world. Nothing derives it from the render scene. Mirror walls are paper-thin surfaces; give them slab colliders.

Keep the twins twins. If you add an optical effect, it lands in cpu_trace.c and shaders/trace.hlsl, with the same caps, the same push order and the same cull threshold. And read Shader constraints before you write a branch that pushes more than one ray.

Clone this wiki locally