기초컴퓨터프로그래밍 · Final Project

Roguelike
Sudoku

// a sudoku dungeon-crawler, written in C
SOURCEsudoku_rogue.c · 418 lines LANGUAGEC (stdio · stdlib · time) BUILDterminal · single file
Overview

The Concept

Plain Sudoku, wrapped in a dungeon crawler.

Each floor is a freshly generated Sudoku puzzle. Solving it takes you one floor deeper. But every wrong digit springs a trap — and some blank cells hide loot.

HP wrong answer → −1
Potions restore HP
Hints reveal a cell
Floors deeper = harder
=== Welcome to Roguelike Sudoku! ===
Floor B1 · HP 3/5 · Potions 1 · Hints 1
    1 2 3  4 5 6  7 8 9
1 | 5 · 8 | 1 · 3 | · 7 2 |
2 | 2 7 · | 9 · 4 | 6 · 8 |
3 | · 4 9 | 6 7 · | 5 1 · |
> 1 5 3   [+] Correct!
>>> You found a hidden potion!
sudoku_rogue.c
Data Model

Architecture at a Glance

Three parallel 9×9 grids hold everything the game needs.

solution
int[9][9]
The full, valid answer key. Generated once per floor; never shown directly.
board
int[9][9]
What the player sees. A copy of solution with cells carved out to 0 (drawn as ·).
items
int[9][9]
Loot hidden under blanks. 0 none · 1 potion · 2 hint.
floor_level current depth
hp / max_hp 3 / 5
potions 1
hints 1
blanks cells left to solve
global state
Differentiation

What Sets It Apart

Four systems that turn an ordinary Sudoku solver into a game.

01
HP System
Plain Sudoku just lets you retype a wrong guess. Here a wrong answer costs HP — and at zero HP the run is over. Solving carries real risk and tension.
02
Dungeon Progression
Not a single one-off board. You descend floor by floor: clear one to drop deeper, and each level adds blanks — difficulty ramps naturally and keeps you coming back.
03
Hidden Item System
Some blanks conceal potions or hints. Open the right cell — by solving or by hint — and the loot is yours. A farming-and-reward loop layered onto the puzzle.
04
Auto Puzzle Maker
Never a saved board — every run generates a fresh one. Relabeling, row/column shuffles and band/stack swaps build it, with a uniqueness check on each erased cell. No memorizing the same grid.
beyond plain sudoku
Roadmap

What We'll Walk Through

part 1
Map Generation
  • The shift pattern
  • Relabeling numbers
  • Row & column swaps
  • Band & stack swaps
part 2
Validity & Uniqueness
  • Placement checking
  • Backtracking solver
  • The limit = 2 trick
  • Carving blanks safely
part 3
The Roguelike Layer
  • Floor difficulty
  • Hidden items
  • HP & game over
  • Hints & rendering
15 minutes · 3 parts
1
// part 1

Map Generation

Build a complete, valid solution first — then shuffle it four different ways, each one provably rule-preserving, so every floor looks brand new.

Map Generation

The Generation Pipeline

We never solve a blank grid from scratch. Instead we start from a guaranteed-valid board and disguise it. Each stage below keeps the Sudoku rules intact — so the result is always solvable.

  • 1
    Shift pattern — stamp out a base solved grid in one pass.
  • 2
    Relabel digits — randomly rename 1–9 so it never looks the same.
  • 3
    Swap rows / columns — within their groups.
  • 4
    Swap bands / stacks — move whole 3×3 blocks.
generate_map()
void generate_map() {
    int shift[9] = {0,3,6,1,4,7,2,5,8};
    int num_map[10];

    // 1 · build + shuffle a random 1-9 label table
    for (int i = 1; i <= 9; i++) num_map[i] = i;
    // ... then randomly swap entries of num_map ...

    // 2 · stamp the base grid, then apply the labels
    for (int r = 0; r < 9; r++)
      for (int c = 0; c < 9; c++)
        solution[r][c] = num_map[(c + shift[r]) % 9 + 1];

    // 3 · safe swaps that keep the rules
    shuffle_solution(solution);
}
generate_map()
Map Generation · Step 1

The Shift Pattern

