render_frame switches on g->state and calls the matching draw
function. The ladder panel is not a state — it is drawn alongside
every state except STATE_TITLE by calling draw_ladder in each
case branch:
void render_frame(game_t *g)
{
SDL_RenderClear(g->ren); /* clear back buffer to black */
draw_background(g);
switch (g->state) {
case STATE_TITLE: draw_title(g); break; /* no ladder on title */
case STATE_QUESTION: draw_question(g); draw_ladder(g); break;
case STATE_CONFIRM: draw_confirm(g); draw_ladder(g); break;
case STATE_CORRECT: draw_correct(g); draw_ladder(g); break;
case STATE_WRONG: draw_wrong(g); draw_ladder(g); break;
case STATE_WIN: draw_win(g); draw_ladder(g); break;
case STATE_GAMEOVER: draw_gameover(g); draw_ladder(g); break;
}
SDL_RenderPresent(g->ren); /* swap back buffer to screen */
}All seven draw functions are static — internal to render.c. They
must be defined or forward-declared before render_frame.
Layout constants
The screen is divided into fixed zones, established in
gen_assets.sh and mirrored here as constants. Add them at the top
of render.c:
#define PAD 16 /* left margin */
#define LINE_H 22 /* pixels per text row */
/* left panel zone tops */
#define Q_Y 10 /* question zone: y = 0–179 */
#define ANS_Y 188 /* answer zone: y = 180–419 */
#define HELP_Y 424 /* help zone: y = 420–599 */
/* right ladder panel */
#define LDDR_X 572 /* ladder left edge */
#define LDDR_Y 12 /* first ladder row */
#define LDDR_H 38 /* pixels per ladder row */Q_Y, ANS_Y, and HELP_Y pin text to the top of each coloured
zone. LDDR_X keeps ladder text inside the right panel. Every draw
function uses these constants — pixel values never appear in the draw
functions themselves.
draw_wrapped
Most text in the game is short enough to fit on one line. Question
text is not. draw_wrapped breaks a string into lines that fit within
a given pixel width, measuring each word with TTF_SizeUTF8 before
committing it to the current line:
static int draw_wrapped(game_t *g, int x, int y, int max_w, const char *s)
{
char buf[512];
char line[512];
char *word;
char *saveptr;
int line_w;
int word_w;
int space_w;
int h;
snprintf(buf, sizeof(buf), "%s", s);
line[0] = '\0';
line_w = 0;
TTF_SizeUTF8(g->font, " ", &space_w, &h); /* measure space once */
word = strtok_r(buf, " ", &saveptr);
while (word != NULL) {
TTF_SizeUTF8(g->font, word, &word_w, &h);
if (line_w > 0 && line_w + space_w + word_w > max_w) {
draw_string(g, x, y, line); /* line full: flush */
y += LINE_H;
line[0] = '\0';
line_w = 0;
}
if (line_w > 0) {
tci_strlcat(line, " ", sizeof(line));
line_w += space_w;
}
tci_strlcat(line, word, sizeof(line));
line_w += word_w;
word = strtok_r(NULL, " ", &saveptr);
}
if (line[0] != '\0') { /* flush last line */
draw_string(g, x, y, line);
y += LINE_H;
}
return (y); /* caller gets the y position after the last line */
}snprintf(buf, sizeof(buf), "%s", s) is the first call — it copies
s into buf with a size limit. snprintf is printf for buffers:
same format strings, same specifiers (%s, %d, %c), but the
output goes into the first argument instead of the terminal. The second
argument is the total buffer size; snprintf never writes past it and
always null-terminates. f04/05
names it as the safer general-purpose choice over strcpy — this is
where it first appears in practice. Run man 3 snprintf.
TTF_SizeUTF8 fills word_w with the pixel width of the string at
the current font size — no rendering happens. The space width is
measured once before the loop.
strtok splits a string in place by replacing delimiter characters
with \0, returning a pointer to each token in turn. State between
calls is stored in a hidden static variable — which means two
interleaved strtok calls on different strings corrupt each other.
strtok_r is the re-entrant variant: the caller supplies saveptr
to hold that state, so the function is safe even when called from
multiple places simultaneously. Run man 3 strtok and man 3 strtok_r.
tci_strlcat from c01/03
takes the total buffer size rather than the remaining bytes, so
tci_strlcat(line, word, sizeof(line)) is all that is needed —
no remaining-space arithmetic.
draw_wrapped returns the y position after the last line written.
The caller uses this to place the next element immediately below the
wrapped text.
draw_ladder
The ladder renders into the right panel. The current level is marked
with >, safe levels with *, already-passed levels with +:
static void draw_ladder(game_t *g)
{
char buf[48];
int i;
int y;
char marker;
for (i = LEVELS - 1; i >= 0; i--) {
y = LDDR_Y + (LEVELS - 1 - i) * LDDR_H; /* top row = highest prize */
if (i == g->level)
marker = '>'; /* current question */
else if (SAFE[i])
marker = '*'; /* safe level */
else if (i < g->level)
marker = '+'; /* already passed */
else
marker = ' ';
snprintf(buf, sizeof(buf), "%c %s", marker, PRIZES[i]);
draw_string(g, LDDR_X, y, buf);
}
}LDDR_H = 38 spreads 15 rows across 570 pixels of panel height,
leaving a small margin at top and bottom. The loop runs top to bottom
on screen — highest prize at the top — so the index runs backwards:
i = LEVELS - 1 down to 0.
draw_lifeline_bar
The lifeline bar shows available lifelines and is called from
draw_question. Each available lifeline contributes its label;
spent lifelines are omitted. snprintf builds the whole string in
one call:
static void draw_lifeline_bar(game_t *g, int y)
{
char buf[128];
snprintf(buf, sizeof(buf), "%s%s%s[W] Walk away",
(g->lifelines & 1) ? "[1] 50:50 " : "", /* bit 0 */
(g->lifelines & 2) ? "[2] Phone " : "", /* bit 1 */
(g->lifelines & 4) ? "[3] Audience " : ""); /* bit 2 */
draw_string(g, PAD, y, buf);
}Each conditional expression contributes either the label string or an
empty string. snprintf concatenates whatever three expressions
produce. [W] Walk away is always present.
draw_audience
The audience result shows a bar chart of percentages, one row per
option. One # character per five percentage points:
static void draw_audience(game_t *g, int y)
{
const char *letters;
char buf[64];
int i;
int j;
int pos;
letters = "ABCD";
draw_string(g, PAD, y, "Ask the Audience:");
y += LINE_H;
for (i = 0; i < 4; i++) {
pos = 0;
buf[pos++] = letters[i]; /* "A: " prefix */
buf[pos++] = ':';
buf[pos++] = ' ';
for (j = 0; j < g->audience[i] / 5 && pos < 55; j++)
buf[pos++] = '#'; /* one '#' per 5 percentage points */
buf[pos++] = ' ';
if (g->audience[i] >= 100) {
buf[pos++] = '1'; buf[pos++] = '0'; buf[pos++] = '0'; /* "100" */
} else if (g->audience[i] >= 10) {
buf[pos++] = '0' + g->audience[i] / 10; /* tens digit */
buf[pos++] = '0' + g->audience[i] % 10; /* units digit */
} else {
buf[pos++] = '0' + g->audience[i]; /* single digit */
}
buf[pos++] = '%';
buf[pos] = '\0'; /* terminate before draw_string */
draw_string(g, PAD, y + i * LINE_H, buf);
}
}snprintf has no format specifier for "repeat this character N times".
Building the # bar with it would require a separate loop to construct
a # string first, then a second snprintf to assemble the final row.
Writing buf character by character handles the bar, the space, and
the percentage in one pass — no intermediate buffer needed.
The integer-to-string conversion uses the same '0' + digit trick
from c02's
tci_printf: subtracting the character '0' (value 48) from a
digit character gives the numeric value; adding it back converts a
digit value to the character. The three branches handle 100, two-
digit, and one-digit percentages respectively.
draw_title
static void draw_title(game_t *g)
{
(void)g;
draw_string(g, PAD, Q_Y, "WHO WANTS TO BE A GAME DEVELOPER?");
draw_string(g, PAD, ANS_Y, "Press Enter to start.");
draw_string(g, PAD, ANS_Y + LINE_H, "Press Escape to quit.");
}The title screen uses no game state — (void)g suppresses the unused
parameter warning. Text lands in the question and answer zones.
draw_question
The question screen is the most complex: prize level, wrapped question text, visible answer options, the lifeline bar, and optionally the phone hint or audience chart:
static void draw_question(game_t *g)
{
question_t *q;
const char *letters;
char buf[256];
int y;
int i;
q = g->questions[g->level];
letters = "ABCD";
draw_string(g, PAD, Q_Y, PRIZES[g->level]); /* prize at top */
draw_wrapped(g, PAD, Q_Y + LINE_H, 560 - PAD * 2, q->text);
y = ANS_Y;
for (i = 0; i < 4; i++) {
if (g->hidden[i])
continue; /* 50:50 hides two */
snprintf(buf, sizeof(buf), "%c. %s", letters[i], q->opts[i]);
draw_string(g, PAD, y, buf);
y += LINE_H + 4;
}
y = HELP_Y + PAD;
draw_lifeline_bar(g, y);
y += LINE_H;
if (g->phone_active) {
if (q->hint)
y = draw_wrapped(g, PAD, y, 560 - PAD * 2, q->hint); /* custom hint */
else {
snprintf(buf, sizeof(buf), "The answer is %c.", letters[q->answer]);
draw_string(g, PAD, y, buf); /* generic fallback */
y += LINE_H;
}
}
if (g->audience[0] || g->audience[1] || g->audience[2] || g->audience[3])
draw_audience(g, y); /* any vote cast */
}The function works through three zones in order.
Question zone. PRIZES[g->level] lands at Q_Y. The question
text starts one row below at Q_Y + LINE_H, passed to draw_wrapped
because questions can exceed the left panel width. The return value of
draw_wrapped is discarded here — the answer zone begins at a fixed
ANS_Y, not relative to however many lines the question needed.
Answer zone. y starts at ANS_Y and advances with each visible
option. The +4 in y += LINE_H + 4 adds a small gap between options,
giving them visual breathing room beyond the bare line height. Options
hidden by 50:50 are skipped; y only advances for the ones that are
drawn, so the remaining options close the gap without leaving blank
rows.
Help zone. y resets to HELP_Y + PAD — a fixed boundary, not
a continuation from wherever the answer loop left off. The lifeline bar
is drawn first. Then y advances by LINE_H before the optional
elements, so they always start one row below the bar.
The phone hint and audience chart are independent: both can appear
simultaneously if the player used both lifelines. The phone block
captures draw_wrapped's return into y so that if the hint spans
multiple lines, the audience chart starts below all of them. The
audience guard checks that at least one percentage is non-zero — the
g->audience array is zeroed in game_init and only filled after
the audience lifeline is used.
560 - PAD * 2 is the max width for both draw_wrapped calls: 560
is the left edge of the divider between the two panels, and subtracting
both margins keeps text clear of both edges.
draw_confirm
The confirm screen repeats the question and options, then asks for confirmation in the help zone:
static void draw_confirm(game_t *g)
{
question_t *q;
const char *letters;
char buf[512];
int y;
int i;
q = g->questions[g->level];
letters = "ABCD";
draw_string(g, PAD, Q_Y, PRIZES[g->level]);
draw_wrapped(g, PAD, Q_Y + LINE_H, 560 - PAD * 2, q->text);
y = ANS_Y;
for (i = 0; i < 4; i++) {
if (g->hidden[i])
continue;
snprintf(buf, sizeof(buf), "%c. %s", letters[i], q->opts[i]);
draw_string(g, PAD, y, buf);
y += LINE_H + 4;
}
draw_string(g, PAD, HELP_Y + PAD, "Is that your final answer?");
snprintf(buf, sizeof(buf),
"Answer: %c -- Enter to confirm, Backspace to go back.",
g->pending); /* pending: letter chosen in STATE_QUESTION */
draw_string(g, PAD, HELP_Y + PAD + LINE_H, buf);
}The confirm screen reuses the question and answer layout from
draw_question unchanged — same prize, same wrapped text, same answer
loop with the same 50:50 skip. The player must see exactly what they
saw when they made their choice. The help zone carries the only new
content: the confirmation prompt and the pending answer.
g->pending was set in handle_question when the player pressed A–D.
It holds the letter as a char ('A'–'D'), formatted directly into
the prompt with %c. The player sees their letter; pressing Enter
commits it, Backspace discards it and returns to STATE_QUESTION.
Notice that draw_confirm has no lifeline bar and no phone or audience
block. The player cannot use a lifeline after selecting an answer but
before confirming — those keys are only handled in STATE_QUESTION.
draw_correct and draw_wrong
Both screens reveal the correct answer after the player has confirmed.
The question text is shown again — not the options — so the player can
read the answer in context. draw_wrapped is used because the question
may wrap, and its return value is captured into y so the reveal lines
always follow immediately below the last line, regardless of how many
lines the question needed:
static void draw_correct(game_t *g)
{
question_t *q;
int y;
q = g->questions[g->level];
draw_string(g, PAD, Q_Y, "Correct!");
y = draw_wrapped(g, PAD, ANS_Y, 560 - PAD * 2, q->text); /* y = row after question */
draw_string(g, PAD, y + 4, "The answer was:"); /* +4: small gap */
draw_string(g, PAD, y + 4 + LINE_H, q->opts[q->answer]);
draw_string(g, PAD, HELP_Y + PAD, "Press Space to continue.");
}
static void draw_wrong(game_t *g)
{
question_t *q;
const char *prize;
int y;
q = g->questions[g->level];
draw_string(g, PAD, Q_Y, "Wrong!");
y = draw_wrapped(g, PAD, ANS_Y, 560 - PAD * 2, q->text); /* y = row after question */
draw_string(g, PAD, y + 4, "The answer was:"); /* +4: small gap */
draw_string(g, PAD, y + 4 + LINE_H, q->opts[q->answer]);
prize = g->safe_level >= 0 ? PRIZES[g->safe_level] : "£0"; /* -1 if no safe reached */
draw_string(g, PAD, HELP_Y + PAD, "You leave with:");
draw_string(g, PAD, HELP_Y + PAD + LINE_H, prize);
draw_string(g, PAD, HELP_Y + PAD + LINE_H * 2, "Press Escape to quit.");
}In draw_correct, y positions the reveal two rows below the question:
y + 4 for the label and y + 4 + LINE_H for the answer text itself.
The help zone is used only for the continue prompt — there is no prize
to display yet, because next_question has not been called. The level
advances only after the player presses Space.
draw_wrong follows the same layout for the question reveal, then
uses all three rows of the help zone: the "You leave with:" label, the
prize string, and the quit instruction. g->safe_level holds the index
of the last banked safe level, or -1 if the player never reached one.
The ternary resolves this to a prize string or "£0" before any
draw_string call — the draw call itself stays unconditional.
Both functions show the question text but not the answer options. The options are no longer relevant once the answer has been evaluated; the question text gives context for the reveal.
draw_win and draw_gameover
static void draw_win(game_t *g)
{
(void)g; /* no game state needed */
draw_string(g, PAD, Q_Y, "YOU HAVE JUST WON £1,000,000!");
draw_string(g, PAD, ANS_Y, "Congratulations!");
draw_string(g, PAD, HELP_Y + PAD, "Press Escape to quit.");
}
static void draw_gameover(game_t *g)
{
const char *prize;
prize = g->safe_level >= 0 ? PRIZES[g->safe_level] : "£0"; /* -1 if no safe reached */
draw_string(g, PAD, Q_Y, "You walk away with:");
draw_string(g, PAD, ANS_Y, prize);
draw_string(g, PAD, HELP_Y + PAD, "Press Escape to quit.");
}draw_gameover handles both a deliberate walk-away and a walk-away
forced by loss — in both cases g->state is STATE_GAMEOVER and
safe_level holds the last banked prize, or -1 if none was reached.
draw_string vs draw_wrapped
Answer options use draw_string — a single line, no wrapping. Question
text uses draw_wrapped. The distinction is a design decision, not a
technical limitation, and it is worth stating explicitly.
If a question is long enough to wrap into the answer zone, the two
regions will overlap and the layout breaks. The code has no guard
against this. draw_wrapped does not know where the answer zone starts;
it just keeps advancing y by LINE_H until the text is exhausted.
This is acceptable because the game is a controlled environment. The
questions are not typed at runtime by the player — they come from a
file the developer controls. The same applies to answer options: if an
option is wider than the left panel, draw_string clips it silently.
Neither path can be reached by the player as long as the question file
is curated.
The discipline is not to eliminate every possible failure, but to identify every possible failure and confirm that the design prevents it from being reachable. Here, the prevention is authorial: keep questions short enough to fit.
A shipped game would bake the questions in at compile time, removing the file argument entirely and making the question set immutable. The file-based approach exists here because it makes the game easier to iterate during development.
If this were a system that accepted arbitrary user input — a text editor, a terminal emulator, a chat client — the overlap would be a bug. In a quiz game with a curated question bank, it is a documented constraint.
Test the display
Build and run:
make re
./game questions.txtThe window should show the title screen. To test a specific draw
function before input handling is in place, temporarily force a state
after game_init:
game_init(&g, questions, count);
g.state = STATE_QUESTION; /* force for testing */Remove the override before the next page.