agentleFS
mattstrublemattstruble/skills/odin-design/SKILL.md

odin-design

Skill2 starsChanged 4 months agoMITView on GitHub

You MUST consult this skill when writing, reviewing, or generating Odin code. Also trigger on .odin files, Odin package structure, allocator patterns, or any Odin-specific design question. LLMs have significant knowledge gaps about Odin — this skill fills them. NOT for general systems programming advice or C/Zig/Rust unless comparing to Odin idioms.

The file

We split this skill into the rules it gives an agent

9 rules. About 0 are common, 4 are distinctive, and 5 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈4

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Critical rule: Never guess Odin standard library APIs.

    line 17
  2. Distinctive:

    Before writing any Odin code, internalize these corrections:

    line 26
  3. Distinctive:

    Split into files for logical grouping: All files in a package see each other — no imports needed.

    line 77
  4. Very specific:

    Import paths are relative to the importing file.

    line 107
  5. Very specific:

    Always defer delete(thing) or defer free(ptr) for default-allocator memory.

    line 161
  6. Very specific:

    Use context.temp_allocator by default.

    line 163
  7. Distinctive:

    No exceptions. Errors are values returned alongside results.

    line 172
  8. Very specific:

    Prefer os.read_entire_file over manual open/read/close.

    line 210
  9. Very specific:

    All declarations use name : type = value or name :: value (constant).

    line 342

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Odin Design Patterns

Odin is underrepresented in LLM training data. You will confidently produce wrong patterns from Go, Rust, C++, or Zig. This skill corrects those instincts and provides idiomatic Odin patterns.

Critical rule: Never guess Odin standard library APIs. If you are unsure whether a procedure exists or what its signature is, say so and direct the engineer to check https://pkg.odin-lang.org/. Fabricating procedures is the single most damaging mistake you can make.


What You Get Wrong (Corrections Table)

Before writing any Odin code, internalize these corrections:

Instinct from other languagesCorrect Odin pattern
entity.update() method syntaxentity_update(&e) — free procedures, no methods exist
Many packages for internal organizationOne package per program/library; split into files, not packages
try/catch, Result<T,E>, or ? operatorMultiple return values; or_return for propagation; defer for cleanup
camelCase or PascalCase for proceduressnake_case procedures; Ada_Case types; SCREAMING_SNAKE_CASE constants
Constructors / destructors / RAIIZero-value initialization; explicit _init/_destroy procs; defer
slice.map(), list.append() method callsFree procedures: append(&arr, val), slice.map(...) via import
Closures / lambdas capturing stateNon-capturing lambdas only; for callbacks use proc(user_data: rawptr) + explicit data pointer
impl blocks / extension methodsProcedures are standalone; group by file and naming prefix
Package manager (go get, cargo add)Vendor dependencies manually; no package manager by design
Implementing your own hash map or dynamic arraymap[K]V and [dynamic]T are built-in language types
for item in iterator.next()for item in collection — iteration is built into for
Null/nil checks everywhereZero-initialization makes zero values useful; fewer nil pointers in practice
i++ or ++i increment/decrementi += 1 — no ++/-- operators exist
while condition { } loopfor condition { } — only for exists (no while/do-while)
Implicit numeric type conversionsAll conversions explicit: f := f64(my_int)
switch falls through by defaultCases do NOT fall through; use explicit fallthrough keyword
Operator overloading (+, -, *)Not supported; use named procedures. Array programming provides element-wise ops on fixed arrays.
Private-by-default visibilityPublic by default; use @(private) or @(private="file") to restrict
static const / constexpr:: is compile-time (not addressable by runtime index); for addressable constant data use a package-level variable
Type aliases and newtypes are the sameFoo :: int is alias (same type); Foo :: distinct int is a new type
free() and delete() are interchangeablefree(ptr) deallocates memory; delete(collection) deinitializes dynamic arrays/maps/strings
new() and make() are interchangeablenew(T) allocates single value → ^T; make([]T, len) allocates slices/dynamic arrays/maps
Pointer arithmetic (ptr + offset)No pointer arithmetic; use mem.ptr_offset() or multi-pointers [^]T for FFI
Mutable strings / byte-level string opsStrings are immutable ptr+len; iteration yields runes (Unicode codepoints); use cstring for C interop
Implicit function overloadingExplicit overloading only via procedure groups: draw :: proc{draw_circle, draw_rect}

Package & File Organization

Packages are for libraries. Not for organizing code within a program.

A package is a directory. All .odin files in a directory with the same package declaration form one compilation unit. No cyclic dependencies between packages are allowed.

