Skip to content

Tinshea/Sims_City

Repository files navigation

Sims City — a city builder written in Haskell, where every citizen is simulated on its own life cycle

Haskell GHC 9.6 Stack SDL2 56/60 tests passing BSD-3

Sims City

A SimCity-inspired city builder written entirely in Haskell. The player zones land, lays roads and wires power across a 1920×1080 grid; the simulation then runs on its own, with each citizen holding a home, a workplace and a shop, walking between them along the road network, and leaving town when it runs out of money, food or sleep.

The interesting part is not the game loop — it is that the whole model is written in a design-by-contract style. Every type carries an invariant, every operation a pre and a post, and those same predicates are what the QuickCheck properties check.


Quick start

You need Stack (which brings its own GHC) and the SDL2 development libraries.

# Debian / Ubuntu
sudo apt install libsdl2-dev libsdl2-ttf-dev
# macOS
brew install sdl2 sdl2_ttf
git clone https://github.com/Tinshea/Sims_City.git
cd Sims_City
chmod +x run.sh test.sh

./run.sh      # build and launch the game (first build takes a while)
./test.sh     # build and run the test suite

On Windows, .\run.ps1 and .\test.ps1 do the same — but see the Windows note first: the test suite runs, the game currently does not.

The first build compiles roughly a hundred packages from scratch. Budget 20–40 minutes and a few GB; subsequent builds are incremental.


What it renders

Every sprite the renderer loads, drawn here on the game's own background tile. Zones are 70×70, roads 30×30, and each zone fills itself with up to four buildings as it gets powered and populated.

The game's sprite set: residential, commercial and industrial zones, road, energy plant, wire, police station, house, shack, grocery, workshop and water


Architecture

The code is laid out as a strict MVC split, with the model kept free of any rendering concern — which is what makes the simulation testable on its own.

flowchart TD
    Main["app/Main.hs<br/>game loop, tool panel, HUD"]
    KB["Controllers.Keyboard"]
    MS["Controllers.Mouse"]
    GS["Model.Model<br/>GameState: money, pollution, safety"]
    V["Model.Ville<br/>city, citizens, A*, economy"]
    S["Model.Shapes<br/>Coord, Forme, Zone, Batiment"]
    TX["View.TextureMap"]
    SP["View.Sprite / SpriteMap"]

    Main --> KB
    Main --> MS
    Main --> GS
    Main --> SP
    SP --> TX
    GS --> V
    V --> S
Loading

Model.Ville and Model.Shapes import no SDL at all. Everything above them does.

