Skip to main content

[engine]

# =============================================================================
# ENGINE (FRAMEWORK FILE) VERSION 2026-04-19
# =============================================================================
# The engine uses Palix sprites to render the animated actors in a scene.
# It also manages tilemaps and tilesets as well as the gamepad module.
# If you know how to make your own engine, you could delete this file.

# -----------------------------------------------------------------------------
module gamepad
var left: bool
var right: bool
var up: bool
var down: bool
var button_a: bool
var button_b: bool
var button_c: bool
var button_d: bool
end module

# -----------------------------------------------------------------------------
class tile_layer
var tilemap: tilemap
var tileset: tileset

var io_tilemap: io_tilemap
var io_tileset_addresses: int[size 1024]

var x: int, y: int

# If screen_space=true, then the camera does not affect this tilemap
var screen_space: bool

# If wrap=true, the tilemap repeats infinitely in every direction
var wrap: bool

# 8 for regular tiles, 16 for jumbo tiles
var _tile_dimension: int

# ---------------------------------------------------------------------------
constructor(io_tilemap: io_tilemap, io_tileset_addresses: int[size 1024])
.io_tilemap <- io_tilemap
.io_tileset_addresses <- io_tileset_addresses
end constructor

# ---------------------------------------------------------------------------
# Use tileset=null to unload.
func load_tileset(tileset: tileset)
.tileset <- tileset

var io_tilemap: io_tilemap
io_tilemap <- .io_tilemap
var io_tileset_addresses: int[size 1024]
io_tileset_addresses <- .io_tileset_addresses

if tileset = null then
io_tilemap.jumbo <- 0
io_tilemap.tile_count <- 0
return
end if

var old_tile_count: pair
old_tile_count <- io_tilemap.tile_count

var tile_count: pair, p: pair

if tileset.jumbo then
io_tilemap.jumbo <- 1
._tile_dimension <- 16
else
io_tilemap.jumbo <- 0
._tile_dimension <- 8
end if

tile_count <- to_pair(tileset.tiles.size)
if tile_count > 1024
do tile_count <- 1024
io_tilemap.tile_count <- tile_count

p <- 0
loop
if p >= tile_count
do drop

var tile: tile
tile <- tileset.tiles[p]
if tile <> null then
# (+3 skips array size)
io_tileset_addresses[p] <- to_address(tile.pixels) + 3
end if

p <- to_pair(p + 1)
end loop

# Clear the remainder
loop
if p >= old_tile_count
do drop

io_tileset_addresses[p] <- 0
p <- to_pair(p + 1)
end loop
end func

# ---------------------------------------------------------------------------
# Use tilemap=null to unload.
func load_tilemap(tilemap: tilemap)
.tilemap <- tilemap

var io_tilemap: io_tilemap
io_tilemap <- .io_tilemap

if tilemap = null then
io_tilemap.col_count <- 0
io_tilemap.row_count <- 0
io_tilemap.tile_codes_address <- 0
io_tilemap.edge_mode <- 0
return
end if

io_tilemap.col_count <- tilemap.col_count
io_tilemap.row_count <- tilemap.row_count

# (+3 skips array size)
io_tilemap.tile_codes_address <- to_address(tilemap.tile_codes) + 3
end func

# ---------------------------------------------------------------------------
func _render()
var io_tilemap: io_tilemap
io_tilemap <- .io_tilemap

if .tilemap <> null then
var x: int, y: int
var x_pair: pair, y_pair: pair

x <- .x
y <- .y

if not .screen_space then
x <- x - engine::camera_x
y <- y - engine::camera_y
end if

x_pair <- to_pair(x)
y_pair <- to_pair(y)

var clip_edge: bool
clip_edge <- not .wrap

io_tilemap.edge_mode <- to_byte(clip_edge) # 0=wrap, 1=clip

if clip_edge and (x_pair <> x or y_pair <> y) then
# Hide the tilemap if to_pair() overflows
io_tilemap.x <- -16384
else
io_tilemap.x <- x_pair
io_tilemap.y <- y_pair
end if
end if
end func

# ---------------------------------------------------------------------------
func get_tile_at_colrow(col: int, row: int): pair
var tilemap: tilemap
tilemap <- .tilemap


