$37
All functions for drawing 2D graphics on screen: filled rectangles, outlined
quads, lines, textured sprites, bars, gauges, backdrops. For text — use the
text-rendering skill instead.
| Need | Function | Page / notes |
|---|---|---|
| Solid-color screen-space rect | AENG_draw_rect | POLY_PAGE_COLOUR |
| Semi-transparent rect | AENG_draw_rect + alpha in color | POLY_PAGE_COLOUR_ALPHA, alpha in high byte |
| Deferred 2D rect (queued, batch) | AENG_draw_rectr + draw_all_boxes() | Internal queue |
| Textured quad (sprite) | PANEL_draw_quad / POLY_add_quad | Specific page for the texture |
| 2D line / bar outline | POLY_add_line | POLY_PAGE_COLOUR or POLY_PAGE_COLOUR_ALPHA |
| Ready-made health bar | PANEL_draw_health_bar(x, y, percentage) | Uses AENG_draw_rect internally |
| Ready-made countdown timer | PANEL_draw_timer(time, x, y) |
Queued, drawn by PANEL_draw_buffered |
| 3D-world-space debug geometry | AENG_world_line, AENG_world_quad | Render pass only; see shadow-corruption notes |
Every 2D primitive call queued into POLY pages needs a matching batch wrapper. This is the single most important thing in this skill.
PANEL_start(); // POLY_frame_init — starts the 2D batch
AENG_draw_rect(...); // queue geometry
POLY_add_line(...); // more geometry
// ... more draws ...
PANEL_finish(); // POLY_frame_draw — flushes the batch to screen
Without PANEL_finish:
Root cause and full forensic write-up: new_game_devlog/shadow_corruption_investigation.md. The devlog describes this for AENG_world_line, but the same mechanism affects AENG_draw_rect, POLY_add_rect, POLY_add_quad, FONT2D_DrawString — anything that feeds POLY pages.
You don't need a wrapper if you're calling from inside OVERLAY_handle, PANEL_draw_health_bar, PANEL_draw_buffered, or similar — those already live inside a start/finish pair opened by their caller. But if you're writing a new module that draws on its own, open your own batch.
The game loop renders HUD between draw_screen() and screen_flip(). Safe
insertion points for your own overlay:
| Location | Safe? | Why |
|---|---|---|
Inside OVERLAY_handle | ✅ yes | Already wrapped by PANEL_start/PANEL_finish |
Immediately after OVERLAY_handle, with own PANEL_start/PANEL_finish | ✅ yes | Inside render pass, own batch |
Before draw_screen() | ❌ no | draw_screen calls POLY_frame_init internally and clears your batch |
After GAMEMENU_draw() | ❌ no | GAMEMENU_draw calls POLY_frame_init internally; anything queued after is orphaned → next-frame shadow reuse |
| From game tick (process_controls, AI, etc.) | ❌ no | Pre-render; same shadow corruption as world_line |
Reference implementations:
new_game/src/ui/hud/panel.cpp:172 — PANEL_draw_health_barnew_game/src/engine/debug/input_debug/input_debug.cpp — input_debug_renderTwo different poly pages handle color vs color+alpha:
| Page | Blending | Color format |
|---|---|---|
POLY_PAGE_COLOUR | Solid (no alpha) | 0xRRGGBB |
POLY_PAGE_COLOUR_ALPHA | SrcAlpha/InvSrcAlpha | 0xAARRGGBB — alpha in HIGH byte |
So for a semi-transparent backdrop:
// 80% opaque black full-screen backdrop
AENG_draw_rect(0, 0, POLY_screen_width, POLY_screen_height,
0xCC000000, // 0xCC = 204/255 ≈ 80% alpha
10, // layer
POLY_PAGE_COLOUR_ALPHA); // alpha-blend page
The alpha bits in 0x??000000 are ignored on POLY_PAGE_COLOUR — the page
doesn't enable blending. Using the wrong page is the most common reason a
"semi-transparent" overlay comes out fully opaque or invisible.
Blending state is set up in new_game/src/engine/graphics/pipeline/poly_render.cpp
under the POLY_PAGE_COLOUR_ALPHA case.
AENG_draw_rect and AENG_draw_rectr take a layer int. Lower layer =
closer to camera (in front). This is counter-intuitive — the name
suggests "higher layer is on top" but the pipeline does the opposite.
Why: AENG_draw_rect stores POLY_Point.Z = 1.0 - layer*0.0001, then
PolyPoint2D::SetSC flips it back to sz = 1.0 - Z = layer*0.0001,
which goes into the shader as depth. Under standard GL_LESS depth test,
smaller sz wins — so layer=1 sits in front of layer=2.
Canonical reference: PANEL_draw_health_bar in ui/hud/panel.cpp:172
draws the black background with layer=2 and the red fill with
layer=1. The fill renders on top.
| Use case | Typical layer | Position |
|---|---|---|
| Foreground accents (dots, fills over backing rects) | 1 | closest |
| Regular HUD element (health bar fill, icon) | 1–3 | close |
| Widget backgrounds (bar backing, box outlines) | 2–4 | behind accents |
| Full-screen modal backdrop | 10+ | farthest, behind HUD |
Common trap: drawing a backing rect at layer N and an accent dot at
layer N+1. The dot's depth value is larger than the background's, so
the depth test rejects it and nothing appears. You need layer N for
the background and layer N-1 for the accent.
Within the same layer value, draw order determines z — later = on top.
Depth write is on for POLY_PAGE_COLOUR, so once a pixel is written
with a given depth, later pixels at the same screen position with a
larger depth (higher layer) are rejected.
For POLY_add_quad / POLY_add_line, z comes from the POLY_Point.Z field
you set on the vertices (typical HUD quad: Z = 0.99999f to push it near the
far plane and let the page's render state handle layering).
Rects here use logical 640×480 coordinates which the pipeline scales
to the actual window. The handy globals are from engine/graphics/pipeline/poly.h:
extern float POLY_screen_width; // logical width (== DisplayWidth == 640)
extern float POLY_screen_height; // logical height (== DisplayHeight == 480)
They are aliases for the DisplayWidth / DisplayHeight constants (640/480)
from engine/platform/uc_common.h. Cast to SLONG when passing to
AENG_draw_rect.
⚠️ If you add text labels to your widgets, the text path (FONT_buffer_add,
FONT_draw) uses literal window pixels, not logical coords. On a window
larger than 640×480 your labels will not line up with the rects. Either:
FONT2D_DrawString / CONSOLE_text_at — same logical space as rects.x_px = x_logical * RealDisplayWidth / 640.Full details and a ready-made helper pattern in the text-rendering skill
under "Mixing text and HUD rects".
engine/graphics/pipeline/aeng.hPOLY_add_rect inline. Your call
site must be inside an active POLY batch (between PANEL_start and
PANEL_finish, or inside code that already wraps you).col: 0xRRGGBB for POLY_PAGE_COLOUR, 0xAARRGGBB for
POLY_PAGE_COLOUR_ALPHA.layer: int, lower = in front (see "Layer parameter" section above).page: usually POLY_PAGE_COLOUR or POLY_PAGE_COLOUR_ALPHA.engine/graphics/pipeline/aeng_globals.hrrect[] array; flushed later when
draw_all_boxes() is called, which itself calls AENG_draw_rect.AENG_draw_rect
inside your own PANEL_start/PANEL_finish is simpler.engine/graphics/pipeline/poly.hPOLY_Point* as the top-left vertex + explicit
width/height. Uses the vertex color and Z. Called by AENG_draw_rect.engine/graphics/pipeline/poly.hquad is POLY_Point* [4]. Each vertex
carries its own position, color, UV. Used for textured sprites and
gradient-filled shapes.ui/hud/panel.cpp:2625–2648.engine/graphics/pipeline/poly.hwidth1/width2 in pixels at endpoints
(taper supported). Uses vertex color. Use POLY_PAGE_COLOUR_ALPHA for
soft outlines, POLY_PAGE_COLOUR for solid.PANEL_draw_gun_sight crosshair in ui/hud/panel.cpp:338.ui/hud/panel.h(0,0) to (1,1). Reach for this when drawing HUD sprites from a texture
page.ui/hud/panel.hpercentage to [0,100]. Uses AENG_draw_rect internally with fixed
HEALTH_BAR_WIDTH/HEIGHT constants. Intended to be called from inside
an overlay.ui/hud/panel.hPANEL_store[]. Flushed by
PANEL_draw_buffered() which is called from OVERLAY_handle. time is
in hundredths of a second.ui/hud/panel.hPOLY_frame_init and POLY_frame_draw.
Use these when writing new HUD code — they're the idiomatic batch pair for
2D primitives in this codebase.Never add a 2D primitive without a batch wrapper. If your call is not
inside OVERLAY_handle or a similar pre-wrapped context, open your own
PANEL_start/PANEL_finish pair around it. The shadow-corruption bug is
silent at compile time and intermittent at runtime (only shows on
specific camera angles or during shadow passes) — easy to ship by accident.
Check the insertion point in the frame. If the call site is in
game_tick, AI, physics, or before draw_screen(), it's wrong regardless
of wrapper. Move it to OVERLAY_handle or the post-OVERLAY_handle slot.
Pick the right page for alpha. If the rect should be transparent,
use POLY_PAGE_COLOUR_ALPHA AND put alpha in the high byte of the color.
Using POLY_PAGE_COLOUR with an ARGB color gives you fully opaque (alpha
bits are ignored).
Layer matters when things overlap — lower = in front. Full-screen backdrops use a high layer (e.g. 10+) so they sit behind regular HUD. Regular HUD uses 1–3. Accents that must sit on top of a backing rect need a lower layer than the backing rect, not higher.
Use game-shared constants for screen dims. POLY_screen_width/height
are floats and update with the window. Don't hardcode 640×480 unless
you're writing legacy-compat code.
If your rect shows up as a grid of white squares on the ground, or as ghostly replicas of characters' shadows, you've hit the VB-slot-reuse bug:
PANEL_finish / POLY_frame_draw closed the batch this frame.POLY_frame_init cleared the pages; the VBs went back to
the pool.Fix: wrap the call in PANEL_start / PANEL_finish (or put it inside
an already-wrapped context like OVERLAY_handle). If you're still seeing
artifacts after wrapping, recheck the insertion point — it might be after
GAMEMENU_draw or another place where a later POLY_frame_init clears
your work.
Full investigation: new_game_devlog/shadow_corruption_investigation.md.
new_game/src/engine/graphics/pipeline/aeng.h,
aeng.cpp, poly.h, poly.cpp.new_game/src/engine/graphics/pipeline/poly_render.cpp.new_game/src/ui/hud/panel.{h,cpp}.new_game/src/engine/debug/input_debug/input_debug.cpp.