Design principles
Basalt applies a small number of ideas consistently across every module. Knowing them makes the framework predictable: once you have seen how one type is shaped, you can usually guess the shape of the next one, and the reason it is shaped that way.
Basalt makes the costs of a program visible at the point where they are paid — the allocation, the failure, the synchronisation. Nothing important happens where you cannot see it.
Memory is explicit
Section titled “Memory is explicit”An operation that needs memory takes the allocator it will use as a parameter:
String8 joined = Str8Cat(arena, first, second);Span<Token> tokens = arena.Push<Token>(1024);Most often that allocator is an arena, because the arena is Basalt’s default. It is not always one. VirtualArray<T> owns its own virtual reservation and needs no arena. Arena::Create and the platform’s AllocateBuffer go to the operating system directly.
There is no Allocator interface to implement and no vtable to install. The parameter names the concrete thing the memory comes from, so the source of a value’s memory is visible at the call site, and you can tell which lifetime it belongs to without following the call.
This is the shape Basalt follows in its own APIs, and the shape to follow in code built on top: take the allocator as a parameter rather than reaching for a global one. That keeps a function’s memory behaviour a property of its call site instead of a property of program state.
Memory is grouped by lifetime rather than tracked per object. You choose when a group of objects dies and release them together, which is what the Arena page is about. Reclaiming a group costs a cursor assignment, whatever the group holds.
The arena is the default, not a rule. Where a lifetime or ownership pattern calls for something else, Basalt provides it: VirtualArray<T> owns a reservation, ChunkList<T> grows without a ceiling, and Array<T> borrows storage you already have.
Data and functions are separate
Section titled “Data and functions are separate”Types that carry data are plain structs with public fields. Operations on them are free functions, grouped by a shared name prefix:
String8 trimmed = Str8Trim(line);usize at = Str8Find(line, Str("="));This keeps the type itself small and independent, which buys three concrete things:
- The type stays a POD.
String8is a pointer and a length, with no constructor and no vtable. So it can bememcpy’d, pushed into an arena, returned in registers, and evaluated in aconstexpr. - Its header stays cheap. Adding an operation adds no include to the type’s header, so it adds no compile time to the files that use the type.
- The operation set is open. Your own code can add
Str8ToUpperin its own header, and it is as much a first-class operation as the ones Basalt ships.
Type the prefix in your editor and you get the same discovery a member list would give you.
Methods where an invariant exists
Section titled “Methods where an invariant exists”A type that must keep a promise about its own state gets methods, because callers have to go through code that maintains the promise.
Arena has Push and PopTo because its bookkeeping fields must stay consistent. Array<T> has Append because count <= capacity is a guarantee it keeps. Mutex has Lock for the same reason.
The deciding question is whether there is an invariant to protect. Operators are the one exception on plain data: a + b on a Vec3 and span[i] are members, because the notation already exists and reads better that way.
Batch operations are the primary shape
Section titled “Batch operations are the primary shape”Where an operation has a bulk hot path, the form that takes a whole group is the real API, and the single-element form is the special case:
Sin(angles, results); // 8 values per step on AVX2TransformPoint(matrix, points, out); // measured 11.75x the per-call versionA group is what SIMD, threading and cache-friendly traversal all need. An API that only offers one element at a time forecloses those, and no caller can add them back.
Two honest limits on this:
- A batch overload ships only where it measured faster. Reductions like
DotandLengthdo not get one: deinterleaving the array costs more than the arithmetic saves, and the wide version measured slower than the plain loop. - The caller’s layout is not dictated. Basalt takes arrays of the natural type rather than requiring you to restructure your data into parallel arrays to reach a fast path.
The least dynamic mechanism that fits
Section titled “The least dynamic mechanism that fits”Each step toward run-time dispatch costs the compiler’s ability to see through a call, so Basalt stops at the first option that covers the need:
| Mechanism | Choose it when |
|---|---|
| One concrete type and free functions | There is one implementation |
| Templates | The set of types is known at compile time |
A tagged union — enum class and a switch |
The set is closed and Basalt owns all of it |
| A table of function pointers | The set is open to callers |
At the last step, a POD struct holding a context pointer and function pointers is the default, because Basalt’s data is arena-native: it stays memcpy-able, Push<T>-compatible and safe to pass by value.
That is a statement about layout, not about call speed. Where a type is heap-owned, single-owner and never lives in an arena, virtual is a good answer and often the clearer one.
Failure is in the signature
Section titled “Failure is in the signature”There are no exceptions. An operation that can fail returns a Result<T, E> by value:
Result<Arena*, MemoryError> created = Arena::Create();if (!created.ok){ return;}Arena* arena = created.value;The failure path is visible at the call site and handled where it happens. Control flow has no non-local exits, and no function can fail in a way its signature did not admit.
Absence and failure are different things and get different shapes. A nullable T* means “there may be nothing here”. A Result<T, E> means “this could not be done”.
Contract violations are a third category, and they are neither. Passing an out-of-range index or appending to a full Array<T> is a bug in the calling code, so it asserts rather than returning a value to inspect.
Types carry nullability and mutability
Section titled “Types carry nullability and mutability”Three conventions hold everywhere, so you can read a signature instead of its documentation:
- A raw
T*may be null, and is checked where you obtain it. AT&is valid by construction. A function takingT&needs no null check; a function returningT*always needs one at the call site. - A view that reads says so.
Span<const T>andString8are read-only;Span<T>andArray<T>write. Writability is a claim the type makes to everyone holding it, and an unneeded claim costs the ability to point at a literal, aconstexprarray, or a field of aconststruct. conston a view is shallow. Aconst Span<T>isT* const, notconst T*: the handle cannot be reseated, the elements can still be written, and every accessor isconst. Read-only isSpan<const T>and nothing else, so aconst Span<T>parameter is the ordinary way to spell an output span.conston a container is deep. A container is its storage, so aconst Array<T>hands outconst T&. What you may do with its bookkeeping follows ownership: a container that frees nothing leavescountandcapacitypublic for you to read, while a type that gives memory back to the operating system —Arena,VirtualArray<T>— keeps its bookkeeping private behind getters, because those are the numbers it frees on.
String8 holds a const u8*. Reading is its role, which is why it can alias a string literal with no copy.
Claims are measured
Section titled “Claims are measured”Any statement about performance in this documentation comes from a measurement, and the number is quoted rather than implied.
Two rules follow from having done that repeatedly:
- A mechanism is measured on every shipped compiler. An 8-wide transform that ran 2.8× slower than the 4-wide path on MSVC ran 6 % faster on clang — same source, same machine. When the sign of a result differs between compilers, the change is a trade-off rather than an improvement.
- The benchmark must resemble the call site. A tight loop keeps registers warm and hides the cost of entering the vector units. The same operation sitting between scalar work pays that cost in full, and can come out slower than the plain version.
Compile speed is a feature
Section titled “Compile speed is a feature”Heavy system headers stay out of widely-included headers and are quarantined in .cpp files. Mutex hides the platform lock behind opaque storage for this reason, rather than naming a type that would drag <Windows.h> into every file that locks something.
Where a header’s compile cost matters, it is measured rather than estimated.