Each cell is filled by one formula. Row by row, the starting number slides along a fixed offset table — so the grid fills diagonally.

base grid
int shift[9] = {0,3,6,1,4,7,2,5,8};

val = (c + shift[r]) % 9 + 1;
// row 0 → 1 2 3 4 5 6 7 8 9
// row 1 → 4 5 6 7 8 9 1 2 3   (rotated by 3)
Why it works: a constant offset between rows guarantees every row, column and 3×3 box sees 1–9 exactly once.
row 0 · 1→9 row 1 · same row, rotated 3
generate_map() · shift base
Map Generation · Step 2

Relabeling the Numbers

The shift grid always looks similar. So we build a random num_map[1..9] and rename every digit through it — pure renaming, positions untouched.

label shuffle
for (int i = 1; i <= 9; i++) num_map[i] = i;
for (int i = 1; i <= 9; i++) {
    int r = rand() % 9 + 1;
    swap_int(&num_map[i], &num_map[r]);
}
// every 1 becomes (say) 7, every 2 becomes 4 ...
solution[r][c] = num_map[val];
Why it works: renaming symbols can't create a duplicate where there wasn't one — the rules survive untouched.
every 1 every 7  → swap their names
generate_map() · num_map
Map Generation · Step 3

Row & Column Swaps

swap_cols() exchanges two columns — but only within the same 3-column stack. Swapping column 1 ↔ column 3 reorders cells inside each box without crossing a box boundary.

swap_cols()
void swap_cols(int g[9][9], int a, int b) {
    for (int r = 0; r < 9; r++)
        swap_int(&g[r][a], &g[r][b]);
}
// safe ONLY when a, b share a stack:
//   {0,1,2}  {3,4,5}  {6,7,8}
Why it works: the three columns of a stack already feed the same boxes — reordering them keeps every box's digits identical.
column 0 column 2  → swap (same stack ✓)
swap_rows() · swap_cols()
Map Generation · Step 4

Band & Stack Swaps

A bigger move: swap two entire 3-row bands. Rows 1–3 trade places with rows 7–9 as whole blocks, so each 3×3 box travels intact.

swap_row_bands()
void swap_row_bands(int g[9][9], int a, int b) {
    for (int i = 0; i < 3; i++)
        swap_rows(g, a*3 + i, b*3 + i);
}
// moves a full {row,row,row} block as one unit
Why it works: moving a band relocates its boxes wholesale — internal structure is preserved, so no rule can break.
band 0 · rows 1–3 band 2 · rows 7–9  → swap
swap_row_bands() · swap_col_stacks()
Map Generation · Combine

shuffle_solution — 200 Random Swaps

shuffle_once() applies one rule-preserving swap, chosen at random from four kinds. shuffle_solution() simply runs it 200 times — compounding into a board that looks nothing like the base pattern, yet is still perfectly valid.

kind 0 · swap rows in a band kind 1 · swap cols in a stack kind 2 · swap two bands kind 3 · swap two stacks
shuffle_solution()
void shuffle_solution(int g[9][9]) {
    for (int i = 0; i < 200; i++) {
        shuffle_once(g);
    }
}
// shuffle_once() picks one of four rule-preserving
// moves — rows, cols, bands or stacks — at random.
shuffle_solution() · shuffle_once()
2
// part 2

Validity & Uniqueness

A good puzzle has exactly one answer. Before we hand a board to the player, we prove it can be solved — and that it can be solved only one way.

Validity

Why Uniqueness Matters

Erasing cells at random can quietly create a puzzle with two valid answers. The player guesses, gets it "wrong," and loses HP unfairly.

0
solutions
Unsolvable. We erased something we shouldn't have — reject it.
1
solution
The sweet spot. Solvable, and exactly one answer. Keep it.
2+
solutions
Ambiguous. More than one fill is legal — a bad puzzle. Reject it.

Every erased cell is validated against this rule before it becomes a real blank.

the core invariant
Validity · Building Block

can_place_number

The atom of every check: can this digit legally go here? Scan three regions for a duplicate — the row, the column, and the 3×3 box.

