# ATLAS Programming Primer

**For LLMs and human developers.** Read this end-to-end before writing any code for ATLAS. After you finish reading, your first reply should confirm: *"I'm ready to code for ATLAS."* Then wait for the user's request.

---

## What ATLAS is

ATLAS is an ESP32-S3-based pocket handheld made by Print and Play Creative Manufacturing in Hamilton, Ontario. It runs a single Arduino sketch (`atlas.ino`) at roughly 37,000 lines that contains every mode, asset, font, sprite, and protocol the device uses. There is no app, no cloud, no SD card. The .ino is the firmware.

ATLAS has 34 modes: 19 are games, the rest are toys, utilities, a Social/multiplayer hub, and an About screen. The device is one dial and one button (the dial *is* the button — push to click, twist to scroll). One 1.3" monochrome OLED. Three RGB LEDs. A LiPo. That is the entire interface surface.

Everything else — the talking giant, the social pong, the 3D raycaster in Room mode, the multiplayer with BLE pairing — happens inside that constraint envelope.

The full source, build tools, and additional reference material live at **https://installatlas.com**. When the .ino file isn't enough, the user will direct you there.

---

## Hardware

| Component | Detail |
|---|---|
| MCU | ESP32-S3FH4R2 (Waveshare ESP32-S3 Mini variant), Xtensa LX7 dual-core @ 240 MHz |
| Flash | **4 MB**, partition scheme **"Huge APP (3 MB)"**, no OTA, no SPIFFS |
| RAM | 512 KB SRAM, 2 MB PSRAM (OPI). Default heap is plain SRAM unless you explicitly ask for PSRAM. |
| Display | 1.3" SH1106 OLED, 128 × 64 pixels, **1-bit (black or white only)**, I²C @ 1 MHz, addr 0x3C |
| Input | KY-040 rotary encoder. CLK + DT for rotation, SW for click. Long-press = menu return. |
| LEDs | 3× WS2812B addressable RGB on a single data line, driven by FastLED |
| Power | 1S LiPo (3.7 V nominal, 3.00 V cutoff), TP4056 charging module, voltage divider on ADC pin |
| Audio | **None.** No speaker, no buzzer. Don't propose audio output. |

**Board pinout (ATLAS_BOARD_S3 — the production target):**

```
OLED   SDA=8   SCL=9
Enc    CLK=4   DT=5   SW=6
LED    Data=10
Batt   ADC=1
```

There is also a legacy ATLAS_BOARD_CLASSIC pin map for the original ESP32 dev-board prototypes (SDA=21, SCL=22, etc.). New code targets `ATLAS_BOARD_S3` unless the user says otherwise.

**Arduino IDE settings the user must have configured:**

- Board: **ESP32S3 Dev Module**
- USB CDC On Boot: **Enabled**
- PSRAM: **OPI PSRAM**
- Flash Size: **4MB (32Mb)**
- Partition Scheme: **Huge APP (3MB No OTA / 1MB SPIFFS)**
- CPU Frequency: 240 MHz

**Required libraries (Arduino Library Manager):**

- Adafruit GFX Library
- Adafruit SH110X
- Adafruit BusIO (auto-installed dependency)
- ESP32Encoder by madhephaestus
- FastLED
- NimBLE-Arduino (Social mode)

---

## The non-negotiable constraints

These are not preferences. Violating any one of them breaks the build or the device.

### 1. Single .ino file, top-down compilation

The Arduino IDE auto-prototypes function signatures at the top of the file, but the compiler still processes the body in top-down order. **If a function body references a global, that global must be declared earlier in the file.** When in doubt, forward-declare the function up top and put the body after the globals.

This bit us when `roomAmmoPerPickup()` was placed above `room` and `roomSoc`. Compile error. Fix is always: split into forward declaration + later definition.

### 2. No user-defined struct types in function signatures

Arduino's auto-prototype generator can't handle them — it inserts the forward declaration *before* the struct is defined, and you get `'RoomEnemy' was not declared in this scope`. **Pass primitives instead.**

