I'm building a game in Odin for the Playdate. Which, having looked at the awesome work of Bazzagibbs, I assumed was a solved problem. I love Odin, I love the Playdate. This should be fun, right?
Right..?

Turns out - nobody had done this before.
If I'd read the README (the thing that's there for you to read...), I would have seen that there are Odin bindings for the Playdate SDK (Bazzagibbs/odin-playdate), but they only target the simulator → a shared library that runs on your desktop. The README mentions a build_device.bat that doesn't exist. No issues, no forks, no blog posts. Just a chart of 'X's and good luck. Neat!
So, having recently completed a nice tape mod and added some case foam to my Iron165 (the keeb purists are going to find me now) → I started doing what I do best...
typing.

So what did I type?

The Playdate SDK expects a pdex.elf → which is an ARM ELF binary with specific relocations that its toolchain (pdc) compresses into a pdex.bin and bundles into a .pdx package.
The device loader decompresses it, applies relocations, and jumps to an entry point defined in a C shim called setup.c. So far, so good.
I'd been testing my game in the simulator on a tight loop, and everything was moving along smoothly. I had added some networking logic to the playdate-odin lib using Playdate's pre-existing TCP and HTTP functionality (more on that later).
I was confident things would work, and I was at a critical game design junction - my game has multiplayer, so I wanted to test the network performance on the actual hardware - so I could determine whether (with the help of a relay server) the game could manage real-time PvP, or if I'd have to switch gears to some turn-based shenanigans.
I built my pdx package (as described above), mounted my Playdate as a Data Disk, and dragged the file over. Everything looked good.
I booted the Playdate → it wasn't bricked. Great!
I booted my game → a black screen, little graphic of tumbling blocks, and then error code e1.
Damn it.
High-level summary for my people who want to stay out of the dirt or have a day job to get back to:
The problem is getting Odin's LLVM backend to produce an ARM object file that plays nice with this loader.
How to make that happen was a journey.

This is what works:

odin build src/playdate/ \
    -target:freestanding_arm32 \
    -build-mode:obj \
    -microarch:cortex-m7 \
    -target-features:"no-movt" \
    -no-entry-point \
    -default-to-nil-allocator \
    -no-thread-local \
    -disable-red-zone

Then you compile the SDK's setup.c shim with arm-none-eabi-gcc, link everything with the SDK's linker script, and run pdc to produce the .pdx bundle.

Pause here.
That was a simple statement. Honestly not all that technical and only took a few seconds to read. Right?
Now, I'm probably outing myself as an idiot - but figuring out what to put in that sentence (along with that odin-build command) cost me about a week of my life.

Here's what didn't work:

The Relocation Problem

The first thing that went wrong was silent → as I mentioned, the binary compiled, linked, packaged, and deployed to the Playdate. Then it crashed.
The Playdate's device loader can only process R_ARM_ABS32 relocations. When it encounters anything else, it doesn't tell you the actual error → it just doesn't fix up the address, and your code jumps to garbage.

LLVM's default ARM code generation uses movw/movt instruction pairs to load 32-bit constants.
These produce R_ARM_THM_MOVW_ABS and R_ARM_THM_MOVT_ABS relocations.
The Playdate loader ignores them completely.

The fix is -target-features:"no-movt". This forces LLVM to use literal pools instead of movw/movt, which generates the R_ARM_ABS32 relocations the loader expects. I spent more time than I'd like to admit staring at arm-none-eabi-objdump -r output before this clicked.

The Missing Standard Library

Odin on freestanding_arm32 means no standard library. No core:fmt, no core:strings, no core:log. The existing odin-playdate bindings use all of these in their context/logger setup.
This one was admittedly simpler to figure out. Once I realized the bindings had these things everywhere, I just had to replace them.

I split the context code using Odin's #+build file tags:
context_sim.odin#+build !freestanding → full logger with core:fmt and friends.
context_device.odin#+build freestanding → minimal logger that just calls system.log_to_console directly.

Same public API. The compiler picks the right file based on the target.