can_place_number()
for (int i = 0; i < 9; i++) {
    if (g[row][i] == num) return 0;   // row
    if (g[i][col] == num) return 0;   // column
}
int br = (row/3)*3, bc = (col/3)*3;   // box origin
for (int r = br; r < br+3; r++)
  for (int c = bc; c < bc+3; c++)
    if (g[r][c] == num) return 0;     // 3x3 box
return 1;
target cell row + column 3×3 box
can_place_number()
Validity · Building Block

find_empty_cell

Scan the grid in reading order and return the first cell still equal to 0. The position is handed back through pointers — the solver always works on the next empty square.

find_empty_cell()
int find_empty_cell(int g[9][9], int *row, int *col) {
    for (int r = 0; r < 9; r++)
      for (int c = 0; c < 9; c++)
        if (g[r][c] == 0) {
            *row = r; *col = c;
            return 1;        // found a blank
        }
    return 0;                // grid is full
}
Return 0 = no blanks left — which, to the solver, means the puzzle is fully and validly filled.
first · in reading order
find_empty_cell()
Validity · Solver

count_solutions — Backtracking

Find a blank, try every legal digit, and recurse. Each placement is temporary — put a number in, explore, then take it back out and try the next. Full grids count as one solution found.

count_solutions()
if (!find_empty_cell(g, &row, &col)) return 1;
for (int num = 1; num <= 9; num++)
  if (can_place_number(g, row, col, num)) {
      g[row][col] = num;               // place
      count += count_solutions(g, limit);
      g[row][col] = 0;                 // undo
  }
count_solutions(grid) ├─ find_empty (0,4) ├─ try 3 → recurse → ✓ 1 ├─ try 5 → recurse → ✗ 0 ├─ try 8 → recurse → ✓ 1 └─ count = 2

place → recurse → undo · the board is restored after every branch

count_solutions()
Validity · Optimization

The limit = 2 Early Exit

Counting all solutions of a sparse grid is expensive. But we don't need the total — we only need to know if it's more than one. So we stop the moment we hit a second answer.

early termination
count += count_solutions(g, limit);
g[row][col] = 0;

if (count >= limit)   // limit == 2
    return count;     // "not unique" — stop now

Finding a 2nd solution is proof enough that the puzzle isn't unique.

We prune the entire remaining search tree the instant count reaches 2 — turning a potentially huge count into a quick yes/no answer.

// the trade
We sacrifice the exact solution count to gain speed. "Two or more" and "exactly two" are treated the same — both just mean not unique.
count_solutions() · limit
Validity · Verdict

has_unique_solution

The public verdict. It copies the board first — because the solver scribbles digits in and out as it works — then asks: does this puzzle have exactly one solution?

Why copy? Backtracking mutates the grid mid-search. Running it on a throwaway copy keeps the real board pristine.
has_unique_solution()
int has_unique_solution(int original[9][9]) {
    int copy[9][9];
    for (int r = 0; r < 9; r++)
      for (int c = 0; c < 9; c++)
        copy[r][c] = original[r][c];

    return count_solutions(copy, 2) == 1;
}
has_unique_solution()
Validity · In Practice

Carving Blanks Safely

Inside prepare_floor(), every cell we try to erase must pass the uniqueness test — or it gets put right back.

pick random cell
board[r][c] != 0
erase it
backup, set to 0
still unique?
has_unique_solution()
yes → keep blank
removed++ · maybe hide an item
no → restore
board[r][c] = backup
prepare_floor() · the guard
board[r][c] = 0;
if (has_unique_solution(board)) removed++;     // commit
else                           board[r][c] = backup;  // revert
prepare_floor() · the guard
3
// part 3

The Roguelike Layer

A solvable puzzle is only the board. The game lives in what wraps it — depth, danger, loot, and a reason to keep descending.

Roguelike

Game State & The Main Loop

One loop runs the whole game: print the board, read three numbers, act. The same r c v input doubles as commands — special triples trigger items or quit.