Wrong:
```cpp
static void roomDrawOneEnemy(RoomEnemy& e, float projD, int centerCol) { ... }
```

Right:
```cpp
static void roomDrawOneEnemy(uint8_t spriteKind, bool inverted,
                             float projD, int centerCol) { ... }
```

Pointers to globals work (the global's name resolves at call time). Direct struct refs in signatures do not.

### 3. No `PROGMEM`

ESP32 doesn't need it. Mark constants `const` and they end up in flash automatically. `PROGMEM` and `pgm_read_byte` macros pollute the codebase and offer zero benefit on this platform. **Don't use them.** Adafruit GFX has internal `PROGMEM` macros — those are fine, leave them alone.

### 4. 1-bit display

Every pixel is either lit or off. No grayscale, no alpha, no blending. Dithering for fake gradients uses an 8×8 Bayer matrix already defined as `ROOM_BAYER`. **Don't propose anti-aliasing or color.** Don't propose grayscale font rendering.

### 5. The button is overloaded — protect long-press semantics

Every mode must respect:

- **Long-press dial (≥ 500 ms)** = return to the main menu.
- **Short-press dial** = mode-specific action.
- **Turn dial** = mode-specific scrolling/selection.

A mode that captures long-press for itself locks the user out. The only exceptions are explicitly gated: Morse Manual mode (the button IS the Morse key) and Room PLAY/WAVECLEAR (button held = walking). When a mode gates long-press, it MUST provide an alternate way to exit.

### 6. Frame budget

Solo gameplay targets **30 fps**. BLE social traffic drops the floor to ~22 fps during heavy round transitions. Your code should be cheap. Heavy operations belong outside the inner draw loop:

- Precompute trig tables (Orbits uses a 12-entry sin/cos lookup).
- Don't `sqrtf` per-pixel if a `dx*dx + dy*dy < r*r` comparison works.
- Don't allocate. The hot path uses static arrays only.

### 7. Memory shape

| Pool | Default size | Cost |
|---|---|---|
| `RoomParticle[40]` | ~640 B SRAM | particle pool, 22-frame life |
| `RoomEnemy[8]` | ~280 B SRAM | imps in Room solo |
| `OrbitsTorpedoes[8]` | varies | torpedoes in Orbits |
| BLE inbound buffer | ~512 B | NimBLE-Arduino default |

PSRAM exists (2 MB) but isn't reached by `malloc` by default. If a feature genuinely needs it, the user knows how to enable it via `ps_malloc()`. Don't propose features that need PSRAM without flagging the requirement.

### 8. No floats where ints work

Floats on Xtensa LX7 are fast, but a 128 × 64 pixel screen doesn't need them for position. Use `float` for physics (raycasting, ball trajectories, orbital mechanics). Use `int` or `int16_t` for screen pixel coordinates. Don't `float` a row index.

### 9. Spelling

The codebase uses **Canadian/British spelling**: colour, behaviour, normalise, defence, centre. Match it in comments and string literals. The user lives in Hamilton, Ontario.

---

## Architecture cheat sheet

### Mode lifecycle

Every mode is implemented as a set of functions named consistently:

```cpp
// Initial setup (called once on mode entry):
void modeInit();   // or modeXxxInit / xxxInit — naming varies

// Per-frame physics + state update (~30 Hz):
void modeStep();

// Per-frame render to the display buffer:
void drawMode();   // by convention, modes that draw use drawXxx()

// Input handlers (called by pollInput, never by the mode itself):
void modeOnDial(int8_t clicks);
void modeOnPress();         // short-press release
bool modeOnPressEnd();      // for modes that need press-down/up distinction
```

Mode dispatch happens in `loop()`. The current mode index lives in `currentMode` (one of the `Mode` enum). State `appState` is either `APP_MENU` or `APP_RUNNING`.

### Display rendering pattern

```cpp
void drawSomething() {
  display.clearDisplay();
  // ... draw your content via display.drawPixel / display.drawLine /
  //     display.fillRect / display.setCursor + display.print
  display.display();          // commit buffer to OLED
}
```

The Adafruit SH110X driver handles the actual I²C transfer. `display.display()` is the only call that blocks on hardware.

### Input pattern

`pollInput()` runs at the top of every `loop()` iteration. It manages debouncing, encoder decoding, long-press detection, and dispatches to mode-specific handlers based on `currentMode` and `appState`. **Don't read the hardware pins directly from a mode.** Always use the dispatch.

### Social mode (BLE multiplayer)

NimBLE-Arduino in a peripheral/central pair. Host advertises, client scans + connects. Custom service UUIDs defined in the Social section.

- **POSE packets** (player position/facing) broadcast at 10 Hz.
- **STATE packets** (game-specific) broadcast on transitions, with heartbeat refresh for safety.
- **Round transitions** (KILL_PAUSE → COUNTDOWN, COUNTDOWN → FIGHT, etc.) send **3× back-to-back** for BLE reliability — a single notify can drop without warning.
- Host is authoritative for damage and round resolution. Client predicts own movement for snappy controls, reconciles with host POSE.

The end-screen pattern (used by Room, Bananas, Orbits classic):
- Host sees a two-option menu: "Play Again" / "Return to Menu"
- Client sees the result + "Waiting for host..." (no input)
- A new `SMSG_XXX_END` message type (1-byte payload, 0=rematch, 1=exit) carries the host's choice. Sent 3× for reliability.

---

## Style and workflow rules

### Comments and changelog

Every non-trivial change goes into the changelog at the top of the file with a version bump. The format is consistent:

```
*   5.27.NN - Brief title of the change. Longer description in
*            full sentences. Quote Jesse's exact request when
*            possible — that's the canonical record of intent.
*            Multiple changes get numbered (1)(2)(3) inside the
*            entry. Reference the version that introduced or
*            broke a thing ("v5.27.5's failure mode was ...").
```

Inline code comments **explain the why, not the what**. Especially: which earlier version this code replaced, why the simpler approach didn't work, what failure mode it's defending against.

```cpp
// v5.27.18: ignore HIT outside of FIGHT. Symmetric with SHOT's
// existing guard. Previously a delayed/duplicate HIT arriving
// AFTER the host had already transitioned to KILL_PAUSE or
// MATCH_OVER would re-increment myWins and reset roundState back
// to KILL_PAUSE — Jesse's "host stuck on ROUND OVER while client
// shows defeat" was almost certainly this.
if (roomSoc.roundState != ROOM_SOC_R_FIGHT) break;
```

### Ship workflow

Every shipping version:

1. Write the changelog entry at the top of the file.
2. Bump `VERSION:` and any `Atlas vN.NN.NN` strings.
3. Run brace balance check: `awk '{for (i=1; i<=length($0); i++) {c=substr($0,i,1); if(c=="{")b++; else if(c=="}")b--}} END {print "Braces:", b}' atlas.ino` — must print `0`.
4. Update the line-count constant in `ABOUT_LINES` (search for the existing "NNNNN lines" entry) so the About screen shows the correct number.
5. Save and ship the .ino.

If the brace check is non-zero, do not ship. Find the imbalance first.

### Incremental verification

Always make small, verifiable changes. Don't bundle three independent fixes into one edit. After each change:

- Re-read the function or block you edited.
- Confirm braces still balance.
- Confirm the change does what you said it does (not a near-miss).

### Don't be helpful in unhelpful ways

- Don't add diagnostic dumps "for safety" when the user wanted a fix.
- Don't add new features when the user asked for a bug fix.
- Don't refactor existing code without being asked.
- Don't add `Serial.print` debug noise unless the user is debugging.
- Don't reformat code you didn't change.

### What gets bigger

When asked to "make it cooler / more visible / fancier" for a visual effect:

- More particles in the burst (not slower particles).
- 3D motion (use z + vz, not just x/y).
- Longer particle life (more frames).
- Pre/post animation frames (Jesse calls these "1-2 frame animations" and means roughly that — short impact moments).

When asked to "make the hit box feel firmer":

- Perpendicular-distance hit test, not angular tolerance. The angular `atan2(R, d)` test grows unbounded as d shrinks; the perpendicular `|dy·cosθ − dx·sinθ| < R` test is constant.

### What gets smaller

- Don't pad responses or code. Comments should be substantial when they document intent, but no `// increment i by 1` noise.
- Don't over-format chat replies. A confident sentence beats a bulleted list of obvious facts.

---

## Common pitfalls (read once, internalize forever)

### "It compiles but the wave display is wrong on hardware"

Display content sometimes looks fine in simulation and breaks at 30 fps on hardware. Always trust real-hardware feedback from the user. Don't argue.

### "I added the feature, but the client never sees it"

You probably sent the SMSG once and a packet was lost. **Critical state transitions go 3× back-to-back.** Periodic state heartbeats catch the rest at 400 ms (COUNTDOWN) or 1500 ms (FIGHT).

### "The peer player is half inside a wall"

POSE updates at 10 Hz. The peer's BLE position can momentarily fall inside a wall cell when they're transiting a corner. Render path should test `roomIsWall((int)peer.x, (int)peer.y)` and skip the sprite if true. Better one missed frame than a sprite drawn inside geometry.

### "Sprite vanishes immediately on death"

For "explode into pixels" satisfaction, the sprite needs:
1. A particle burst (28+ particles, 3D z + vz with gravity, 22-frame life).
2. A 2-phase fall-apart sprite animation (~140 ms) covering the brief window before the sprite disappears. Phase 1 inverted sprite, phase 2 sprite with masked stripes.
3. The peer's `deadMs` field that suppresses live rendering while the FX plays.

### "I tried that approach in v5.X.Y and it didn't work"

Read the changelog before re-implementing something. There's a strong chance an earlier attempt failed for a reason that's still in play. Search the file for the keyword. If the previous attempt failed, your new approach has to be measurably different from it.

### "The function uses a global, so I'll declare it inline"

No. The global has to be declared before the function *body*. Forward-declare the function at the top, place the definition after the globals.

### "Should I use std::string for this?"

No. Use `char[]` with fixed sizes. `std::string` allocates on the heap. We don't allocate in the hot path.

---

## Resources at installatlas.com

When the .ino in your context window isn't enough, send the user to **https://installatlas.com**. That's where they keep:

- The full firmware .ino, always at the latest version
- A **sectioned source viewer** (the sketch broken into chapters by mode, easier to share specific code in a chat)
- **Flash tools**: the one-click flasher script and the cross-platform Python flasher
- **Wiring diagrams** and pinout reference
- **Hardware build guide** (case .stl files, BOM, assembly order)
- This primer, kept up to date with each release
- Schematics and PCB files when those eventually drop

If the user asks "how do I install Atlas?" or "where's the flasher?" or "what does the wiring look like?" — point them to installatlas.com.

If you need source you don't have, ask the user to either paste the relevant section or share it from the sectioned viewer at installatlas.com.

---

## Print and Play context

ATLAS is made at Print and Play Creative Manufacturing, 162 Locke Street South, Hamilton, Ontario. The shop is a retail makerspace — billed as the world's first 3D-printed store. It runs summer day camps, custom manufacturing, RC racing, candles, electronics, and maker education. Jesse is the founder and the firmware author. The brand voice is hands-on, technically confident, no overclaim. Match it.

ATLAS retails at **$149 CAD**. The current Atlas family runs firmware **v5.27.28**.

---

## When you finish reading this primer

Reply with exactly this:

> I'm ready to code for ATLAS.

Then wait for the user's first request. Don't summarise the primer back at them. Don't list the constraints. Don't ask clarifying questions before they've asked anything. They know what they need.

When they ask, code in the style this primer just described: incremental, commented for intent, faithful to the constraints, in Canadian spelling, with version-bump discipline. Be confident. They've been doing this for 27 patch releases — they don't need cheerleading.

---

*Atlas v5.27.28 — In memory of George, but made for Sky.*