The game itself uses a different approach for cross-platform → separate packages per platform (src/playdate/, src/desktop/) with shared game logic in src/game/ behind a HAL (Not the 9000 kind, just a hardware abstraction layer). Different build scripts compile different entry points. Post on this coming later.

The Stubs

The brew arm-none-eabi-gcc package doesn't include newlib, so the SDK's linker script (which requires libc.a and libm.a) fails. I created empty archive stubs and wrote minimal C implementations for memcpy, memmove, memset, strlen, strcmp, memcmp, plus ARM-specific stubs for __aeabi_read_tp (TLS) and the unwind personality routines.

The setup.c shim also needs system headers that don't exist in a bare-metal environment. I wrote minimal stubs for stdint.h, stdlib.h, stdbool.h, string.h, stdio.h, and math.h → just enough type declarations to get it to compile.
After all of this, I was able to build and run my test application that I'd created to measure RTT for each frame.
It loaded, ran, connected to a little python server on my laptop. Smooth sailing from here!

Nope.

The E0 Crash

With the pipeline working for small test binaries, I tried compiling the actual game. It crashed immediately on the Playdate. Error code E0. No diagnostic, no stack trace. Just a dead screen and a number.

A minimal networking test app (nettest) worked perfectly on the same hardware. Same pipeline, same flags. The only difference was code size.

I spent a while bisecting. The crash wasn't size-dependent (a 200KB C binary with random data worked fine). It wasn't relocation count. It wasn't the .odinti section. It wasn't the linker script. It wasn't the optimization level.

After an embarrassingly long time building, flashing, testing, and repeating - I discovered something. Something incredibly obvious.
There was a crashlog.txt on the Playdate's USB data disk.
It very directly informed me that the cfsr register read 00010000 → undefined instruction fault. The crash PC pointed to playdate_assertion_failure_proc, which is Odin's runtime trap (UDF #254).

An assertion was failing. In the allocator.

The Odin runtime's arena_init asserts block.used == 0 on freshly allocated memory. The Playdate's realloc returns unzeroed heap memory → memory that was previously used by the pdx loader for decompression and relocation processing. Small binaries worked because the loader used less heap, leaving clean memory for the allocator. Large binaries ate enough heap during loading that every subsequent allocation got dirty memory.

The fix was an incredibly simple one:
-define:NO_PLAYDATE_TEMP_ALLOCATOR=true. Skip the arena-based temp allocator entirely. The TLS buffer also needed to be ≥1032 bytes (Odin's ChaCha8 random state), up from the 64 I'd initially guessed.

WiFi Networking

With the game running on hardware, I added WiFi networking bindings to the odin-playdate fork. The Playdate SDK exposes TCP client and HTTP APIs. No server sockets, no UDP, no listen/accept. You can connect outbound. That's it.

I built a small RTT measurement tool to test latency from the actual hardware. I measured round-trip from a Playdate on my (bad) home WiFi to a laptop running an echo server. The results were interesting: RTT clustered at exact 33ms intervals (32, 33, 65, 66, 99, 132). The Playdate's TCP stack processes network I/O once per frame at 30fps. The actual network latency is under 10ms on local WiFi → the 33ms floor is just the frame-quantized polling.

This was enough to validate lockstep multiplayer. Two Playdates connecting through a relay server, exchanging 12-byte input packets at 30fps, with a 1-to-3-frame input buffer. 360 bytes per second per direction. First networked Playdate game, as far as I know.
(Though there is the incredible helloyellow that actually inspired me to pick this idea up again after 2 years of ignoring it).

The Fork

All of this is in my fork of odin-playdate (Open Source and MIT, like the original). The additions over the upstream bindings:

• WiFi networking API bindings (TCP client + HTTP)
• Build tag split for freestanding ARM (context_sim.odin / context_device.odin)
• Network field added to the main API struct
• Documentation for device builds, the NO_PLAYDATE_TEMP_ALLOCATOR flag, and the networking API

The game itself is untitled, I'm just calling it Project Endling for now → a 1-bit (ofc) isometric MMBN-inspired grid battler with diablo-like itemization and procedurally generated dungeons, running on both Playdate and desktop (crossplay!) from a single codebase via the aforementioned HAL.