| name | game-code-architecture |
| description | Design and review guidelines for this SDL3 Pong codebase: frame-loop constraints, testable layering, and design patterns. Use when designing new features, populating the ISSUES.md, implementing a task, reviewing a diff or branch, or when the user asks for an architecture or standards check. |
Game architecture & code standards
Cross-cutting rules that hold on every change. Testing strategy and milestone acceptance live in docs/ROADMAP.md; repo layout and file placement in repo-conventions.
When to apply
- Roadmap planning — architecture and technical choices.
- Before coding — sketch where logic lives (pure helper vs SDL boundary vs
Application) and what gets a TEST_CASE.
- While implementing — keep
update() / render() cheap; push math and policy into testable free functions.
- On review — walk the checklist; flag violations with a one-line why and a concrete fix.
Frame loop (Application::run)
pollEvents → tickFrameClock → update(dt) → render(). Treat update and render as hot paths. Per-frame budget at V-Sync 60 Hz is ~16.6 ms for all work combined; at 120 Hz ~8.3 ms.
- No recurring per-frame heap allocation in
update() / render(). The smell is new / growing vector / std::string rebuilt every tick. A one-off alloc on an infrequent event (score change, level load, hot-plug) is fine — cache the result on that event and re-read it every frame. When derived state must change every frame and has no event to hook into (FPS readout, glyph rects), prefer a reusable stack/scratch buffer; an explicit per-frame arena is the last resort, never ad-hoc new per tick.
- No unbounded or heavy algorithms (deep search, nested loops over dynamic collections, recursion). Loops should be O(1) or O(n) with small, compile-time-known n.
- Think about data layout for anything touched every frame: contiguous structs, scalars, fixed arrays. Avoid pointer chasing,
std::function, and virtual dispatch in inner loops without need.
- Precompute static layout once at construction / level load, not each frame.
- Format text on the change event, not every frame. Hold the formatted string as state and refresh it when the underlying value changes. If there is no clear change event (debug overlay, FPS counter), format into a stack buffer via
std::to_chars into a std::array<char, N>.
- Readability over cleverness, but not at the cost of obvious per-frame waste.
Layering
main.cpp → thin entry
Application → SDL lifetime, event pump, frame loop; injects IClock + IRandomSource (+ later: IPaddleController, …)
PlayfieldLayout, FrameTiming, PaddleMotion → pure free functions (doctest, no SDL video)
PlayfieldRenderer, TextRenderer::draw* → thin SDL draw boundaries
Playfield::* → logical constants + coordinate convention
- SDL stays concentrated in
Application and thin adapters. Gameplay math must not call SDL. SDL_FRect returned by value is a POD, not an SDL call.
- Single owner per SDL API — exactly one wrapping adapter per primitive (
ClockSdlTicks is the only caller of SDL_GetTicksNS). Keeps the rest testable and the mock surface one line.
- Inject time, randomness, input via small interfaces with sensible defaults — owners take
unique_ptr<IFoo> = nullptr and fall back internally to the production impl; tests pass a fake. No SDL_GetTicks, rand(), or hidden globals in gameplay code.
- Typed range helpers, not raw
next() — IRandomSource::intInRange / doubleInRange rather than a uint32_t-returning primitive.
- Pure logic ships with tests. See the testing strategy in
docs/ROADMAP.md.
- Logical pixels everywhere — origin top-left, +Y down; tuning in
Playfield:: constants. Matches SDL_SetRenderLogicalPresentation.
- Half-extents at layout APIs — pass
paddleHalfWidth, not full width, so call sites express "centered on" without scattering / 2.
- Controllers / policies as data + functions — return a request struct consumed by a pure update step; no deep inheritance trees.
C++ shape: constexpr, const, pure functions
Keep data (structs, tuning, cached rects) separate from algorithms (collision, clamping, layout, state transitions). Algorithms are free functions taking all inputs as parameters — no reaching into Application or hidden globals.
constexpr for compile-time facts — resolutions, half-extents, dash counts, glyph tables, win scores. Prefer named constants over magic numbers; static_cast when mixing int and float.
- Do not
constexpr what must stay runtime — injected clock/random, SDL handles, vectors built from caller parameters.
- Pure functions = same inputs → same outputs, no I/O, no globals.
[[nodiscard]] on pure helpers.
- Structs hold state; free functions implement policy —
Paddle{position, half-size, speed} + PaddleMotion::stepCenterY(request, …); Score{value, text} + setScore(score, newValue); never a deep Paddle::update() that reads SDL or controllers. When two fields share an invariant (Score::text == std::to_string(Score::value)), expose only the setter that maintains it and treat direct field mutation as a review-flagged bug.
- Const-correct interfaces, value types at boundaries —
const query methods; const& or pass-by-value for small PODs; return fresh values rather than mutating caller state unless the API is explicitly in-out.
std::string_view for non-owning string params. Use const std::string& only when you genuinely need a std::string API inside.
- Delete copy / move on owners and polymorphic bases. Mark all four special members explicitly — slicing and accidental copies of SDL handles are bugs, not features.
- Defend at the boundary — pure helpers handle degenerate inputs and document the choice in the header once, then test it (
FrameTiming::secondsBetween → 0.0 on non-monotonic input; centerDashSegments(0, …) → empty vector).
Smells: a helper that reads Application, SDL, or file-scope mutable state; a member function that mixes input polling with motion math; a constexpr on something that still allocates; an owner with implicit copy/move; const std::string& where std::string_view would do; a new SDL API called from more than one TU.
Patterns worth copying
- Free function + explicit inputs — testable without a window (
FrameTiming::secondsBetween, layout helpers).
- Cache at construction —
PlayfieldRenderer builds m_centerDashes once; draw() only blits.
- Header stays SDL-free when cheap — forward-declare SDL types; hide heavy includes behind
unique_ptr.
- Separate "compute rects" from "draw rects" — the compute half is pure and tested; the draw half is a thin SDL loop. Same shape works as a sink/callback when even the intermediate
vector would allocate per frame (TextRenderer::drawText passes a [renderer](const SDL_FRect&){…} lambda to the shared inner helper).
- Top-level hot-path functions read at intent level — mechanical detail (formatting, byte packing, inner draw loops) lives in a named helper one level down.
Application::render() reads cached Score.text and issues one drawTextCentered call per side; the to_string, the cache invalidation, and the per-pixel loop all live in their own helpers.
- Seed mutable caches in
init, not lazily — m_lastTickNs = m_clock->now() so the first frame's dt is real.
- Non-fatal SDL setup logs and keeps going — V-Sync / LogicalPresentation failures fall back gracefully rather than aborting.
- Linear scan for small lookup tables is fine when documented — name the obvious future optimisation in a comment rather than reaching for
std::map reflexively.
- Placeholder code is labeled with the replacing milestone — inline when it is a few lines, behind a clearly-named private helper (
Application::placeholderScoreDriver) when it grows beyond that, but never a separate class or type. The name itself flags the deletion target for the future milestone.
Review checklist