Arrays

Heap-backed and arena-backed arrays in Chasm, len vs cap, array_new, array_fixed, and typed struct arrays.

Chasm has two kinds of arrays. The right choice depends on where the array lives and whether its maximum size is known upfront.

len vs cap, The Most Important Distinction

Every array tracks two numbers: len and cap. These are different things and confusing them is the most common source of bugs.

arr = array_new(8)   # create an array with capacity 8
print(arr.len)       # 0 , no elements stored yet
print(arr.cap)       # 8 , room for 8 elements

arr.push(10)
arr.push(20)
print(arr.len)       # 2 , two elements stored
print(arr.cap)       # 8 , capacity unchanged

len is how many elements are actually in the array right now. It starts at 0 and grows as you call .push.

cap is how much memory has been reserved. The array can hold up to cap elements before it needs to allocate more space (for array_new) or abort (for array_fixed).

Think of it like a box: cap is the size of the box, len is how many items are inside.

Reading out of bounds:

.get(i) is only valid for 0 <= i < len. Accessing at index len or beyond reads uninitialized memory or aborts. Always loop over 0..arr.len, not 0..arr.cap.

array_new, Heap-backed, Growable

array_new(initialCapacity) creates an empty array that grows automatically as you push elements. Use it for local scratch data inside functions:

def main() do
  hits = array_new(4)    # starts empty, capacity 4
  hits.push(42)
  hits.push(7)
  hits.push(99)

  # Iterate using len, not cap
  for i in 0..hits.len do
    print(hits.get(i))
  end
end

When the array outgrows its capacity, it doubles: capacity goes from 4 → 8 → 16 → 32. Each doubling copies all existing elements into a larger allocation. This is automatic, you never call realloc yourself.

The initial capacity argument is a hint, not a limit. Passing 4 when you expect 4 elements avoids the first reallocation. Passing 0 works too, the first push triggers the first allocation.

Why the initial capacity matters

Every push that exceeds cap triggers a heap allocation. In a hot loop this can cause unpredictable frame times. If you know the upper bound, pass it up front:

# Bad: may reallocate many times during the loop
bullets = array_new(0)
for i in 0..100 do bullets.push(0) end

# Good: allocates once
bullets = array_new(100)
for i in 0..100 do bullets.push(0) end

Typed struct arrays

Pass a struct type name as the first argument to create a typed array. .push, .get, and .set operate on the struct directly, no casting:

defstruct Bullet do
  x     :: float
  y     :: float
  speed :: float
end

bullets = array_new(Bullet, 64)
bullets.push(Bullet{ x: 0.0, y: 0.0, speed: 5.0 })

b :: Bullet = bullets.get(0)
bullets.set(0, b with { x: b.x + b.speed })
print(bullets.len)   # 1
print(bullets.cap)   # 64

Without the Bullet type argument, .get returns an opaque integer handle. With it, .get returns a real Bullet struct by value.

array_fixed, Arena-backed, Fixed Capacity

array_fixed(cap) allocates from the arena that matches the attribute's declared lifetime. The key differences from array_new:

  1. No heap allocation, memory comes from the arena
  2. Capacity is fixed, pushing beyond cap aborts with an error
  3. Automatic reset, when the lifetime resets (e.g. frame ends), the entire arena is wiped; there is no per-element cleanup
@bullets :: script     = array_fixed(64)    # up to 64 elements, reset on reload
@sparks  :: frame      = array_fixed(256)   # up to 256 elements, reset every tick
@records :: persistent = array_fixed(16)    # up to 16 elements, never reset

The @sparks example is the most powerful: 256 slots of scratch space that cost nothing to reset, the frame arena pointer just snaps back to where it started.

cap is the hard limit

Unlike array_new which doubles, array_fixed will abort if you push more than cap elements:

@pool :: script = array_fixed(4)

def on_tick(dt :: float) do
  @pool.push(1)
  @pool.push(2)
  @pool.push(3)
  @pool.push(4)
  @pool.push(5)   # runtime abort: fixed array overflow
end

Always verify arr.len < arr.cap before pushing to a fixed array when the count is dynamic.

Pre-filled slots with a default value

Pass a second argument to pre-fill all cap slots at initialization time. This sets len = cap immediately, every slot is ready to read:

@bullet_x :: script = array_fixed(8, 0.0)   # []float, len=8, all 0.0
@active   :: script = array_fixed(8, 0)     # []int,   len=8, all 0

Without a default, len = 0 and slots must be filled with .push before they can be read.

# No default: start empty and push
@scores :: script = array_fixed(8)
def on_init() do
  @scores.push(0)
  @scores.push(0)
  # ...
end

# With default: start full, use set/get immediately
@scores :: script = array_fixed(8, 0)
def on_tick(dt :: float) do
  @scores.set(0, @scores.get(0) + 1)
end

Struct arrays with array_fixed

Pass a struct literal as the default to pre-fill all slots with that value:

defstruct Enemy do
  x      :: float = 0.0
  y      :: float = 0.0
  hp     :: int   = 10
  active :: int   = 0
end

@enemies :: script = array_fixed(32, Enemy{})

def on_tick(dt :: float) do
  i = 0
  while i < @enemies.len do
    e = @enemies.get(i)
    if e.active == 1 do
      @enemies.set(i, e with { x: e.x + 2.0 * dt })
    end
    i = i + 1
  end
end

Enemy{} uses struct field defaults, so every slot starts with x=0, y=0, hp=10, active=0. On hot-reload the script arena resets and all 32 slots reinitialize from scratch, no manual cleanup needed.

Array Methods

MethodDescription
arr.lenNumber of elements currently stored
arr.capTotal capacity, how many elements fit before overflow
arr.push(v)Append v to the end; increments len
arr.get(i)Read element at index i (0-based, must be < len)
arr.set(i, v)Write element at index i (must be < len)
arr.pop()Remove and return the last element; decrements len
arr.clear()Set len to 0; capacity and allocated memory unchanged

.clear() does not free memory, it just resets the length counter. The slots are still allocated; calling .push after .clear() reuses them from index 0.

Iterating Arrays

Always iterate over 0..arr.len, never 0..arr.cap:

# Correct
for i in 0..arr.len do
  print(arr.get(i))
end

# Also correct, array iteration syntax
for item in arr do
  print(item)
end

Choosing the Right Array

array_newarray_fixed
LifetimeHeap, manual / GC on scope exitArena, tied to frame/script/persistent
GrowsYes, doubles on overflowNo, abort on overflow
Initial len0 (or cap with pre-fill default)0 or cap (with default value)
Reset costPer-element (GC or manual)Zero, arena pointer reset
Use forLocal scratch in functionsModule attributes (@name)

Rule of thumb:

If it lives at module level as an @attr, use array_fixed. If it lives inside a function as a local variable, use array_new.