var col_count: int, row_count: int
col_count <- tilemap.col_count
row_count <- tilemap.row_count

if col >= 0 and col < col_count and row >= 0 and row < row_count then
return tilemap.tile_codes[row * col_count + col]
else
return -1
end if
end func

# ---------------------------------------------------------------------------
func set_tile_at_colrow(col: int, row: int, tile_code: pair)
var tilemap: tilemap
tilemap <- .tilemap

var col_count: int, row_count: int
col_count <- tilemap.col_count
row_count <- tilemap.row_count

if col >= 0 and col < col_count and row >= 0 and row < row_count then
tilemap.tile_codes[row * col_count + col] <- tile_code
end if
end func

# ---------------------------------------------------------------------------
func get_tile_at_xy(x: int, y: int): pair
var tile_dimension: int
tile_dimension <- ._tile_dimension
var result: pair
# Euclidean division handles negative (x,y) correctly
result <- .get_tile_at_colrow(x // tile_dimension, y // tile_dimension)
return result
end func

# ---------------------------------------------------------------------------
func set_tile_at_xy(x: int, y: int, tile_code: pair)
var tile_dimension: int
tile_dimension <- ._tile_dimension
.set_tile_at_colrow(x // tile_dimension, y // tile_dimension, tile_code)
end func

# ---------------------------------------------------------------------------
func get_color_at_xy(x: int, y: int): byte
var tile_code: pair, tx: int, ty: int
var tile: tile

var tileset: tileset
tileset <- .tileset

var tile_dimension: int
tile_dimension <- ._tile_dimension

tile_code <- .get_tile_at_colrow(x // tile_dimension, y // tile_dimension)

# Bits 0..9 encode the tile index (0..1023) from the tileset
var tile_index: pair
tile_index <- to_pair(math::bit_and(tile_code, $03ff))

if tile_code >= 0 and tile_index < tileset.tiles.size then
tile <- tileset.tiles[tile_index]

if tile <> null then
tx <- x %% tile_dimension
ty <- y %% tile_dimension
var color: byte
color <- tile.pixels[ty * tile_dimension + tx]
return color
end if
end if

return 0 # clear color
end func
end class

# -----------------------------------------------------------------------------
module engine
var camera_x: int, camera_y: int

var tile_layer_a: tile_layer
var tile_layer_b: tile_layer
var tile_layer_c: tile_layer

var frame_counter: pair

var _thinklist_head: actor
var _thinklist_tail: actor

# A special "fence" node that is not actually used,
# but marks the start/end of the circlular list
var _renderlist_fence: actor

# For a given depth, the next node will be added before this one
# in the renderlist
var _insertion_points: actor[size 4]
var _last_render_actor: actor

var _actor_count: int

# Avoid having to clear sprites that are unused
var _used_sprites: int

# 0=none, 1=thinking, 2=rendering, 3=clearing
var _busy: byte

# ---------------------------------------------------------------------------
func init()
io::paint_mode <- 0
engine::frame_counter <- io::frame_counter

engine::_thinklist_head <- null
engine::_thinklist_tail <- null

var insertion_points: actor[size 4]
insertion_points <- new actor[size 4]()

engine::_insertion_points <- insertion_points

var renderlist_fence: actor
renderlist_fence <- new actor()

# Create the circular list
renderlist_fence._renderlist_prev_link <- renderlist_fence
renderlist_fence._renderlist_next_link <- renderlist_fence

# Wire up the pointers
insertion_points[0] <- renderlist_fence
insertion_points[1] <- renderlist_fence
insertion_points[2] <- renderlist_fence
insertion_points[3] <- renderlist_fence
engine::_last_render_actor <- renderlist_fence
engine::_renderlist_fence <- renderlist_fence

engine::_actor_count <- 0
engine::_used_sprites <- 0

io::background_color <-- 13 # skyscape

engine::tile_layer_a <-
| new tile_layer(io::tilemap_a, io::tileset_a_addresses)
engine::tile_layer_b <-
| new tile_layer(io::tilemap_b, io::tileset_b_addresses)
engine::tile_layer_c <-
| new tile_layer(io::tilemap_c, io::tileset_c_addresses)
end func

# ---------------------------------------------------------------------------
func load_actor_spot(spot: s_actor_spot): actor
var actor: actor

if spot.actor_factory <> null then
actor <- spot.actor_factory()
else
actor <- new actor()
end if

actor.apply_spot(spot)

engine::add_actor_behind(actor)

return actor
end func

# ---------------------------------------------------------------------------
func load_actor_spots(spots: s_actor_spot[])
var i, size
size <- spots.size
i <- 0
loop
if i >= size
do drop
engine::load_actor_spot(spots[i])
i <- i + 1
end loop
end func

# ---------------------------------------------------------------------------
func clear_actors()
var old_busy: byte
old_busy <- engine::_busy

# 0=none, 1=thinking, 2=rendering, 3=clearing
if old_busy = 3
do return # already clearing

if old_busy = 2
do kernel::fail("Actor list is busy")

engine::_busy <- 3

# The loop could simply use get_next_actor() and remove_actor(), but the
# loop is optimized based on the guarantee that add_actor() can't happen
var actor: actor
actor <- engine::_thinklist_head

# renderlist is temporarily corrupted during this loop
loop
if actor = null
do drop

if actor._renderlist_prev_link <> null then
# This actor is not already marked for removal, so remove it

# Remove from list
actor._renderlist_prev_link <- null
actor._renderlist_next_link <- null

engine::_actor_count <- engine::_actor_count - 1

actor.on_removed()
end if

actor <- actor._thinklist_next_link
end loop

# Clear out the thinklist
engine::_prune_thinklist()

# Now reset the renderlist
var renderlist_fence: actor
renderlist_fence <- engine::_renderlist_fence

# Empty the circular list
renderlist_fence._renderlist_prev_link <- renderlist_fence
renderlist_fence._renderlist_next_link <- renderlist_fence

var insertion_points: actor[size 4]
insertion_points <- engine::_insertion_points

# Wire up the pointers
insertion_points[0] <- renderlist_fence
insertion_points[1] <- renderlist_fence
insertion_points[2] <- renderlist_fence
insertion_points[3] <- renderlist_fence
engine::_last_render_actor <- renderlist_fence

engine::_actor_count <- 0

engine::_busy <- old_busy
end func

# ---------------------------------------------------------------------------
# Add the actor in front of all other actors in its depth level
func add_actor(actor: actor)
engine::_add_actor(actor, false)
actor.on_added()
end func

# ---------------------------------------------------------------------------
# Add the actor behind all other actors in its depth level
func add_actor_behind(actor: actor)
engine::_add_actor(actor, true)
actor.on_added()
end func

# ---------------------------------------------------------------------------
func _add_actor(actor: actor, behind: bool)
# 0=none, 1=thinking, 2=rendering, 3=clearing
if engine::_busy >= 2
do kernel::fail("Actor list is busy")

if actor._renderlist_prev_link <> null
do kernel::fail("Actor already added")

# --- Append to thinklist
if engine::_thinklist_tail = null then
# the list was empty
engine::_thinklist_head <- actor
engine::_thinklist_tail <- actor
actor._thinklist_next_link <- null
elsif actor._thinklist_next_link = null
| and engine::_thinklist_tail <> actor then

# it's not already in thinklist, so add it
engine::_thinklist_tail._thinklist_next_link <- actor
engine::_thinklist_tail <- actor
end if

# --- Insert into renderlist
var insertion_points: actor[size 4]
insertion_points <- engine::_insertion_points
var depth: byte
depth <- actor._depth

var insertion_point: actor
insertion_point <- insertion_points[depth]

if behind then
# Find the depth+1 insertion point
var next_insertion_point: actor
if depth < 3 then
next_insertion_point <- insertion_points[depth + 1]
else
next_insertion_point <- engine::_renderlist_fence
end if
if insertion_point = next_insertion_point then
# Our depth sublist is empty, so revert to the non-behind algorithm
behind <- false
else
# Insert before next_insertion_point, i.e. the end of our sublist
insertion_point <- next_insertion_point
end if
end if

# Add the actor immediately before the insertion_point
actor._renderlist_next_link <- insertion_point
actor._renderlist_prev_link <- insertion_point._renderlist_prev_link

actor._renderlist_prev_link._renderlist_next_link <- actor
insertion_point._renderlist_prev_link <- actor

# (no fixups are needed if we inserted before next_insertion_point)
if not behind then
insertion_points[depth] <- actor

# adjust other insertion points
loop
if depth = 0
do drop
depth <- to_byte(depth - 1)

if insertion_points[depth] = insertion_point
do insertion_points[depth] <- actor
end loop
end if

engine::_actor_count <- engine::_actor_count + 1
end func

# ---------------------------------------------------------------------------
func remove_actor(actor: actor)
engine::_remove_actor(actor)
actor.on_removed()
end func

# ---------------------------------------------------------------------------
func _remove_actor(actor: actor)
# 0=none, 1=thinking, 2=rendering, 3=clearing
if engine::_busy = 2
do kernel::fail("Actor list is busy")

var insertion_points: actor[size 4]
var next_link: actor

insertion_points <- engine::_insertion_points
next_link <- actor._renderlist_next_link

if next_link = null or actor = engine::_renderlist_fence
do kernel::fail("Actor not added")

# (We don't process thinklist at all here, because we might be
# in the middle of iterating the list. Clearing _renderlist_next_link
# will mark it for removal by _prune_thinklist().)

# If this is an insertion_points, then move to the next one
if insertion_points[0] = actor
do insertion_points[0] <- next_link

if insertion_points[1] = actor
do insertion_points[1] <- next_link

if insertion_points[2] = actor
do insertion_points[2] <- next_link

if insertion_points[3] = actor
do insertion_points[3] <- next_link

if engine::_last_render_actor = actor
do engine::_last_render_actor <- actor._renderlist_prev_link

# Remove from list
actor._renderlist_next_link._renderlist_prev_link <- actor._renderlist_prev_link
actor._renderlist_prev_link._renderlist_next_link <- actor._renderlist_next_link
actor._renderlist_prev_link <- null
actor._renderlist_next_link <- null

engine::_actor_count <- engine::_actor_count - 1
end func

# ---------------------------------------------------------------------------
func get_first_actor(): actor
var actor: actor
actor <- engine::_thinklist_head

loop
if actor = null or actor._renderlist_prev_link <> null
do drop

# Skip over this actor because it is marked for removal
actor <- actor._thinklist_next_link
end loop

return actor
end func

# ---------------------------------------------------------------------------
func get_next_actor(current: actor): actor
var actor: actor

if current = null then
actor <- null
else
actor <- current._thinklist_next_link
loop
if actor = null or actor._renderlist_prev_link <> null
do drop

# Skip over this actor because it is marked for removal
actor <- actor._thinklist_next_link
end loop
end if

return actor
end func

# ---------------------------------------------------------------------------
# Remove the actors that were marked for removal
func _prune_thinklist()
var current: actor
var previous: actor
previous <- null
current <- engine::_thinklist_head
loop
if current = null then
# Reached the end
if previous = null then
# Previous was the head
engine::_thinklist_head <- null
engine::_thinklist_tail <- null
else
# Previous was the tail
engine::_thinklist_tail <- previous
previous._thinklist_next_link <- null
end if

drop
end if

var next: actor
next <- current._thinklist_next_link

if current._renderlist_prev_link = null then
# This actor was marked for removal
current._thinklist_next_link <- null

# (Don't update previous, because it is the previous non-removed node.)
else
# This actor was not marked for removal
if previous = null then
engine::_thinklist_head <- current
else
previous._thinklist_next_link <- current
end if

previous <- current
end if

current <- next
end loop
end func

# ---------------------------------------------------------------------------
func get_camera_cx(): int
# Viewport dimensions are 320 x 224, so 160 is half the width
return engine::camera_x + 160
end func

# ---------------------------------------------------------------------------
func set_camera_cx(value: int)
engine::camera_x <- value - 160
end func

# ---------------------------------------------------------------------------
func get_camera_cy(): int
# Viewport dimensions are 320 x 224, so 112 is half the height
return engine::camera_y + 112
end func

# ---------------------------------------------------------------------------
func set_camera_cy(value: int)
engine::camera_y <- value - 112
end func

# ---------------------------------------------------------------------------
func render()
# 0=none, 1=thinking, 2=rendering, 3=clearing
if engine::_busy <> 0
do kernel::fail("Nested engine call")

engine::_busy <- 2

# Update game camera
engine::tile_layer_a._render()
engine::tile_layer_b._render()
engine::tile_layer_c._render()

# Update frame counter
var frame_delta: pair
frame_delta <- engine::frame_counter
engine::frame_counter <- io::frame_counter
frame_delta <- to_pair(engine::frame_counter - frame_delta)

if frame_delta < 0
do frame_delta <- 1 # overflow

var renderlist_fence: actor
var end_actor: actor
renderlist_fence <- engine::_renderlist_fence
end_actor <- engine::_last_render_actor

var sprite_index: int
sprite_index <- 0

if end_actor = renderlist_fence then
# This situation arises after adding to an empty list;
# ensure that end_actor is an actual actor
end_actor <- renderlist_fence._renderlist_prev_link
end if

# (If we're still at the fence, then the list is empty)
if end_actor <> renderlist_fence then
# Count how many actors until the end
var sprites_left: byte
sprites_left <- 64

var actor: actor
actor <- end_actor
loop
actor <- actor._renderlist_next_link

if actor = renderlist_fence
do drop

if actor.visible then
sprites_left <- to_byte(sprites_left - 1)
if sprites_left = 0
do drop
end if
end loop

# Start allocating sprites

if sprites_left = 0 then
engine::_last_render_actor <- actor
else
# The sequence wraps back to the head, so start rendering with the remainder
# because its z is higher

# actor = renderlist_fence
loop
actor <- actor._renderlist_next_link

if actor.visible then
actor.render(sprite_index, frame_delta)
sprite_index <- sprite_index + 1

sprites_left <- to_byte(sprites_left - 1)
if sprites_left = 0
do drop
end if

if actor = end_actor
do drop
end loop

engine::_last_render_actor <- actor
end if

# Now do the first part again
sprites_left <- 64
actor <- end_actor
loop
actor <- actor._renderlist_next_link

if actor = renderlist_fence
do drop

if actor.visible then
actor.render(sprite_index, frame_delta)
sprite_index <- sprite_index + 1

sprites_left <- to_byte(sprites_left - 1)
if sprites_left = 0
do drop
end if
end loop
end if

# Hide any remaining unused sprites
var i: int
i <- sprite_index
loop
if i >= engine::_used_sprites
do drop
if i >= 64
do drop

# Hide this sprite
io::sprites[i].pixels_address <- 0

i <- i + 1
end loop
engine::_used_sprites <- sprite_index

engine::_busy <- 0
end func

# ---------------------------------------------------------------------------
# Wait for the next video frame to get painted.
# This also synchronizes the main loop to at most 30 fps.
func wait_for_paint()
io::irq_pending <- 1 # "write 1 to clear" irq_pending
io::paint_mode <- 3 # request paint
io::irq_wake_mask <- 1 # sleep until the paint finishes
kernel::sleep()
end func

# ---------------------------------------------------------------------------
func think()
# 0=none, 1=thinking, 2=rendering, 3=clearing
if engine::_busy <> 0
do kernel::fail("Nested engine call")

engine::_busy <- 1

var buttons: byte
buttons <-- io::gamepad_0.buttons
gamepad::button_a <- math::bit_and(buttons, 1) <> 0
gamepad::button_b <- math::bit_and(buttons, 2) <> 0
gamepad::button_c <- math::bit_and(buttons, 4) <> 0
gamepad::button_d <- math::bit_and(buttons, 8) <> 0

gamepad::left <- false
gamepad::right <- false
gamepad::up <- false
gamepad::down <- false

var p: pair
p <- io::gamepad_0.x
if p >= 512 then
gamepad::right <- true
elsif p <= -512 then
gamepad::left <- true
end if

p <- io::gamepad_0.y
if p >= 512 then
gamepad::down <- true
elsif p <= -512 then
gamepad::up <- true
end if

var actor: actor
actor <- engine::get_first_actor()
loop
if actor = null
do drop

# Don't process actors that were marked for removal
if actor._renderlist_prev_link <> null
do actor.think()

actor <- engine::get_next_actor(actor)
end loop

# Now that we've finished processing, clean up any nodes that were
# marked for removal by engine::remove_actor()
engine::_prune_thinklist()

engine::_busy <- 0
end func
end module