18 KiB
Dusk — Claude Code rules
See STATUS.md for a periodically-refreshed inventory of subsystem
maturity, test coverage gaps, and known open issues — check it before
assuming a subsystem is fully wired up or before picking a next task.
File headers
Every C, H, and JS file starts with:
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
JS files use // comment style instead.
C conventions
Types
Always use the project-defined aliases instead of bare C primitives:
| Use | Not |
|---|---|
bool_t |
bool |
int_t |
int |
float_t |
float |
char_t |
char |
Use uint8_t, uint16_t, int32_t, etc. for fixed-width integers.
All struct and enum types end in _t (animation_t, errorret_t, …).
Naming
- Functions — snake_case, prefixed with their module:
assetLock(),entityPositionInit(),moduleAssetBatchCtor() - Struct fields — camelCase:
keyframeCount,localPosition - Macros / constants — UPPER_SNAKE_CASE:
ENTITY_ID_INVALID,ERROR_OK,COMPONENT_TYPE_COUNT - Files — snake_case matching the primary type:
entityposition.c,moduleassetbatch.c
Header files (.h)
- Use
#pragma once— no include guards. - Declare every public function,
#define, andexternglobal. - Write a JSDoc block (
/** … */) above every declaration explaining purpose,@params, and@returns. - Only include headers that the
.hfile itself strictly requires for the types it exposes. Move everything else to the.cfile. Do not use forward declarations as a workaround — use the real include in the.cfile instead.
Implementation files (.c)
- Contain function bodies only; no declarations.
- Pull in whatever additional includes the implementation needs.
- Do not use
staticorinlineon functions. Every function, including internal helpers, must be declared in the matching.hand defined in the.cfile. Internal helpers belong near the bottom of the.cfile, not at the top with astaticqualifier.staticandinlineon functions are only appropriate when the function body is written directly inside a.hfile.staticon variables (file-scope state) is fine and expected.
Formatting
- Hard-wrap all lines at 80 characters.
Error handling
Return errorret_t from fallible functions. Use these macros:
errorOk(); // return success
errorThrow("msg %d", val); // return failure with message
errorChain(someCall()); // propagate failure, continue on success
errorIsOk(ret) / errorIsNotOk(ret) // test a result
errorCatch(ret); // handle + free an error
Never return raw error codes or use errno for in-engine errors.
Memory
Use the project allocator — never raw malloc/free:
memoryAllocate(size) // allocate
memoryFree(ptr) // free
memoryZero(dest, size) // zero a block
memoryCopy(dest, src, size) // copy
Asserts
Prefer specific assert macros over bare assert():
assertNotNull(ptr, "msg");
assertTrue(cond, "msg");
assertFalse(cond, "msg");
assertUnreachable("msg");
assertIsMainThread("msg");
Build system
Each subdirectory has its own CMakeLists.txt that adds sources with:
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
myfile.c
)
Never add source files to the root CMakeLists.txt directly.
Platform support
Targets
Set DUSK_TARGET_SYSTEM at CMake configure time to select a platform:
DUSK_TARGET_SYSTEM |
Macro defined | Platform |
|---|---|---|
linux |
DUSK_LINUX |
Linux desktop |
knulli |
DUSK_KNULLI |
Knulli (handheld) |
psp |
DUSK_PSP |
Sony PSP |
gamecube |
DUSK_GAMECUBE |
Nintendo GameCube |
wii |
DUSK_WII |
Nintendo Wii |
Layer structure
src/dusk/ core, platform-agnostic game logic
src/duskgl/ OpenGL abstraction (Linux, Knulli, PSP)
src/dusksdl2/ SDL2 window + input (Linux, Knulli, PSP)
src/dusklinux/ Linux + Knulli platform impl
src/duskpsp/ PSP platform impl
src/duskdolphin/ GameCube / Wii platform impl (no SDL2/OpenGL)
Dolphin is the only target that bypasses SDL2 and OpenGL entirely — it uses native GameCube/Wii rendering and input APIs.
Platform guards
Use the compile-time macros for platform-specific code:
#ifdef DUSK_PSP
// PSP-only path
#elif defined(DUSK_GAMECUBE) || defined(DUSK_WII)
// GameCube / Wii path
#else
// Generic / Linux fallback
#endif
Additional capability macros set per-target:
DUSK_SDL2, DUSK_OPENGL, DUSK_OPENGL_ES, DUSK_OPENGL_LEGACY,
DUSK_INPUT_GAMEPAD, DUSK_INPUT_KEYBOARD, DUSK_INPUT_POINTER,
DUSK_PLATFORM_ENDIAN_BIG / DUSK_PLATFORM_ENDIAN_LITTLE.
Abstraction pattern
Platform-specific implementations are wired in via #define macros in
each platform's displayplatform.h / inputplatform.h etc., which
the core calls through. Functions that a platform does not support are
simply left undefined — the core guards calls with #ifdef.
Adding platform-specific code
- Put it under
src/dusk<platform>/in the matching subsystem folder. - Gate any core call-site with the appropriate
#ifdef DUSK_<PLATFORM>or capability macro. - Keep the
src/dusk/core free of platform ifdefs — delegate through the platform header macros instead.
Adding a new asset loader type
- Add an enum value to
assetloadertype_t(before_COUNT) insrc/dusk/asset/loader/assetloader.h. - Add fields to the input/loading/output unions in
assetloader.h. - Implement
assetXxxLoaderSync,assetXxxLoaderAsync, andassetXxxDisposein a newsrc/dusk/asset/loader/xxx/directory. - Register the three callbacks in
ASSET_LOADER_CALLBACKS[]insrc/dusk/asset/loader/assetloader.c. - If user-facing, create a JS module (see below) and a
.d.tsfile.
Adding a new entity component
- Create
src/dusk/entity/component/<category>/entityMyComp.h/.cwith structentityMyComp_t,entityMyCompInit(), and optionallyentityMyCompDispose(),entityMyCompRender(). - Add the include to
src/dusk/entity/componentlist.hheader block (orsrc/duskrpg/entity/gamecomponentlist.hfor a game-specific component, appended after the engine's inbuilt ones). - Add a row:
Params are
X(MYCOMP, entityMyComp_t, myComp, entityMyCompInit, NULL, NULL)(enumName, type, field, init, dispose, render)— passNULLfor any callback the component doesn't need. This auto-generates the enum, union field, and definition entry. - If JS-facing, create the script module and
.d.ts(see below).
Entities/components/scenes have no JSON serialize/deserialize path —
that was removed in favor of building scenes from C-coded prefabs
(below) or from JerryScript (Entity/Component/Scene, see
"Adding a new script (JS) module").
Adding a new entity/scene prefab
Entity prefabs (src/dusk/entity/entityprefab.h) and scene prefabs
(src/dusk/scene/sceneprefab.h) follow the same pattern:
- Write an apply function:
errorret_t entityPrefabXxxApply(mgr, entityId)(orerrorret_t scenePrefabXxxApply(sceneId)), building up the entity/scene with the normal component/entity APIs. - Add an entry to the sentinel-terminated
ENTITY_PREFABS[](insrc/dusk/entity/entityprefablist.h, or a game-specific list it includes) orSCENE_PREFABS[]:{ .name = "MY_PREFAB", .extends = "", .apply = entityPrefabXxxApply }extendsnames another prefab to apply first (recurses throughentityPrefabResolveAndApply/scenePrefabResolveAndApply), or""for none. Do not add an enum or count field — the array is iterated until.name[0] == '\0'. entityPrefabResolveAndApply/scenePrefabResolveAndApplyonly resolve names against the C-coded registry above — there is no JSON asset fallback. Throws if no prefab with that name is registered.
Adding a new cutscene item type
- Create
src/duskrpg/cutscene/item/<category>/cutsceneMyItem.h/.cwith a data struct (e.g.cutscenemyitem_t) andcutsceneMyItemStart(item, data)/cutsceneMyItemUpdate(item, data)(the latter returnstrueonce the item has completed). Add a matchingcutscenemyitemdata_truntime-data struct only if the item needs per-run state across ticks (most don't). - Add
CUTSCENE_ITEM_TYPE_MY_ITEMto the enum and a union member tocutsceneitem_t(andcutsceneitemdata_tif it has runtime data) insrc/duskrpg/cutscene/item/cutsceneitem.h. - Register the
{ start, update }pair inCUTSCENE_ITEM_CALLBACKS[]incutsceneitem.c. - Add an authoring macro to
src/duskrpg/cutscene/cutscene.h:used inside a#define CUTSCENE_MY_ITEM(ARGS...) \ { .type = CUTSCENE_ITEM_TYPE_MY_ITEM, .myItem = { ARGS } }CUTSCENE(NAME, SIZE, PAUSE_TYPE, ...)block.
Save system
Save data lives under src/dusk/save/ (save.h/savefile.h/
saveplatform.h). Slots are fixed-count (SAVE_FILE_COUNT_MAX), each
holding a yyjson document persisted with its byte size and a CRC32
checksum. saveLoad/saveSave read/write the JSON fresh every call —
callers own the returned/passed yyjson_doc/yyjson_mut_doc and must
free it themselves. Actual file I/O goes through platform-specific
saveplatform_t/stream hooks (one implementation per platform under
src/dusk<platform>/save/) — do not add direct filesystem calls to the
core save.c, extend the platform stream hooks instead.
Network system
src/dusk/network/ (network.h) is a connection-state layer only — a
networkstate_t state machine (DISCONNECTED/CONNECTING/CONNECTED/
DISCONNECTING) plus an HTTP client (network/http/) used for one-off
requests. It is not a multiplayer/replication protocol — that layer
doesn't exist yet (see ROADMAP.md items on the socket server/client
and packet handlers). Platform-specific connection logic (e.g. PSP's
sceNetApctl polling, GameCube/Wii's if_config()) lives under
src/dusk<platform>/network/, wired through networkplatform.h macros
the same way display/input are. Whenever this eventually grows a
multiplayer protocol, apply ROADMAP.md's principle: never trust
incoming packet data — validate defensively with errorret_t/
errorThrow(), not asserts.
Adding a new script (JS) module
Dusk embeds JerryScript (src/dusk/script/, fetched via
cmake/modules/Findjerryscript.cmake). Today only Entity, the
generic Component wrapper, and Scene are registered (see
src/dusk/script/module/modulelist.c) — no per-component-type typed
wrappers exist yet (e.g. no .position on a POSITION component);
entity.add(TYPE)/entity.getComponent(TYPE) always return the
generic Component.
- Create
src/dusk/script/module/<category>/moduleMyMod.h/.c.- Declare
extern scriptproto_t MODULE_MYMOD_PROTO;in the header. - Use
moduleBaseFunction(name)to define JS-callable functions — these are the one exception to "nostaticin.cfiles": the macro itself expands to astatic jerry_value_t name(...)JerryScript external-handler trampoline, never called by name from other C files, so it isn't declared in the.h. - Register props/funcs in
moduleMyModInit()withscriptProtoDefineProp/scriptProtoDefineFunc/scriptProtoDefineStaticFunc.
- Declare
#includethe header insrc/dusk/script/module/modulelist.cand callmoduleMyModInit()inmoduleListInit()(andDisposeinmoduleListDispose()).- For a component module that adds a typed wrapper for a specific
component type, create
src/dusk/script/module/entity/component/modulecomponentlist.c(it doesn't exist yet — the first such module creates it) soentity.add()can return the typed wrapper instead of the genericComponent. - Create
types/<category>/mymod.d.tsand add a/// <reference path="..." />line totypes/index.d.ts.
Script module type declarations
Whenever a src/dusk/script/module/**/*.c file is created or modified,
check whether the corresponding types/**/*.d.ts needs updating and
apply any changes before finishing the task.
JavaScript (asset scripts)
- Use
varfor module-level state;constfor values that never change. - Always use semicolons.
- Scene objects are plain objects (
var scene = {}) with assigned methods. - Export via
module.exports = scene. - Async scene init should use
async functionandawait.
Coding style
ASCII only
Source files (.c, .h, .js) must contain only ASCII characters (U+0000–U+007F).
Non-ASCII characters are banned even in comments and string literals.
Use ASCII-only substitutes instead:
--or-instead of—(em dash)->instead of→(arrow)xor*instead of×(multiplication)
Only non-script asset files (e.g. .po locale files) may contain non-ASCII text.
Indentation
2 spaces. No tabs.
Keyword and operator spacing
No space between a keyword or function name and its opening parenthesis:
if(!ptr) return;
for(uint8_t i = 0; i < count; i++) {
while(entry->state != DONE) {
switch(type) {
sizeof(assetbatch_t)
memoryZero(ptr, size)
Spaces around all binary operators and after every comma:
pos->flags |= ENTITY_POSITION_FLAG_WORLD_DIRTY;
(size_t)end - (size_t)start
foo(a, b, c)
Braces
Opening brace on the same line as the statement (K&R style) for all
constructs — functions, if, else, for, while, switch:
void assetEntryLock(assetentry_t *entry) {
...
}
if(dirty) {
...
} else {
...
}
Guard returns
Short guards go on one line with no braces:
if(!ptr) return;
if(!b || !b->batch) return jerry_undefined();
if(!(flags & DIRTY)) return;
Blank lines
- One blank line between functions; no blank line at the start or end of a function body.
- One blank line between logical blocks inside a function body.
- No trailing blank lines at the end of a file.
Pointer placement
* is attached to the variable name, not the type:
assetentry_t *entry
const char_t *name
void *ptr
uint8_t *d = (uint8_t *)dest;
Casts
Space between cast and operand:
(assetbatch_t *)user
(uint8_t *)dest
(textureformat_t)v
Return
No parentheses around the return value:
return ptr;
return MEMORY_POINTERS_IN_USE;
switch / case
case indented 2 spaces from switch; body indented 2 more from case:
switch(type) {
case ASSET_LOADER_TYPE_TEXTURE:
descs[i].input.texture = (textureformat_t)v;
break;
default:
break;
}
Multi-line function signatures
When parameters don't fit on one line, put each on its own line indented
2 spaces; the closing ) { (definition) or ); (declaration) goes on
its own line at column 0:
void assetEntryInit(
assetentry_t *entry,
const char_t *name,
const assetloadertype_t type,
assetloaderinput_t *input
) {
errorret_t memoryCompare(
const void *a,
const void *b,
const size_t size
);
Structs and enums
Anonymous inner struct or enum with a typedef, _t suffix, closing
brace and name on the same line:
typedef struct {
errorcode_t code;
char_t *message;
} errorstate_t;
typedef enum {
ASSET_LOADER_TYPE_NULL,
ASSET_LOADER_TYPE_COUNT
} assetloadertype_t;
Designated initialisers
Spaces inside braces; .field = value:
jsassetentry_t e = { .entry = entry };
assetbatchloadedpend_t init = { .batch = batch };
Ternary operator
Spaces around ? and ::
const float val = psx > 0.0f ? pt[0][0] / psx : 0.0f;
const placement
const before the type, * attached to the variable:
const char_t *name
const void *src
const size_t size
Comments in .c files
- Do not use section dividers (
/* ---- ... ---- */). Just let the functions follow one another with a single blank line between them. - Multi-line explanatory comments inside function bodies use
//lines:// Script modules are freed; orphaned JS wrapper objects now get GC'd // so their finalizers fire before assetDispose() checks ref counts. jerry_heap_gc(JERRY_GC_PRESSURE_HIGH); - Do not use
/* */for inline or inline-block comments inside.cfunction bodies.
Comments in .h files
Every public declaration gets a Javadoc block (/** … */) with
@param and @returns where relevant. Keep it on the lines immediately
above the declaration with no blank line in between.
Color system
Colors are defined in src/dusk/display/color.csv and code-generated
into a color.h header by tools/color/csv/__main__.py.
Each row in the CSV has name,r,g,b,a with channel values in [0.0, 1.0].
The script emits four #define variants per color plus a bare alias:
COLOR_<NAME>_4B color4b(r8, g8, b8, a8) // default alias target
COLOR_<NAME>_3B color3b(r8, g8, b8)
COLOR_<NAME>_3F color3f(rf, gf, bf)
COLOR_<NAME>_4F color4f(rf, gf, bf, af)
COLOR_<NAME> COLOR_<NAME>_4B
color_t is color4b_t (four uint8_t channels).
To add a new color, append a row to color.csv and rebuild — do not
hand-edit the generated header.
Tests
- Tests live in
test/mirroringsrc/dusk/structure. - Use cmocka; include
dusktest.h. - Test functions:
static void test_something(void **state). - After each test, assert
memoryGetAllocatedCount() == 0to catch leaks. - Build with
-DDUSK_BUILD_TESTS=ON.