1 5 3 place 3 at (1,5)
0 0 1 use potion
0 0 2 use hint
0 0 0 quit
main() loop · condensed
while (hp > 0) {
    print_game();
    scanf("%d %d %d", &r, &c, &v);

    // r c v = 0 0 0 quit · 0 0 1 potion · 0 0 2 hint
    // ... each handled inline, then `continue` ...

    if (solution[r][c] == v) {       // correct
        board[r][c] = v; blanks--;
    } else {
        hp--;                        // wrong → trap
    }
}
main() loop
Roguelike · Progression

Difficulty That Scales by Floor

Deeper floors hand you emptier boards. The blank count grows with depth — then caps, so it never becomes impossible.

prepare_floor()
blanks = 25 + (floor_level * 2);
if (blanks > 60) blanks = 60;     // hard ceiling
floor B1
27
blanks
floor B5
35
blanks
floor B10
45
blanks
floor B18+
60
capped
Uniqueness still rules: if the target count can't be erased while keeping one solution, the floor simply ships with fewer blanks.
prepare_floor() · difficulty
Roguelike · Loot

Hidden Items in the Blanks

The moment a blank is committed, we roll for loot. A single rand() % 100 decides what — if anything — hides beneath it, recorded in the items grid.

item roll
int roll = rand() % 100;
if      (roll < 15) items[r][c] = 1;   // potion
else if (roll < 30) items[r][c] = 2;   // hint
// else: nothing hidden here

probability per blank

15%potion
15%hint
70%nothing

Reveal the right digit and the loot is yours — turning a plain solve into a bit of exploration.

prepare_floor() · items
Roguelike · Stakes

HP, Mistakes & Game Over

Every wrong digit is a trap: hp--. The loop condition is the fail state — when HP hits zero, while (hp > 0) ends and the run is over.

hp 3 / max_hp 5

wrong answer + game over
if (solution[r][c] == v) {
    board[r][c] = v; blanks--;     // correct
} else {
    hp--;                          // trap sprung
}
// ... loop ends the moment hp hits 0
if (hp <= 0) { /* print defeat + final floor */ }
No second chances banked: potions restore HP up to max_hp, never beyond.
main() · HP & defeat
Roguelike · Assist

The Hint System

Spend a hint and the game picks a random blank, then fills the truth straight from solution. If loot was hiding there, you collect it too — a free reveal and a possible reward.

Always correct: the answer comes from solution[hr][hc], so a hint can never cost you HP.
hint command · inside main()
do {
    hr = rand() % 9; hc = rand() % 9;
} while (board[hr][hc] != 0);    // find a blank

board[hr][hc] = solution[hr][hc];   // reveal truth
blanks--;

if (items[hr][hc] == 1) potions++;  // grab loot
if (items[hr][hc] == 2) hints++;
main() · hint command
Roguelike · Output

Rendering the Board

The data is a flat 9×9 of ints, but the screen needs structure. print_game() draws 0 as ·, and prints a | every 3 columns and a divider every 3 rows — so the 3×3 boxes read clearly in plain text.

print_game()
if (board[r][c] == 0) printf(". ");
else                  printf("%d ", board[r][c]);
if ((c+1) % 3 == 0) printf("| ");   // box wall
// ...
if ((r+1) % 3 == 0) printf("+---+---+---+");
Floor B3 · 4/5 · 2 · 1
remaining blanks: 31
    1 2 3  4 5 6  7 8 9
  +-------+-------+-------+
1 | 5 · 8 | 1 · 3 | · 7 2 |
2 | 2 7 · | 9 · 4 | 6 · 8 |
3 | · 4 9 | 6 7 · | 5 1 · |
  +-------+-------+-------+
print_game()
Wrap-up

Key Implementation Points

01
Shift-built solution
A valid board stamped in one pass with a fixed offset table.
02
Rule-preserving shuffles
Relabel digits, swap rows/cols/bands — every move stays legal.
03
Backtracking solver
Place, recurse, undo — counting solutions to verify the puzzle.
04
Uniqueness guard
limit = 2 early exit keeps every floor solvable exactly one way.
05
Floor difficulty
Blanks scale with depth and cap at 60 — pressure without impossibility.
06
Roguelike loop
HP, hidden items, and hints turn a solve into a descent.
six ideas, one file
기초컴퓨터프로그래밍

Thank you

// questions? — let's open the floor
sudoku_rogue.c · 418 lines · C