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.