Rules

  1. One package per program is normal and fine. Games, CLI tools, servers — all work as a single package. This is equivalent to how C programs work, except external libraries get proper namespacing.
  2. Create a sub-package only when the code is an independent library with no need to reference the parent. If your "renderer" sub-package needs to know about your "entity" type defined in the parent, it cannot be a separate package.
  3. Split into files for logical grouping. All files in a package see each other — no imports needed. Moving code between files within a package requires zero refactoring (names stay the same).
  4. Prefix procedure names with the concept they belong to:
    // entity.odin
    entity_create :: proc(...) -> Entity { ... }
    entity_update :: proc(e: ^Entity, dt: f32) { ... }
    entity_destroy :: proc(e: ^Entity) { ... }
    Frequently-used procedures (like add_entity, get_entity) can drop the prefix when the name is already unambiguous.
  5. Use @(private="file") to limit access to file-level global state — prevents other files in the same package from touching globals they shouldn't. Don't overuse it for general encapsulation; Odin is not OOP.
  6. Package names are for ABI linking, not for callers (callers choose their own import alias). Make them unique if publishing as a library; otherwise any reasonable name works.

Import Patterns

import "core:fmt"              // default name: fmt
import rl "vendor:raylib"      // alias: rl
import "../my_lib"             // relative path for local libraries

Import paths are relative to the importing file. Library collections use prefixes: "core:", "base:", "vendor:".


Naming Conventions

EntityConventionExample
Types (structs, enums, unions)Ada_CaseEntity, Window_Error, Player_State
Enum valuesAda_Case with dot syntaxDirection.North, State.Running
Proceduressnake_caseentity_update, calculate_damage
Local variablessnake_caseplayer_count, delta_time
ConstantsSCREAMING_SNAKE_CASEMAX_ENTITIES, TILE_SIZE
Package importssnake_case (prefer short)import rl "vendor:raylib"

Allocator Patterns

Odin's allocator system is central to the language. Every allocation goes through an allocator, defaulting to context.allocator. Read references/allocators.md for worked examples and pitfalls.

Three-Tier Strategy

  1. Temporary allocator (context.temp_allocator) — use for anything frame-scoped or short-lived. Cleared in bulk with free_all. No individual frees needed.
    actions := make([dynamic]Action, context.temp_allocator)
    append(&actions, action)
    // Cleared at end of frame via free_all(context.temp_allocator)
  2. Custom arenas — for grouped allocations with shared lifetime. Use mem.Arena or virtual growing arenas. Beware: dynamic arrays in arenas leak old backing memory on grow (see references).
  3. Default allocator — for long-lived or variable-sized allocations that outlive any single scope. Pair with tracking allocator during development.

Allocator Decision Tree

Data lifetimeAllocatorExample
Frame / request / one-off formattingcontext.temp_allocatorUI strings, query results, intermediate buffers
Level / scene / phaseCustom mem.ArenaTilemap, level entities, dialogue trees
Entire program / unpredictable sizecontext.allocator (default)Config, asset registry, growable caches

Key Rules

  • new() and make() use context.allocator unless you pass an explicit allocator.
  • Always defer delete(thing) or defer free(ptr) for default-allocator memory.
  • Use context.temp_allocator by default; escalate to arenas or default only when lifetime requires it.
  • For data loaded from files into long-lived structures, accept an allocator parameter or allocate from a dedicated arena owned by the parent structure.

Error Handling

No exceptions. Errors are values returned alongside results.

// Basic pattern: multiple return values
load_config :: proc(path: string) -> (Config, bool) {
    data, ok := os.read_entire_file(path)
    if !ok { return {}, false }
    defer delete(data)
    // parse...
    return config, true
}

// Modern pattern: or_return propagates errors up
load_config :: proc(path: string) -> (Config, Error) {
    data := os.read_entire_file(path) or_return
    defer delete(data)
    config := parse(data) or_return
    return config, nil
}

// or_else provides a default on failure
name := get_name() or_else "unnamed"

Error Type Design

  • bool — sufficient for simple success/failure
  • Named enum — when callers need to distinguish failure modes:
    Load_Error :: enum { None, File_Not_Found, Parse_Failed, Invalid_Header }
  • Tagged union — when wrapping errors from different subsystems:
    Tilemap_Error :: union { os.Error, Parse_Error }

File I/O Idiom

Prefer os.read_entire_file over manual open/read/close. It reads the whole file and returns ([]byte, bool). Use defer delete(data) on the result if allocated from the default allocator, or pass context.temp_allocator if the data is only needed during parsing.