Module Lines Role
Model/Ville.hs 686 City state, citizen life cycle, A* routing, power, pollution, economy
app/Main.hs 257 SDL loop, tool panel, build preview, HUD
Model/Model.hs 254 GameState, build actions, money, tick counter
Model/Shapes.hs 194 Geometric primitives and the zone/building algebra
View/* 206 Texture and sprite maps, drawing
Controllers/* 72 Keyboard and mouse event folding

Design by contract

This is the spine of the project, and the reason it was written for a functional-programming course rather than a games one. Each type declares what it means for a value to be well formed, and each operation declares what it needs and what it guarantees:

invariantCoord :: Coord -> Bool
invariantCoord (C x y) = x >= 0 && y >= 0

preConstruit  :: Ville -> Zone -> ZonId -> Bool
construit     :: Ville -> Zone -> ZonId -> Ville
postConstruit :: Ville -> Zone -> ZonId -> Ville -> Bool

The same predicates are then reused as global city properties, so an invariant is stated once and checked from both the unit tests and the generators:

Property What it asserts
prop_routes_connexes Every road tile is reachable from every other — the network is one component
prop_ville_sansCollision No two zones overlap
prop_zones_dijoints Zones are pairwise disjoint
prop_zone_adjacent_route Every buildable zone touches a road
prop_wire_wireorelectrique Every wire is adjacent to a plant or to another wire

Simulation

Citizen life cycle

A citizen is a three-state machine, and its occupation is a second one nested inside the Habitant state.

stateDiagram-v2
    [*] --> Immigrant: spawned when pollution is low,<br/>safety sufficient, population under 100
    Immigrant --> Habitant: a residence, a workplace<br/>and a shop are assigned
    Habitant --> Emigrant: money, hunger or fatigue hits 0
    Emigrant --> [*]: expelled

    state Habitant {
        [*] --> Travailler
        Travailler --> Shopping: hunger < 20
        Travailler --> Dormir: fatigue < 20
        Shopping --> Travailler: hunger restored
        Dormir --> Travailler: rested
    }
Loading
Occupation Trigger Effect per cycle
Travailler default +$200, −5 hunger, −10 fatigue
Shopping hunger < 20 walks to its grocery, +20 hunger/tick
Dormir fatigue < 20 walks to its residence, +20 fatigue/tick
Move destination changed advances along the path to the target zone

Citizen state is re-evaluated every 100 ticks; immigrants arrive at roughly 1% of ticks, 1–20 at a time.

Routing

Citizens do not walk in straight lines. The road tiles are lifted into a graph — type Graph = Map ZonId [ZonId], built by linking every road to its adjacent roads — and paths are found with A* over that graph, using Manhattan distance between zone centres as the heuristic and a min-priority queue as the frontier:

aStar :: Ville -> ZonId -> ZonId -> Maybe Path
aStar ville start goal =
    let graph = createGraph ville
        heuristic a b = manhattanDistance (coordZone ville a) (coordZone ville b)
    in search graph heuristic start goal

Building and economy

Infrastructure Size Cost Effect
Residential zone 70×70 $100 Up to 4 houses or shacks; collects rent
Commercial zone 70×70 $100 4 groceries; restores citizen hunger
Industrial zone 70×70 $100 4 workshops; pays $200 per work cycle; +10 pollution
Road 30×30 $10 Required adjacency for every zone; must connect to the existing network
Energy plant 70×70 $500 Powers adjacent zones; +50 pollution
Wire 30×30 $5 Carries power from a plant to distant zones
Police station 70×70 $100 +1 safety per station within 500px of a residential or commercial zone

A zone only fills with buildings once it is powered, and isPowered walks the wire chain back to a plant. Money, population, pollution and safety are shown live in the HUD, with pollution and safety colour-coded by severity.


Testing

$ ./test.sh
Compiling the model and its tests (no SDL) ...

Shapes Tests
  ...
Ville Tests
  ...
QuickCheck Properties
  isPowered property [✘]
  updateCitizens property [✘]
  invariant_creer_imigrant property [✘]

60 examples, 4 failures

56 of 60 pass. The four failures are all in the same place, and the cause is worth stating plainly: the Arbitrary instances generate values that violate the project's own invariants. QuickCheck happily produces C {cx = -15, cy = 8} for a coordinate, or RectangleP _ 0 7 for a shape — even though invariantCoord requires non-negative coordinates and invariantForme requires positive dimensions. The properties then fail on inputs the model was never meant to accept, and updateCitizens reaches a partial function (error "Building with BatId … not found") on a citizen whose residence id was invented by the generator.

The fix is in the generators, not in the model: constrain them to satisfy the invariants that are already written down a few lines above.

test.sh / test.ps1 deliberately compile only Model.Ville, Model.Shapes and the specs rather than calling stack test. Nothing under test imports SDL, so the suite has no reason to require it — and this way it also runs on Windows.


Windows: the game does not build (yet)

The test suite runs. The game does not, and the blocker sits in the toolchain rather than in this code.

GHC 9.6 on Windows links with clang and ld.lld, while MSYS2 ships SDL2 built for two different environments — neither of which links cleanly against it:

SDL2 flavour Failure
mingw-w64-clang-x86_64-SDL2 (what Stack 3.x looks for) ld.lld: error: -exclude-symbols:WinMain is not allowed in .drectve
mingw-w64-x86_64-SDL2 (GCC build) ld.lld: error: undefined symbol: __stack_chk_guard, referenced from libSDL2main.a

Both are known GHC/SDL2 interactions on Windows, not defects here. The path of least resistance is WSL or a Linux/macOS machine, where the apt/brew line at the top is all that is needed.

To get as far as possible on Windows:

stack exec -- pacman -Sy --noconfirm
stack exec -- pacman -S --noconfirm msys2-keyring        # the bundled keyring has expired
stack exec -- pacman -S --noconfirm mingw-w64-clang-x86_64-pkg-config `
                                    mingw-w64-clang-x86_64-SDL2 `
                                    mingw-w64-clang-x86_64-SDL2_ttf
.\test.ps1                                                # this part works

Project structure

Sims_City/
├── run.sh / run.ps1     # build + launch
├── test.sh / test.ps1   # build + run the suite, without SDL
├── app/Main.hs          # SDL loop, tool panel, HUD
├── src/
│   ├── Model/           # Ville, Model, Shapes — no SDL, fully testable
│   ├── View/            # TextureMap, SpriteMap, Sprite
│   └── Controllers/     # Keyboard, Mouse
├── test/                # HSpec specs + QuickCheck properties
├── assets/              # sprites (BMP) and the AVELIRE font
├── package.yaml         # hpack manifest; minijeu.cabal is generated from it
└── stack.yaml           # resolver lts-22.7 (GHC 9.6.4)

Rapport_PAF.pdf is the original coursework report.

Credits

  • Malek Bouzarkouna
  • Paul Mekhail

Sorbonne Université — Master STL, Programmation Avancée en style Fonctionnel, 2024.

Distributed under the BSD-3 license; see LICENSE.

About

A SimCity-inspired city-builder game built in Haskell with SDL2, developed as part of an advanced functional programming course (PAF) at STL Master.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages