Game World (Tile Engine)
A 1000x1000 map has a million tiles but only a handful of terrain types. Flyweight shares the heavy data across all tiles of the same type. Bridge decouples game entities from how they get rendered.
Key Abstractions
Flyweight holding shared intrinsic state: terrain name, walkability, movement cost, and display symbol
Flyweight factory ensuring exactly one TileType instance exists per terrain type
Map cell referencing a shared TileType plus its own position and items as extrinsic state
Bridge abstraction for game objects, delegating visual output to a Renderer implementation
Bridge implementation interface for rendering entities and tiles in different formats
Class Diagram
The Key Insight
Think about what a tile map actually stores. A 1000x1000 grid has a million tiles. Each tile has a terrain type with properties like texture name, walkability, movement cost, display character. But how many distinct terrain types exist? Five. Maybe ten. You are duplicating the same data a million times over.
Flyweight separates intrinsic state from extrinsic state. Intrinsic state is the stuff that does not change between tiles of the same type: "grass is walkable, costs 1 to traverse, renders as a dot." That gets shared. Extrinsic state is what varies per tile: position, items sitting on it, fog of war status. That stays with each tile. One reference per tile instead of four duplicated properties. On a big map, this saves hundreds of megabytes.
Then there is the rendering problem. A Player needs to draw itself. But how? ASCII characters for debugging, sprites for the real game, nothing at all for a headless server. If you bake rendering into the entity, you are stuck with one approach. Bridge decouples the "what" from the "how." The entity holds a reference to a Renderer. Swap the renderer, and every entity draws differently without changing a single line in Player or Enemy.
Requirements
Functional
- Tile-based game map of configurable size, built from a 2D terrain layout
- Multiple terrain types with shared properties: name, walkability, movement cost, symbol
- Game entities (players, enemies) placed at specific positions on the map
- Rendering system that supports multiple output formats without modifying entity code
- Ability to swap renderers at runtime
Non-Functional
- Memory usage proportional to unique terrain types, not total tile count
- Adding a new renderer requires zero changes to existing entity or map classes
- Flyweight objects must be immutable to prevent cross-tile corruption
- Factory must guarantee one instance per terrain type with no duplicates
Design Decisions
Why Flyweight for tile types?
Every tile on the map needs terrain information. Without sharing, a 1000x1000 map means a million objects each carrying name, walkability, movement cost, and symbol fields. That is four properties times a million. With Flyweight, all grass tiles point to the same TileType("grass") object. You store one reference per tile instead of four values. The factory ensures no duplicates. Scale the map to 10,000x10,000 and the savings go from noticeable to essential.
Why a factory for flyweight management?
Without a centralized factory, every piece of code that creates tiles needs to manually check "have I already made a grass TileType?" That logic gets scattered and error-prone. The factory's get_tile_type() method handles it in one place: check the cache, create if missing, return the shared instance. It is the single source of truth. No coordination needed, no duplicates possible.
Why Bridge for rendering instead of inheritance?
Picture the alternative. Player needs rendering, so you create ConsolePlayer and GraphicsPlayer. Enemy needs it too, so you create ConsoleEnemy and GraphicsEnemy. Add a third renderer and you have three more classes. Add a fourth entity type and things spiral. That is the classic cartesian product problem: M entities times N renderers = M*N classes. Bridge composes them instead. Any entity works with any renderer. Adding a new renderer is one class. Adding a new entity is one class. They are independent dimensions.
Why are TileType objects immutable?
All grass tiles on the map share a single TileType("grass") instance. If you let someone mutate that object, say change walkable to false, every grass tile in the entire world becomes unwalkable in one shot. That might occasionally be what you want (a global terrain event), but 99% of the time it is a bug. Making TileType immutable prevents accidental corruption. If you need a seasonal change or a spell that alters terrain, create a new TileType and reassign specific tiles.
Interview Follow-ups
- "How would you handle terrain transitions or animated tiles?" Add visual state to the Tile (extrinsic side), not the TileType. Each tile can track its own animation frame or blend factor with neighbors. The shared flyweight stays clean. The Renderer reads both the TileType and the per-tile visual state when drawing.
- "How would you add fog of war?" Fog is extrinsic state, per-tile, not per-terrain-type. Add an
exploredboolean to Tile. The Renderer checks it before drawing. Unexplored tiles render as blank or question marks. The TileType is unaffected because visibility is not an intrinsic property of grass or water. - "What if you need to render 100,000 tiles per frame without lag?" ConsoleRenderer is for debugging. A production renderer would batch tiles by TileType, because all grass tiles use the same texture. Flyweight already groups them. The renderer collects all tile positions per type, sends one draw call per texture atlas region, and lets the GPU handle the rest.
- "How would you persist the map to disk?" Save the terrain layout as a 2D array of type names, not full Tile objects. On load, rebuild through the factory. The flyweight structure is reconstructed automatically. Position data comes from array indices. Items serialize separately as lists per coordinate.
Code Implementation
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3
4
5 class TileType:
6 """Flyweight: intrinsic state shared across all tiles of this terrain."""
7
8 __slots__ = ("_name", "_walkable", "_movement_cost", "_symbol")
9
10 def __init__(self, name: str, walkable: bool, movement_cost: int, symbol: str):
11 self._name = name
12 self._walkable = walkable
13 self._movement_cost = movement_cost
14 self._symbol = symbol
15
16 @property
17 def name(self) -> str:
18 return self._name
19
20 @property
21 def walkable(self) -> bool:
22 return self._walkable
23
24 @property
25 def movement_cost(self) -> int:
26 return self._movement_cost
27
28 @property
29 def symbol(self) -> str:
30 return self._symbol
31
32 def __repr__(self) -> str:
33 return f"TileType({self._name})"
34
35 def __hash__(self) -> int:
36 return hash(self._name)
37
38 def __eq__(self, other: object) -> bool:
39 if not isinstance(other, TileType):
40 return NotImplemented
41 return self._name == other._name
42
43
44 class TileTypeFactory:
45 """Flyweight factory. Guarantees one TileType instance per terrain name."""
46
47 _DEFAULTS = {
48 "grass": (True, 1, "."),
49 "water": (False, 0, "~"),
50 "mountain": (False, 0, "^"),
51 "sand": (True, 2, ":"),
52 "forest": (True, 3, "T"),
53 }
54
55 def __init__(self):
56 self._cache: dict[str, TileType] = {}
57
58 def get_tile_type(
59 self,
60 name: str,
61 walkable: bool | None = None,
62 movement_cost: int | None = None,
63 symbol: str | None = None,
64 ) -> TileType:
65 if name not in self._cache:
66 if walkable is None:
67 defaults = self._DEFAULTS.get(name, (True, 1, "?"))
68 walkable, movement_cost, symbol = defaults
69 self._cache[name] = TileType(name, walkable, movement_cost or 0, symbol or "?")
70 return self._cache[name]
71
72 @property
73 def type_count(self) -> int:
74 return len(self._cache)
75
76 def cached_types(self) -> list[str]:
77 return list(self._cache.keys())
78
79
80 class Tile:
81 """Context object. References a shared TileType and holds extrinsic state."""
82
83 def __init__(self, tile_type: TileType, row: int, col: int):
84 self.tile_type = tile_type
85 self.row = row
86 self.col = col
87 self.items: list[str] = []
88
89 def add_item(self, item: str) -> None:
90 self.items.append(item)
91
92 def __repr__(self) -> str:
93 return f"Tile({self.tile_type.name}, {self.row}, {self.col})"
94
95
96 class GameMap:
97 """2D grid of Tiles built using the TileTypeFactory."""
98
99 def __init__(self, terrain_data: list[list[str]], factory: TileTypeFactory):
100 self._rows = len(terrain_data)
101 self._cols = len(terrain_data[0]) if terrain_data else 0
102 self._factory = factory
103 self._grid: list[list[Tile]] = []
104
105 for r, row in enumerate(terrain_data):
106 tile_row = []
107 for c, terrain_name in enumerate(row):
108 tile_type = factory.get_tile_type(terrain_name)
109 tile_row.append(Tile(tile_type, r, c))
110 self._grid.append(tile_row)
111
112 @property
113 def rows(self) -> int:
114 return self._rows
115
116 @property
117 def cols(self) -> int:
118 return self._cols
119
120 def get_tile(self, row: int, col: int) -> Tile:
121 return self._grid[row][col]
122
123 @property
124 def factory(self) -> TileTypeFactory:
125 return self._factory
126
127
128 # ---------- Bridge Pattern ----------
129
130 class Renderer(ABC):
131 """Implementation side of the Bridge."""
132
133 @abstractmethod
134 def render_entity(self, entity: "GameEntity") -> None: ...
135
136 @abstractmethod
137 def render_map(self, game_map: GameMap) -> None: ...
138
139
140 class ConsoleRenderer(Renderer):
141 """ASCII rendering for terminals and debugging."""
142
143 def render_entity(self, entity: "GameEntity") -> None:
144 print(f" [{entity.symbol}] {entity.name} at ({entity.row}, {entity.col})")
145
146 def render_map(self, game_map: GameMap) -> None:
147 print(f"\n Map ({game_map.rows}x{game_map.cols}):")
148 for r in range(game_map.rows):
149 row_str = " ".join(game_map.get_tile(r, c).tile_type.symbol for c in range(game_map.cols))
150 print(f" {row_str}")
151 print()
152
153
154 class GraphicsRenderer(Renderer):
155 """Simulated graphical rendering (would use sprites in production)."""
156
157 def render_entity(self, entity: "GameEntity") -> None:
158 print(f" [Graphics] Drawing sprite for {entity.name} at pixel ({entity.col * 32}, {entity.row * 32})")
159
160 def render_map(self, game_map: GameMap) -> None:
161 total = game_map.rows * game_map.cols
162 print(f"\n [Graphics] Rendering {total} tiles with sprite sheet")
163 for r in range(game_map.rows):
164 for c in range(game_map.cols):
165 tile = game_map.get_tile(r, c)
166 # In real code, this would blit a texture. We just log the first row.
167 if r == 0:
168 print(f" Sprite: {tile.tile_type.name}.png at ({c * 32}, {r * 32})")
169 print(f" [Graphics] ... ({total - game_map.cols} more tiles)\n")
170
171
172 class GameEntity:
173 """Abstraction side of the Bridge. Delegates rendering to a Renderer."""
174
175 def __init__(self, name: str, row: int, col: int, symbol: str, renderer: Renderer):
176 self.name = name
177 self.row = row
178 self.col = col
179 self.symbol = symbol
180 self._renderer = renderer
181
182 def render(self) -> None:
183 self._renderer.render_entity(self)
184
185 def set_renderer(self, renderer: Renderer) -> None:
186 self._renderer = renderer
187
188
189 class Player(GameEntity):
190 def __init__(self, name: str, row: int, col: int, renderer: Renderer):
191 super().__init__(name, row, col, "@", renderer)
192
193
194 class Enemy(GameEntity):
195 def __init__(self, name: str, row: int, col: int, renderer: Renderer):
196 super().__init__(name, row, col, "E", renderer)
197
198
199 if __name__ == "__main__":
200 factory = TileTypeFactory()
201
202 # Build a 10x10 map with mixed terrain
203 terrain = [
204 ["grass", "grass", "water", "water", "sand", "sand", "grass", "grass", "grass", "mountain"],
205 ["grass", "grass", "water", "water", "sand", "sand", "grass", "grass", "grass", "mountain"],
206 ["grass", "grass", "grass", "water", "sand", "grass", "grass", "grass", "mountain", "mountain"],
207 ["grass", "grass", "grass", "grass", "grass", "grass", "grass", "mountain", "mountain", "mountain"],
208 ["sand", "sand", "grass", "grass", "grass", "grass", "grass", "grass", "mountain", "mountain"],
209 ["sand", "sand", "sand", "grass", "grass", "grass", "grass", "grass", "grass", "grass"],
210 ["water", "water", "sand", "sand", "grass", "grass", "grass", "grass", "grass", "grass"],
211 ["water", "water", "water", "sand", "grass", "grass", "grass", "grass", "grass", "grass"],
212 ["water", "water", "water", "sand", "sand", "grass", "grass", "grass", "grass", "grass"],
213 ["water", "water", "water", "water", "sand", "sand", "grass", "grass", "grass", "grass"],
214 ]
215
216 game_map = GameMap(terrain, factory)
217
218 # Flyweight stats
219 total_tiles = game_map.rows * game_map.cols
220 print(f"Total tiles created: {total_tiles}")
221 print(f"Unique TileType objects: {factory.type_count}")
222 print(f"Cached types: {factory.cached_types()}")
223 print(f"Memory ratio: {total_tiles} tiles sharing {factory.type_count} flyweights")
224 print()
225
226 # Identity check: all grass tiles share the exact same TileType object
227 grass_a = game_map.get_tile(0, 0).tile_type
228 grass_b = game_map.get_tile(3, 4).tile_type
229 print(f"Tile (0,0) type: {grass_a} id={id(grass_a)}")
230 print(f"Tile (3,4) type: {grass_b} id={id(grass_b)}")
231 print(f"Same object? {grass_a is grass_b}")
232 print()
233
234 # Render map with ConsoleRenderer
235 console = ConsoleRenderer()
236 console.render_map(game_map)
237
238 # Place entities and render with ConsoleRenderer
239 player = Player("Hero", 2, 2, console)
240 enemy1 = Enemy("Goblin", 5, 7, console)
241 enemy2 = Enemy("Dragon", 8, 1, console)
242
243 print("Entities (ConsoleRenderer):")
244 player.render()
245 enemy1.render()
246 enemy2.render()
247 print()
248
249 # Swap to GraphicsRenderer and render the same entities
250 graphics = GraphicsRenderer()
251 player.set_renderer(graphics)
252 enemy1.set_renderer(graphics)
253 enemy2.set_renderer(graphics)
254
255 print("Entities (GraphicsRenderer):")
256 player.render()
257 enemy1.render()
258 enemy2.render()
259 print()
260
261 # Render map with GraphicsRenderer
262 graphics.render_map(game_map)
263
264 # Final flyweight verification
265 water_a = game_map.get_tile(0, 2).tile_type
266 water_b = game_map.get_tile(9, 0).tile_type
267 mountain_a = game_map.get_tile(0, 9).tile_type
268 mountain_b = game_map.get_tile(4, 8).tile_type
269 print("Flyweight identity checks:")
270 print(f" Water (0,2) is Water (9,0)? {water_a is water_b}")
271 print(f" Mountain (0,9) is Mountain (4,8)? {mountain_a is mountain_b}")Common Mistakes
- ✗Storing position inside TileType. Position is extrinsic state that varies per tile. Putting it in the flyweight defeats sharing.
- ✗Creating a new TileType for every tile. Without the factory cache, you get a million objects instead of five.
- ✗Hardcoding rendering inside GameEntity. That couples your domain model to a specific output format. Bridge keeps them independent.
- ✗Making the flyweight mutable. If one tile changes the 'walkable' property on its TileType, every tile sharing that type changes too.
Key Points
- ✓A million tiles share maybe 5 TileType objects. Memory drops from O(tiles * properties) to O(tiles * 1 reference + types * properties).
- ✓TileTypeFactory is the gatekeeper. It caches flyweights and never creates duplicates.
- ✓Bridge means adding a new renderer (WebGL, SVG) never touches Player, Enemy, or any entity class.
- ✓Intrinsic state (terrain properties) is shared and immutable. Extrinsic state (position, items) is per-tile and mutable.