From 1bae89e01cea0d527ede485413662ec20b3b865a Mon Sep 17 00:00:00 2001 From: dev wells Date: Tue, 16 Jun 2026 20:56:04 +0000 Subject: [PATCH] init --- .gitignore | 16 ++++++++++++++++ README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ justfile | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ assets/index.md | 3 +++ docs/build.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/hot_reload.md | 25 +++++++++++++++++++++++++ docs/index.md | 4 ++++ src/game/config.odin | 5 +++++ src/game/game.odin | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/game/memory.odin | 7 +++++++ src/game/render.odin | 17 +++++++++++++++++ src/lib/hot_reload/main.odin | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib/platform/platform.odin | 14 ++++++++++++++ src/lib/release/main.odin | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 14 file(s) changed, 588 insertion(s)(+), 0 deletion(s)(-) diff --git a/.gitignore b/.gitignore new file mode 100644 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.DS_Store + +# jj local metadata for colocated git repos +.jj/ + +# Odin/Raylib build artifacts +build/ +*.bin +*.dSYM/ +*.dylib +*.so +*.dll + +# Runtime logs +log.txt +*.log diff --git a/README.md b/README.md new file mode 100644 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Odin + Raylib Template + +A minimal starter for Odin + Raylib games with a basic window, hot reloading, and release builds. + +This template is intentionally light on game opinions. Put your game state and logic in `src/game`; the `src/lib` packages provide the launcher and hot-reload infrastructure. + +## Requirements + +- [Odin](https://odin-lang.org/) +- [just](https://just.systems/) +- Optional: [`watchexec`](https://watchexec.github.io/) or `fswatch` for `just dev` + +Raylib is provided by Odin's `vendor:raylib` package. + +## Commands + +```bash +just check # check all packages +just build # build the hot-reload game library +just build-loader # build the hot-reload loader executable +just run # build and run the hot-reload loader in the foreground +just reload # rebuild the game library for a running loader +just dev # run loader in the background and rebuild on src/game changes; requires watchexec or fswatch +just release # build an optimized non-hot-reload executable +just clean # remove build artifacts +``` + +## Layout + +```text +assets/ Game assets +docs/ Template docs +src/game/ Your game package +src/lib/hot_reload/ Hot-reload loader executable +src/lib/release/ Release executable entry point +src/lib/platform/ Small platform constants/helpers +``` + +## Hot reload + +The game package exports a small `game_*` API used by both the hot-reload loader and release executable. + +Persistent state should live in `Game_Memory` or be reachable from it. During hot reload, the loader keeps the existing `Game_Memory` pointer when `size_of(Game_Memory)` is unchanged. If the memory layout size changes, the loader restarts the game memory. + +Runtime controls: + +- `F5`: force hot reload +- `F6`: restart game memory +- `Esc`: quit + +## Platform notes + +The Odin code uses compile-time `when ODIN_OS == ...` checks for platform-specific constants such as dynamic library extensions. The build is set up for macOS and Linux, with Windows extension support included but not yet validated in this repository. diff --git a/justfile b/justfile new file mode 100644 --- /dev/null +++ b/justfile @@ -0,0 +1,73 @@ +odin_root := `odin root` +project := "template" +game_pkg := "src/game" +hot_loader_pkg := "src/lib/hot_reload" +release_pkg := "src/lib/release" +hot_dir := "build/hot_reload" +release_dir := "build/release" +dll_ext := if os() == "windows" { ".dll" } else { if os() == "macos" { ".dylib" } else { ".so" } } +exe_ext := if os() == "windows" { ".exe" } else { ".bin" } +hot_exe := project + "_hot_reload" + exe_ext +release_exe := release_dir + "/" + project + exe_ext +raylib_dir := if os() == "macos" { odin_root + "/vendor/raylib/macos" } else { if os() == "linux" { odin_root + "/vendor/raylib/linux" } else { odin_root + "/vendor/raylib/windows" } } +linker_flags := if os() == "windows" { "" } else { "-Wl,-rpath " + raylib_dir } + +# List available recipes +default: + @just --list + +# Check all template packages +check: + odin check {{game_pkg}} -strict-style -vet -no-entry-point + odin check {{hot_loader_pkg}} -strict-style -vet + odin check {{release_pkg}} -strict-style -vet + +# Build the game shared library used by the hot reload loader +build-game: + @mkdir -p {{hot_dir}} + odin build {{game_pkg}} \ + -extra-linker-flags:"{{linker_flags}}" \ + -define:RAYLIB_SHARED=true \ + -build-mode:dll \ + -out:{{hot_dir}}/game_tmp{{dll_ext}} \ + -strict-style -vet -debug + @mv {{hot_dir}}/game_tmp{{dll_ext}} {{hot_dir}}/game{{dll_ext}} + +# Alias for the hot-reload game library build +build: build-game + +# Build the hot reload loader executable +build-loader: + odin build {{hot_loader_pkg}} -out:{{hot_exe}} -strict-style -vet -debug + +# Build and run the hot reload template +run: build-game build-loader + ./{{hot_exe}} + +# Rebuild the shared game library for a running loader +reload: build-game + @printf "Hot reloaded.\n" + +# Launch the loader and rebuild the game library on source changes. Requires watchexec or fswatch. +dev: build-game build-loader + @if command -v watchexec >/dev/null 2>&1; then \ + ./{{hot_exe}} & \ + watchexec -w {{game_pkg}} -e odin -- just reload; \ + elif command -v fswatch >/dev/null 2>&1; then \ + ./{{hot_exe}} & \ + fswatch -o {{game_pkg}} | while read -r _event; do just reload; done; \ + else \ + printf "watchexec or fswatch is required for 'just dev'. Install one or use 'just run' + 'just reload'.\n"; \ + exit 1; \ + fi + +# Optimized release build without hot reload +release: + @mkdir -p {{release_dir}} + odin build {{release_pkg}} \ + -out:{{release_exe}} \ + -strict-style -vet -no-bounds-check -o:speed + +# Clean build artifacts +clean: + rm -rf build/ {{hot_exe}} {{hot_exe}}.dSYM diff --git a/assets/index.md b/assets/index.md new file mode 100644 --- /dev/null +++ b/assets/index.md @@ -0,0 +1,3 @@ +# Assets + +A place for game assets diff --git a/docs/build.md b/docs/build.md new file mode 100644 --- /dev/null +++ b/docs/build.md @@ -0,0 +1,54 @@ +# Build Notes + +The template uses `just` recipes around Odin commands. + +## Development + +```bash +just run +``` + +This builds: + +- `src/game` as `build/hot_reload/game.{dylib,so,dll}` with `-build-mode:dll` +- `src/lib/hot_reload` as `template_hot_reload.{bin,exe}` + +Then it runs the loader. + +To reload manually while the loader is running: + +```bash +just reload +``` + +For automatic rebuilds, install `watchexec` or `fswatch` and run: + +```bash +just dev +``` + +`just run` starts the hot-reload loader in the foreground; use `just reload` manually from another terminal after editing `src/game`. `just dev` starts the loader in the background and keeps a file watcher running in the foreground to call `just reload` automatically when `src/game` changes. + +## Release + +```bash +just release +``` + +This builds `src/lib/release` as an optimized executable in `build/release/`. Release builds link directly against `src/game` and do not use hot reload. + +## Platform conditionals + +Odin supports compile-time OS checks with `when`: + +```odin +when ODIN_OS == .Windows { + // Windows-only code +} else when ODIN_OS == .Darwin { + // macOS-only code +} else when ODIN_OS == .Linux { + // Linux-only code +} +``` + +For larger platform-specific implementations, prefer Odin's file suffix convention, such as `file_windows.odin`, `file_darwin.odin`, or `file_linux.odin`. diff --git a/docs/hot_reload.md b/docs/hot_reload.md new file mode 100644 --- /dev/null +++ b/docs/hot_reload.md @@ -0,0 +1,25 @@ +# Hot Reload + +Hot reload is implemented by compiling `src/game` as a dynamic library and running a separate loader executable from `src/lib/hot_reload`. + +## How it works + +1. `just build-game` builds `build/hot_reload/game.{dylib,so,dll}`. +2. The loader copies that file to a versioned path such as `game_0.dylib`. +3. The loader uses `core:dynlib` to load exported symbols with the `game_` prefix. +4. When the canonical game library changes, the loader copies and loads the new version. +5. If `size_of(Game_Memory)` is unchanged, the old memory pointer is passed to `game_hot_reloaded`. +6. If the size changed, the loader shuts down the old game memory and initializes fresh memory. + +## Rules of thumb + +- Keep persistent game state in `Game_Memory` or reachable from it. +- Temporary per-frame allocations should use `context.temp_allocator` and be cleared each frame. +- Changing the size of `Game_Memory` restarts memory automatically. +- Do not store function pointers from the hot-loaded library in persistent memory unless you manage their lifetime carefully. + +## Controls + +- `F5`: force a reload +- `F6`: force a memory restart +- `Esc`: quit diff --git a/docs/index.md b/docs/index.md new file mode 100644 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,4 @@ +# Docs + +- [Build notes](build.md) +- [Hot reload](hot_reload.md) diff --git a/src/game/config.odin b/src/game/config.odin new file mode 100644 --- /dev/null +++ b/src/game/config.odin @@ -0,0 +1,5 @@ +package game + +WINDOW_WIDTH :: #config(WINDOW_WIDTH, 1280) +WINDOW_HEIGHT :: #config(WINDOW_HEIGHT, 720) +WINDOW_TITLE :: #config(WINDOW_TITLE, "Odin + Raylib Template") diff --git a/src/game/game.odin b/src/game/game.odin new file mode 100644 --- /dev/null +++ b/src/game/game.odin @@ -0,0 +1,75 @@ +package game + +import rl "vendor:raylib" + +@(export) +game_init_window :: proc() { + rl.SetConfigFlags({.WINDOW_RESIZABLE, .VSYNC_HINT}) + rl.InitWindow(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) + rl.SetTargetFPS(500) + rl.SetExitKey(nil) +} + +@(export) +game_init :: proc() { + g = new(Game_Memory) + g^ = Game_Memory{run = true} + game_hot_reloaded(g) +} + +@(export) +game_update :: proc() { + if rl.IsKeyPressed(.ESCAPE) { + g.run = false + } + + render_frame() + free_all(context.temp_allocator) +} + +@(export) +game_should_run :: proc() -> bool { + when ODIN_OS != .JS { + if rl.WindowShouldClose() { + return false + } + } + + return g != nil && g.run +} + +@(export) +game_shutdown :: proc() { + free(g) + g = nil +} + +@(export) +game_shutdown_window :: proc() { + rl.CloseWindow() +} + +@(export) +game_memory :: proc() -> rawptr { + return g +} + +@(export) +game_memory_size :: proc() -> int { + return size_of(Game_Memory) +} + +@(export) +game_hot_reloaded :: proc(mem: rawptr) { + g = (^Game_Memory)(mem) +} + +@(export) +game_force_reload :: proc() -> bool { + return rl.IsKeyPressed(.F5) +} + +@(export) +game_force_restart :: proc() -> bool { + return rl.IsKeyPressed(.F6) +} diff --git a/src/game/memory.odin b/src/game/memory.odin new file mode 100644 --- /dev/null +++ b/src/game/memory.odin @@ -0,0 +1,7 @@ +package game + +Game_Memory :: struct { + run: bool, +} + +g: ^Game_Memory diff --git a/src/game/render.odin b/src/game/render.odin new file mode 100644 --- /dev/null +++ b/src/game/render.odin @@ -0,0 +1,17 @@ +package game + +import rl "vendor:raylib" + +BACKGROUND_COLOR :: rl.Color{13, 15, 22, 255} +TEXT_COLOR :: rl.Color{200, 210, 230, 255} +SUBTLE_COLOR :: rl.Color{120, 132, 160, 255} + +render_frame :: proc() { + rl.BeginDrawing() + rl.ClearBackground(BACKGROUND_COLOR) + + rl.DrawText("Odin + Raylib Template", 24, 24, 28, TEXT_COLOR) + rl.DrawText("F5 hot reload | F6 restart memory | Esc quit", 24, 64, 18, SUBTLE_COLOR) + + rl.EndDrawing() +} diff --git a/src/lib/hot_reload/main.odin b/src/lib/hot_reload/main.odin new file mode 100644 --- /dev/null +++ b/src/lib/hot_reload/main.odin @@ -0,0 +1,190 @@ +package main + +import platform "../platform" +import "core:dynlib" +import "core:fmt" +import "core:log" +import "core:mem" +import "core:os" +import "core:path/filepath" +import "core:time" + +GAME_DLL_DIR :: "build/hot_reload/" +GAME_DLL_PATH :: GAME_DLL_DIR + "game" + platform.DYNAMIC_LIBRARY_EXTENSION + +Game_API :: struct { + lib: dynlib.Library, + init_window: proc(), + init: proc(), + update: proc(), + should_run: proc() -> bool, + shutdown: proc(), + shutdown_window: proc(), + memory: proc() -> rawptr, + memory_size: proc() -> int, + hot_reloaded: proc(mem: rawptr), + force_reload: proc() -> bool, + force_restart: proc() -> bool, + modification_time: time.Time, + api_version: int, +} + +copy_game_library :: proc(to: string) -> bool { + copy_err := os.copy_file(to, GAME_DLL_PATH) + if copy_err != nil { + fmt.printfln("Failed to copy %s to %s: %v", GAME_DLL_PATH, to, copy_err) + return false + } + return true +} + +load_game_api :: proc(api_version: int) -> (api: Game_API, ok: bool) { + mod_time, mod_time_error := os.last_write_time_by_name(GAME_DLL_PATH) + if mod_time_error != os.ERROR_NONE { + fmt.printfln( + "Failed getting last write time of %s, error code: %v", + GAME_DLL_PATH, + mod_time_error, + ) + return + } + + game_dll_name := fmt.tprintf( + GAME_DLL_DIR + "game_%d" + platform.DYNAMIC_LIBRARY_EXTENSION, + api_version, + ) + copy_game_library(game_dll_name) or_return + + _, ok = dynlib.initialize_symbols(&api, game_dll_name, "game_", "lib") + if !ok { + fmt.printfln("Failed initializing symbols: %s", dynlib.last_error()) + return + } + + api.api_version = api_version + api.modification_time = mod_time + ok = true + return +} + +unload_game_api :: proc(api: ^Game_API) { + if api.lib != nil { + if !dynlib.unload_library(api.lib) { + fmt.printfln("Failed unloading lib: %s", dynlib.last_error()) + } + } + + versioned_path := fmt.tprintf( + GAME_DLL_DIR + "game_%d" + platform.DYNAMIC_LIBRARY_EXTENSION, + api.api_version, + ) + if os.remove(versioned_path) != nil { + fmt.printfln("Failed to remove %s", versioned_path) + } +} + +main :: proc() { + exe_path := os.args[0] + exe_dir := filepath.dir(string(exe_path), context.temp_allocator) + os.set_working_directory(exe_dir) + + context.logger = log.create_console_logger() + + default_allocator := context.allocator + tracking_allocator: mem.Tracking_Allocator + mem.tracking_allocator_init(&tracking_allocator, default_allocator) + context.allocator = mem.tracking_allocator(&tracking_allocator) + + reset_tracking_allocator :: proc(a: ^mem.Tracking_Allocator) -> bool { + had_leaks := false + for _, value in a.allocation_map { + log.errorf("%v: Leaked %v bytes\n", value.location, value.size) + had_leaks = true + } + mem.tracking_allocator_clear(a) + return had_leaks + } + + game_api_version := 0 + game_api, game_api_ok := load_game_api(game_api_version) + if !game_api_ok { + fmt.println("Failed to load Game API") + mem.tracking_allocator_destroy(&tracking_allocator) + return + } + + game_api_version += 1 + game_api.init_window() + game_api.init() + + old_game_apis := make([dynamic]Game_API, default_allocator) + + for game_api.should_run() { + game_api.update() + force_reload := game_api.force_reload() + force_restart := game_api.force_restart() + reload := force_reload || force_restart + game_dll_mod, game_dll_mod_err := os.last_write_time_by_name(GAME_DLL_PATH) + + if game_dll_mod_err == os.ERROR_NONE && game_api.modification_time != game_dll_mod { + reload = true + } + + if reload { + new_game_api, new_game_api_ok := load_game_api(game_api_version) + if new_game_api_ok { + force_restart = + force_restart || game_api.memory_size() != new_game_api.memory_size() + if !force_restart { + append(&old_game_apis, game_api) + game_memory := game_api.memory() + game_api = new_game_api + game_api.hot_reloaded(game_memory) + } else { + game_api.shutdown() + if reset_tracking_allocator(&tracking_allocator) { + log.warn("Restarting after memory leak report") + } + for &old_game_api in old_game_apis { + unload_game_api(&old_game_api) + } + clear(&old_game_apis) + unload_game_api(&game_api) + game_api = new_game_api + game_api.init() + } + game_api_version += 1 + } + } + + if len(tracking_allocator.bad_free_array) > 0 { + for bad_free in tracking_allocator.bad_free_array { + log.errorf("Bad free at: %v", bad_free.location) + } + panic("Bad free detected") + } + } + + free_all(context.temp_allocator) + game_api.shutdown() + if reset_tracking_allocator(&tracking_allocator) { + log.warn("Exiting after memory leak report") + } + + for &old_game_api in old_game_apis { + unload_game_api(&old_game_api) + } + delete(old_game_apis) + + game_api.shutdown_window() + unload_game_api(&game_api) + mem.tracking_allocator_destroy(&tracking_allocator) +} + +when ODIN_OS == .Windows { + @(export) + NvOptimusEnablement: u32 = 1 + + @(export) + AmdPowerXpressRequestHighPerformance: i32 = 1 +} diff --git a/src/lib/platform/platform.odin b/src/lib/platform/platform.odin new file mode 100644 --- /dev/null +++ b/src/lib/platform/platform.odin @@ -0,0 +1,14 @@ +package platform + +when ODIN_OS == .Windows { + DYNAMIC_LIBRARY_EXTENSION :: ".dll" + EXECUTABLE_EXTENSION :: ".exe" +} else when ODIN_OS == .Darwin { + DYNAMIC_LIBRARY_EXTENSION :: ".dylib" + EXECUTABLE_EXTENSION :: ".bin" +} else when ODIN_OS == .Linux { + DYNAMIC_LIBRARY_EXTENSION :: ".so" + EXECUTABLE_EXTENSION :: ".bin" +} else { + #panic("Unsupported desktop OS for this template") +} diff --git a/src/lib/release/main.odin b/src/lib/release/main.odin new file mode 100644 --- /dev/null +++ b/src/lib/release/main.odin @@ -0,0 +1,52 @@ +package main + +import game "../../game" +import "core:log" +import "core:mem" +import "core:os" +import "core:path/filepath" + +_ :: mem + +USE_TRACKING_ALLOCATOR :: #config(USE_TRACKING_ALLOCATOR, false) + +main :: proc() { + exe_path := os.args[0] + exe_dir := filepath.dir(string(exe_path), context.temp_allocator) + os.set_working_directory(exe_dir) + + context.logger = log.create_console_logger() + + when USE_TRACKING_ALLOCATOR { + default_allocator := context.allocator + tracking_allocator: mem.Tracking_Allocator + mem.tracking_allocator_init(&tracking_allocator, default_allocator) + context.allocator = mem.tracking_allocator(&tracking_allocator) + } + + game.game_init_window() + game.game_init() + + for game.game_should_run() { + game.game_update() + } + + free_all(context.temp_allocator) + game.game_shutdown() + game.game_shutdown_window() + + when USE_TRACKING_ALLOCATOR { + for _, value in tracking_allocator.allocation_map { + log.errorf("%v: Leaked %v bytes\n", value.location, value.size) + } + mem.tracking_allocator_destroy(&tracking_allocator) + } +} + +when ODIN_OS == .Windows { + @(export) + NvOptimusEnablement: u32 = 1 + + @(export) + AmdPowerXpressRequestHighPerformance: i32 = 1 +} -- tangled.sh