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 aTEST_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 isnew/ growingvector/std::stringrebuilt 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-hocnewper 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_charsinto astd::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
Applicationand thin adapters. Gameplay math must not call SDL.SDL_FRectreturned by value is a POD, not an SDL call. - Single owner per SDL API — exactly one wrapping adapter per primitive (
ClockSdlTicksis the only caller ofSDL_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> = nullptrand fall back internally to the production impl; tests pass a fake. NoSDL_GetTicks,rand(), or hidden globals in gameplay code. - Typed range helpers, not raw
next()—IRandomSource::intInRange/doubleInRangerather than auint32_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. MatchesSDL_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.
constexprfor compile-time facts — resolutions, half-extents, dash counts, glyph tables, win scores. Prefer named constants over magic numbers;static_castwhen mixingintandfloat.- Do not
constexprwhat 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 deepPaddle::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 —
constquery 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_viewfor non-owning string params. Useconst std::string&only when you genuinely need astd::stringAPI 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.0on 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 —
PlayfieldRendererbuildsm_centerDashesonce;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
vectorwould allocate per frame (TextRenderer::drawTextpasses 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 cachedScore.textand issues onedrawTextCenteredcall per side; theto_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'sdtis 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::mapreflexively. - 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
- New gameplay logic is pure and tested, or justified as SDL-only with a manual smoke note.
-
update/renderadd no recurring per-frame alloc and no unbounded work. - Hot-path state is structs/scalars, not growable containers rebuilt each frame.
- SDL surface area did not spread into math/AI/collision — each new SDL API has one wrapping adapter.
- Time / randomness / input go through injected abstractions; production defaults stay one-line.
- Constants live in
Playfield::(or the entity's header), not magic numbers in the frame loop. - Tuning uses
constexprwhere appropriate; gameplay math stays pure (explicit inputs, no globals/SDL). - APIs are const-correct; structs hold state, free functions hold algorithms; owners / polymorphic bases delete copy/move.
- Pure helpers defend at the boundary and document degenerate inputs.
- Non-owning string params use
std::string_view; non-owning byte rangesstd::span. - Change is minimal for the milestone — no speculative frameworks.