moonshot

technical

gnista

gnistaswedish for "spark". is a game engine built from scratch in c0, compiled by coff to a real ELF64 binary and run as a windowed ring-3 process on moonshot. it is not a prototype meant to be rewritten later, the engine and moonshot are grown together, each real limitation the engine hits becomes a real fix in the kernel or the compiler.

entities and the pool

an entity is a layout, a position, a size, a velocity, a game-defined kind and a handful of game-defined scratch fields, and the engine only ever reads one of them itself, the alive flag. entities live in a pool, which is one allocation and a multiply. c0 has no arrays and no pointer types, so an entity reference is just the address of its record, which is exactly what layout field access already expects, and iterating is a loop over indices. the pool is allocated once at startup, because the ring-3 heap is bump-only with no free, so anything allocated per frame would exhaust it within seconds. slots are recycled for the same reason. pool_spawn hands back the first free slot, or zero when there is none, and a game that spawns on a timer will hit a full pool eventually, which is normal rather than an error. without reuse a long game would exhaust the heap no matter how large it is.

a static block is not a separate kind of thing. it is an ordinary pool member with a flag saying it never moves, so one collision routine and one drawing routine cover everything. aabb_hit tests any two entities for axis-aligned overlap, and touching edges do not count, since two things resting exactly against each other would otherwise report colliding forever. next to it sit centre_inside, which asks whether one entity's middle is inside another, because clipping the corner of a cell should not count as being in it, and a squared centre distance, squared because c0 has no square root and a comparison against a squared threshold works just as well.

collision resolution reverses only the axis of shallowest penetration, not both. reversing both means a glancing corner hit sends an entity back the way it came on both axes, which reads as entities sticking together. the entity is also pushed back out along that axis, because two things still overlapping on the next frame collide again, reverse again and vibrate in place instead of separating.

one atomic present

each draw syscall (fill, blit, present) is atomic on its own,the syscall instruction clears the interrupt flag for the duration of a single syscall, so one call cannot itself be preempted mid-flight. but a redraw made of several separate calls in a row is not atomic across those calls. moonshot's scheduler can and does switch to another task in the gap between two syscalls, so a multi-call draw sequence can be visibly torn by a task switch landing mid-sequence, and a continuously moving sprite hits that gap constantly. so nothing in gnista draws straight to the window. every visible change is composited into a local buffer first and handed over in exactly one call, which makes it atomic regardless of what the scheduler does around it.

there are two renderers built on that rule, and they are kept separate because they suit different games. when the view never moves, the cheap thing is to redraw only the bounding box of each entity's old and new position with a single blit. that box paints background over whatever was there, so it is composed with every entity that intersects it, not just the one being presented, or an entity sitting inside another's box would vanish until its own turn came round. the box also has to stay bounded. c0 has no optimizer, so the per-pixel compositing loop is as expensive as it looks, and a box that grows with a scheduling gap grows the work with the square of that gap. the simulation step is therefore clamped so an entity can never move further in one frame than the box can cover, the clamp derived from the same value the buffer is sized from so the two cannot drift apart. under heavy scheduling pressure a sprite moves slower in wall-clock terms instead of jumping, which is the trade every engine makes under the name maximum delta time, and it also means an entity cannot step straight through an obstacle in one oversized move.

when the view moves, every pixel changes and incremental redraw saves nothing, so the other renderer keeps a View, a whole frame buffer plus a camera position, draws in world coordinates and hands the finished frame over with present rather than blit. present copies whole rows and skips the per-pixel transparency test, which a full frame does not need. the frame's own fills and spans write two pixels per 64-bit store rather than four bytes per pixel, which is safe without a remainder check because kakel's geometry is cell aligned so a row always holds an even number of pixels.c0 has no shift operator, so packing two pixels into one word is a multiply by 2^32. the paired writes and the row-wise present together are the difference between a frame that fits the budget and one that does not, and neither is worth much without the other. on top of that sit rectangles, circles, a software alpha blend for circles, since the kernel stays out of blending by design, a bar for meters and a camera follow that clamps to the world's edges, plus a small linear congruential generator for anything that needs randomness.

surviving a resize

a kakel window is not a fixed size. every split, close and windowed spawn reflows the layout, and a window a program is drawing into can get wider, narrower, taller or shorter while it runs, and nothing tells the program. it has to look, which is one win_info call per frame and nothing next to a full-frame present. what a program cannot do is grow its buffer, because alloc is bump-only, so the view asks win_max once for the largest window the machine can ever hand out, allocates for that and treats the current size as a sub-rectangle of it. every renderer already reads the view's width and height for its clipping and strides, so moving those two numbers is the whole of the adaptation.

frames and time

a program only draws while it is scheduled, and moonshot's round-robin scheduler hands out ticks in turn, so a frame is never drawn during the time the process is not running. honest wall-clock movement and even motion cannot both be had, because making up lost time after a gap is the lurch the eye objects to. gnista chooses even motion. movement is a fixed step per rendered frame with a separate speed constant, so a busier system slows things down instead of making them skip. animation is the exception and runs on real elapsed time from ticks, so a sprite keeps cycling its frames at the right rate whatever the scheduler is doing.

sprites

a sprite is a layout too, a width, a height, a frame count and the address of the raw pixels. sprites are loaded from a file at run time through readfile, the size query first so the buffer can be sized, then one read, and the pixel field then points into that buffer rather than copying it out. the file format is by design the shape the layout already has, three integers then the pixels, so loading is three reads and a pointer rather than a parse. the file is what moonshot's own sprite editor, pensel, saves, so art can be drawn on the machine and picked up by a game with no rebuild anywhere. a baked-in sprite is compiled in as a fallback so a game still runs on a machine where nobody has drawn anything yet, and frame advance wraps on the sprite's own frame count rather than assuming a number.

the sprite layout's field names do not reuse the entity's, and that is a rule rather than taste. field names live in one flat table shared by every layout in a file (see c0), so a name reused across two layouts has to land at the same offset in both, and the compiler rejects it otherwise. any layout that wants a common name like x or w either matches the existing offset exactly or picks a different name.

the syscalls it runs on

gnista does not get any syscalls of its own, it runs entirely on moonshot's general ring-3 surface, documented on knekt, which gives it window info and the window ceiling, fill, key polling, blit and present, file reading, a per-process heap and a monotonic tick counter.