This tutorial covers the object module — the entity engine that manages a fixed pool of game objects, dispatches per-type callbacks, and resolves collision against the map module's tile properties. It is what examples/games/mapandobjects and examples/maps/slope_collision are built on. Add object to LIB_MODULES.
It is a fixed pool of 80 entity slots. Each slot holds one 64-byte t_objs record. Every object carries a type (0-63), and each type registers up to three callbacks — init, update, refresh. Once a frame you call objUpdateAll() and the engine walks the live objects and calls each one's update function with its record already copied into a C-visible workspace.
It is not an ECS. There are no components, no queries, no systems. The record is a fixed struct with fixed field names, and a large part of it is reserved for the engine's own physics and collision bookkeeping. You cannot add a field; you repurpose count, dir, tempo, hitpoints and parentID, which exist precisely so you have somewhere to put game state.
It is also not core SDK. The implementation lives in lib/contrib/object.asm and the policy in lib/contrib/README.md is explicit: it is inherited from PVSnesLib, it carries no API-stability guarantee, and the core team treats it as example-tier code. This tutorial documents it as it behaves today, rough edges included — see the Gotchas section, which is longer than usual for a reason.
<snes.h> does not pull in <snes/object.h>. Include it explicitly, and include <snes/map.h> with it — the ACT_* action constants and the T_* tile-property constants your callbacks compare against are declared in map.h, not in object.h.
The module's dependencies are declared in make/common.mk:
Those are not optional extras. The engine reads the map module's camera (mapGetCameraX() / mapGetCameraY()) to decide which objects are near enough to update, it reads the map's metatile property table to resolve collision, and it pokes the dynamic sprite engine's oambuffer refresh flags when an object crosses the screen edge. A Makefile that lists object without them will not link.
That is the line both shipped examples use.
| Where | Bytes | Contents |
|---|---|---|
| Bank $00 (the 8 KB C band) | 5 | the engine's current id, slot and kill flag |
| Bank $7E (the far band) | 6910 | 80 × 64-byte records, objWorkspace (64) right behind them, the per-type active-list heads, the three 256-byte callback tables, the object-loading scratch buffer |
Measured on examples/maps/slope_collision: the .obj_bank7e RAMSECTION is $1AFE bytes at $7E:2466, .obj_bank00 is 5 bytes. The big allocation sits in the far band, so it does not eat your scarce bank-$00 RAM budget (see Far RAM Tutorial — FAR objects beyond the 8 KB band) — but 6.8 KB is 6.8 KB, and the pool is fixed at compile time. OB_MAX is 80 whether you spawn 3 objects or 80.
Per-frame CPU cost is dominated by two block copies per live, in-range object: 64 bytes into the workspace before the callback, 60 back out after — about 870 CPU cycles, 7,000 master cycles, per object. Budget for that before putting 40 objects on screen. The collision routines a callback calls add none (since 2026-10-09; each used to copy the record out and back in, which made six copies an object where there are now two).
Call it once per type, after objInitEngine(), passing the functions directly:
The parameters are typed, so a callback with the wrong signature is a compile error rather than a corrupted stack. Any of the three may be 0: the engine tests every pointer before dispatching and skips a null one.
C callbacks routinely land outside bank $00 — in slope_collision.sym, marioinit sits in bank $01. That works because the compiler pushes a function pointer as a four-byte slot, bank byte included, and objInitFunctions stores all three bytes. Until 2026-09-18 it did not (see the Gotchas), and both examples registered their types through a hand-written assembly routine; if you are porting code that copied that shim, delete it.
The three callback signatures are:
idx is the raw slot index (0-79), not the handle objNew() returned. That is the value every engine function taking an object wants — pass it straight back to objCollidMap, objUpdateXY and friends.
objInitEngine() clears the pool, rebuilds the free list and sets the default gravity and friction. Call it once, before registering types or spawning anything.
objNew(type, x, y) claims a free slot and returns a handle — not an index. The handle packs the slot's rolling unique id in the high byte and the slot index in the low byte, so that a stale handle to a recycled slot can be detected. It returns 0 when the pool is full, and also stores the handle in objGetCurrentId().
The canonical init callback looks like this (from examples/maps/slope_collision/mario.c):
The collision box is yours to set. objNew does not guess it: width, height, xofs and yofs are zero until the init callback fills them in, and a zero-sized box collides with nothing.
objLoadObjects(const u8 *table) walks a table of five 16-bit words per entry, terminated by a single 0xFFFF:
| Word | Meaning |
|---|---|
| 0 | x — spawn position in map pixels |
| 1 | y — spawn position in map pixels |
| 2 | type — the type index whose init callback is called |
| 3 | minx — passed through to the init callback |
| 4 | maxx — passed through to the init callback |
It does not create the objects itself. For each row it calls that type's init callback with the five values; the callback is what calls objNew. That is why the enemy init functions in mapandobjects copy minx / maxx into objWorkspace — those two words are how a level editor hands a patrol range to an enemy.
You do not hand-author this table. It is the .o16 file tmx2snes emits from a Tiled object layer named Entities (the name is matched exactly). Each Tiled object's type becomes word 2; custom string properties named minx and maxx become words 3 and 4. See Scrolling maps for the rest of the pipeline. The converter caps a map at 64 objects, below the engine's pool of 80.
This is the part a newcomer gets wrong, so it is worth understanding why it is shaped this way.
The 80 records live in bank $7E, above $2000. Plain C pointers on this target are bank-$00 addresses (see the RAM constraint in KNOWN_LIMITATIONS.md), so a plain pointer cannot reach the array. The engine keeps one 64-byte scratch record at a fixed address, which C touches with ordinary struct syntax:
It is a FAR object (Far RAM Tutorial — FAR objects beyond the 8 KB band): objWorkspace.xpos reads and writes at the cost of any global, and a pointer to it, or to one of its fields, is a FAR pointer. It was in bank $00 until 2026-10-09; it now sits right behind the pool, which is what lets the collision routines work on it where it is.
The contract is a copy-in / copy-out sandwich around every callback:
Engine functions you call from inside the callback keep the illusion intact: objCollidMap, objCollidMapWithSlopes, objCollidMap1D and objUpdateXY work on the workspace itself when it holds the object you name — the case inside that object's callback — so the values you see afterwards are the ones the routine computed, and nothing was copied. Called for any other object, they flush the workspace to its record, work on the record you named, and leave that one in the workspace.
objGetPointer(handle) validates a handle and loads that object into the workspace. It returns the slot index plus one, or 0 when the handle is stale — that is the validity test:
There is only one workspace, so anything that loads a different object replaces what you were looking at:
Until 2026-09-19 this was a trap: the engine wrote the workspace back into the slot it was iterating, whatever the workspace held by then, so an update callback that peeked at the player returned with the player's 64 bytes written over the enemy's slot. The workspace now tracks its owner — the slot it mirrors. Every reload first flushes the current contents to that owner, and every write-back goes to the owner, not to the caller's index:
What is still yours to manage: after the peek, objWorkspace is the player. Writing to it edits the player, and the collision routines you call next with the enemy's idx reload the enemy first. If you want to keep editing your own object, call objGetPointer(my_own_handle) to come back.
xpos and ypos are three-byte fields, not integers. Byte 0 is the sub-pixel fraction; bytes 1-2 are the whole-pixel world coordinate. Velocity (xvel / yvel) is a signed 8.8 value added into that, so 256 is one pixel per frame and 0x0140 is 1.25 pixels per frame.
Read a pixel position the way the examples do:
Write one by setting both bytes (and zeroing the fraction if you want an exact landing):
Note that objUpdateXY(idx) is what actually integrates velocity into position. The engine never does it for you; a callback that never calls it has objects that accelerate and never move.
| Field | Written by |
|---|---|
| prev, next, nID, type | engine only — do not touch |
| xpos, ypos | engine (objNew, objUpdateXY, collision snapping) and you |
| xvel, yvel | you; collision zeroes them on impact and applies gravity/friction |
| width, height, xofs, yofs | you, in the init callback |
| xmin, xmax | you (conventionally the patrol range from the .o16) |
| tilestand, tilesprop, tilebprop | collision routines |
| onscreen | objUpdateAll |
| action | you (plus one engine write — see the gotcha below) |
| sprnum, sprframe, sprflip, sprpal, sprid3216, sprblk3216 | you |
| count, dir, tempo, hitpoints, parentID, status, tileabove, sprrefresh | you — the engine never reads or writes these |
The last row matters: despite their Doxygen comments, tileabove, status and sprrefresh are never touched by the engine. They are free storage.
Both examples run the same loop, and the placement is deliberate:
objUpdateAll() belongs before WaitForVBlank(), in the active frame. It is pure computation plus two block copies per object; it writes no PPU register and no VRAM. Putting it after WaitForVBlank() would spend your VBlank budget on gameplay logic and starve mapVblank().
Your callbacks stage sprite output the same way — oamDynamicDraw(n) queues a dynamic sprite, and the NMI handler uploads the queue.
objUpdateAll does not call every live object's callback. It first tests the object against a virtual screen — roughly one screen plus 64 pixels of margin on each side, relative to the map camera (-64 to 320 in X, -64 to 288 in Y). Objects outside that box are skipped entirely: they do not move, do not fall, do not animate. This is the engine's culling strategy, and it is the reason enemies far off-screen cost nothing.
A second, tighter test (-32 to 256 X, -32 to 224 Y) maintains the onscreen flag. That flag is what objRefreshAll() filters on.
Two consequences worth internalising:
objRefreshAll() is a second pass that calls each on-screen object's refresh callback. It exists so a scrolling game can separate "think" from "draw" and avoid sprite flicker at the screen edge. Run it after objUpdateAll() — it depends on the onscreen flags that pass sets.
Neither shipped example uses it; both do their drawing inline in the update callback and register a null refresh pointer. If you register a refresh callback for a type, register a real one — see the gotcha below.
All three map-collision routines read the map module's metatile property table, the one mapLoad() built from the .b16 file. They resolve the object's collision box to map cells, look up each cell's property word, and act on it. That is the whole relationship between the two modules: the object engine has no collision data of its own, and calling any of these before mapLoad() reads an uninitialised table.
The property words are the T_* constants from map.h:
| Property | Value | Effect in the collision routines |
|---|---|---|
| T_SOLID | 0xFF00 | blocks — any property with a non-zero high byte is treated as solid |
| T_LADDER | 0x0001 | treated as standable — the object rests on it |
| T_FIRES | 0x0002 | sets ACT_BURN |
| T_SPIKE | 0x0004 | sets ACT_DIE |
| T_PLATE | 0x0008 | treated as standable (one-way platform) |
| T_SLOPEU1 … T_SLOPEUD2 | 0x0020-0x0025 | slope surfaces — only objCollidMapWithSlopes understands these |
Gravity is additionally suppressed on a frame where nothing solid was found underneath but the last tile property read was non-zero and the object's action carries the ACT_CLIMB bit — that is the hook for ladder climbing.
| Function | Gravity | Friction | Slopes | Use it for |
|---|---|---|---|---|
| objCollidMap(idx) | yes | yes | no | side-view platformers on flat and stepped terrain |
| objCollidMapWithSlopes(idx) | yes | yes | yes | platformers with diagonal terrain |
| objCollidMap1D(idx) | no | no | no | top-down movement, where "down" is not a direction |
All three write back into the workspace:
They take the slot index, the value handed to your update callback — not the handle from objNew.
A plain AABB test between two objects' boxes (xpos + xofs, width, ypos + yofs, height), returning 1 on overlap and 0 otherwise. It takes a callback's idx or a handle, as do objCollidMap, objCollidMapWithSlopes, objCollidMap1D and objUpdateXY: they work on a live slot and mask the handle's id byte themselves (since 2026-09-21 — before that a handle sent them to memory past the pool, silently). Only objKill and objGetPointer need the whole handle, because the id byte is how they detect a stale one. Like the map collision routines it flushes the workspace on entry, so an object that has just moved itself is tested where it now is.
objCollidMapWithSlopes is what examples/maps/slope_collision is named for. A slope tile is not a wall: walking into one must lift the object to the surface height for its horizontal position within the tile, not stop it. The routine does that by indexing a per-slope-type height table with the object's centre X relative to the tile.
The six slope properties, as map.h names them, are a 1×1 slope ascending (T_SLOPEU1) and descending (T_SLOPED1) to the right, plus the lower and upper halves of a 2×1 slope in each direction (T_SLOPELU2, T_SLOPELD2, T_SLOPEUU2, T_SLOPEUD2). You assign them per tile in the map's attribute layer.
The functional test testing/manifests/movement_slope_collision.toml pins exactly the distinction that matters, measured on luna with RIGHT held:
| Frame | Position | What it proves |
|---|---|---|
| 60 | (32, 96) | spawned and settled on flat ground |
| 120 | (99, 96) | 67 pixels along the flat — Y untouched |
| 144 | (127, 89) | on the slope: 28 pixels right, lifted 7 pixels up |
| 216 | (209, 96) | back on flat ground past the slope |
| 300 | (209, 96) | RIGHT still held, but a solid tile stops him dead |
Frames 144 and 300 are the two halves of the contract: a slope lifts, a wall stops. The companion manifest for mapandobjects pins the flat-ground case for objCollidMap the same way — walking right and left along a floor with Y constant at 192, which is the "he never falls through the floor" negative.
One practical note from the same example: Mario is declared with width = 14 and xofs = 1 rather than a full 16. The comment in mario.c says a width of 16 misbehaves on slopes. Leave a pixel of slack on each side of a 16-pixel sprite.
Three ways, in order of how often you want them:
objKillCurrent() is the one to reach for. objUpdateAll clears its flag before each update callback and checks it after; calling it kills the object the engine is currently updating, after the workspace has been written back, which is the only safe moment. It is not checked around the refresh callback — calling it there does nothing until the next update pass.
objKill(handle) takes a handle, unlike almost everything else in this API. It unlinks the object from its type's active list and returns the slot to the free list. Remember that it loads the victim into the workspace on the way, so the workspace is the victim afterwards (your own edits were flushed to your slot first).
This module is inherited from PVSnesLib and has not had the audit the core modules have. The following are verified against lib/contrib/object.asm and the shipped ROMs. Every public function is now executed by a test, but seven of the sixteen (objCollidMap1D, objCollidObj, objInitFriction1D, objInitGravity, objKill, objKillAll, objRefreshAll) only by the library fixture (testing/fixtures/libtests), not by any example — they have assertions, not mileage.
It read its arguments with the pre-A6 stack map, from before pointers became four-byte slots, so every offset was wrong: what it took for the type was the update callback's bank byte. Its comment then claimed C could not pass a bank byte at all, and it wrote $00 into all three. Both examples worked around it with a hand-written assembly routine using :label.
The compiler pushes pea.w :fn alongside pea.w fn for every pointer, so the bank is on the stack — the routine now reads it. Both examples dropped their shim and register from C, and their manifests pass unchanged, which is what proves the dispatch reaches bank $01 correctly.
Neither the update pass, the refresh pass nor the objLoadObjects init dispatch checked for a null pointer, and the tables start zeroed, so an object whose type was never registered called address $00:0000. All three sites now test the 24-bit pointer and skip the call. An unregistered type is inert: it keeps its slot, is never updated, and objLoadObjects skips its table entry. A null refresh callback is likewise safe, so objRefreshAll() no longer depends on every type having one.
The routine block-copied the table into its scratch buffer with the source bank forced to $00. The examples got away with it because their .o16 data sat in a SUPERFREE section the linker happened to place in bank $00 — an ASSET_SECTION table (the asset banks, ROM_BANKS - 1 down to 1, by design, see .claude/rules/bank0_budget.md) would have loaded garbage instead. The DMA now takes the bank the caller pushed, so the table can live anywhere.
Same family, one week later. objCollidMap, objCollidMapWithSlopes and objCollidMap1D set the data bank to a hardcoded $00 at their thirteen tile reads, while mapLoad had been storing the map's real bank since far pointers landed (v0.19.0). The day the examples' maps moved to the asset banks, Mario sank into the ground in slope_collision. The reads now use the bank mapLoad stored; the library fixture pins it with a map in bank $02. See KNOWN_LIMITATIONS.md.
objInitGravity(gravity, friction) stored both values, but the three collision routines added the assemble-time constant GRAVITY instead of reading the one you passed; only friction was honoured. They now read the variable, which objInitEngine still seeds with GRAVITY (41, i.e. 41/256 pixel per frame squared), so the default is unchanged and the argument is finally real.
When a collision routine found a T_FIRES or T_SPIKE tile it wrote the action word without indexing by the current object, so the hazard always landed in object slot 0 instead of the one that touched it. All three routines did it, at six sites. Writing this page is what found it.
The index register already held the object there — the neighbouring tilesprop stores use it — so the fix was the missing ,x. No map in the corpus carries T_FIRES or T_SPIKE, which is both why it survived and why the fix could not change any existing behaviour.
The write-back after a callback went to the slot being iterated, whatever the workspace held. The workspace is now owner-tracked (see What reloads the workspace above): edits are flushed to their owner before any reload, and write-backs go to the owner. The cost is about 140 CPU cycles per callback — enough to move a late-frame write across a frame boundary, which is how the slope example's manifest noticed.
The routine stored its result in a scratch variable and returned without loading it, so the caller got whatever the accumulator held: 1 on contact by accident, and on every "no contact" exit an intermediate such as x + width — non-zero, so truthy. Nothing in the corpus called it, which is how it survived. It also read the slots without flushing the workspace first, so a callback that had just moved its object tested the old position. Both fixed; the first call from a test found them.
The owner-tracked workspace of 2026-09-19 had three holes, all found by the first test that edited an object outside a callback and then let the engine run:
The routine zeroes velocity on a wall hit but never decelerates otherwise; PVSnesLib's FRICTION1D constant was defined and never used. The default is unchanged — no friction, so ported code behaves the same — but objInitFriction1D(amount) now makes every objCollidMap1D call move xvel and yvel toward zero by amount, clamped at zero. objInitEngine() resets it to 0.
Whenever any object's onscreen flag flips, the engine sets the refresh flag on all 128 oambuffer entries, once per frame at most. That re-uploads every dynamic sprite's tiles that frame. It is correct, but it is a cost spike that lands exactly when a scrolling camera is busiest — worth knowing when you are chasing a dropped frame (see Profiling: measuring where the frame goes).
On failure objNew returns 0 but does not clear the current id, which still holds the previous successful handle. This used to let objLoadObjects write the workspace into the previously created object; since the owner-tracked workspace (2026-09-19) the workspace has no owner at that point and nothing is written. objGetCurrentId() itself is still stale, so keep guarding with the return value, as the examples do: if (objNew(type, xp, yp) == 0) return;.
After killing every live object it forced the free-list head back to slot 0. objKill had already pushed each freed slot onto the list, so every slot chained ahead of slot 0 — anything killed after it — became unreachable. Two objects of different types were enough: the pool came back with 79 slots, and lost more on each level change. The reset is gone; the library fixture asserts that 80 allocations succeed after objKillAll().
Most of the corpus does not use it, and that is a reasonable default. Reach for your own array when:
Where it earns its keep is the case it was written for: a scrolling side-view level with a dozen or more heterogeneous entities placed in a map editor, each with its own behaviour, where off-screen culling and data-driven spawning matter. That is exactly examples/games/mapandobjects.