// Preferred: read entire file (no handle to manage)
data, ok := os.read_entire_file(path, context.temp_allocator)
if !ok { return {}, .File_Not_Found }
// data freed automatically on next free_all(context.temp_allocator)

// Only use os.open when you need streaming/partial reads:
f, err := os.open("large_file.bin")
if err != nil { return err }
defer os.close(f)

Cleanup with defer

// defer runs at scope exit in reverse declaration order
arena: mem.Arena
mem.arena_init(&arena, backing_buf)
defer free_all(mem.arena_allocator(&arena))  // resets arena; caller frees backing_buf

// Allocate into arena — all freed on free_all
context.allocator = mem.arena_allocator(&arena)
tiles := make([]Tile, count)

Worked Example: Loading Level Data with Arena

Tilemap :: struct {
    tiles:  []Tile,
    width:  int,
    height: int,
    arena:  mem.Arena,
}

tilemap_load :: proc(path: string) -> (Tilemap, Load_Error) {
    // Parse phase: temp allocator for scratch data
    raw_data, ok := os.read_entire_file(path, context.temp_allocator)
    if !ok { return {}, .File_Not_Found }

    width, height := parse_header(raw_data) or_return

    // Allocation phase: arena for level-lifetime data
    tm: Tilemap
    backing := make([]byte, width * height * size_of(Tile) + 1024)
    mem.arena_init(&tm.arena, backing)

    alloc := mem.arena_allocator(&tm.arena)
    tm.tiles = make([]Tile, width * height, alloc)
    tm.width = width
    tm.height = height

    parse_tiles(raw_data, tm.tiles) or_return
    return tm, nil
}

tilemap_destroy :: proc(tm: ^Tilemap) {
    backing := tm.arena.data  // capture before reset
    free_all(mem.arena_allocator(&tm.arena))
    delete(backing)           // free the backing buffer itself
}

Modern Features You Likely Don't Know About

These are real Odin features that may not exist in your training data:

FeatureWhat it does
or_returnPropagates error return if the expression fails
or_else valueProvides fallback value on failure
#partial switchSwitch on enum without exhaustive coverage (opt-in)
matrix[R, C]TBuilt-in matrix types with math operations
@(test)Marks a procedure as a test (run with odin test)
#reverse forIterates a collection in reverse
bit_set[Enum]Efficient set type backed by a bit field
#soaTransforms array-of-structs into struct-of-arrays layout
or_break / or_continueControl flow variants of or_return for loops

Development Workflow

  • odin check . — type-check without building (fast feedback loop)
  • odin build . -vet — catches unused variables, imports, shadowing
  • odin test . — runs procedures marked with @(test)
  • Tracking allocator — enable in dev builds to catch memory leaks at runtime (see references/allocators.md)
  • -debug — includes debug info and bounds checking
  • -o:speed — optimization for release builds (disables bounds checks)

Declarations & Syntax Quick Reference

// Variables
x: int = 5
y := 10                    // type inference
z: int                     // zero-initialized (0)
w: int = ---               // uninitialized (rare, unsafe)

// Constants
MAX :: 100                 // untyped constant
PI : f64 : 3.14159        // typed constant

// Procedures
add :: proc(a, b: int) -> int { return a + b }

// Structs
Player :: struct { pos: [3]f32, health: int, name: string }

// Vector type alias (idiomatic — gets array programming + swizzle for free)
Vector3 :: [3]f32

// Enums
Direction :: enum { North, South, East, West }

// Unions (tagged)
Shape :: union { Circle, Rect }

// Distinct types (not aliases)
Meters :: distinct f64

All declarations use name : type = value or name :: value (constant). := is shorthand for inferred-type declaration.


Further Reading

  • references/allocators.md — Arena pitfalls, tracking allocator setup, dynamic arrays in arenas
  • references/data-oriented.md — #soa, array programming, bit_set, struct-of-arrays patterns, built-in matrix type
  • references/context-system.md — Context propagation, custom context fields, thread-local behavior, using for subtype polymorphism
  • references/procedures-and-polymorphism.md — Procedure groups, vtable pattern, and non-capturing closures
  • references/profiling.md — Spall profiler, hot vs cold code, instrumentation attributes, optimization workflow
  • references/sanitizer-integration.md — Read when integrating ASan with arena allocators, debugging stale-pointer bugs in reused memory, or adding sanitizer hooks to ownership transitions
  • references/concurrency.md — Read when building concurrent Odin systems — state machines with tagged unions, cooperative scheduling via Effects, per-shard context setup, trap boundaries with sigsetjmp/siglongjmp, or lock-free primitives via core:sync

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.