Repository: joshbduncan/word-search-generator (language: python)

Issue:
Implement shape masks
[I made a very basic proof of concept that's disconnected from the rest of the generator code this afternoon](https://github.com/duck57/word-search-generator/commit/18e221f7c783301f307911566f9c1c17ad016286).  I've put plenty of TODO items in the commit message.  Some of the code I wrote seems quite duplicituous.  Let's use this issue for higher-level design discussions and comments on the commit to discuss the implementation in its current code.

I was inspired to write this as well as #21 after stumbling across a [different Python word search generator](https://scipython.com/blog/making-a-word-search-puzzle-in-python/)

I could implement this one, but if I don't get the time to finish it before Halloween, I may not be able to return until March [unlikely that it will be untouched for that entire time, but I expect to have much less availablilty to work on side projects over the winter].


Discussion / hints:
Before I do any more work on this, what do you think of my Puzzle object and approach to the modification functions?  My gut feeling suggests that there is some far easier way to wrap a `list[list[chr]]` into an object and handle all those modification functions.  However, I'm drawing a blank as to what those changes may be.
Hey, I was out of town last weekend so I haven't had the time to review this. I definitely like the idea so I'll try to take a look at the implementation this weekend and get back to you. Thanks!
Take your time: I’ll be traveling this weekend, so I won’t have time to poke at this again until next week.  Once we decide on the data structure and filter function strategy, it should mostly be a straightforward plug-and-chug for me to update the existing generate.py code to use the new functionality and implement several more built-in filters to be available.

When you get to look at this, let me know if you think of any CLI ergonomics.  As of now, I do not plan to implement CLI access to this functionality, but I have nothing against adding it if you have a suggestion ready by the time I start trying to implement this for real.  If you instead implement the CLI for this after I’ve submitted my initial PR, that’s fine by me.  My initial guess says that this feature set is more relevant when using the generator as a library rather than as an app.

— Chris
On Oct 13, 2022, 10:33 -0400, Josh Duncan ***@***.***>, wrote:
> Hey, I was out of town last weekend so I haven't had the time to review this. I definitely like the idea so I'll try to take a look at the implementation this weekend and get back to you. Thanks!
> —
> Reply to this email directly, view it on GitHub, or unsubscribe.
> You are receiving this because you authored the thread.Message ID: ***@***.***>

Yes, I agree about the CLI. Maybe it's something we can implement later but I think it's best to just get everything working in the API.
This is a bit of a thought dump for when I go to do the implementation.  Feel free to ignore this if you don't find it useful when giving my demo code an in-depth review.

## Data Structure of Puzzle

For 90% of what I want to do, `list[list[str]]` is all I need.  However, there are some instances when I think treating the Puzzle as `dict[Position, str]` (a.k.a. `dict[tuple[int, int], str]`) would be more convenient.

## Properties or just additional tasks for the setter?

I highly doubt this would become a performance-sensitive issue. However, I'm unfamiliar with how Python caches its @properties—would they need to be recomputed every time they're accessed or just each time they've been accessed after the Puzzle has changed?

* Translation to `dict` or `list[list]` form from the "true" internal representation
* `by_chr() -> dict[chr, set[Position]]`: List out all the cells containing the character, used for…
* `dead_cells() -> set[Position]`: set of cells masked out of the board (but within its nominal boundaries)
* `empty_cells() -> set [Position]`: same as above but for fully-empty cells
* When implementing this, alter `fit_word` to choose starting positions from something like `random.choice(empty_cells | by_chr()[word[0]])`.

## Structure of filter functions

I'm going to change up the signature of these functions.  Namely, instead of some ad-hoc boolean about whether to blackout or clear the selection, separate out the selections and effects into separate functions.  ~~Remaining to be decided:  does the selection function take an effect function as a param or do I make the filter list accept tuples, making it `list[tuple[selection, effect]]`?~~

Typing this out, probably best to use the effect function as a param in the pattern function.

Below are the four effect functions I've thought of.


```python3
def mark_oob(_: chr) -> chr:
    return config.OOB_CHR

def mark_clear(_: chr) -> chr:
    return ""

def toggle_cell(c: chr) -> chr:
    return "" if c == config.OOB_CHR else config.OOB_CHR

def random(c: chr, effect: Optional[Callable[[chr], chr]] = None, strength: float = 0.5) -> chr:
    if not effect:
        effect = random.choice([mark_oob, mark_clear, toggle_cell])
    return effect(c) if random.random() < strength else c
```

1. Do I make these (or the selection functions) functions or methods of a Puzzle instance?
2. I'm going to have to get familiar with functools.partial if I don't want to use nested functions, won't I?

List of patterns to implement:

- [x] Whole board (currently implemented as blackout)
- [ ] Triangle (specify a Direction to anchor its corner)
- [x] Pick cell
- [ ] Circle/ellipse
- [ ] rectangular punch

------

There were probably some other items, but I started typing this over my lunch break on Friday and then returned to it after work this evening, so there's plenty I've forgotten.
@duck57, if we are going to put in the work to build this out I want to clean up a few things to make the module a bit more robust before we start.

So far, I've already done the work to create a word class that tracks the text, position, coordinates (for potential use), xy position for display. So far, it's def better and just keeping a set of words and a separate key. I just need to finish implementing this into the function.

I'm also cleaning up the utility and generate function to use the actual puzzle object so that we don't have to pass around so many variables. I just pass the puzzle object and extract the variable I need to do the work from there.

Next, I'm going to clean up the actual puzzle generator function. I bolted on checks as I implemented them so I need to go back in and clean things up.

I hope to have this all done in the next few days.
I see you've made some of the changes already.  I've done similar refactors (RE: moving things to a class because of excessively redundant function signatures) on my own projects before.

Is the plan to finish the cleanup and then merge #17?  If so, I'll wait until after that has been merged to explore more so there will be a stable foundation to build from.
Exactly! I've got the work done for creating the word class object and will commit it tomorrow. I think that's the last thing I'm going to squeeze into that PR. I wanted to have a more solid foundation and easier extensibility before we work to add these advanced features. It would be even harder to make the switch later.
For the sake of consistent nomenclature, should this feature be called "masks" or "filters"?
@duck57, I like masks. I feel like more people would understand that over filer.
> 1. Do I make these (or the selection functions) functions or methods of a Puzzle instance?

I would make them methods of the Puzzle object since they only interact with a "puzzle".
> 2\. I'm going to have to get familiar with functools.partial if I don't want to use nested functions, won't I?

I've never actually used functools.partial in a working program ( just doesn't come to mind)... But yes, it could certainly help to reduce nesting and clean up a function.
So, on your proof of concept, I see that masks are applied by expanding the puzzle. What is your plan for making that work with puzzle size? Say I supply a puzzle size of 10h x 20w like below with some random masks.

``` pycon
>>> p = Puzzle(
    10,
    20,
    masks=[
        expand(0, 4, True),
        expand(0, -7, True),
        expand(-3, 0, False),
        expand(3, 0, False),
        expand(0, 2, False),
        expand(0, -2, False),
    ],
)
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
```

If I now call p.width or p.height the values don't match what I originally set.

```pycon
>>> p.height
16
>>> p.width
35
>>> print(p)
```

I understand what is happening but do you think the user will? Should we "remove" the masked areas from the actual puzzle size they specify like below?

```
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
```

Your original "I" shaped example wouldn't work in this case though as you are masking out 8 cols and the puzzle size is only 6 cols. This could throw the exception you have set up. But if you reduced the masks size to 2 cols and could control the height you would end up with the following...

```pycon
>>> print(
    Puzzle(
        6,
        masks=[
            remove_rectangle(row=2, col=0, width=2, height=2, True),
            remove_rectangle(row=2, col=4, width=2, height=2, True),
        ],
    )
)
. . . . . .
. . . . . .
# # . . # #
# # . . # #
. . . . . .
. . . . . .
```

Just some thoughts as I try and wrap my head around the implementation...



And I don't think it would be too hard to implement a bitmap mask using PIL...
@duck57, FYI, I had to patch a small booboo so I just published v2.0.1. My check for all of the placed words properties when checking to see if the word had a 'position'. Well the way we have position setup it always return true making all words show up in those properties and the key no matter if they were placed on the board or not. Was easy to notice when I tried to fit 100 words in a 5x5 puzzle. 🤦‍♂️
I just pushed a commit to my fork's `mask-test` branch.  It's very much so a WIP.  As I said in the commit message, truly a minimal viable proof-of-concept for the changes.  However, I probably won't have time to do in-depth work on it again until mid-February.

This commit (or series of commits, rather):

1. Merges in #17 to use it as the starting base [I started this on Tuesday, so it's before you've made your comments]
2. Re-implements a few masks to work on a `list[list[str]]` instead of a Puzzle object—the Puzzle object seemed like it would end up being a replacement for the WordSearch object if it kept growing.
3. Leaves `puzzle.py` mostly alone.  The re-implementations are in `masks.py`
4. At least adds some support for polling which cells contain which character (though I'd still need to write tests to see if it works/is used appropriately)
5. The new mask implementation has been hooked up with WordSearch objects.  They do correctly build the word searches around the fenced-off coordinates.
6. Allow for the creation of WordSearches with width≠height

Some to-do items [this list is as much for me as for you]:

- Increase the number of masks.  Notable absences in the current set are the triangle (built from a corner), circle/ellipse, and clear_column/row.
- Provide a proper demo of constructing a WordSearch using masks.  For one, I want to make a heart shape with a circle and triangle that gets mirrored.
- Documentation!!!!  If you review all this (or when I come back in the spring), I hope I remember how this all fits together.
- Pick which implementation to build from.  They're quite similar, puzzle.py and masks.py.
- Alter the `find_a_fit` function to randomly choose from cells that are either empty or contain the starting letter of the word instead of choosing randomly from any cell on the grid.
- Write tests to make sure it actually works

To address some of your comments,

- At least for PyCharm's type-checker, it likes the nested functions more than it likes a Partial object.  I wonder if there are any performance implications between nested functions and Partials.
- Would we want to bring in PIL as a dependency or should we have some code that will accept a bitmap mask, but specify in the documentation that anyone who wants to use this would have to import PIL themself?
- When I made my take-two implementation, I kept the behavior of altering the size for the expand and chop functions.  I'd hope that the names alone would make it clear enough that these functions would alter the size of the grid.  Perhaps the `mirror` functions in masks.py should be renamed to something like `grow_mirror`

If I have unexpected free time this weekend or early next week, I may take a stab at addressing some of the to-do items.  If not, it can be a project for me in the spring.  Feel free to use my chicken scratch as the base for your implementation if you want if you don't want to wait until later February.
@duck57, so after talking it over with a few friends, I wanted to take a go at creating a different approach for masking. I'm a graphic designer by trade so masks are something I use a lot and this approach just works better with my brain. It needs some cleanup and has zero type hints at the moment but it should be pretty easy to understand. I have included a pretty extensive readme below.

I plan to add more shapes, probably recalculate the heart, and check on some rounding issues, but most of the base is there. The bitmap mask could easily be expanded to work with PIL (which is already a requirement of the PDF generator) and allow user images to work as masks.

This could pretty easily be implemented into the actual package.

I'm on my lunch break and rambling at this point, so check it out if you have time, and let me know what you think.

https://gist.github.com/joshbduncan/949caf7a6d0ae6d9d2ada564a0562f4f

# Puzzle Masks

Masks allow you to "mask" areas of a WordSearch puzzle, making those areas inactive for placing characters.

## Mask() Base Class

All puzzle masks are based on the base `Mask` class. There are two subclasses, `Bitmap` and `Polygon`, that inherits from `Mask`.

```python
def __init__(self, method=1, static=True):
    """A puzzle mask object.

    Args:
        method (int, optional): Masking method. Defaults to 1.
            1. Standard (Intersection)
            2. Additive
            3. Subtractive
        static (bool, optional): Mask should not be recalculated
        and reapplied after a size change. Defaults to True.
    """
    self.puzzle_size = None
    self.grid = None
    self.method = method
    self.static = static
```

The base mask class only has a few key properties, `Mask.method` and `Mask.static`.

### Mask().method

`Mask.method` determines how the mask is applied to the puzzle.

To best understand `Mask.method`, let me show what a sample mask looks like.

```pycon
>>> mask = Diamond()
>>> mask.generate(11)
>>> mask.show()
# # # # # * # # # # #
# # # # * * * # # # #
# # # * * * * * # # #
# # * * * * * * * # #
# * * * * * * * * * #
* * * * * * * * * * *
# * * * * * * * * * #
# # * * * * * * * # #
# # # * * * * * # # #
# # # # * * * # # # #
# # # # # * # # # # #
```

As you can see in the output above, a mask (no matter the type) is made up of `ACTIVE` (\*) and `INACTIVE` (#) spaces. `ACTIVE` (\*) spaces will "act" on a puzzle depending on the `Mask.method`. If it helps, in the physical world, the above mask would be a square with a diamond shape cut out of the middle, masking all of the areas marked `INACTIVE` (#), and revealing all of the areas marked `ACTIVE` (\*).

#### Method Types

1. Standard: All `INACTIVE` (#) spaces from the mask will deactivate corresponding spaces on the current puzzle, intersecting with any previously applied masks.

```pycon
>>> p = Puzzle(15)
>>> p.apply_mask(Ellipse(15, 7))
>>> p.show()
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # * * * * * * * # # # #
# * * * * * * * * * * * * * #
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
# * * * * * * * * * * * * * #
# # # # * * * * * * * # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
>>> p.apply_mask(Ellipse(7, 15, method=2))
>>> p.show()
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # * * * * * * * # # # #
# # # # * * * * * * * # # # #
# # # # * * * * * * * # # # #
# # # # * * * * * * * # # # #
# # # # * * * * * * * # # # #
# # # # * * * * * * * # # # #
# # # # * * * * * * * # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
```

Using the default standard method (`method=1`) on the second vertical oval mask, you can see that it interacts with the previous mask so only intersecting/overlapping areas are active on the puzzle.

2. Additive: All `ACTIVE` (\*) spaces from the mask will activate corresponding spaces on the current puzzle, no matter the current puzzle state.

```pycon
>>> p = Puzzle(15)
>>> p.apply_mask(Ellipse(15, 7))
>>> p.show()
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # * * * * * * * # # # #
# * * * * * * * * * * * * * #
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
# * * * * * * * * * * * * * #
# # # # * * * * * * * # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
>>> p.apply_mask(Ellipse(7, 15, method=2))
>>> p.show()
# # # # # # * * * # # # # # #
# # # # # * * * * * # # # # #
# # # # # * * * * * # # # # #
# # # # # * * * * * # # # # #
# # # # * * * * * * * # # # #
# * * * * * * * * * * * * * #
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
# * * * * * * * * * * * * * #
# # # # * * * * * * * # # # #
# # # # # * * * * * # # # # #
# # # # # * * * * * # # # # #
# # # # # * * * * * # # # # #
# # # # # # * * * # # # # # #
```

Using the additive method (`method=2`) on the second vertical oval mask, you can see that it doesn't interact with the previous mask at all and simply activates all of it's area on the current puzzle.

3. Subtractive: All `ACTIVE` (\*) spaces from the mask will deactivate corresponding spaces on the current puzzle, no matter the current puzzle state.

```pycon
>>> p = Puzzle(15)
>>> p.apply_mask(Ellipse(15, 7))
>>> p.show()
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # * * * * * * * # # # #
# * * * * * * * * * * * * * #
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
# * * * * * * * * * * * * * #
# # # # * * * * * * * # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
>>> p.apply_mask(Ellipse(7, 15, method=3))
>>> p.show()
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# * * * # # # # # # # * * * #
* * * * # # # # # # # * * * *
* * * * # # # # # # # * * * *
* * * * # # # # # # # * * * *
# * * * # # # # # # # * * * #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
```

Using the subtractive method (`method=3`) on the second vertical oval mask, you can see that it doesn't interact with the previous mask at all and simply deactivates all of its area on the current puzzle.

### Mask().static

A `Puzzle` object retains all applied masks so that they can be reapplied if the puzzle size changes. Only masks marked as non static `Mask.static = False` will be reapplied. All masks are marked as `True` by default.

The reason for this property, is there are many [Preset Masks](#preset-masks) that are calculated based on the puzzle size. These masks will easily scale if you change the puzzle size. But a problem arises when you create a custom [Bitmap](#bitmap-masks) or [Polygon](#polygon-masks) mask that can't be easily recalculated to fit on a different puzzle size. In this case (`Mask.static = True`) the mask will remain in `Puzzle.masks` but will not be re-applied when the puzzle size changes.

If you would like to remove all static masks from `Puzzle.masks` after a resize, you can use `Puzzle.remove_static_masks()`. If you want to remove all masks from a puzzle (static or not), use `Puzzle.remove_masks()`

Masks can be applied to a `Puzzle` object using `Puzzle.apply_mask()` (for singular operations) or `Puzzle.apply_mask([List])` (for multiple operations).

### Mask() Methods

- Mask.generate() will generate a mask at the supplied puzzle size (required). If no points have been specified the mask will be solid.
- Mask.show() will show a visual representation of the mask. Mostly for creating and testing.
- Mask.flip_horizontal() will flip the entire mask horizontally (left to right).
- Mask.flip_vertical() will flip the entire mask vertically (top to bottom).
- Mask.transpose() will interchange each row with the corresponding column of the mask.

### Mask Shape Centering

Please note, anytime a mask shape with a calculated center (Triangle, Diamond, Ellipse, Star, Heart) is applied to a puzzle with an even `Puzzle.size` the mask will be offset one grid unit toward the top-left origin point (0, 0) since there is no true center.

`Puzzle` size is even and an `Ellipse` size is odd...

```pycon
>>> p = Puzzle(9)
>>> p.apply_mask(Ellipse(8, 4))
>>> p.show()
# # # # # # # # #
# # # # # # # # #
# * * * * * * # #
* * * * * * * * #
* * * * * * * * #
# * * * * * * # #
# # # # # # # # #
# # # # # # # # #
# # # # # # # # #
```

`Puzzle` size is odd and an `Ellipse` size is even...

```pycon
>>> p = Puzzle(10)
>>> p.apply_mask(Ellipse(9, 5))
>>> p.show()
# # # # # # # # # #
# # # # # # # # # #
# # * * * * * # # #
* * * * * * * * * #
* * * * * * * * * #
* * * * * * * * * #
# # * * * * * # # #
# # # # # # # # # #
# # # # # # # # # #
# # # # # # # # # #
```

## Preset Masks

Current preset masks:

- Bitmap Masks
    - Ellipse
    - Circle

- Polygon Mask
    - Triangle
    - Diamond
    - Pentagon
    - Hexagon
    - Octagon
    - Star
    - Heart

## Bitmap Masks

Bitmap masks work similarly to bitmap images. Every point (grid square) specified in the `Bitmap.points` property will be included in the mask.

Masks that inherit from Bitmap:
- [Ellipse](#ellipse)
- [Circle](#circle)

### Ellipse

Draw an ellipse at the specified `width` and `height` on the puzzle. This is the mask type that was used above to explain [Mask Method Types](#method-types).

```pycon
>>> p = Puzzle(20)
>>> p.apply_mask(Ellipse(18,10))
>>> p.show()
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # * * * * * * * * # # # # # #
# # # # * * * * * * * * * * * * # # # #
# # * * * * * * * * * * * * * * * * # #
# * * * * * * * * * * * * * * * * * * #
# * * * * * * * * * * * * * * * * * * #
# * * * * * * * * * * * * * * * * * * #
# * * * * * * * * * * * * * * * * * * #
# # * * * * * * * * * * * * * * * * # #
# # # # * * * * * * * * * * * * # # # #
# # # # # # * * * * * * * * # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
```

### Circle

Draw a circle that fills the entire puzzle. And, since a circle is just an ellipse with width == height, the `Circle` class inherits from the `Ellipse` class but doesn't accept any parameters.

```pycon
>>> p = Puzzle(10)
>>> p.apply_mask(Circle())
>>> p.show()
# # # * * * * # # #
# * * * * * * * * #
# * * * * * * * * #
* * * * * * * * * *
* * * * * * * * * *
* * * * * * * * * *
* * * * * * * * * *
# * * * * * * * * #
# * * * * * * * * #
# # # * * * * # # #
```

🍩 Donuts anyone?

```pycon
>>> p = Puzzle(21)
>>> e1 = Ellipse(21, 21)
>>> e2 = Ellipse(9, 9, method=3)
>>> p.apply_masks([e1, e2])
>>> p.show()
# # # # # # # * * * * * * * # # # # # # #
# # # # # * * * * * * * * * * * # # # # #
# # # # * * * * * * * * * * * * * # # # #
# # # * * * * * * * * * * * * * * * # # #
# # * * * * * * * * * * * * * * * * * # #
# * * * * * * * * * * * * * * * * * * * #
# * * * * * * * # # # # # * * * * * * * #
* * * * * * * # # # # # # # * * * * * * *
* * * * * * # # # # # # # # # * * * * * *
* * * * * * # # # # # # # # # * * * * * *
* * * * * * # # # # # # # # # * * * * * *
* * * * * * # # # # # # # # # * * * * * *
* * * * * * # # # # # # # # # * * * * * *
* * * * * * * # # # # # # # * * * * * * *
# * * * * * * * # # # # # * * * * * * * #
# * * * * * * * * * * * * * * * * * * * #
# # * * * * * * * * * * * * * * * * * # #
# # # * * * * * * * * * * * * * * * # # #
# # # # * * * * * * * * * * * * * # # # #
# # # # # * * * * * * * * * * * # # # # #
# # # # # # # * * * * * * * # # # # # # #
```

## Polygon Masks

Polygon masks accept a list of at least 3 points. During mask generation those points will be connected using the [Bresenham's line algorithm](https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm), then the shape will be filled using the `Polygon.fill_shape()` method.

```pycon
>>> p = Puzzle(11)
>>> polygon = Polygon([(1,1), (7,4), (2,9)])
>>> p.apply_mask(polygon)
# # # # # # # # # # #
# * * # # # # # # # #
# * * * * # # # # # #
# * * * * * * # # # #
# * * * * * * * # # #
# # * * * * * # # # #
# # * * * * # # # # #
# # * * * # # # # # #
# # * * # # # # # # #
# # * # # # # # # # #
# # # # # # # # # # #
```

⚠️ I have noticed that on some polygons Bresenham's line algorithm calculation can be slightly off when drawing back toward the origin point (after the halfway point). This may be due to rounding but I am not sure at this point. I have created a fix (not yet implemented) that draws the first half of the path from the origin, then returns to the origin and draws the second half of the path in reverse order. I just need to iron out a few edge cases before implementing it.

Masks that inherit from Polygon
- [Rectangle](#rectangle)
- [Triangle](#triangle)
- [Diamond](#diamond)
- [Star](#star)
- [Heart](#heart)
- [ConvexPolygon](#convexpolygon)

### Rectangle

Draw a rectangle mask from 4 points. The points should be specified as a list of (x, y) tuples. The default origin point of (0, 0) is at the top-left of the puzzle.

```pycon
>>> p = Puzzle(11)
>>> p.apply_mask(Rectangle(5,7))
>>> p.show()
* * * * * * * # # # #
* * * * * * * # # # #
* * * * * * * # # # #
* * * * * * * # # # #
* * * * * * * # # # #
# # # # # # # # # # #
# # # # # # # # # # #
# # # # # # # # # # #
# # # # # # # # # # #
# # # # # # # # # # #
# # # # # # # # # # #
```

You can also specify a specific (x, y) origin `position=(2,3)` from where the rectangle will be drawn.

```pycon
>>> p = Puzzle(11)
>>> p.apply_mask(Rectangle(5,7, position=(3,4)))
>>> p.show()
# # # # # # # # # # #
# # # # # # # # # # #
# # # # # # # # # # #
# # # # * * * * * * *
# # # # * * * * * * *
# # # # * * * * * * *
# # # # * * * * * * *
# # # # * * * * * * *
# # # # # # # # # # #
# # # # # # # # # # #
# # # # # # # # # # #
```

### Triangle

Draw a triangle that fills the entire puzzle.

* An odd `puzzle_size` will generate an Isosceles Triangle.
* An even `puzzle_size` will generate a Scalene Triangle.

⚠️ If you prefer an [Equilateral Triangle](#equilateral-triangle), don't worry that is built-in too using the [ConvexPolygon](#ConvexPolygon) mask so no need to worry with the calculation.

```pycon
>>> p = Puzzle(11)
>>> p.apply_mask(Triangle())
>>> p.show()
# # # # # * # # # # #
# # # # # * * # # # #
# # # # * * * # # # #
# # # # * * * * # # #
# # # * * * * * # # #
# # # * * * * * * # #
# # * * * * * * * # #
# # * * * * * * * * #
# * * * * * * * * * #
# * * * * * * * * * *
* * * * * * * * * * *
```

### Diamond

Draw a diamond that fills the entire puzzle.

* An odd `puzzle_size` will generate a Rhombus with equal sides.

```pycon
>>> p = Puzzle(10)
>>> p.apply_mask(Diamond())
>>> p.show()
# # # # * # # # # #
# # # * * * # # # #
# # * * * * * * # #
# * * * * * * * * #
* * * * * * * * * *
# * * * * * * * * #
# # * * * * * * # #
# # * * * * * # # #
# # # * * * # # # #
# # # # * # # # # #
```

* An even `puzzle_size` will generate a Trapezoid with unequal sides.

```pycon
>>> p = Puzzle(11)
>>> p.apply_mask(Diamond())
>>> p.show()
# # # # # * # # # # #
# # # # * * * # # # #
# # # * * * * * # # #
# # * * * * * * * # #
# * * * * * * * * * #
* * * * * * * * * * *
# * * * * * * * * * #
# # * * * * * * * # #
# # # * * * * * # # #
# # # # * * * # # # #
# # # # # * # # # # #
```

If you want to generate an Equilateral Diamond (equal sides, with opposing sides parallel to each other) no matter the `puzzle_size`, there is a pre-built [EquilateralDiamond](#equilateraldiamond) mask based on calculations from the [ConvexPolygon](#ConvexPolygon) mask it inherits from.

### Star

The Star or [Pentagram](https://en.wikipedia.org/wiki/Pentagram) is regular is a regular 5-pointed star polygon. The points for the star are calculated using the `calculate_regular_convex_polygon_points()` function just like the [Pentagon](#pentagon) below. The points are then rearranged and connected just like any Polygon mask.

```pycon
>>> p = Puzzle(13)
>>> p.apply_mask(Heart())
>>> p.show()
# # # # # # * # # # # # #
# # # # # # * # # # # # #
# # # # # * * * # # # # #
# # # # # * * * # # # # #
* * * * * * * * * * * * *
# * * * * * * * * * * * #
# # # * * * * * * * # # #
# # # * * * * * * * # # #
# # # * * * * * * * # # #
# # # * * * # * * * # # #
# # * * * # # # * * * # #
# # * # # # # # # # * # #
# # # # # # # # # # # # #
```

The star will fill as much of the puzzle as possible and can be rotated.

```pycon
>>> p = Puzzle(13)
>>> p.apply_mask(Heart(rotation=30))
>>> p.show()
# # # # # # # # # # # # #
# # # * # # # # # # # # #
# # # * * # # # # * * # #
# # # * * * # * * * # # #
# # # # * * * * * * # # #
# # # * * * * * * # # # #
# * * * * * * * * * # # #
* * * * * * * * * * * # #
# # # # * * * * * * * * #
# # # # * * * # # # # # #
# # # # # * * # # # # # #
# # # # # * # # # # # # #
# # # # # * # # # # # # #
```

### Heart

```pycon
>>> p = Puzzle(13)
>>> p.apply_mask(Heart())
>>> p.show()
# # * * # # # # # * * # #
# * * * * # # # * * * * #
# * * * * * # * * * * * #
* * * * * * * * * * * * *
* * * * * * * * * * * * *
* * * * * * * * * * * * *
* * * * * * * * * * * * *
# * * * * * * * * * * * #
# # * * * * * * * * * # #
# # # * * * * * * * # # #
# # # # * * * * * # # # #
# # # # # * * * # # # # #
# # # # # # * # # # # # #
```

### ConvexPolygon

Draw a regular [Convex Polygon](https://en.wikipedia.org/wiki/Convex_polygon) mask with 3 or more sides. All points are calculated from the puzzle center and cover as much of the available puzzle area as possible.

All ConvexPolygon masks accept two parameters, `sides` and `rotation`. 

Masks that inherit from ConvexPolygon:
- [EquilateralTriangle](#equilateraltriangle)*
- [EquilateralDiamond](#equilateraldiamond)*
- [Pentagon](#pentagon)*
- [Hexagon](#hexagon)*
- [Octagon](#octagon)*

\* Are calculated as [Regular Polygons](https://en.wikipedia.org/wiki/Regular_polygon)

### EquilateralTriangle

A [Triangle](https://en.wikipedia.org/wiki/Equilateral_triangle) in which all 3 sides have the same length and all three internal angles are also congruent to each other at 60°.

```pycon
# # # # # # * # # # # # #
# # # # # * * * # # # # #
# # # # # * * * # # # # #
# # # # * * * * * # # # #
# # # # * * * * * # # # #
# # # * * * * * * * # # #
# # # * * * * * * * # # #
# # * * * * * * * * * # #
# # * * * * * * * * * # #
# * * * * * * * * * * * #
# # # # # # # # # # # # #
# # # # # # # # # # # # #
# # # # # # # # # # # # #
```

♺ Want it rotated?

```pycon
>>> p = Puzzle(13)
>>> p.apply_mask(EquilateralTriangle(45))
>>> p.show()
# # # # # # # # # # # # #
# # # # # # # # # # # # #
# # * * * # # # # # # # #
# # * * * * * * * * # # #
# # * * * * * * * * * * *
# # # * * * * * * * * * #
# # # * * * * * * * * # #
# # # * * * * * * * # # #
# # # * * * * * * # # # #
# # # * * * * * # # # # #
# # # # * * * # # # # # #
# # # # * * # # # # # # #
# # # # * # # # # # # # #
```

### EquilateralDiamond

A Diamond (rotated square) with 4 equal sides.

```pycon
# # # # # # * # # # # # #
# # # # # * * * # # # # #
# # # # * * * * * # # # #
# # # * * * * * * * # # #
# # * * * * * * * * * # #
# * * * * * * * * * * * #
* * * * * * * * * * * * *
# * * * * * * * * * * * #
# # * * * * * * * * * # #
# # # * * * * * * * # # #
# # # # * * * * * # # # #
# # # # # * * * # # # # #
# # # # # # * # # # # # #
```

### Pentagon

A [simple Pentagon](https://en.wikipedia.org/wiki/Pentagon) with 5 equal sides.

```pycon
# # # # # # * # # # # # #
# # # # * * * * * # # # #
# # # * * * * * * * # # #
# * * * * * * * * * * * #
* * * * * * * * * * * * *
* * * * * * * * * * * * *
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# # * * * * * * * * * # #
# # * * * * * * * * * # #
# # # # # # # # # # # # #
```

### Hexagon

A [simple Hexagon]

```pycon
# # # # # # * # # # # # #
# # # # * * * * * # # # #
# # * * * * * * * * * # #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# # * * * * * * * * * # #
# # # # * * * * * # # # #
# # # # # # * # # # # # #
```

### Octagon

```pycon
# # # # # * * # # # # # #
# # # * * * * * * # # # #
# # * * * * * * * * * # #
# # * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * *
* * * * * * * * * * * * *
* * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * # #
# # * * * * * * * * * # #
# # # # * * * * * * # # #
# # # # # # * * # # # # #
```

⚠️ If you want the Octagon to look like a stop sign you can set `rotation=30` at creation.

❗️ Please note, there seems to be an off-by-1 miscalculation with the Octagon. It could be a rounding issue but I'm not sure at the moment. Hopefully, I can sort it out later.
# Some Fun Puzzle Masks

I had a few free minutes tonight so I made some fun mask shapes. It's really easy to make these. I could easily create a library of them.

## Donut 🍩

```pycon
>>> p = Puzzle(21)
>>> p.apply_mask(Ellipse(21, 21))
>>> p.apply_mask(Ellipse(9, 9, method=3))

              S W X O M K B
          T C G B J G K B R G Y
        Q C O Q T M O H B W R T L
      M C B G X K L G D Z K X O M F
    H O C T W Q F H Q J R U G Z F L H
  T R W D R V R B B R B F E V E J B O U
  T A J U W B Z           L J M F B A I
C E G U H U L               F O I T K P R
X U O A F A                   F F K G Y Y
L M O R I U                   K T W Z J H
V F Y N J V                   B N A D B V
W Y Y G H E                   C A L Z T S
R R R P Y N                   H H S G Z Z
L V C C F D B               W T X C Z F C
  F R Q O I P X           T G B Y I H Z
  Y I F C A H Q Z E Z R F V Z H D I C M
    M C J V E Z H M F X N L G T R J W
      J O L T T G T V R J A T F A G
        K M H Y X D O C D L A J C
          E C O P D X M J F J R
              O I R J O J A
```

## Smiley Face

```pycon
>>> p = Puzzle(21)
>>> p.apply_mask(Rectangle(2,6, position=(6,4), method=3))
>>> p.apply_mask(Rectangle(2,6, position=(13,4), method=3))
>>> p.apply_mask(Rectangle(9,2, position=(6,14), method=3))
>>> p.apply_mask(Rectangle(2,2, position=(5,13), method=3))
>>> p.apply_mask(Rectangle(2,2, position=(14,13), method=3))

              P T X L S G P
          J N Y H M I I N A Z T
        Y F W D U S N I D V S J J
      C P X T K Z Z R L L H S H D E
    Y B M X     B S V J I     A S Q P
  N Z R Z M     A L P R O     I U S P H
  L U L N L     K H E Y H     E O D Q D
S Q D I V O     B P E E C     O H D Y C J
B R B L I Y     K K P K J     T C I E L N
Y S Z T Q S     Y Q P M G     C M Q R N U
D J X A R V U F O R J H K D E N R E F N T
R Q C K N F F O L I S W A X K L U S U G D
A T B B J C O W P K U Q Q E U H Z Y T Y K
H O W J R     C D F S P O O     W K S H S
  O Q M C                       B L O L
  G A M A B                   F S N G K
    Q B A D X E I Q M D H B P A L E V
      J L B E Q I Q T Y H V V Q O Y
        X U G E T H A S P P G C T
          Q G N L V Y S S S Q J
              V R R R B E D
```

# Tree

```pycon
>>> p = Puzzle(15)
>>> p.apply_mask(EquilateralTriangle())
>>> p.apply_mask(Rectangle(3,3, position=(6,12), method=2))

              V
            D E Y
            M G E
          U G K W R
          F N B W D
        Z J Q Y K P R
        P E P A W X S
      Z L K Q N P N T W
      U D B M Z H R N I
    I H Z V T X I H F I W
    A E D X N B E U S V U
  S Y K D R A T C V M L G K
            F I Q
            A A S
            U Z M
```

## Six Pointed Star

```pycon
>>> p = Puzzle(21)
>>> p.apply_mask(EquilateralTriangle())
>>> p.apply_mask(EquilateralTriangle(rotation=180))

                    D
                  S H L
                  E O Y
                E L L L H
                A Z X C F
  L Z W O J Y P F Y L Q C D Y R F O C E
    O S F T K K R B P C X O O D O Y O
    A B T K K L T X B J K J E Q T O Q
      I G F P S O E O J X N C P W T
      B Q P W G T S G L H N O O U K
        D E E E W B H O Z J U D R
      B X E M O K U F X A V Z T X D
      H I D R C R B R S M R I F P R
    G O H W N G Y P A J M D F F K K B
    L B F T V R D T V V Q D G B U N C
  R T M N X B H I I M I Q G X Z C Y D A
                H S U L N
                G Y E Y S
                  R Q P
                  G S E
                    N
```

## Cross

```pycon
>>> p = Puzzle(15)
>>> p.apply_mask(Rectangle(3,15, position=(6,0)))
>>> p.apply_mask(Rectangle(15,3, position=(0,6), method=2))
>>> p.invert_masking()

M B K U J J       E E P O Z I
B K G V S K       Q F S J H Z
Y G X S F P       T E C V L Y
K T S R P B       K C D N D Z
F U K Q N W       P R K H C D
Q W L F W O       R T A H J E



X K S C G E       O U N J K K
R P F O U Y       J L J L K H
L A P O A R       K G U P C V
T J C Y K W       U M W A J Y
I P M V T T       I U Y G S K
Y J J R T V       B T M L O D
```

## Inverted Cross In Circle

```pycon
p = Puzzle(21)
p.apply_mask(Circle())
p.apply_mask(Rectangle(31,3, position=(0,9), method=3))
p.apply_mask(Rectangle(3,31, position=(9,0), method=3))

              H N       S B
          G K I F       U C F V
        K J F Z S       R S H X U
      O X C N D U       L R Z O H K
    I P P Q I L I       Z H N A B I A
  C M O P B P T E       W A I P Y P I E
  A P B S V D S J       X N U E G O A M
P I O J D K T Z A       D T K U A B I T I
S K S T G O Z M W       I L E D T N J C T



L L N B I I Z W J       M N Z H P K A O D
H I E Z E Y W A Q       L N M Y L Y M O X
  T G R W M C K D       M E T I D P Q N
  J M K K X U C Z       X E G A X B B O
    N U W C X M G       U C L G F S Y
      F M K C L S       I M M V D L
        A R G I B       R A J I Q
          L C X P       S M P I
              F U       Q K
```

## "Hole-y" Cross 😉

```pycon
>>> p = Puzzle(21)
>>> p.apply_mask(Rectangle(7,7, method=3))
>>> p.apply_mask(Rectangle(7,7, position=(14,0), method=3))
>>> p.apply_mask(Rectangle(7,7, position=(0,14), method=3))
>>> p.apply_mask(Rectangle(7,7, position=(14,14), method=3))
>>> p.apply_mask(Ellipse(5,5, method=3))

              K D G N K C N
              S S H U S S K
              S C H L N I N
              M G A J U G E
              C U Y U M H B
              M R V I Z I R
              I O Q Y H P J
K D G P X H X O K Q U R F K W K J U I A X
M F Y N J Y U T M       M C Q T F E T Q T
C C H O E J U S           I G L J U E Y E
E R I E T G S K           K J C D Y H C Y
Z F D J N F I Y           M X I C T Q R L
B Y H N D H P A E       O X C C N O G L M
W A H A O H F Z D L Y Z U W E Q T G C Y G
              X K A R X L P
              H T T F I A F
              I P M K V G A
              G D Z S O Y G
              T J X F P W O
              Y S V E R X T
              R F E Q U S B
```

## Checkboard

```pycon
>>> p = Puzzle(30)
>>> p.invert_masking()
>>> for x in range(0, 30, 5):
>>>     for y in range(0, 30, 5):
>>>         if (x//5) % 2 == 0 and (y//2) % 5 == 0:
>>>             p.apply_mask(Rectangle(5,5, position=(x,y), method=2))
>>>         if (x//5) % 2 != 0 and (y//2) % 5 != 0:
>>>             p.apply_mask(Rectangle(5,5, position=(x,y), method=2))

O B W Z O           B R W U B           C J M N G
N J K D Y           U X U I J           K N W J I
E U R S H           H R A A F           J F L A F
Q T F U A           O B E O H           N Q L S O
I O S X N           I A H N U           K M U Z M
          N D J W K           W W W F S           G Q Q M Y
          D Q E F K           O I L Z F           J U T V S
          E B B M Y           O F Y B C           V T Y Z K
          B T I Y M           O N A L C           M T T A M
          F W P I U           R V E E R           P J I F E
A P W M Q           K M U J V           I C S A B
K G I S H           V P H O S           V J I X A
K K B M F           X X Y P J           D S O H P
R D I R B           V Z S E M           X W U A F
E D G T O           Q Z T L J           E G T Y W
          T X E H E           H M F T N           Q X A C T
          A T H C Q           J D B F V           E A U M X
          D S H S A           A M U N E           V J Q C C
          E V H W T           U O M T I           A Q X T D
          I L U Y M           S P P A C           R W X B Q
V T C F W           Z D Y N K           F P I K S
B C D I U           D M I P Y           P C G H T
O P F E U           Q K R J Q           M Q Y H Z
J Z D H Q           L L G W G           R N L V I
Z Q J S Z           M I G Z P           K X W D C
          Y L U H I           M R D J O           K G Y H U
          M L A U T           E D P X Y           N W Q V U
          V P O N X           T T H C S           W J B A R
          C U I O E           G P I H S           F J O F A
          W G Q E N           D C L A S           L L V M V
```
Those look amazing!  Haven't had too much time to do some proper code review.

Fix patch:
diff --git a/CHANGLOG.md b/CHANGLOG.md
index f08d27a..555f393 100644
--- a/CHANGLOG.md
+++ b/CHANGLOG.md
@@ -8,7 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## Added
 - Testing to make sure key only shows placed words in response to bug fixed in v2.0.1
+- Word class now tracks all coordinates of word (using this in place of keeping a separate 'solution' puzzle)
+- Puzzle Masking
+    - Puzzle output (show(), save()) crops output to the puzzle mask so no dead space
+        - Key is offset by cropped bounding box
+    - CLI implementation
+        - -m, --mask Mask the puzzle to a particular shape (choices: circle, diamond, triangle, heart, hexagon, octagon, pentagon, star).
+        - -im, --image-mask Mask the puzzle shape to a provided image (accepts: BMP, JPEG, PNG).
 
+### Changed
+- CSV export no longer includes the solution
+- Moved all types from 'types.py' file to appropriate object files to help with type checking
+- no_duped_words() function was edited to no longer accept a WordSearch object to make testing easier
+- `.show()`, `.save()`, and `.json` now crop the puzzle to the mask active area (bounding box)
+- CLI arguments/flags now displayed in alphabetical order within help
 
 ## [2.0.1] - 2022-10-28
 
diff --git a/donkey.pdf b/donkey.pdf
deleted file mode 100644
index 359ccb6..0000000
Binary files a/donkey.pdf and /dev/null differ
diff --git a/donkey2.pdf b/donkey2.pdf
deleted file mode 100644
index 8e8e283..0000000
Binary files a/donkey2.pdf and /dev/null differ
diff --git a/requirements-dev.txt b/requirements-dev.txt
index bee9f1b..30e96b2 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -2,7 +2,7 @@ black==22.3.0
 flake8==4.0.1
 fpdf2==2.5.0
 isort==5.10.1
-mypy==0.931
+mypy==0.990
 pre-commit==2.17.0
 PyPDF2==1.26.0
 pytest==6.2.5
diff --git a/setup.cfg b/setup.cfg
index 38a8c55..eca9979 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -48,4 +48,5 @@ console_scripts =
     word-search = word_search_generator.cli:main
 
 [flake8]
+extend-ignore = E203
 max-line-length = 88
diff --git a/src/word_search_generator/__init__.py b/src/word_search_generator/__init__.py
index 5fa938b..ea2b357 100644
--- a/src/word_search_generator/__init__.py
+++ b/src/word_search_generator/__init__.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 """
     Word Search
     -----------
@@ -7,16 +9,25 @@
     :license: MIT, see LICENSE for more details.
 """
 
+# TODO: implement @classmethod factory for random puzzles
+
 __app_name__ = "word-search"
 __version__ = "2.0.1"
 
 
 import json
 from pathlib import Path
-from typing import Iterable, Optional, Union
+from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
+
+from . import export, generate, utils
+from .config import ACTIVE, INACTIVE, max_puzzle_size, min_puzzle_size
+from .mask import CompoundMask, Mask
+from .word import Direction, KeyInfo, KeyInfoJson, Wordlist
 
-from . import config, export, generate, utils
-from .types import DirectionSet, Key, Puzzle, Wordlist
+Puzzle = List[List[str]]
+DirectionSet = Set[Direction]
+Key = Dict[str, KeyInfo]
+KeyJson = Dict[str, KeyInfoJson]
 
 
 class WordSearch:
@@ -47,8 +58,9 @@ def __init__(
 
         # setup puzzle
         self._puzzle: Puzzle = []
-        self._solution: Puzzle = []
-        self._size: int = size if size else 0
+        self._size: int = 0
+        self._masks: List[Any] = []
+        self._mask: Puzzle = []
 
         # setup words
         self._words: Wordlist = set()
@@ -66,10 +78,23 @@ def __init__(
             utils.validate_level(secret_level) if secret_level else self.directions
         )
 
+        if size:
+            if not isinstance(size, int):
+                raise TypeError("Size must be an integer.")
+            if not min_puzzle_size <= size <= max_puzzle_size:
+                raise ValueError(
+                    f"Puzzle size must be >= {min_puzzle_size}"
+                    + f" and <= {max_puzzle_size}"
+                )
+            self._size = size
         if self.words:
-            self._size = generate.calc_puzzle_size(self._words, self._directions, size)
+            self._size = utils.calc_puzzle_size(self._words, self._directions, size)
             self._generate()
 
+    # **************************************************** #
+    # ******************** PROPERTIES ******************** #
+    # **************************************************** #
+
     @property
     def words(self) -> Wordlist:
         """The current puzzle words."""
@@ -78,7 +103,7 @@ def words(self) -> Wordlist:
     @property
     def placed_words(self) -> Wordlist:
         """The current puzzle words."""
-        return {word for word in self._words if word.direction}
+        return {word for word in self._words if word.placed}
 
     @property
     def hidden_words(self) -> Wordlist:
@@ -88,7 +113,7 @@ def hidden_words(self) -> Wordlist:
     @property
     def placed_hidden_words(self) -> Wordlist:
         """The current puzzle words."""
-        return {word for word in self.hidden_words if word.direction}
+        return {word for word in self.hidden_words if word.placed}
 
     @property
     def secret_words(self) -> Wordlist:
@@ -98,7 +123,7 @@ def secret_words(self) -> Wordlist:
     @property
     def placed_secret_words(self) -> Wordlist:
         """The current secret puzzle words."""
-        return {word for word in self.secret_words if word.direction}
+        return {word for word in self.secret_words if word.placed}
 
     @property
     def puzzle(self) -> Puzzle:
@@ -106,13 +131,41 @@ def puzzle(self) -> Puzzle:
         return self._puzzle
 
     @property
-    def solution(self) -> Puzzle:
+    def solution(self) -> None:
         """Solution to the current puzzle state."""
-        return self._solution
+        self.show(solution=True)
+
+    @property
+    def mask(self) -> Puzzle:
+        """The current puzzle state."""
+        return self._mask
+
+    @property
+    def masks(self) -> List[Mask]:
+        """Puzzle masking status."""
+        return self._masks
+
+    @property
+    def masked(self) -> bool:
+        """Puzzle masking status."""
+        return bool(self.masks)
+
+    @property
+    def bounding_box(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
+        """Bounding box of the active puzzle area."""
+        return utils.find_bounding_box(self.mask)
+
+    @property
+    def cropped_puzzle(self) -> Puzzle:
+        """The current puzzle state cropped to the mask."""
+        min_x, min_y = self.bounding_box[0]
+        max_x, max_y = self.bounding_box[1]
+        return [[c for c in row[min_x:max_x]] for row in self.puzzle[min_y:max_y]]
 
     @property
     def key(self) -> Key:
-        """The current puzzle answer key (1-based)."""
+        """The current puzzle answer key (1-based) based from
+        Position(0, 0) of the entire puzzle (not masked area)."""
         return {word.text: word.key_info for word in self.placed_words}
 
     @property
@@ -120,18 +173,22 @@ def json(self) -> str:
         """The current puzzle, words, and answer key in JSON."""
         if not self.key:
             return json.dumps({})
+        # TODO: add secret words to JSON output
         return json.dumps(
             {
                 "puzzle": self.puzzle,
+                "mask": self.mask,
                 "words": [word.text for word in self.placed_words],
                 "key": {
-                    word.text: word.key_info_json
-                    for word in self.words
-                    if word.direction
+                    word.text: word.key_info_json for word in self.words if word.placed
                 },
             }
         )
 
+    # ********************************************************* #
+    # ******************** GETTERS/SETTERS ******************** #
+    # ********************************************************* #
+
     @property
     def directions(self) -> DirectionSet:
         """Valid directions for puzzle words."""
@@ -147,7 +204,7 @@ def directions(self, val: Union[int, str, Iterable[str]]):
             of valid directions from the Direction object.
         """
         self._directions = utils.validate_level(val)
-        self._reset_puzzle()
+        self._generate()
 
     def _set_level(self, val: int) -> None:
         """Set valid puzzle directions to a predefined level set.
@@ -180,7 +237,7 @@ def secret_directions(self, val: Union[int, str, Iterable[str]]):
             self._secret_directions = utils.validate_level(val)
         else:
             self._secret_directions = None
-        self._reset_puzzle()
+        self._generate()
 
     @property
     def size(self) -> int:
@@ -201,41 +258,19 @@ def size(self, val: int):
         """
         if not isinstance(val, int):
             raise TypeError("Size must be an integer.")
-        if not config.min_puzzle_size <= val <= config.max_puzzle_size:
+        if not min_puzzle_size <= val <= max_puzzle_size:
             raise ValueError(
-                f"Puzzle size must be >= {config.min_puzzle_size}"
-                + f" and <= {config.max_puzzle_size}"
+                f"Puzzle size must be >= {min_puzzle_size}"
+                + f" and <= {max_puzzle_size}"
             )
-        if self._size != val:
+        if self.size != val:
             self._size = val
-            self._reset_puzzle()
-
-    def reset_size(self):
-        """Reset the puzzle size to the default setting
-        (based on longest word length and total words)."""
-        self._size = generate.calc_puzzle_size(self._words, self._directions)
-        self._reset_puzzle()
+            self._reapply_masks()
+            self._generate()
 
-    def _generate(self) -> None:
-        generate.fill_words(self)
-        if self.key:
-            generate.fill_blanks(self)
-
-    def _process_input(self, words: str, action: str = "add", secret: bool = False):
-        if secret:
-            clean_words = utils.cleanup_input(words, secret=True)
-        else:
-            clean_words = utils.cleanup_input(words)
-
-        if action == "add":
-            # remove all new words first so any updates are reflected in the word list
-            self._words.symmetric_difference_update(clean_words)
-            self._words.update(clean_words)
-        if action == "remove":
-            self._words.difference_update(clean_words)
-        if action == "replace":
-            self._words.clear()
-            self._words.update(clean_words)
+    # ************************************************* #
+    # ******************** METHODS ******************** #
+    # ************************************************* #
 
     def show(self, solution: bool = False) -> None:
         """Show the current puzzle with or without the solution.
@@ -252,9 +287,9 @@ def save(self, path: Union[str, Path], solution: bool = False) -> str:
         """Save the current puzzle to a file.
 
         Args:
-            path (Union[str, Path]): A file save path
+            path (Union[str, Path]): A file save path.
             solution (bool, optional): Include solution with the saved file.
-                                       Defaults to False.
+            Only applies to PDF file type. Defaults to False.
 
         Returns:
             str: Final save path of the file.
@@ -268,12 +303,54 @@ def save(self, path: Union[str, Path], solution: bool = False) -> str:
         path = export.validate_path(path)
         # write the file
         if ftype == "csv":
-            saved_file = export.write_csv_file(path, self, solution)
+            saved_file = export.write_csv_file(path, self)
         else:
             saved_file = export.write_pdf_file(path, self, solution)
         # return saved file path
         return str(saved_file)
 
+    def reset_size(self):
+        """Reset the puzzle size to the default setting
+        (based on longest word length and total words)."""
+        self.size = utils.calc_puzzle_size(self._words, self._directions)
+        self._generate()
+
+    # *************************************************************** #
+    # ******************** PROCESSING/GENERATION ******************** #
+    # *************************************************************** #
+
+    def _generate(self, fill_puzzle: bool = True) -> None:
+        """Generate the puzzle grid."""
+        self._puzzle = utils.build_puzzle(self.size, "")
+        for word in self.words:
+            word.remove_from_puzzle()
+        if not self.mask or len(self.mask) != self.size:
+            self._mask = utils.build_puzzle(self.size, ACTIVE)
+        if fill_puzzle:
+            self._fill_puzzle()
+
+    def _fill_puzzle(self) -> None:
+        if self.words:
+            generate.fill_words(self)
+        if self.key:
+            generate.fill_blanks(self)
+
+    def _process_input(self, words: str, action: str = "add", secret: bool = False):
+        if secret:
+            clean_words = utils.cleanup_input(words, secret=True)
+        else:
+            clean_words = utils.cleanup_input(words)
+
+        if action == "add":
+            # remove all new words first so any updates are reflected in the word list
+            self._words.symmetric_difference_update(clean_words)
+            self._words.update(clean_words)
+        if action == "remove":
+            self._words.difference_update(clean_words)
+        if action == "replace":
+            self._words.clear()
+            self._words.update(clean_words)
+
     def add_words(
         self, words: str, secret: bool = False, reset_size: bool = False
     ) -> None:
@@ -289,7 +366,7 @@ def add_words(
         self._process_input(words, "add", secret)
         if reset_size:
             self.reset_size()
-        self._reset_puzzle()
+        self._generate()
 
     def remove_words(self, words: str, reset_size: bool = False) -> None:
         """Remove words from the puzzle.
@@ -302,7 +379,7 @@ def remove_words(self, words: str, reset_size: bool = False) -> None:
         self._process_input(words, "remove")
         if reset_size:
             self.reset_size()
-        self._reset_puzzle()
+        self._generate()
 
     def replace_words(
         self, words: str, secret: bool = False, reset_size: bool = False
@@ -319,15 +396,98 @@ def replace_words(
         self._process_input(words, "replace", secret)
         if reset_size:
             self.reset_size()
-        self._reset_puzzle()
+        self._generate()
 
-    def _reset_puzzle(self):
-        """Reset and regenerate the puzzle."""
-        self._puzzle = []
-        self._solution = []
-        self._key = {}
+    # ************************************************* #
+    # ******************** MASKING ******************** #
+    # ************************************************* #
+
+    def apply_mask(self, mask: Mask) -> None:
+        """Apply a singular mask object to the puzzle."""
+        if not isinstance(mask, (Mask, CompoundMask)):
+            raise TypeError("Please provide a Mask object.")
+        if mask.puzzle_size != self.size:
+            mask.generate(self.size)
+        for y in range(self.size):
+            for x in range(self.size):
+                if mask.method == 1:
+                    if mask.mask[y][x] == ACTIVE and self.mask[y][x] == ACTIVE:
+                        self.mask[y][x] = ACTIVE
+                    else:
+                        self.mask[y][x] = INACTIVE
+                elif mask.method == 2:
+                    if mask.mask[y][x] == ACTIVE:
+                        self.mask[y][x] = ACTIVE
+                else:
+                    if mask.mask[y][x] == ACTIVE:
+                        self.mask[y][x] = INACTIVE
+        # add mask to puzzle instance for later reference
+        if mask not in self.masks:
+            self.masks.append(mask)
+        # fill in the puzzle
         self._generate()
 
+    def apply_masks(self, masks: Iterable[Mask]) -> None:
+        """Apply a group of masks to the puzzle."""
+        for mask in masks:
+            self.apply_mask(mask)
+
+    def show_mask(self) -> None:
+        """Show the current puzzle mask."""
+        if self.masked:
+            for row in self.mask:
+                print(" ".join(row))
+        else:
+            print("Empty mask.")
+
+    def invert_mask(self) -> None:
+        """Invert the current puzzle mask. Has no effect on the
+        actual mask(s) found in `WordSearch.mask`."""
+        self._mask = [
+            [ACTIVE if c == INACTIVE else INACTIVE for c in row] for row in self.mask
+        ]
+        self._generate()
+
+    def flip_mask_horizontal(self) -> None:
+        """Flip the current puzzle mask along the vertical axis (left to right).
+        Has no effect on the actual mask(s) found in `WordSearch.mask`."""
+        self._mask = [r[::-1] for r in self.mask]
+        self._generate()
+
+    def flip_mask_vertical(self) -> None:
+        """Flip the current puzzle mask along the horizontal axis (top to bottom).
+        Has no effect on the actual mask(s) found in `WordSearch.mask`."""
+        self._mask = self.mask[::-1]
+        self._generate()
+
+    def transpose_mask(self) -> None:
+        """Interchange each row with the corresponding column
+        of the current puzzle mask. Has no effect on the actual
+        mask(s) found in `WordSearch.mask`."""
+        self._mask = list(map(list, zip(*self.mask)))
+        self._generate()
+
+    def remove_masks(self) -> None:
+        """"""
+        self._masks = []
+        self._mask = utils.build_puzzle(self.size, ACTIVE)
+        self._generate()
+
+    def remove_static_masks(self) -> None:
+        self._masks = [mask for mask in self.masks if not mask.static]
+
+    def _reapply_masks(self) -> None:
+        """Reapply all current masks to the puzzle."""
+        self._mask = utils.build_puzzle(self.size, ACTIVE)
+        for mask in self.masks:
+            if mask.static and mask.puzzle_size != self.size:
+                continue
+            self.apply_mask(mask)
+
+    # ******************************************************** #
+    # ******************** DUNDER METHODS ******************** #
+    # ******************************************************** #
+
     def __eq__(self, __o: object) -> bool:
         if isinstance(__o, WordSearch):
             return all(
@@ -346,7 +506,8 @@ def __repr__(self):
             f"{self.__class__.__name__}"
             + f"('{','.join([word.text for word in self.hidden_words])}', "
             + f"{utils.direction_set_repr(self.directions)}, "
-            + f"{self.size}, '{','.join([word.text for word in self.secret_words])}',"
+            + f"{self.size}, "
+            + f"'{','.join([word.text for word in self.secret_words])}',"
             + f"{utils.direction_set_repr(self.secret_directions)})"
         )
 
diff --git a/src/word_search_generator/__main__.py b/src/word_search_generator/__main__.py
index 0c38da2..4f170f5 100644
--- a/src/word_search_generator/__main__.py
+++ b/src/word_search_generator/__main__.py
@@ -1,6 +1,8 @@
+from __future__ import annotations
+
 import sys
 
-from word_search_generator.cli import main
+from .cli import main
 
 if __name__ == "__main__":
     sys.exit(main())
diff --git a/src/word_search_generator/cli.py b/src/word_search_generator/cli.py
index daa0a69..a9471fa 100644
--- a/src/word_search_generator/cli.py
+++ b/src/word_search_generator/cli.py
@@ -1,10 +1,16 @@
+from __future__ import annotations
+
 import argparse
 import pathlib
 import sys
 from typing import Optional, Sequence
 
-from word_search_generator import WordSearch, __app_name__, __version__, config, utils
-from word_search_generator.types import Direction
+from . import WordSearch, __app_name__, __version__, config, utils
+from .mask import shapes
+from .mask.bitmap import Image
+from .word import Direction
+
+BUILTIN_SHAPES = shapes.get_shape_objects()
 
 
 class RandomAction(argparse.Action):
@@ -67,35 +73,21 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
         prog=__app_name__,
         formatter_class=argparse.RawDescriptionHelpFormatter,
     )
-    group = parser.add_mutually_exclusive_group()
-    group.add_argument(
+    words_group = parser.add_mutually_exclusive_group()
+    secret_words_group = parser.add_mutually_exclusive_group()
+    mask_group = parser.add_mutually_exclusive_group()
+    words_group.add_argument(
         "words",
         type=str,
         nargs="*",
         default=sys.stdin,
         help="Words to include in the puzzle (default: stdin).",
     )
-    group.add_argument(
-        "-r",
-        "--random",
-        type=int,
-        action=RandomAction,
-        help="Generate {n} random words to include in the puzzle.",
-    )
-    group2 = parser.add_mutually_exclusive_group()
-    group2.add_argument(
-        "-x",
-        "--secret-words",
-        type=str,
-        default="",
-        help="Secret bonus words not included in the word list.",
-    )
-    group2.add_argument(
-        "-rx",
-        "--random-secret-words",
-        type=int,
-        action=RandomAction,
-        help="Generate {n} random secret words to include in the puzzle.",
+    parser.add_argument(
+        "-c",
+        "--cheat",
+        action="store_true",
+        help="Show the puzzle solution or include it within the `-o, --output` file.",
     )
     # new implementation of -l, --level allowing for more flexibility
     # keeping -l, --level for backwards compatibility
@@ -108,12 +100,41 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
         help="Difficulty level (numeric) or cardinal directions \
             puzzle words can go. See valid arguments above.",
     )
+    mask_group.add_argument(
+        "-im",
+        "--image-mask",
+        type=pathlib.Path,
+        help="Mask the puzzle to a provided image \
+            (accepts: BMP, JPEG, PNG).",
+    )
+    mask_group.add_argument(
+        "-m",
+        "--mask",
+        choices=BUILTIN_SHAPES,
+        metavar="MASK_SHAPE",
+        help=f"Mask the puzzle to a shape \
+            (choices: {', '.join(BUILTIN_SHAPES)}).",
+    )
     parser.add_argument(
-        "-xd",
-        "--secret-difficulty",
-        action=DifficultyAction,
-        help="Difficulty level (numeric) or cardinal directions \
-            secret puzzle words can go. See valid arguments above.",
+        "-o",
+        "--output",
+        type=pathlib.Path,
+        help="Output path for saved puzzle. Specify export type by appending "
+        "'.pdf' or '.csv' to your path (default: PDF).",
+    )
+    words_group.add_argument(
+        "-r",
+        "--random",
+        type=int,
+        action=RandomAction,
+        help="Generate {n} random words to include in the puzzle.",
+    )
+    secret_words_group.add_argument(
+        "-rx",
+        "--random-secret-words",
+        type=int,
+        action=RandomAction,
+        help="Generate {n} random secret words to include in the puzzle.",
     )
     parser.add_argument(
         "-s",
@@ -123,23 +144,43 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
         help=f"{config.min_puzzle_size} <= puzzle size <= {config.max_puzzle_size}",
     )
     parser.add_argument(
-        "-c",
-        "--cheat",
+        "-pm",
+        "--preview-masks",
         action="store_true",
-        help="Show the puzzle solution or include it within the `-o, --output` file.",
+        help="Preview all built-in mask shapes.",
+    )
+    secret_words_group.add_argument(
+        "-x",
+        "--secret-words",
+        type=str,
+        default="",
+        help="Secret bonus words not included in the word list.",
     )
     parser.add_argument(
-        "-o",
-        "--output",
-        type=pathlib.Path,
-        help="Output path for saved puzzle. Specify export type by appending "
-        "'.pdf' or '.csv' to your path (default: PDF).",
+        "-xd",
+        "--secret-difficulty",
+        action=DifficultyAction,
+        help="Difficulty level (numeric) or cardinal directions \
+            secret puzzle words can go. See valid arguments above.",
     )
     parser.add_argument(
         "--version", action="version", version=f"%(prog)s {__version__}"
     )
     args = parser.parse_args(argv)
 
+    # check for mask preview first
+    if args.preview_masks:
+        preview_size = 21
+        words = utils.get_random_words(21)
+        puzzle = WordSearch(words)
+        for shape in BUILTIN_SHAPES:
+            mask = eval(f"shapes.{shape}")()
+            mask.generate(preview_size)
+            print(f"{shape}")
+            mask.show(True)
+            print()
+        return 0
+
     # process puzzle words
     words = ""
     if args.random:
@@ -176,6 +217,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
         secret_level=args.secret_difficulty,
     )
 
+    # apply masking is specified
+    if args.mask:
+        puzzle.apply_mask(eval(f"shapes.{args.mask}")())
+    if args.image_mask:
+        puzzle.apply_mask(Image(args.image_mask))
+
     # show the result
     if args.output:
         foutput = puzzle.save(path=args.output, solution=args.cheat)
diff --git a/src/word_search_generator/config.py b/src/word_search_generator/config.py
index 5baf0ba..0858a1c 100644
--- a/src/word_search_generator/config.py
+++ b/src/word_search_generator/config.py
@@ -1,4 +1,6 @@
-from word_search_generator.types import Direction
+from __future__ import annotations
+
+from .word import Direction
 
 # puzzle settings
 min_puzzle_size = 5
@@ -7,6 +9,10 @@
 max_puzzle_words = 100
 max_fit_tries = 100
 
+# puzzle grid settings
+ACTIVE = "*"
+INACTIVE = "#"
+
 # puzzle difficulty levels
 level_dirs = {
     1: {Direction.E, Direction.S},
diff --git a/src/word_search_generator/export.py b/src/word_search_generator/export.py
index c781631..ebd58e1 100644
--- a/src/word_search_generator/export.py
+++ b/src/word_search_generator/export.py
@@ -6,10 +6,10 @@
 
 from fpdf import FPDF
 
-from word_search_generator import config, utils
+from . import config, utils
 
 if TYPE_CHECKING:  # pragma: no cover
-    from word_search_generator import WordSearch
+    from . import WordSearch
 
 
 def validate_path(path: Union[str, Path]) -> Path:
@@ -35,25 +35,23 @@ def validate_path(path: Union[str, Path]) -> Path:
     return path
 
 
-def write_csv_file(path: Path, puzzle: WordSearch, solution: bool = False) -> Path:
+def write_csv_file(path: Path, ws: WordSearch) -> Path:
     """Write current puzzle to CSV format at `path`.
 
     Args:
         path (Path): Path to write the file to.
-        puzzle (Type[WordSearch]): Current Word Search puzzle.
-        solution (bool, optional): Include the puzzle solution. Defaults to False.
+        ws (WordSearch): Current Word Search puzzle.
 
     Returns:
         Path: Final save path.
     """
-
-    word_list = utils.get_word_list_list(puzzle.key)
+    word_list = utils.get_word_list_list(ws.key)
     with open(path, mode="w") as f:
         f_writer = csv.writer(
             f, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL
         )
         f_writer.writerow(["WORD SEARCH"])
-        for row in puzzle.puzzle:
+        for row in ws.cropped_puzzle:
             f_writer.writerow(row)
         f_writer.writerow([""])
         f_writer.writerow(["Word List:"])
@@ -65,21 +63,14 @@ def write_csv_file(path: Path, puzzle: WordSearch, solution: bool = False) -> Pa
             ]
         )
         f_writer.writerow(
-            [f"* Words can go {utils.get_level_dirs_str(puzzle.directions)}."]
+            [f"* Words can go {utils.get_level_dirs_str(ws.directions)}."]
         )
         f_writer.writerow([""])
         answer_key_intro = (
-            "Answer Key (*= Secret Words)"
-            if puzzle.placed_secret_words
-            else "Answer Key"
+            "Answer Key (*= Secret Words)" if ws.placed_secret_words else "Answer Key"
         )
         f_writer.writerow([f"{answer_key_intro}: "])
-        f_writer.writerow(utils.get_answer_key_list(puzzle.placed_words))
-        if solution:
-            f_writer.writerow([""])
-            f_writer.writerow(["SOLUTION"])
-            for row in puzzle.solution:
-                f_writer.writerow([c if c else " " for c in row])
+        f_writer.writerow(utils.get_answer_key_list(ws.placed_words, ws.bounding_box))
     return path.absolute()
 
 
@@ -120,12 +111,12 @@ def write_pdf_file(path: Path, puzzle: WordSearch, solution: bool = False) -> Pa
     return path.absolute()
 
 
-def draw_puzzle_page(pdf: FPDF, puzzle: WordSearch, solution: bool = False) -> FPDF:
+def draw_puzzle_page(pdf: FPDF, ws: WordSearch, solution: bool = False) -> FPDF:
     """Draw the puzzle information on a FPDF PDF page.
 
     Args:
         pdf (FPDF): FPDF PDF document.
-        puzzle (Type[WordSearch]): Current Word Search puzzle.
+        ws (WordSearch): Current Word Search puzzle.
         solution (bool, optional): Highlight the puzzle solution. Defaults to False.
 
     Returns:
@@ -144,21 +135,28 @@ def draw_puzzle_page(pdf: FPDF, puzzle: WordSearch, solution: bool = False) -> F
 
     # calculate the puzzle size and letter font size
     pdf.set_left_margin(0.75)
-    gsize = config.pdf_puzzle_width / len(puzzle.puzzle)
+    gsize = config.pdf_puzzle_width / len(ws.cropped_puzzle[0])
     gmargin = 0.6875 if gsize > 36 else 0.75
     font_size = int(72 * gsize * gmargin)
     # calculate flexible font size based on word count
     # to ensure all words and the puzzle key fit on one page
     info_font_size = config.pdf_font_size_XL - (
-        len(puzzle.words) - config.min_puzzle_words
+        len(ws.words) - config.min_puzzle_words
     ) * (6 / (config.max_puzzle_words - config.min_puzzle_words))
     pdf.set_font_size(font_size)
 
     # draw the puzzle
-    for r, row in enumerate(puzzle.puzzle):
+    placed_words_coordinates = {
+        coord
+        for coords in [
+            word.offset_coordinates(ws.bounding_box) for word in ws.placed_words
+        ]
+        for coord in coords  # type: ignore
+    }  # type: ignore
+    for r, row in enumerate(ws.cropped_puzzle):
         for c, char in enumerate(row):
             # draw a border around correct letters if solution was requested
-            if solution and puzzle.solution[r][c]:
+            if solution and (r, c) in placed_words_coordinates:
                 pdf.set_text_color(255, 0, 0)
                 pdf.multi_cell(gsize, gsize, char, align="C", ln=3)
                 pdf.set_text_color(0, 0, 0)
@@ -171,14 +169,14 @@ def draw_puzzle_page(pdf: FPDF, puzzle: WordSearch, solution: bool = False) -> F
     pdf.set_font("Helvetica", "BU", size=info_font_size)
     pdf.cell(
         pdf.epw,
-        txt=f"Find words going {utils.get_level_dirs_str(puzzle.directions)}:",
+        txt=f"Find words going {utils.get_level_dirs_str(ws.directions)}:",
         align="C",
         ln=2,
     )
     pdf.ln(0.125)
 
     # write word list
-    word_list = utils.get_word_list_str(puzzle.key)
+    word_list = utils.get_word_list_str(ws.key)
     pdf.set_font("Helvetica", "B", size=info_font_size)
     pdf.set_font_size(info_font_size)
     pdf.multi_cell(
@@ -194,14 +192,15 @@ def draw_puzzle_page(pdf: FPDF, puzzle: WordSearch, solution: bool = False) -> F
     pdf.set_margin(0)
     # rotate the page to write answer key upside down
     answer_key_intro = (
-        "Answer Key (*= Secret Words)" if puzzle.placed_secret_words else "Answer Key"
+        "Answer Key (*= Secret Words)" if ws.placed_secret_words else "Answer Key"
     )
     with pdf.rotation(angle=180, x=pdf.epw / 2, y=pdf.eph / 2):
         pdf.set_xy(pdf.epw - pdf.epw, 0)
         pdf.set_margin(0.25)
         pdf.set_font("Helvetica", size=config.pdf_font_size_S)
         pdf.write(
-            txt=f"{answer_key_intro}: " + utils.get_answer_key_str(puzzle.placed_words)
+            txt=f"{answer_key_intro}: "
+            + utils.get_answer_key_str(ws.placed_words, ws.bounding_box)
         )
 
     return pdf
diff --git a/src/word_search_generator/generate.py b/src/word_search_generator/generate.py
index 6ac72ca..f870a5f 100644
--- a/src/word_search_generator/generate.py
+++ b/src/word_search_generator/generate.py
@@ -3,16 +3,15 @@
 import copy
 import random
 import string
-import sys
 from functools import wraps
-from math import log2
-from typing import TYPE_CHECKING, List, Optional, Sized, Tuple
+from typing import TYPE_CHECKING, List, Tuple
 
-from word_search_generator.config import max_fit_tries
-from word_search_generator.types import Direction, Fit, Fits, Puzzle, Word, Wordlist
+from .config import ACTIVE, INACTIVE, max_fit_tries
+from .utils import in_bounds
+from .word import Direction, Fit, Fits, Word, Wordlist
 
 if TYPE_CHECKING:  # pragma: no cover
-    from word_search_generator import WordSearch
+    from . import Puzzle, WordSearch
 
 ALPHABET = list(string.ascii_uppercase)
 
@@ -38,44 +37,12 @@ def wrapper(*args, **kwargs):
     return decorator
 
 
-def out_of_bounds(puzzle: WordSearch, position: Tuple[int, int]) -> bool:
-    """Validate `position` is within the current puzzle bounds."""
-    width = height = puzzle.size
-    row, col = position
-    if row < 0 or col < 0 or row > height - 1 or col > width - 1:
-        return True
-    return False
-
-
-def calc_puzzle_size(words: Wordlist, level: Sized, size: Optional[int] = None) -> int:
-    """Calculate the puzzle grid size."""
-    all_words = list(word.text for word in words)
-    longest_word_length = len(max(all_words, key=len))
-    shortest_word_length = len(min(all_words, key=len))
-    if not size:
-        longest = max(10, longest_word_length)
-        # calculate multiplier for larger word lists so that most have room to fit
-        multiplier = len(all_words) / 15 if len(all_words) > 15 else 1
-        # level lengths in config.py are nice multiples of 2
-        l_size = log2(len(level)) if level else 1  # protect against log(0) in tests
-        size = round(longest + l_size * 2 * multiplier)
-    else:
-        if size < shortest_word_length:
-            print(
-                "Puzzle sized adjust to fit word with the shortest length.",
-                file=sys.stderr,
-            )
-            size = shortest_word_length + 1
-    return size
-
-
 def capture_all_paths_from_position(
-    puzzle: WordSearch, position: Tuple[int, int]
+    puzzle: Puzzle, placed_words: Wordlist, position: Tuple[int, int]
 ) -> List[List[str]]:
     """Capture strings from `position` in each direction."""
-    placed_words = tuple(word.text for word in puzzle.placed_words)
     # calculate how large of a search radius to check (length of each path)
-    radius = max([len(word) for word in placed_words]) if placed_words else 0
+    radius = max([len(word.text) for word in placed_words]) if placed_words else 0
     # track each part (start/end) of the search radius for each direction
     # [N, S], [SW, NE] [W, E], [NW, SE]
     paths: List[List[str]] = [["", ""], ["", ""], ["", ""], ["", ""]]
@@ -86,9 +53,9 @@ def capture_all_paths_from_position(
         row, col = position
         chars: List[str] = []
         for _ in range(radius):
-            if out_of_bounds(puzzle, (row, col)):
+            if not in_bounds(col, row, len(puzzle), len(puzzle)):
                 break
-            chars.append(puzzle.solution[row][col])
+            chars.append(puzzle[row][col])
             row += direction_coord.r_move
             col += direction_coord.c_move
             # add the captured string of characters to the correct
@@ -100,22 +67,24 @@ def capture_all_paths_from_position(
     return paths
 
 
-def no_duped_words(puzzle: WordSearch, char: str, position: Tuple[int, int]) -> bool:
+def no_duped_words(
+    puzzle: Puzzle, placed_words: Wordlist, char: str, position: Tuple[int, int]
+) -> bool:
     """Make sure that adding `char` at `position` will not create a
     duplicate of any word already placed in the puzzle."""
-    paths = capture_all_paths_from_position(puzzle, position)
+    paths = capture_all_paths_from_position(puzzle, placed_words, position)
     before_ct = after_ct = 0
-    for word in list(puzzle.key.keys()):
+    for word in placed_words:
         for path in paths:
             before = "•".join(path)
             after = char.join(path)
-            if word in before:
+            if word.text in before:
                 before_ct += 1
-            if word[::-1] in before:
+            if word.text[::-1] in before:
                 before_ct += 1
-            if word in after:
+            if word.text in after:
                 after_ct += 1
-            if word[::-1] in after:
+            if word.text[::-1] in after:
                 after_ct += 1
     if before_ct == after_ct:
         return True
@@ -123,7 +92,11 @@ def no_duped_words(puzzle: WordSearch, char: str, position: Tuple[int, int]) ->
 
 
 def test_a_fit(
-    puzzle: WordSearch, word: str, position: Tuple[int, int], direction: Direction
+    puzzle: Puzzle,
+    mask: Puzzle,
+    word: str,
+    position: Tuple[int, int],
+    direction: Direction,
 ) -> List[Tuple[int, int]]:
     """Test if word fits in the puzzle at the specified
     coordinates heading in the specified direction."""
@@ -131,8 +104,11 @@ def test_a_fit(
     row, col = position
     # iterate over each letter in the word
     for char in word:
+        # first check if the spot is inactive on the mask
+        if mask[row][col] == INACTIVE:
+            return []
         # if the current puzzle space is empty or if letters match
-        if puzzle.solution[row][col] == "" or puzzle.solution[row][col] == char:
+        if puzzle[row][col] == "" or puzzle[row][col] == char:
             coordinates.append((row, col))
         else:
             return []
@@ -140,18 +116,18 @@ def test_a_fit(
         row += direction.r_move
         col += direction.c_move
         # if new coordinates are off of puzzle cancel fit test
-        if out_of_bounds(puzzle, (row, col)):
+        if not in_bounds(col, row, len(puzzle), len(puzzle)):
             return []
     return coordinates
 
 
-def find_a_fit(puzzle: WordSearch, word: str, position: Tuple[int, int]) -> Fit:
+def find_a_fit(ws: WordSearch, word: str, position: Tuple[int, int]) -> Fit:
     """Look for random place in the puzzle where `word` fits."""
     fits: Fits = {}
     random_direction = None
     # check all directions for level
-    for d in puzzle.directions:
-        coords = test_a_fit(puzzle, word, position, d)
+    for d in ws.directions:
+        coords = test_a_fit(ws.puzzle, ws.mask, word, position, d)
         if coords:
             fits[Direction(d).name] = coords
     # if the word fits, pick a random fit for placement
@@ -160,38 +136,40 @@ def find_a_fit(puzzle: WordSearch, word: str, position: Tuple[int, int]) -> Fit:
     return random_direction
 
 
-def fill_words(puzzle: WordSearch) -> None:
-    """Fill `puzzle` with the supplied `words`.
+def fill_words(ws: WordSearch) -> None:
+    """Fill `ws.puzzle` with the supplied `words`.
     Some words will be skipped if they don't fit."""
-    puzzle._solution = [[""] * puzzle.size for _ in range(puzzle.size)]
     # try to place each word on the puzzle
-    for word in puzzle.hidden_words:
+    for word in ws.hidden_words:
         try_to_fit_word(
-            puzzle=puzzle,
+            ws=ws,
             word=word,
         )
     # try to place each secret word on the puzzle
     # "real" words are given priority
     # this is always done after those have been placed
-    for word in puzzle.secret_words:
+    for word in ws.secret_words:
         try_to_fit_word(
-            puzzle=puzzle,
+            ws=ws,
             word=word,
         )
 
 
 @retry()
 def try_to_fit_word(
-    puzzle: WordSearch,
+    ws: WordSearch,
     word: Word,
 ) -> None:
     """Try to fit `word` at randomized coordinates.
     @retry wrapper controls the number of attempts"""
-    row = random.randint(0, puzzle.size - 1)
-    col = random.randint(0, puzzle.size - 1)
-    # try and find a directional fit using the starting coordinates
-    fit = find_a_fit(puzzle, word.text, (row, col))
-    work_puzzle = copy.deepcopy(puzzle.solution)
+
+    row = random.randint(0, ws.size - 1)
+    col = random.randint(0, ws.size - 1)
+    if ws.mask[row][col] == INACTIVE:
+        raise WordFitError
+    # try and find a directional fit using the starting coordinates if not INACTIVE
+    fit = find_a_fit(ws, word.text, (row, col))
+    work_puzzle = copy.deepcopy(ws.puzzle)
     if not fit:
         raise WordFitError
     d, coords = fit
@@ -200,18 +178,19 @@ def try_to_fit_word(
     for i, char in enumerate(word.text):
         check_row = coords[i][0]
         check_col = coords[i][1]
-        if no_duped_words(puzzle, char, (check_row, check_col)):
+        if no_duped_words(ws.puzzle, ws.placed_words, char, (check_row, check_col)):
             work_puzzle[check_row][check_col] = char
         else:
-            work_puzzle = copy.deepcopy(puzzle.solution)
+            work_puzzle = copy.deepcopy(ws.puzzle)
             break
-    if work_puzzle == puzzle.solution:
+    if work_puzzle == ws.puzzle:
         raise WordFitError
-    puzzle._solution = copy.deepcopy(work_puzzle)
+    ws._puzzle = copy.deepcopy(work_puzzle)
     # update placement info for word
-    word._start_row = row
-    word._start_column = col
+    word.start_row = row
+    word.start_column = col
     word.direction = Direction[d]
+    word.coordinates = coords
 
 
 def no_matching_neighbors(puzzle: Puzzle, char: str, position: Tuple[int, int]) -> bool:
@@ -236,18 +215,19 @@ def no_matching_neighbors(puzzle: Puzzle, char: str, position: Tuple[int, int])
     return True
 
 
-def fill_blanks(puzzle: WordSearch) -> None:
+def fill_blanks(ws: WordSearch) -> None:
     """Fill empty puzzle spaces with random characters."""
-    puzzle._puzzle = copy.deepcopy(puzzle.solution)
     # iterate over the entire puzzle
-    for row in range(len(puzzle._puzzle)):
-        for col in range(len(puzzle._puzzle[0])):
+    for row in range(ws.size):
+        for col in range(ws.size):
             # if the current spot is empty fill with random character
-            if puzzle._puzzle[row][col] == "":
+            if ws.puzzle[row][col] == "" and ws.mask[row][col] == ACTIVE:
                 while True:
                     random_char = random.choice(ALPHABET)
                     if no_matching_neighbors(
-                        puzzle._puzzle, random_char, (row, col)
-                    ) and no_duped_words(puzzle, random_char, (row, col)):
-                        puzzle._puzzle[row][col] = random_char
+                        ws.puzzle, random_char, (row, col)
+                    ) and no_duped_words(
+                        ws.puzzle, ws.placed_words, random_char, (row, col)
+                    ):
+                        ws.puzzle[row][col] = random_char
                         break
diff --git a/src/word_search_generator/mask/__init__.py b/src/word_search_generator/mask/__init__.py
new file mode 100644
index 0000000..e3a2857
--- /dev/null
+++ b/src/word_search_generator/mask/__init__.py
@@ -0,0 +1,310 @@
+from typing import Any, List, Optional, Tuple
+
+from ..config import ACTIVE, INACTIVE, max_puzzle_size, min_puzzle_size
+from ..utils import find_bounding_box
+
+
+class MaskNotGenerated(Exception):
+    """Mask has not yet been generated."""
+
+    pass
+
+
+class Mask:
+    """This class represents Mask object that can be applied
+    to a WordSearch puzzle."""
+
+    METHODS = [1, 2, 3]
+
+    def __init__(
+        self,
+        points: Optional[List[Tuple[int, int]]] = None,
+        method: int = 1,
+        static: bool = True,
+    ) -> None:
+        """Initialize a WordSearch puzzle mask object.
+
+        Args:
+            points (Optional[List[Tuple[int, int]]], optional): Coordinate points
+            used to build the mask. Defaults to None.
+            method (int, optional): How Mask is applied to the puzzle
+            (1=Standard (Intersection), 2=Additive, 3=Subtractive). Defaults to 1.
+            static (bool, optional): Should this mask be reapplied
+            after changes to the parent puzzle size. Defaults to True.
+
+        Notes: Bitmap points will be filled in solid, Polygon points will first
+        be connected by lines in the order they are supplied and then the resulting
+        shape will be filled in using a ray-casting algorithm.
+        """
+        self.points = points if points else []
+        self.method = method
+        self.static = static
+        self._puzzle_size: Optional[int] = None
+        self._mask: List[Any] = []
+
+    @property
+    def mask(self) -> List[Any]:
+        """Mask as a 2-D array (List[List[str]])."""
+        return self._mask
+
+    @property
+    def method(self) -> int:
+        """Mask method."""
+        return self._method
+
+    @method.setter
+    def method(self, val: int) -> None:
+        """Set the Mask method.
+
+        Args:
+            method (int): How Mask is applied to the puzzle
+            (1=Standard (Intersection), 2=Additive, 3=Subtractive). Defaults to 1.
+
+        Raises:
+            ValueError: Must 1, 2, or 3 (see `Mask.METHODS`).
+        """
+        if not isinstance(val, int):
+            raise TypeError("Must be an integer.")
+        if isinstance(val, int) and val not in Mask.METHODS:
+            raise ValueError(f"Must be one of {Mask.METHODS}")
+        self._method = val
+
+    @property
+    def static(self) -> int:
+        """Mask static reapplication."""
+        return self._static
+
+    @static.setter
+    def static(self, val: bool) -> None:
+        """Set the Mask static property.
+
+        Args:
+            val (bool): Should this mask be reapplied after
+            changes to the parent puzzle size. Defaults to True.
+
+        Raises:
+            TypeError: Must be a boolean value.
+        """
+        if not isinstance(val, bool):
+            raise TypeError("Must be a boolean value.")
+        self._static = val
+
+    @property
+    def puzzle_size(self) -> Optional[int]:
+        """Size of the puzzle the mask will applied to. Used with the
+        `generate()` to to calculate points and placement."""
+        return self._puzzle_size
+
+    @puzzle_size.setter
+    def puzzle_size(self, val: int) -> None:
+        """Set the `Mask.puzzle_size` value. Should match the size
+        of the puzzle the mask will be applied to.
+
+        Args:
+            val (int): Size.
+
+        Raises:
+            TypeError: Must be an integer.
+            ValueError: Must be greater than `config.min_puzzle_size` and
+            less than `config.max_puzzle_size`.
+        """
+        if not isinstance(val, int):
+            raise TypeError("Must be an integer.")
+        if not min_puzzle_size <= val <= max_puzzle_size:
+            raise ValueError(
+                f"Must be >= {min_puzzle_size}" + f" and <= {max_puzzle_size}"
+            )
+        self._puzzle_size = val
+        if not self.static:
+            self.reset_points()
+
+    @property
+    def bounding_box(self) -> Optional[Tuple[Tuple[int, int], Tuple[int, int]]]:
+        """Bounding box of the masked area as a rectangle defined
+        by a Tuple of (top-left edge as x, y, bottom-right edge as x, y). Returned
+        points may lie outside of the puzzle bounds. This property is used
+        for filling mask shapes so it needs to know the actual mask bounds no
+        matter where lie."""
+
+        if not self.points:
+            return None
+        min_x, min_y = self.points[0]
+        max_x, max_y = self.points[0]
+        for x, y in self.points:
+            if x < min_x:
+                min_x = x
+            elif x > max_x:
+                max_x = x
+            if y < min_y:
+                min_y = y
+            elif y > max_y:
+                max_y = y
+        return ((min_x, min_y), (max_x, max_y))
+
+    @staticmethod
+    def build_mask(size: int, char: str = INACTIVE) -> List[List[str]]:
+        """Generate a 2-D array (square) of `size` filled with `char`.
+
+        Args:
+            size (int): Size of array.
+            char (str, optional): Character to fill the array with.
+            Defaults to `config.INACTIVE`.
+
+        Returns:
+            List[List[str]]: 2-D array filled will `char`.
+        """
+        return [[char] * size for _ in range(size)]
+
+    def generate(self, puzzle_size: int) -> None:
+        """Generate a new mask at `puzzle_size` and either fill points (`Bitmap`),
+        or connect points (`Polygon`) and then fill the resulting polygon shape."""
+        self.puzzle_size = puzzle_size
+        self._mask = Mask.build_mask(self.puzzle_size)
+        self._draw()
+
+    def _draw(self) -> None:
+        """Placeholder for custom subclass `_draw()` methods.
+
+        Raises:
+            MaskNotGenerated: Mask has not yet been generated.
+        """
+        if not self.puzzle_size:
+            raise MaskNotGenerated(
+                "Please use `object.generate()` before calling `object.show()`."
+            )
+        pass
+
+    def show(self, active_only: bool = False) -> None:
+        """Pretty print the mask. When `active_only` is True only the masked
+        areas that lie within the bound of (0,0) and (`puzzle_size`, `puzzle_size`)
+        will be shown. Used for mask creation and testing.
+
+        Args:
+            active_only (bool, optional): Only output the masked areas
+            that lie within the bounds of `Mask.puzzle_size`. Used for
+            mask creation and testing. Defaults to False.
+
+        Raises:
+            MaskNotGenerated: Mask has not yet been generated.
+        """
+        if not self.puzzle_size or not self.bounding_box:
+            raise MaskNotGenerated(
+                "Please use `object.generate()` before calling `object.show()`."
+            )
+        if active_only:
+            ((min_x, min_y), (max_x, max_y)) = find_bounding_box(self._mask)
+        else:
+            ((min_x, min_y), (max_x, max_y)) = (
+                (0, 0),
+                (self.puzzle_size, self.puzzle_size),
+            )
+        for r in self.mask[min_y : max_y + 1]:
+            if active_only:
+                r = [c if c == ACTIVE else " " for c in r]
+            print(" ".join(r[min_x : max_x + 1]))
+
+    def invert(self) -> None:
+        """Invert the mask. Has no effect on the mask `method`."""
+        self._mask = [
+            [ACTIVE if c == INACTIVE else INACTIVE for c in row] for row in self.mask
+        ]
+
+    def flip_horizontal(self) -> None:
+        """Flip mask along the vertical axis (left to right)."""
+        self._mask = [r[::-1] for r in self.mask]
+
+    def flip_vertical(self) -> None:
+        """Flip mask along the horizontal axis (top to bottom)."""
+        self._mask = self.mask[::-1]
+
+    def transpose(self) -> None:
+        """Reverse/permute the axes of the mask."""
+        self._mask = list(map(list, zip(*self.mask)))
+
+    def reset_points(self) -> None:
+        """Reset all mask coordinate points."""
+        self.points = []
+
+
+class CompoundMask(Mask):
+    """This class represents a subclass of the Mask object
+    and allows you to generate a single mask from a set of masks."""
+
+    def __init__(
+        self, masks: Optional[List[Mask]] = None, method: int = 1, static: bool = True
+    ) -> None:
+        """Initialize a WordSearch puzzle compound mask object
+        built from multiple `Mask` objects.
+
+        Args:
+            masks (Optional[List[Mask]], optional): Masks objects used to build
+            a the CompoundMask. Defaults to None.
+            method (int, optional): How Mask is applied to the puzzle
+            (1=Standard (Intersection), 2=Additive, 3=Subtractive). Defaults to 1.
+            static (bool, optional): Should this mask be reapplied
+            after changes to the parent puzzle size. Defaults to True.
+        """
+        super().__init__(method=method, static=static)
+        self.masks = masks if masks else []
+
+    @property
+    def bounding_box(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
+        """Bounding box of the masked area as a rectangle defined
+        by a Tuple of (top-left edge as x, y, bottom-right edge as x, y).
+
+        Note: This is a special implementation of the `bounding.box` property
+        in use just for the `CompoundMask` object. Normally the `bounding_box`
+        property on a mask is not confined to the puzzle bounds since it is mainly
+        used in the calculation of filling the shapes. Since a `CompoundMask` is
+        really just a collection of `Masks` the `bounding_box` is limited by the
+        puzzle bounds."""
+        return find_bounding_box(self._mask)
+
+    def add_mask(self, mask: Mask) -> None:
+        self.masks.append(mask)
+
+    def generate(self, puzzle_size: int) -> None:
+        """Generate a new mask at `puzzle_size` and the apply all Mask objects
+        from `CompoundMask.masks` in order.
+
+        Note: Unlike the parent `Mask` object a `CompoundMask` is initially filled
+        with `config.ACTIVE`. This allows for the proper inaction between masks."""
+        self.puzzle_size = puzzle_size
+        self._mask = Mask.build_mask(self.puzzle_size, ACTIVE)
+        for mask in self.masks:
+            mask.generate(self.puzzle_size)
+            self._apply_mask(mask)
+
+    def _apply_mask(self, mask: Mask) -> None:
+        """Apply `Mask` to the compound mask.
+
+        Args:
+            mask (Mask): Mask to apply.
+
+        Raises:
+            MaskNotGenerated: Mask has not yet been generated.
+        """
+        if not self.puzzle_size:
+            raise MaskNotGenerated(
+                "Please use `object.generate()` before calling `object.show()`."
+            )
+        for y in range(self.puzzle_size):
+            for x in range(self.puzzle_size):
+                if mask.method == 1:
+                    if mask.mask[y][x] == ACTIVE and self.mask[y][x] == ACTIVE:
+                        self.mask[y][x] = ACTIVE
+                    else:
+                        self.mask[y][x] = INACTIVE
+                elif mask.method == 2:
+                    if mask.mask[y][x] == ACTIVE:
+                        self.mask[y][x] = ACTIVE
+                else:
+                    if mask.mask[y][x] == ACTIVE:
+                        self.mask[y][x] = INACTIVE
+
+
+# Import all base masks shapes for easier access
+# (eg. `mask.Star` vs. `mask.polygon.Star`)
+from .bitmap import Bitmap, Image  # noqa: F401, E402
+from .ellipse import Ellipse  # noqa: F401, E402
+from .polygon import Polygon, Rectangle, RegularPolygon, Star  # noqa: F401, E402
diff --git a/src/word_search_generator/mask/bitmap.py b/src/word_search_generator/mask/bitmap.py
new file mode 100644
index 0000000..6fb483e
--- /dev/null
+++ b/src/word_search_generator/mask/bitmap.py
@@ -0,0 +1,111 @@
+from pathlib import Path
+from typing import List, Optional, Tuple, Union
+
+from PIL import Image as PILImage
+from PIL import ImageChops
+
+from ..config import ACTIVE
+from ..utils import in_bounds
+from . import Mask, MaskNotGenerated
+
+
+class ContrastError(Exception):
+    ...
+
+
+class Bitmap(Mask):
+    """This class represents a subclass of the Mask object
+    and generates a mask from a set of coordinate points."""
+
+    def __init__(
+        self,
+        points: Optional[List[Tuple[int, int]]] = None,
+        method: int = 1,
+        static: bool = True,
+    ) -> None:
+        """Initialize a WordSearch puzzle bitmap mask object.
+
+        Args:
+            points (Optional[List[Tuple[int, int]]], optional): Coordinate points
+            used to build the mask. Defaults to None.
+            method (int, optional): How Mask is applied to the puzzle
+            (1=Standard (Intersection), 2=Additive, 3=Subtractive). Defaults to 1.
+            static (bool, optional): Should this mask be reapplied
+            after changes to the parent puzzle size. Defaults to True.
+        """
+        super().__init__(points=points, method=method, static=static)
+
+    def _draw(self) -> None:
+        """Set each coordinate point from `object.points` as
+        `config.ACTIVE` in `Object._mask`.
+
+        Raises:
+            MaskNotGenerated: Mask has not yet been generated.
+        """
+        if not self.puzzle_size:
+            raise MaskNotGenerated(
+                "No puzzle size specified. Please use the `.generate()` method."
+            )
+        for x, y in self.points:
+            if in_bounds(x, y, self.puzzle_size, self.puzzle_size):
+                self._mask[y][x] = ACTIVE
+
+
+class Image(Bitmap):
+    """This class represents a subclass of the Bitmap object
+    and generates a mask a mask from a raster image."""
+
+    threshold = 200  # normalization contrast point
+
+    def __init__(
+        self, fp: Union[str, Path], method: int = 1, static: bool = False
+    ) -> None:
+        """Generate a bitmap mask from a raster image.
+
+        Note: Ideally, the raster image should be a single color (dark) on a solid
+        or transparent background (think black pixels on white background).
+        Multi-color images will work, but know that all colors will be converted
+        to grayscale first.
+
+        Args:
+            fp (Union[str, Path]): A filepath (string) or `pathlib.Path` object
+            to the raster image the mask will be generated from.
+            method (int, optional): How Mask is applied to the puzzle
+            (1=Standard (Intersection), 2=Additive, 3=Subtractive). Defaults to 1.
+            static (bool, optional): Should this mask be reapplied
+            after changes to the parent puzzle size. Defaults to True.
+        """
+        super().__init__(method=method, static=static)
+        self.fp = fp
+
+    def generate(self, puzzle_size: int) -> None:
+        """Generate a new mask at `puzzle_size` from a raster image."""
+        self.puzzle_size = puzzle_size
+        self._mask = Mask.build_mask(self.puzzle_size)
+        self.points = Image.process_image(
+            PILImage.open(self.fp, formats=("BMP", "JPEG", "PNG")),
+            self.puzzle_size,
+            Image.threshold,
+        )
+        if not self.points:
+            raise ContrastError("The provided image lacked enough contrast.")
+        self._draw()
+
+    @staticmethod
+    def process_image(image: PILImage, size: int, threshold: int = 200) -> PILImage:
+        """Take a `PIL.Image` object, convert it to black-and-white, trim any
+        excess pixels from the edges, resize it, and return all of the black
+        pixels as a (x, y) coordinates."""
+        image = image.convert("L").point(
+            lambda px: 255 if px > Image.threshold else 0, mode="1"
+        )
+        diff = ImageChops.difference(image, PILImage.new("L", image.size, (255)))
+        bbox = diff.getbbox()
+        image = image.crop(bbox)
+        image.thumbnail((size, size), resample=0)
+        w, _ = image.size
+        return [
+            (0 if i == 0 else i % w, i // w)
+            for i, px in enumerate(image.getdata())
+            if px <= threshold
+        ]
diff --git a/src/word_search_generator/mask/ellipse.py b/src/word_search_generator/mask/ellipse.py
new file mode 100644
index 0000000..87cec1f
--- /dev/null
+++ b/src/word_search_generator/mask/ellipse.py
@@ -0,0 +1,98 @@
+import math
+from typing import List, Optional, Tuple
+
+from ..utils import distance, float_range
+from . import Mask
+from .bitmap import Bitmap
+
+
+class Ellipse(Bitmap):
+    """This class represents a subclass of the Bitmap object
+    and generates an Ellipse masks."""
+
+    def __init__(
+        self,
+        width: Optional[int] = None,
+        height: Optional[int] = None,
+        center: Optional[Tuple[int, int]] = None,
+        method: int = 1,
+        static: bool = True,
+    ) -> None:
+        """Generate an ellipse mask.
+
+        Args:
+            width (Optional[int], optional): Ellipse width. Defaults to
+            `puzzle_width` provided to the `.generate()` method.
+            height (Optional[int], optional): Ellipse height. Defaults to
+            `puzzle_width` provided to the `.generate()` method.
+            center (Optional[Tuple[int, int]], optional): Center origin point
+            from which the ellipse will be calculated. Defaults to puzzle center.
+            method (int, optional): How Mask is applied to the puzzle
+            (1=Standard (Intersection), 2=Additive, 3=Subtractive). Defaults to 1.
+            static (bool, optional): Should this mask be reapplied
+            after changes to the parent puzzle size. Defaults to True.
+        """
+        super().__init__(method=method, static=static)
+        self.width = width
+        self.height = height
+        self.center = center
+
+    def generate(self, puzzle_size: int) -> None:
+        """Generate a new mask at `puzzle_size`."""
+        self.puzzle_size = puzzle_size
+        self._mask = Mask.build_mask(self.puzzle_size)
+        self.points = Ellipse.calculate_ellipse_points(
+            self.width if self.width else self.puzzle_size,
+            self.height if self.height else self.puzzle_size,
+            self.center
+            if self.center
+            else (self.puzzle_size // 2, self.puzzle_size // 2),
+            puzzle_size,
+        )
+        self._draw()
+
+    @staticmethod
+    def calculate_ellipse_points(
+        width: int,
+        height: int,
+        origin: Tuple[int, int],
+        puzzle_size: int,
+    ) -> List[Tuple[int, int]]:
+        """Calculate all coordinates within an ellipse."""
+        width_r = width / 2
+        height_r = height / 2
+        ratio = width_r / height_r
+        # determine points to check for fitness inside of radius
+        if (width_r * 2) % 2 == 0:
+            max_pointsX = math.ceil(width_r - 0.5) * 2 + 1
+        else:
+            max_pointsX = math.ceil(width_r) * 2
+        if (height_r * 2) % 2 == 0:
+            max_pointsY = math.ceil(height_r - 0.5) * 2 + 1
+        else:
+            max_pointsY = math.ceil(height_r) * 2
+        # calculate the origin offset
+        if puzzle_size % 2 == 0 and width % 2 != 0:
+            x_offset = origin[0] - 1
+        else:
+            x_offset = origin[0]
+        if puzzle_size % 2 == 0 and height % 2 != 0:
+            y_offset = origin[1] - 1
+        else:
+            y_offset = origin[1]
+        # check all points and see if they fit inside of the ellipse
+        points = []
+        minY = -max_pointsY / 2 + 1
+        maxY = max_pointsY / 2 - 1
+        minX = -max_pointsX / 2 + 1
+        maxX = max_pointsX / 2 - 1
+        for y in float_range(minY, maxY + 1):
+            for x in float_range(minX, maxX + 1):
+                if Ellipse.within_radius(x, y, width_r, ratio):
+                    points.append((int(x + x_offset), int(y + y_offset)))
+        return points
+
+    @staticmethod
+    def within_radius(x: int, y: int, radius: float, ratio: float) -> bool:
+        """Check if a coordinate is within a given radius."""
+        return distance(x, y, ratio) <= radius
diff --git a/src/word_search_generator/mask/polygon.py b/src/word_search_generator/mask/polygon.py
new file mode 100644
index 0000000..9539c4a
--- /dev/null
+++ b/src/word_search_generator/mask/polygon.py
@@ -0,0 +1,374 @@
+import math
+from typing import List, Optional, Tuple
+
+from ..config import ACTIVE
+from ..utils import in_bounds, round_half_up
+from . import Mask, MaskNotGenerated
+
+
+class Polygon(Mask):
+    """This class represents a subclass of the Mask object
+    and generates a polygon mask from a set of coordinate points."""
+
+    def __init__(
+        self,
+        points: Optional[List[Tuple[int, int]]] = None,
+        method: int = 1,
+        static: bool = True,
+    ) -> None:
+        """Generate a polygon mask from 3 or more coordinate points.
+        The (0, 0) coordinate is at top-left of the 2-D array.
+
+        Note: There is no need to include the origin point at the end
+        of your polygon path. All paths are automatically closed by
+        returning to the origin point (eg. `.points[0]`).
+
+        Args:
+            points (Optional[List[Tuple[int, int]]], optional): Polygon
+            coordinate points. Defaults to None.
+            method (int, optional): How Mask is applied to the puzzle
+            (1=Standard (Intersection), 2=Additive, 3=Subtractive). Defaults to 1.
+            static (bool, optional): Should this mask be reapplied
+            after changes to the parent puzzle size. Defaults to True.
+
+        Raises:
+            ValueError: _description_
+        """
+        if points and len(points) < 3:
+            raise ValueError(
+                "Minimum of 3 points (vertices) required to create a Polygon."
+            )
+        super().__init__(points=points, method=method, static=static)
+
+    @property
+    def split_points(self) -> Tuple[List[Tuple[int, int]], List[Tuple[int, int]]]:
+        """Polygon points path split in half for better drawing
+        performance with Bresenham's line algorithm."""
+        left_offset = (
+            len(self.points) // 2 + 1
+            if len(self.points) % 2 == 0
+            else len(self.points) // 2 + 2
+        )
+        left_side = self.points[0:left_offset]
+        right_offset = len(self.points) // 2
+        right_side = [self.points[0]] + list(reversed(self.points))[:right_offset]
+        return left_side, right_side
+
+    def generate(self, puzzle_size: int) -> None:
+        """Generate a new mask at `puzzle_size`."""
+        self.puzzle_size = puzzle_size
+        self._mask = Mask.build_mask(self.puzzle_size)
+        self._draw()
+
+    def _draw(self) -> None:  # doesn't draw evenly on second half pf point (going up)
+        """Connect each coordinate point within `.points` in the
+        order they are listed and then fill in the resulting shape."""
+        for i in range(len(self.points)):
+            p1 = self.points[i]
+            p2 = self.points[(i + 1) % len(self.points)]
+            self._connect_points(p1, p2)
+        self._fill_shape()
+
+    def _draw_in_halves(self) -> None:
+        """Starting with the first coordinate in `.points`, connect the first
+        half of the coordinates, then return to the origin and connect the second
+        half of the points, and then fill in the resulting shape.
+
+        Note: Uses the `.split_points` property and seems to work with the
+        Bresenham's line algorithm for some shape (eg. Stars)."""
+        for points in self.split_points:
+            for i in range(len(points) - 1):
+                p1 = points[i]
+                p2 = points[i + 1]
+                self._connect_points(p1, p2)
+        self._fill_shape()
+
+    def _connect_points(
+        self, p1: Tuple[int, int], p2: Tuple[int, int], c: str = ACTIVE
+    ) -> None:
+        """Connect two points within a grid using Bresenham's line algorithm.
+        The line will be drawn using the single character string `c`."""
+        if not self.puzzle_size:
+            raise MaskNotGenerated(
+                "No puzzle size specified. Please use the `generate()` method."
+            )
+        x1, y1 = p1
+        x2, y2 = p2
+        dx = abs(x2 - x1)
+        dy = abs(y2 - y1)
+        x, y = x1, y1
+        sx = -1 if x1 > x2 else 1
+        sy = -1 if y1 > y2 else 1
+        if dx > dy:
+            err = dx / 2.0
+            while x != x2:
+                if in_bounds(x, y, self.puzzle_size, self.puzzle_size):
+                    self.mask[y][x] = c
+                err -= dy
+                if err < 0:
+                    y += sy
+                    err += dx
+                x += sx
+        else:
+            err = dy / 2.0
+            while y != y2:
+                if in_bounds(x, y, self.puzzle_size, self.puzzle_size):
+                    self.mask[y][x] = c
+                err -= dx
+                if err < 0:
+                    x += sx
+                    err += dy
+                y += sy
+            if in_bounds(x, y, self.puzzle_size, self.puzzle_size):
+                self.mask[y][x] = c
+
+    def _fill_shape(self, c: str = ACTIVE) -> None:
+        """Fill the interior of a polygon using the single character string `c`."""
+
+        def ray_casting(point, polygon):
+            """Ray Casting algorithm used to determine if a
+            coordinate is within a polygon."""
+            x, y = point
+            ct = 0
+            for i in range(len(polygon) - 1):
+                x1, y1 = polygon[i]
+                x2, y2 = polygon[i + 1]
+                if (y < y1) != (y < y2) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
+                    ct += 1
+            return ct % 2 == 1
+
+        if not self.puzzle_size or not self.bounding_box:
+            raise MaskNotGenerated(
+                "No puzzle size specified. Please use the `generate()` method."
+            )
+
+        # check all points within the polygon bounding box
+        bbox = self.bounding_box
+        min_x, min_y = bbox[0]
+        max_x, max_y = bbox[1]
+        for y in range(min_y, max_y + 1):
+            for x in range(min_x, max_x + 1):
+                in_polygon = ray_casting((x, y), self.points + [self.points[0]])
+                if in_polygon and in_bounds(x, y, self.puzzle_size, self.puzzle_size):
+                    self.mask[y][x] = c
+
+
+class Rectangle(Polygon):
+    """This subclass of `Polygon` represents a Rectangle mask object."""
+
+    def __init__(
+        self,
+        width: int,
+        height: int,
+        origin: Optional[Tuple[int, int]] = None,
+        method: int = 1,
+        static: bool = True,
+    ) -> None:
+        """Generate a rectangle polygon.
+
+        Note: (0, 0) coordinate is at top-left of puzzle.
+
+        Args:
+            width (int): Rectangle width.
+            height (int): Rectangle height.
+            origin (Tuple[int, int], optional): Top-left origin point from
+            which polygon be drawn. Defaults to puzzle top-left at (0, 0).
+            method (int, optional): How Mask is applied to the puzzle
+            (1=Standard (Intersection), 2=Additive, 3=Subtractive). Defaults to 1.
+            static (bool, optional): Should this mask be reapplied
+            after changes to the parent puzzle size. Defaults to True.
+        """
+        originX, originY = origin if origin else (0, 0)
+        points = [
+            (originX, originY),
+            (originX, originY + height - 1),
+            (originX + width - 1, originY + height - 1),
+            (originX + width - 1, originY),
+        ]
+        super().__init__(points=points, method=method, static=static)
+
+
+class RegularPolygon(Polygon):
+    """This subclass of `Polygon` represents a RegularPolygon mask object."""
+
+    def __init__(
+        self,
+        vertices: int = 3,
+        radius: Optional[int] = None,
+        center: Optional[Tuple[int, int]] = None,
+        angle: float = 0.0,
+        method: int = 1,
+        static: bool = False,
+    ) -> None:
+        """Generate a regular polygon mask with 3 or more sides.
+        All sides and internal angles will be equal.
+
+        Args:
+            vertices (int, optional): Vertices (sides) of polygon (>=3).
+            Defaults to 3.
+            radius (Optional[int], optional): Distance from center point to vertices.
+            Defaults to half of the `puzzle_width` provided to the `.generate()` method.
+            center (Optional[Tuple[int, int]], optional): Center origin point
+            from which the polygon will be calculated. Defaults to puzzle center.
+            angle (float, optional): Rotation angle in degrees polygon.
+            Defaults to 0.0.
+            method (int, optional): How Mask is applied to the puzzle
+            (1=Standard (Intersection), 2=Additive, 3=Subtractive). Defaults to 1.
+            static (bool, optional): Should this mask be reapplied
+            after changes to the parent puzzle size. Defaults to True.
+
+        Raises:
+            ValueError: Polygon vertices must be >=3.
+        """
+        if vertices < 3:
+            raise ValueError(
+                "Minimum of 3 points (vertices) required to create a Polygon."
+            )
+        super().__init__(method=method, static=static)
+        self.vertices = vertices
+        self.radius = radius
+        self.center = center
+        self.angle = angle
+
+    def generate(self, puzzle_size: int) -> None:
+        self.puzzle_size = puzzle_size
+        self._mask = Mask.build_mask(self.puzzle_size)
+        radius = (
+            self.puzzle_size // 2 - 1 if puzzle_size % 2 == 0 else self.puzzle_size // 2
+        )
+        center = (radius, radius)
+        self.points = RegularPolygon.calculate_vertices(
+            self.vertices,
+            self.radius if self.radius else radius,
+            self.center if self.center else center,
+            self.angle,
+        )
+        self._draw()
+
+    @staticmethod
+    def calculate_vertices(
+        vertices: int,
+        radius: int,
+        center: Tuple[int, int],
+        angle: float,
+    ):
+        points = []
+        cx, cy = center
+        angle_step = 360.0 / vertices
+        angle = angle - 90  # rotated -90 so polygons are oriented North
+        for _ in range(vertices):
+            x, y = RegularPolygon.cos_sin_from_degrees(angle)
+            points.append(
+                (
+                    int(round_half_up(x * radius + cx)),
+                    int(round_half_up(y * radius + cy)),
+                )
+            )
+            angle += angle_step
+        return points
+
+    @staticmethod
+    def cos_sin_from_degrees(degrees: float) -> Tuple[float, float]:
+        """Return a (cosine, sin) pair for a given angle(in degrees).
+        Corrects for proper 90 degree angles."""
+        degrees = degrees % 360.0
+        proper_90s = {90.0: (0.0, 1.0), 180.0: (-1.0, 0), 270.0: (0, -1.0)}
+        if degrees in proper_90s:
+            return proper_90s[degrees]
+        rad = math.radians(degrees)
+        return math.cos(rad), math.sin(rad)
+
+
+class Star(Polygon):
+    """This subclass of `Polygon` represents a Star mask object."""
+
+    def __init__(
+        self,
+        outer_vertices: int = 5,
+        outer_radius: Optional[int] = None,
+        inner_radius: Optional[int] = None,
+        center: Optional[Tuple[int, int]] = None,
+        angle: float = 0.0,
+        method: int = 1,
+        static: bool = False,
+    ) -> None:
+        """Generate a pointed star polygon mask.
+
+        Args:
+            outer_vertices (int, optional): Number of outer vertices (>=3).
+            Defaults to 5.
+            outer_radius (Optional[int], optional): Distance from center point
+            to outer vertices. Defaults to None.
+            inner_radius (Optional[int], optional): Distance from center point
+            to inner vertices. Defaults to None.
+            center (Optional[Tuple[int, int]], optional): Center origin point
+            from which the polygon will be calculated. Defaults to puzzle center.
+            angle (float, optional): Rotation angle in degrees polygon.
+            Defaults to 0.0.
+            method (int, optional): How Mask is applied to the puzzle
+            (1=Standard (Intersection), 2=Additive, 3=Subtractive). Defaults to 1.
+            static (bool, optional): Should this mask be reapplied
+            after changes to the parent puzzle size. Defaults to True.
+
+        Raises:
+            ValueError: Polygon outer vertices must be >=3.
+        """
+        if outer_vertices < 3:
+            raise ValueError(
+                "Minimum of 3 points (vertices) required to create a Polygon."
+            )
+        super().__init__(method=method, static=static)
+        self.outer_vertices = outer_vertices
+        self.outer_radius = outer_radius
+        self.inner_radius = inner_radius
+        self.center = center
+        self.angle = angle
+
+    def generate(self, puzzle_size: int) -> None:
+        self.puzzle_size = puzzle_size
+        self._mask = Mask.build_mask(self.puzzle_size)
+        puzzle_radius = (
+            self.puzzle_size // 2 - 1 if puzzle_size % 2 == 0 else self.puzzle_size // 2
+        )
+        puzzle_center = (puzzle_radius, puzzle_radius)
+        self.points = Star.calculate_vertices(
+            self.outer_vertices,
+            self.outer_radius if self.outer_radius else puzzle_radius,
+            self.inner_radius if self.inner_radius else puzzle_radius // 2,
+            self.center if self.center else puzzle_center,
+            self.angle,
+        )
+        self._draw_in_halves()
+
+    @staticmethod
+    def calculate_vertices(
+        outer_vertices: int,
+        outer_radius: int,
+        inner_radius: int,
+        center: Tuple[int, int],
+        angle: float,
+    ):
+        points = []
+        cx, cy = center
+        angle_step = 180.0 / outer_vertices
+        angle = angle - 90  # rotated -90 so polygons are oriented North
+        for _ in range(outer_vertices):
+            # calculate outer point/vertex
+            x, y = RegularPolygon.cos_sin_from_degrees(angle)
+            points.append(
+                (
+                    int(round_half_up(x * outer_radius + cx)),
+                    int(round_half_up(y * outer_radius + cy)),
+                )
+            )
+            angle += angle_step
+            # calculate inner point/vertex
+            x, y = RegularPolygon.cos_sin_from_degrees(angle)
+            points.append(
+                (
+                    int(round_half_up(x * inner_radius + cx)),
+                    int(round_half_up(y * inner_radius + cy)),
+                )
+            )
+            angle += angle_step
+        return points
diff --git a/src/word_search_generator/mask/shapes.py b/src/word_search_generator/mask/shapes.py
new file mode 100644
index 0000000..ad5398d
--- /dev/null
+++ b/src/word_search_generator/mask/shapes.py
@@ -0,0 +1,209 @@
+import inspect
+import math
+import sys
+from typing import Tuple
+
+from ..config import ACTIVE
+from . import CompoundMask, Mask
+from .ellipse import Ellipse
+from .polygon import Polygon, Rectangle, RegularPolygon, Star
+
+
+def get_shape_objects():
+    """Return all built-in shape objects from this file"""
+    # {key:value for (key,value) in dictonary.items()}
+    return [
+        name
+        for name, obj in inspect.getmembers(sys.modules[__name__], inspect.isclass)
+        if obj.__module__ is __name__
+    ]
+
+
+class Circle(Ellipse):
+    def __init__(self) -> None:
+        super().__init__()
+
+
+class Diamond(RegularPolygon):
+    def __init__(self) -> None:
+        super().__init__(vertices=4, angle=90)
+
+
+class Donut(CompoundMask):
+    def __init__(self) -> None:
+        super().__init__()
+
+    def generate(self, puzzle_size: int) -> None:
+        self.puzzle_size = puzzle_size
+        self._mask = Mask.build_mask(puzzle_size, ACTIVE)
+        donut, hole = Donut.calculate_golden_donut_ratio(self.puzzle_size)
+        self.masks = [
+            Ellipse(donut, donut),
+            Ellipse(hole, hole, method=3),
+        ]
+        for mask in self.masks:
+            mask.generate(self.puzzle_size)
+            self._apply_mask(mask)
+
+    @staticmethod
+    def calculate_golden_donut_ratio(puzzle_size: int) -> Tuple[int, int]:
+        donut = puzzle_size - 1 if puzzle_size % 2 == 0 else puzzle_size
+        hole = int(math.pow(puzzle_size - 2, 2) // (3 * (puzzle_size - 1)))
+        if hole % 2 == 0:
+            hole += 1
+        return donut, hole
+
+
+class Heart(CompoundMask):
+    def __init__(self) -> None:
+        super().__init__()
+
+    def generate(self, puzzle_size: int) -> None:
+        if puzzle_size < 8:
+            raise ValueError("Puzzle size must be > 7 for a Heart mask.")
+
+        self.puzzle_size = puzzle_size
+        self._mask = Mask.build_mask(puzzle_size, ACTIVE)
+
+        # calculate both top ellipses
+        even_odd_offset = self.puzzle_size % 2
+        ellipse_size = self.puzzle_size // 2 + even_odd_offset
+        center_offset = 1 if self.puzzle_size % 2 != 0 and ellipse_size % 2 == 0 else 0
+        ellipse_center = self.puzzle_size // 2 - ellipse_size // 2 + center_offset
+        left_ellipse = Ellipse(
+            width=ellipse_size,
+            height=ellipse_size,
+            center=(
+                ellipse_center,
+                ellipse_center,
+            ),
+        )
+        right_ellipse = Ellipse(
+            width=ellipse_size,
+            height=ellipse_size,
+            center=(
+                self.puzzle_size // 2
+                + ellipse_center
+                - (1 if self.puzzle_size % 2 == 0 else 0),
+                ellipse_center,
+            ),
+            method=2,
+        )
+        left_ellipse.generate(self.puzzle_size)
+        right_ellipse.generate(self.puzzle_size)
+
+        # calculate the bottom half polygon
+        if (
+            right_ellipse.bounding_box is not None
+            and left_ellipse.bounding_box is not None
+        ):
+            x1 = min(x for x, _ in left_ellipse.points)
+            y1 = max(
+                y
+                for y in range(len(left_ellipse.mask))
+                if left_ellipse.mask[y][x1] == ACTIVE
+            )
+            x2 = left_ellipse.bounding_box[1][1]
+            y2 = right_ellipse.bounding_box[1][1] * 2
+            x3 = right_ellipse.bounding_box[1][0]
+            y3 = y1
+            x4 = left_ellipse.bounding_box[1][0]
+            y4 = left_ellipse.bounding_box[1][1]
+            y4 = min(
+                y
+                for y in range(len(left_ellipse.mask))
+                if left_ellipse.mask[y][x4] == ACTIVE
+            )
+            poly = Polygon(
+                [(x1, y1), (x2, y2), (x3, y3), (x4, y4)],
+                method=2,
+            )
+            print(poly.points)
+        poly.generate(self.puzzle_size)
+
+        self.masks = [left_ellipse, right_ellipse, poly]
+
+        for mask in self.masks:
+            self._apply_mask(mask)
+
+
+class Hexagon(RegularPolygon):
+    def __init__(self) -> None:
+        super().__init__(vertices=6, angle=90)
+
+
+class Octagon(RegularPolygon):
+    def __init__(self) -> None:
+        super().__init__(vertices=8, angle=22.5)
+
+
+class Pentagon(RegularPolygon):
+    def __init__(self) -> None:
+        super().__init__(vertices=5)
+
+
+class Star5(Star):
+    def __init__(self) -> None:
+        super().__init__()
+
+
+class Star6(CompoundMask):
+    def __init__(self) -> None:
+        super().__init__()
+        self.masks = [
+            (RegularPolygon()),
+            (RegularPolygon(angle=180, method=2)),
+        ]
+
+
+class Star8(Star):
+    def __init__(self) -> None:
+        super().__init__(outer_vertices=8)
+
+
+class Tree(CompoundMask):
+    def __init__(self) -> None:
+        super().__init__()
+
+    def generate(self, puzzle_size: int) -> None:
+        self.puzzle_size = puzzle_size
+        self._mask = Mask.build_mask(puzzle_size, ACTIVE)
+        # build the tree top
+        tree_top = RegularPolygon(vertices=3)
+        tree_top.generate(self.puzzle_size)
+        tree_top_width = tree_top.points[2][0] - tree_top.points[1][0] + 1
+        # calculate the tree trunk size and position
+        tree_trunk_width = (
+            tree_top_width // 4 + 1
+            if tree_top_width // 4 % 2 == 0
+            else tree_top_width // 4
+        )
+        tree_trunk_height = self.puzzle_size - tree_top.points[1][1]
+        tree_trunk_position_x = (
+            self.puzzle_size // 2 - tree_trunk_width // 2 - 1
+            if self.puzzle_size % 2 == 0
+            else self.puzzle_size // 2 - tree_trunk_width // 2
+        )
+        tree_trunk_position_y = tree_top.points[1][1]
+        # build the tree trunk
+        tree_trunk = Rectangle(
+            width=tree_trunk_width,
+            height=tree_trunk_height,
+            origin=(
+                tree_trunk_position_x,
+                tree_trunk_position_y,
+            ),
+            method=2,
+        )
+        tree_trunk.generate(self.puzzle_size)
+        self.masks = [tree_top, tree_trunk]
+        for mask in self.masks:
+            self._apply_mask(mask)
+
+
+class Triangle(RegularPolygon):
+    def __init__(self) -> None:
+        super().__init__(vertices=3)
+
+
+BUILTIN_SHAPES = get_shape_objects()
diff --git a/src/word_search_generator/utils.py b/src/word_search_generator/utils.py
index 1967108..6d50fe2 100644
--- a/src/word_search_generator/utils.py
+++ b/src/word_search_generator/utils.py
@@ -1,21 +1,116 @@
 from __future__ import annotations
 
+import copy
+import math
 import random
 import string
-from typing import TYPE_CHECKING, Any, Iterable, List, Tuple
+import sys
+from math import log2
+from typing import TYPE_CHECKING, Any, Iterable, List, Optional, Sized, Tuple, Union
 
-from word_search_generator import config
-from word_search_generator.types import (
-    Direction,
-    DirectionSet,
-    Key,
-    Puzzle,
-    Word,
-    Wordlist,
-)
+from . import config
+from .word import Direction, Word
 
 if TYPE_CHECKING:  # pragma: no cover
-    from word_search_generator import WordSearch
+    from . import DirectionSet, Key, Puzzle, WordSearch
+    from .word import Wordlist
+
+
+def calc_puzzle_size(words: Wordlist, level: Sized, size: Optional[int] = None) -> int:
+    """Calculate the puzzle grid size."""
+    all_words = list(word.text for word in words)
+    longest_word_length = len(max(all_words, key=len))
+    shortest_word_length = len(min(all_words, key=len))
+    if not size:
+        longest = max(10, longest_word_length)
+        # calculate multiplier for larger word lists so that most have room to fit
+        multiplier = len(all_words) / 15 if len(all_words) > 15 else 1
+        # level lengths in config.py are nice multiples of 2
+        l_size = log2(len(level)) if level else 1  # protect against log(0) in tests
+        size = round(longest + l_size * 2 * multiplier)
+    else:
+        if size < shortest_word_length:
+            print(
+                "Puzzle sized adjust to fit word with the shortest length.",
+                file=sys.stderr,
+            )
+            size = shortest_word_length + 1
+    return size
+
+
+def build_puzzle(size: int, char: str) -> Puzzle:
+    return [[char] * size for _ in range(size)]
+
+
+def round_half_up(
+    n: float, decimals: int = 0
+) -> Union[float, Any]:  # mypy 0.95+ weirdness
+    """Round numbers in a consistent and familiar format."""
+    multiplier = 10**decimals
+    return math.floor(n * multiplier + 0.5) / multiplier
+
+
+def float_range(
+    start: Union[int, float],
+    stop: Optional[Union[int, float]] = None,
+    step: Optional[Union[int, float]] = None,
+):
+    """Generate a float-based range for iteration."""
+    start = float(start)
+    if stop is None:
+        stop = start + 0.0
+        start = 0.0
+    if step is None:
+        step = 1.0
+    count = 0
+    while True:
+        temp = float(start + count * step)
+        if step > 0 and temp >= stop:
+            break
+        elif step < 0 and temp <= stop:
+            break
+        yield temp
+        count += 1
+
+
+def distance(x: int, y: int, ratio: float) -> float:
+    """Calculate the distance between two coordinates on a grid."""
+    return math.sqrt(math.pow(y * ratio, 2) + math.pow(x, 2))
+
+
+def in_bounds(x: int, y: int, width: int, height: int) -> bool:
+    """Validate position (x, y) is within the supplied bounds."""
+    return x >= 0 and x < width and y >= 0 and y < height
+
+
+def find_bounding_box(
+    grid: List[List[str]], edge: str = config.ACTIVE
+) -> Tuple[Tuple[int, int], Tuple[int, int]]:
+    """Bounding box of the masked area as a rectangle defined
+    by a Tuple of (top-left edge as x, y, bottom-right edge as x, y)"""
+    size = len(grid)
+    min_y = 0
+    for i, r in enumerate(grid):
+        if edge in r:
+            min_y = i
+            break
+    max_y = size
+    for i, r in enumerate(reversed(grid)):
+        if edge in r:
+            max_y = size - 1 - i
+            break
+    cols = [list(c) for c in zip(*grid)]  # mypy not playing nice w/ `list(zip(*grid))``
+    min_x = 0
+    for i, r in enumerate(cols):
+        if edge in r:
+            min_x = i
+            break
+    max_x = size
+    for i, r in enumerate(reversed(cols)):
+        if edge in r:
+            max_x = size - 1 - i
+            break
+    return ((min_x, min_y), (max_x, max_y))
 
 
 def cleanup_input(words: str, secret: bool = False) -> Wordlist:
@@ -111,53 +206,61 @@ def direction_set_repr(ds: DirectionSet) -> str:
     return ("'" + ",".join(d.name for d in ds) + "'") if ds else "None"
 
 
-def highlight_solution(puzzle: WordSearch) -> Puzzle:
+def highlight_solution(ws: WordSearch) -> Puzzle:
     """Add highlighting to puzzle solution."""
-    output: Puzzle = []
-    for r, line in enumerate(puzzle.puzzle):
-        line_chars = []
-        # check to see if character if part of the solution
-        for c, char in enumerate(line):
-            if puzzle.solution[r][c]:
-                line_chars.append(f"\u001b[1m\u001b[31m{char}\u001b[0m")
-            else:
-                line_chars.append(f"{char}")
-        output.append(line_chars)
+    output: Puzzle = copy.deepcopy(ws.puzzle)
+    for word in ws.placed_words:
+        if (
+            word.start_column is None
+            or word.start_row is None
+            or word.direction is None
+        ):  # only here for mypy
+            continue  # pragma: no cover
+        x = word.start_column
+        y = word.start_row
+        for char in word.text:
+            output[y][x] = f"\u001b[1m\u001b[31m{char}\u001b[0m"
+            x += word.direction.c_move
+            y += word.direction.r_move
     return output
 
 
-def make_header(puzzle: Puzzle, text: str) -> str:
-    """Generate a header that fits the current puzzle."""
-    hr = "-" * max(11, (len(puzzle) * 2 - 1))
+def make_header(size: int, text: str) -> str:
+    """Generate a header that fits the current puzzle size."""
+    hr = "-" * max(11, (size * 2 - 1))
     padding = " " * ((len(hr) - len(text)) // 2)
     return f"""{hr}
 {padding}{text}{padding}
 {hr}"""
 
 
-def stringify(puzzle: Puzzle) -> str:
+def stringify(puzzle: Puzzle, bbox: Tuple[Tuple[int, int], Tuple[int, int]]) -> str:
     """Convert puzzle array of nested lists into a string."""
+    min_x, min_y = bbox[0]
+    max_x, max_y = bbox[1]
     output = []
-    for line in puzzle:
-        output.append(" ".join(line))
+    for line in puzzle[min_y : max_y + 1]:
+        output.append(" ".join([c if c else " " for c in line[min_x : max_x + 1]]))
     return "\n".join(output)
 
 
-def format_puzzle_for_show(puzzle: WordSearch, show_solution: bool = False) -> str:
-    header = make_header(puzzle.puzzle, "WORD SEARCH")
-    word_list = get_word_list_str(puzzle.key)
+def format_puzzle_for_show(ws: WordSearch, show_solution: bool = False) -> str:
+    word_list = get_word_list_str(ws.key)
     # highlight solution if provided
-    puzzle_list = highlight_solution(puzzle) if show_solution else puzzle.puzzle
+    puzzle_list = highlight_solution(ws) if show_solution else ws.puzzle
+    # calculate header length based on cropped puzzle size to account for masks
+    header_width = ws.bounding_box[1][0] - ws.bounding_box[0][0] + 1
+    header = make_header(header_width, "WORD SEARCH")
     answer_key_intro = (
-        "Answer Key (*= Secret Words)" if puzzle.placed_secret_words else "Answer Key"
+        "Answer Key (*= Secret Words)" if ws.placed_secret_words else "Answer Key"
     )
     return f"""{header}
-{stringify(puzzle_list)}
+{stringify(puzzle_list, ws.bounding_box)}
 
 Find these words: {word_list if word_list else '<ALL SECRET WORDS>'}
-* Words can go {get_level_dirs_str(puzzle.level)}.
+* Words can go {get_level_dirs_str(ws.level)}.
 
-{answer_key_intro}: {get_answer_key_str(puzzle.placed_words)}"""
+{answer_key_intro}: {get_answer_key_str(ws.placed_words, ws.bounding_box)}"""
 
 
 def get_level_dirs_str(level: DirectionSet) -> str:
@@ -177,18 +280,32 @@ def get_word_list_list(key: Key) -> List[str]:
     return [k for k in sorted(key.keys()) if not key[k]["secret"]]
 
 
-def get_answer_key_list(words: Wordlist) -> List[Any]:
-    """Return a easy to read answer key for display/export."""
-    keys = []
-    for w in sorted(words, key=lambda word: word.text):
-        keys.append(w.key_string)
-    return keys
+def get_answer_key_list(
+    words: Wordlist, bbox: Tuple[Tuple[int, int], Tuple[int, int]]
+) -> List[Any]:
+    """Return a easy to read answer key for display/export. Resulting coordinates
+    will be offset by the supplied values. Used for masked puzzles.
+
+    Args:
+        words (Wordlist): A list of `Word` objects.
+        bbox (Tuple[int, int, int, int]): Puzzle mask bounding box
+        coordinates should be offset by.
+    """
+    return [w.key_string(bbox) for w in sorted(words, key=lambda word: word.text)]
+
 
+def get_answer_key_str(
+    words: Wordlist, bbox: Tuple[Tuple[int, int], Tuple[int, int]]
+) -> str:
+    """Return a easy to read answer key for display. Resulting coordinates
+    will be offset by the supplied values. Used for masked puzzles.
 
-def get_answer_key_str(words: Wordlist) -> str:
-    """Return a easy to read answer key for display."""
-    keys = get_answer_key_list(words)
-    return ", ".join(keys)
+    Args:
+        words (Wordlist): A list of `Word` objects.
+        bbox (Tuple[int, int, int, int]): Puzzle mask bounding box
+        coordinates should be offset by.
+    """
+    return ", ".join(get_answer_key_list(words, bbox))
 
 
 def get_random_words(n: int) -> str:
diff --git a/src/word_search_generator/types.py b/src/word_search_generator/word.py
similarity index 60%
rename from src/word_search_generator/types.py
rename to src/word_search_generator/word.py
index db80bb5..f723a60 100644
--- a/src/word_search_generator/types.py
+++ b/src/word_search_generator/word.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import sys
 from enum import Enum, unique
 from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Union
@@ -5,7 +7,11 @@
 if sys.version_info >= (3, 8):
     from typing import TypedDict
 else:
-    from typing_extensions import TypedDict
+    from typing_extensions import TypedDict  # pragma: no cover
+
+
+Fit = Optional[Tuple[str, List[Tuple[int, int]]]]
+Fits = Dict[str, List[Tuple[int, int]]]
 
 
 @unique
@@ -63,29 +69,33 @@ def __init__(
         self,
         text: str,
         secret: bool = False,
-    ):
+    ) -> None:
         self.text = text.upper()
-        self._start_row: Optional[int] = None
-        self._start_column: Optional[int] = None
+        self.start_row: Optional[int] = None
+        self.start_column: Optional[int] = None
+        self.coordinates: List[Tuple[int, int]] = []
         self.direction: Optional[Direction] = None
         self.secret = secret
 
     @property
-    def start_row(self) -> Union[int, None]:
-        return self._start_row
-
-    @property
-    def start_column(self) -> Union[int, None]:
-        return self._start_column
+    def placed(self) -> bool:
+        # used `is not None` since 0 vals for start_row/column are not truthy
+        return all(
+            (
+                self.start_column is not None,
+                self.start_row is not None,
+                self.direction is not None,
+            )
+        )
 
     @property
     def position(self) -> Position:
         return Position(self.start_row, self.start_column)
 
     @position.setter
-    def position(self, val: Position):
-        self._start_row = val.row
-        self._start_column = val.column
+    def position(self, val: Position) -> None:
+        self.start_row = val.row
+        self.start_column = val.column
 
     @property
     def position_xy(self) -> Union[str, None]:
@@ -112,13 +122,37 @@ def key_info_json(self) -> KeyInfoJson:
             "secret": self.secret,
         }
 
-    @property
-    def key_string(self) -> Union[str, None]:
-        return (
-            f"{'*' if self.secret else ''}{self.text} "
-            + f"{self.direction.name if self.direction else self.direction}"
-            + f" @ {self.position_xy}"
-        )
+    def key_string(
+        self, bbox: Tuple[Tuple[int, int], Tuple[int, int]]
+    ) -> Union[str, None]:
+        if isinstance(self.start_row, int) and isinstance(self.start_column, int):
+            return (
+                f"{'*' if self.secret else ''}{self.text} "
+                + f"{self.direction.name if self.direction else self.direction}"
+                + f" @ {(self.offset_position_xy(bbox))}"
+            )
+        return None
+
+    def offset_position_xy(
+        self, bbox: Tuple[Tuple[int, int], Tuple[int, int]]
+    ) -> Union[Tuple[int, int], None]:
+        if isinstance(self.start_row, int) and isinstance(self.start_column, int):
+            offset_start_row = self.start_row + 1 - bbox[0][1]
+            offset_start_column = self.start_column + 1 - bbox[0][0]
+            return (offset_start_column, offset_start_row)
+        return None
+
+    def offset_coordinates(
+        self, bbox: Tuple[Tuple[int, int], Tuple[int, int]]
+    ) -> Union[List[Tuple[int, int]], None]:
+        return [(x - bbox[0][0], y - bbox[0][1]) for x, y in self.coordinates]
+
+    def remove_from_puzzle(self):
+        """Remove word placement details when a puzzle is reset."""
+        self.start_row = None
+        self.start_column = None
+        self.coordinates = []
+        self.direction = None
 
     def __eq__(self, __o: object) -> bool:
         if not isinstance(__o, Word):
@@ -128,17 +162,11 @@ def __eq__(self, __o: object) -> bool:
     def __hash__(self) -> int:
         return hash(self.text)
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         return f"{self.__class__.__name__}('{self.text}', " + f"{self.secret})"
 
-    def __str__(self):
+    def __str__(self) -> str:
         return self.text
 
 
-Puzzle = List[List[str]]
-Key = Dict[str, KeyInfo]
-KeyJson = Dict[str, KeyInfoJson]
-Fit = Optional[Tuple[str, List[Tuple[int, int]]]]
-Fits = Dict[str, List[Tuple[int, int]]]
-DirectionSet = Set[Direction]
 Wordlist = Union[Set[Word], Any]


Test patch:
diff --git a/tests/test_Mask.py b/tests/test_Mask.py
new file mode 100644
index 0000000..46d35f5
--- /dev/null
+++ b/tests/test_Mask.py
@@ -0,0 +1,612 @@
+from pathlib import Path
+
+import pytest
+from PIL import Image as PILImage
+
+from word_search_generator import WordSearch
+from word_search_generator.config import ACTIVE, INACTIVE
+from word_search_generator.mask import CompoundMask, Mask, MaskNotGenerated
+from word_search_generator.mask.bitmap import Bitmap, ContrastError, Image
+from word_search_generator.mask.ellipse import Ellipse
+from word_search_generator.mask.polygon import Polygon, RegularPolygon, Star
+from word_search_generator.mask.shapes import Circle, Heart
+from word_search_generator.utils import get_random_words
+
+
+def test_mask_property_points_set_during_init():
+    m = Mask([(1, 2), (3, 4)])
+    assert len(m.points) == 2
+
+
+def test_mask_property_mask_empty():
+    m = Mask()
+    assert m.mask == []
+
+
+def test_mask_property_method_undefined():
+    m = Mask()
+    assert m.method == 1
+
+
+def test_mask_property_method_setter():
+    m = Mask(method=2)
+    assert m.method == 2
+
+
+def test_mask_property_method_setter_invalid_type():
+    with pytest.raises(TypeError):
+        m = Mask(method="a")  # type: ignore  # noqa: F841
+
+
+def test_mask_property_method_setter_invalid_value():
+    with pytest.raises(ValueError):
+        m = Mask(method=7)  # type: ignore # noqa: F841
+
+
+def test_mask_property_static_undefined():
+    m = Mask()
+    assert m.static
+
+
+def test_mask_property_static_setter():
+    m = Mask(static=False)
+    assert not m.static
+
+
+def test_mask_property_static_setter_invalid():
+    with pytest.raises(TypeError):
+        m = Mask(static=7)  # type: ignore # noqa: F841
+
+
+def test_mask_property_puzzle_size_undefined():
+    m = Mask()
+    assert m.puzzle_size is None
+
+
+def test_mask_property_puzzle_size_setter():
+    size = 21
+    m = Mask()
+    m.puzzle_size = size
+    assert m.puzzle_size == size
+
+
+def test_mask_property_puzzle_size_setter_non_static():
+    size = 21
+    m = Mask(static=False)
+    m.puzzle_size = size
+    assert m.puzzle_size == size
+
+
+def test_mask_property_puzzle_size_setter_invalid_type():
+    m = Mask()
+    with pytest.raises(TypeError):
+        m.puzzle_size = "a"  # type: ignore  # noqa: F841
+
+
+def test_mask_property_puzzle_size_setter_invalid_value():
+    m = Mask()
+    with pytest.raises(ValueError):
+        m.puzzle_size = 7000  # type: ignore # noqa: F841
+
+
+def test_mask_property_bounding_box():
+    m = Mask([(1, 2), (3, 4)])
+    assert m.bounding_box == ((1, 2), (3, 4))
+
+
+def test_mask_property_bounding_box_unsorted():
+    m = Mask([(3, 4), (1, 2)])
+    assert m.bounding_box == ((1, 2), (3, 4))
+
+
+def test_mask_property_bounding_box_no_points():
+    m = Mask()
+    assert m.bounding_box is None
+
+
+def test_build_mask():
+    size = 3
+    char = "J"
+    m = Mask.build_mask(size, char)
+    assert m == [[char] * size] * size
+
+
+def test_generate():
+    size = 5
+    m = Mask()
+    m.generate(size)
+    assert m.puzzle_size == size
+    assert m.mask == [[INACTIVE] * size for _ in range(5)]
+
+
+def test_show_not_generated():
+    m = Mask()
+    with pytest.raises(MaskNotGenerated):
+        m.show()
+
+
+def test_show(capsys):
+    m = Mask([(1, 2), (3, 4)])
+    m.generate(5)
+    match = """# # # # #
+# # # # #
+# # # # #
+# # # # #
+# # # # #
+"""
+    m.show()
+    capture = capsys.readouterr()
+    assert capture.out == match
+
+
+def test_show_active_only(capsys):
+    size = 5
+    m = Mask([(0, 0), (1, 1)])
+    m.generate(size)
+    for x, y in m.points:
+        m.mask[y][x] = ACTIVE
+    match = "*  \n  *\n"
+    m.show(True)
+    capture = capsys.readouterr()
+    assert capture.out == match
+
+
+def test_invert():
+    m = Mask()
+    m._mask = [
+        [ACTIVE, INACTIVE, ACTIVE],
+        [INACTIVE, ACTIVE, INACTIVE],
+        [ACTIVE, INACTIVE, ACTIVE],
+    ]
+    m.invert()
+    assert m.mask == [
+        [INACTIVE, ACTIVE, INACTIVE],
+        [ACTIVE, INACTIVE, ACTIVE],
+        [INACTIVE, ACTIVE, INACTIVE],
+    ]
+
+
+def test_flip_horizontal():
+    m = Mask()
+    m._mask = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
+    m.flip_horizontal()
+    assert m.mask == [[3, 2, 1], [6, 5, 4], [9, 8, 7]]
+
+
+def test_flip_vertical():
+    m = Mask()
+    m._mask = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
+    m.flip_vertical()
+    assert m.mask == [[7, 8, 9], [4, 5, 6], [1, 2, 3]]
+
+
+def test_transpose():
+    m = Mask()
+    m._mask = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
+    m.transpose()
+    assert m.mask == [[1, 4, 7], [2, 5, 8], [3, 6, 9]]
+
+
+def test_ungenerated_mask():
+    """Test MaskNotGenerated exception when mask hasn't been generated yet."""
+    m = Mask()
+    with pytest.raises(MaskNotGenerated):
+        m._draw()
+
+
+def test_empty_compound_mask():
+    cm = CompoundMask()
+    assert cm.masks == []
+
+
+def test_compound_mask_masks_during_init():
+    m1 = Mask()
+    m2 = Mask()
+    m3 = Mask()
+    masks = [m1, m2, m3]
+    cm = CompoundMask(masks)
+    assert cm.masks == masks
+
+
+def test_compound_mask_add_mask():
+    m1 = Mask()
+    m2 = Mask()
+    m3 = Mask()
+    masks = [m1, m2, m3]
+    cm = CompoundMask([m1, m2])
+    cm.add_mask(m3)
+    assert cm.masks == masks
+
+
+def test_compound_mask_bounding_box():
+    size = 11
+    cm = CompoundMask()
+    cm.generate(size)
+    assert cm.bounding_box == ((0, 0), (size - 1, size - 1))
+
+
+def test_compound_mask_generate():
+    size = 11
+    cm = CompoundMask()
+    cm.generate(size)
+    assert cm.puzzle_size == size
+    assert cm.mask == [[ACTIVE] * size] * size
+
+
+def test_compound_mask_generate_submasks():
+    size = 11
+    m1 = Mask()
+    cm = CompoundMask([m1])
+    cm.generate(size)
+    assert m1.puzzle_size == size
+    assert m1.mask == [[INACTIVE] * size] * size
+
+
+def test_compound_mask_apply_mask_error():
+    m1 = Mask()
+    cm = CompoundMask()
+    with pytest.raises(MaskNotGenerated):
+        cm._apply_mask(m1)
+
+
+def test_compound_mask_apply_mask_method_1():
+    size = 11
+    m1 = Mask()
+    m1.generate(size)
+    m1.invert()
+    cm = CompoundMask()
+    cm.generate(size)
+    cm._apply_mask(m1)
+    assert m1.mask == cm.mask
+
+
+def test_compound_mask_apply_mask_method_2():
+    size = 11
+    m1 = Mask(method=2)
+    m1.generate(size)
+    m1.invert()
+    cm = CompoundMask()
+    cm.generate(size)
+    cm._apply_mask(m1)
+    assert m1.mask == cm.mask
+
+
+def test_compound_mask_apply_mask_method_3():
+    size = 11
+    m1 = Mask(method=3)
+    m1.generate(size)
+    m1.invert()
+    cm = CompoundMask()
+    cm.generate(size)
+    cm._apply_mask(m1)
+    assert cm.mask == [[INACTIVE] * size] * size
+
+
+# ************************************************ #
+# ******************** BITMAP ******************** #
+# ************************************************ #
+
+
+def test_bitmap_mask_init():
+    bm = Bitmap(method=2, static=False)
+    assert isinstance(bm, Mask)
+    assert bm.method == 2
+    assert bm.static is False
+
+
+def test_bitmap_mask_draw():
+    size = 5
+    bm = Bitmap([(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)])
+    bm.generate(size)
+    test_mask = [
+        ["*", "#", "#", "#", "#"],
+        ["#", "*", "#", "#", "#"],
+        ["#", "#", "*", "#", "#"],
+        ["#", "#", "#", "*", "#"],
+        ["#", "#", "#", "#", "*"],
+    ]
+    assert bm.mask == test_mask
+
+
+def test_bitmap_mask_draw_exception():
+    bm = Bitmap()
+    with pytest.raises(MaskNotGenerated):
+        bm._draw()
+
+
+def test_image_mask_init(tmp_path):
+    name = "test_image.jpg"
+    path = Path.joinpath(tmp_path, name)
+    im = Image(path, method=2, static=False)
+    assert isinstance(im, Mask)
+    assert im.fp == path
+    assert im.method == 2
+    assert im.static is False
+
+
+def test_image_mask_solid_black(tmp_path):
+    name = "test_image_black.jpg"
+    test_img = PILImage.new("L", (100, 100), (0))
+    img_path = Path.joinpath(tmp_path, name)
+    test_img.save(img_path, "JPEG")
+    size = 11
+    im = Image(img_path)
+    im.generate(size)
+    assert im.mask == [[ACTIVE] * size] * size
+
+
+def test_image_mask_contrast_exception(tmp_path):
+    name = "test_image_white.png"
+    test_img = PILImage.new("L", (100, 100), (255))
+    img_path = Path.joinpath(tmp_path, name)
+    test_img.save(img_path, "PNG")
+    size = 11
+    im = Image(img_path)
+    with pytest.raises(ContrastError):
+        im.generate(size)
+
+
+# ************************************************* #
+# ******************** ELLIPSE ******************** #
+# ************************************************* #
+
+
+def test_calculate_ellipse_points_method_1():
+    """Even `puzzle_size`, odd `width` and `height`."""
+    puzzle_size = 6
+    points = Ellipse.calculate_ellipse_points(
+        width=5,
+        height=3,
+        origin=(puzzle_size // 2, puzzle_size // 2),
+        puzzle_size=puzzle_size,
+    )
+    assert len(points) % 2 == 1
+
+
+def test_calculate_ellipse_points_method_2():
+    """Odd `puzzle_size`, even `width` and `height`."""
+    puzzle_size = 7
+    points = Ellipse.calculate_ellipse_points(
+        width=6,
+        height=4,
+        origin=(puzzle_size // 2, puzzle_size // 2),
+        puzzle_size=puzzle_size,
+    )
+    assert len(points) % 2 == 0
+
+
+def test_within_radius_true():
+    width = 10
+    height = 6
+    width_r = width / 2
+    height_r = height / 2
+    ratio = width_r / height_r
+    assert Ellipse.within_radius(1, 2, width_r, ratio)
+
+
+def test_within_radius_false():
+    width = 4
+    height = 2
+    width_r = width / 2
+    height_r = height / 2
+    ratio = width_r / height_r
+    assert not Ellipse.within_radius(7, 11, width_r, ratio)
+
+
+# ************************************************* #
+# ******************** POLYGON ******************** #
+# ************************************************* #
+
+
+def test_polygon_too_few_points():
+    with pytest.raises(ValueError):
+        pm = Polygon(((0, 0), (1, 1)))  # type: ignore # noqa: F841
+
+
+def test_connect_points_no_generated():
+    pm = Polygon()
+    with pytest.raises(MaskNotGenerated):
+        pm._connect_points((0, 0), (1, 1))
+
+
+def test_fill_shape_no_generated():
+    pm = Polygon()
+    with pytest.raises(MaskNotGenerated):
+        pm._fill_shape("J")
+
+
+def test_regular_polygon_too_few_vertices():
+    with pytest.raises(ValueError):
+        pm = RegularPolygon(2)  # noqa: F841
+
+
+def test_star_polygon_too_few_vertices():
+    with pytest.raises(ValueError):
+        pm = Star(2)  # noqa: F841
+
+
+# ************************************************ #
+# ******************** SHAPES ******************** #
+# ************************************************ #
+
+
+def test_heart_puzzle_too_small():
+    m = Heart()
+    with pytest.raises(ValueError):
+        m.generate(7)
+
+
+# **************************************************** #
+# ******************** WORDSEARCH ******************** #
+# **************************************************** #
+
+
+def test_masked_property_true():
+    p = WordSearch("pig horse cow")
+    p.apply_mask(Star(5))
+    assert p.masked
+
+
+def test_masked_property_false():
+    p = WordSearch("pig horse cow")
+    assert not p.masked
+
+
+def test_add_mask_error():
+    p = WordSearch("pig horse cow")
+    with pytest.raises(TypeError):
+        p.apply_mask("J")  # type: ignore
+
+
+def test_apply_mask_method_1():
+    p = WordSearch(get_random_words(10), size=10)
+    p.apply_mask(Circle())
+    assert p.puzzle[0][0] == ""
+    assert p.puzzle[0][p.size - 1] == ""
+    assert p.puzzle[p.size - 1][0] == ""
+    assert p.puzzle[p.size - 1][p.size - 1] == ""
+
+
+def test_apply_mask_method_2():
+    size = 10
+    p = WordSearch(get_random_words(10), size=size)
+    m1 = Mask()
+    m1.generate(size)
+    p.apply_mask(m1)
+    m2 = Mask(method=2)
+    m2.generate(size)
+    m2.invert()
+    p.apply_mask(m2)
+    assert p.puzzle[0][0] != ""
+    assert p.puzzle[0][p.size - 1] != ""
+    assert p.puzzle[p.size - 1][0] != ""
+    assert p.puzzle[p.size - 1][p.size - 1] != ""
+
+
+def test_apply_mask_method_3():
+    size = 10
+    p = WordSearch(get_random_words(10), size=size)
+    m1 = Mask()
+    m1.generate(size)
+    m1.invert()
+    p.apply_mask(m1)
+    p.apply_mask(Ellipse(method=3))
+    assert p.puzzle[size // 2][size // 2] == ""
+
+
+def test_apply_masks():
+    size = 21
+    p = WordSearch("pig horse cow", size=size)
+    p.apply_masks([Circle(), Ellipse(2, 2, method=3)])
+    assert len(p.masks) == 2
+    assert p.puzzle[size // 2][size // 2] == ""
+
+
+def test_show_mask(capsys):
+    size = 5
+    p = WordSearch("pig horse cow", size=size)
+    p.apply_mask(Circle())
+    match = """# * * * #
+* * * * *
+* * * * *
+* * * * *
+# * * * #
+"""
+    p.show_mask()
+    capture = capsys.readouterr()
+    assert capture.out == match
+
+
+def test_show_mask_empty(capsys):
+    size = 5
+    p = WordSearch("pig horse cow", size=size)
+    p.show_mask()
+    capture = capsys.readouterr()
+    assert capture.out == "Empty mask.\n"
+
+
+def test_puzzle_invert_mask():
+    size = 21
+    p = WordSearch("pig horse cow", size=size)
+    p.apply_mask(Circle())
+    p.invert_mask()
+    assert p.puzzle[0][0] != ""
+    assert p.puzzle[0][p.size - 1] != ""
+    assert p.puzzle[p.size - 1][0] != ""
+    assert p.puzzle[p.size - 1][p.size - 1] != ""
+
+
+def test_puzzle_flip_mask_horizontal():
+    size = 6
+    p = WordSearch("pig horse cow", size=size)
+    m = Polygon([(0, 0), (0, size - 1), (2, size - 1), (2, 0)])
+    p.apply_mask(m)
+    p.flip_mask_horizontal()
+    assert p.mask == [r[::-1] for r in m.mask]
+
+
+def test_puzzle_flip_mask_vertical():
+    size = 6
+    p = WordSearch("pig horse cow", size=size)
+    m = Polygon([(0, 0), (size - 1, 0), (size - 1, 2), (0, 2)])
+    p.apply_mask(m)
+    p.flip_mask_vertical()
+    assert p.mask == m.mask[::-1]
+
+
+def test_puzzle_transpose_mask():
+    size = 6
+    p = WordSearch("pig horse cow", size=size)
+    m1 = Polygon([(0, 0), (0, size - 1), (2, size - 1), (2, 0)])
+    m2 = Polygon([(0, 0), (size - 1, 0), (size - 1, 2), (0, 2)])
+    m2.generate(size)
+    p.apply_mask(m1)
+    p.transpose_mask()
+    assert p.mask == m2.mask
+
+
+def test_remove_masks():
+    p = WordSearch("pig horse cow")
+    p.apply_masks([Circle(), Ellipse(2, 2, method=3)])
+    p.remove_masks()
+    assert p.masks == []
+    assert not p.masked
+
+
+def test_remove_static_masks():
+    p = WordSearch("pig horse cow")
+    p.apply_masks([Star(), Ellipse(2, 2, method=3)])
+    assert len(p.masks) == 2
+    p.remove_static_masks()
+    assert len(p.masks) == 1
+
+
+def test_reapply_masks_static():
+    p = WordSearch("pig horse cow")
+    p.apply_mask(Circle())
+    assert p.puzzle[0][0] == ""
+    assert p.puzzle[0][p.size - 1] == ""
+    assert p.puzzle[p.size - 1][0] == ""
+    assert p.puzzle[p.size - 1][p.size - 1] == ""
+    p.size = 30
+    p._reapply_masks()
+    assert p.puzzle[0][0] != ""
+    assert p.puzzle[0][p.size - 1] != ""
+    assert p.puzzle[p.size - 1][0] != ""
+    assert p.puzzle[p.size - 1][p.size - 1] != ""
+
+
+def test_reapply_masks_dynamic_scale_up():
+    p = WordSearch("pig horse cow", size=11)
+    p.apply_mask(Star())
+    ct = sum([1 for x in p.puzzle if x != ""])  # type: ignore
+    p.size = 21
+    assert ct != sum([1 for x in p.puzzle if x != ""])  # type: ignore
+
+
+def test_reapply_masks_dynamic_scale_down():
+    p = WordSearch("pig horse cow", size=21)
+    p.apply_mask(Star())
+    ct = sum([1 for x in p.puzzle if x != ""])  # type: ignore
+    p.size = 11
+    assert ct != sum([1 for x in p.puzzle if x != ""])  # type: ignore
diff --git a/tests/test_Word.py b/tests/test_Word.py
index 27cd0ad..94823f9 100644
--- a/tests/test_Word.py
+++ b/tests/test_Word.py
@@ -1,4 +1,4 @@
-from word_search_generator.types import Direction, Position, Word
+from word_search_generator.word import Direction, Position, Word
 
 
 def test_empty_start_row():
@@ -16,6 +16,13 @@ def test_empty_position():
     assert w.position == Position(None, None)
 
 
+def test_position_xy():
+    w = Word("test")
+    w.start_row = 1
+    w.start_column = 1
+    assert w.position_xy == "(2, 2)"
+
+
 def test_empty_position_xy():
     w = Word("test")
     assert not w.position_xy
@@ -43,3 +50,13 @@ def test_repr():
 def test_str():
     w = Word("test")
     assert str(w) == "test".upper()
+
+
+def test_empty_key_string():
+    w = Word("test")
+    assert w.key_string(((0, 0), (10, 10))) is None
+
+
+def test_offset_empty_position_xy():
+    w = Word("test")
+    assert w.offset_position_xy(((0, 0), (10, 10))) is None
diff --git a/tests/test_WordSearch.py b/tests/test_WordSearch.py
index 97069c6..6442f70 100644
--- a/tests/test_WordSearch.py
+++ b/tests/test_WordSearch.py
@@ -3,13 +3,15 @@
 
 import pytest
 
-from word_search_generator import WordSearch, config, utils
+from word_search_generator import Key, Puzzle, WordSearch, config, utils
 from word_search_generator.config import level_dirs
-from word_search_generator.types import Direction, Key, Puzzle, Word
 from word_search_generator.utils import get_random_words
+from word_search_generator.word import Direction, Word
 
 WORDS = "dog, cat, pig, horse, donkey, turtle, goat, sheep"
 
+# TODO: test for p = WordSearch(size=15)
+
 
 def check_key(key: Key, puzzle: Puzzle) -> bool:
     """Test the puzzle key against the current puzzle state."""
@@ -25,13 +27,13 @@ def check_key(key: Key, puzzle: Puzzle) -> bool:
 
 
 def test_empty_object():
-    puzzle = WordSearch()
-    assert len(puzzle.words) == 0
+    ws = WordSearch()
+    assert len(ws.words) == 0
 
 
 def test_input_cleanup():
-    puzzle = WordSearch(WORDS)
-    assert len(puzzle.words) == 8
+    ws = WordSearch(WORDS)
+    assert len(ws.words) == 8
 
 
 def test_junky_input_cleanup():
@@ -41,205 +43,193 @@ def test_junky_input_cleanup():
     junk,,,,,,   , ,i
 
     words,"""
-    junk_puzzle = WordSearch(junky_words)
-    assert len(junk_puzzle.words) == 4
+    junk_ws = WordSearch(junky_words)
+    assert len(junk_ws.words) == 4
 
 
 def test_set_puzzle_level():
-    puzzle = WordSearch(WORDS)
-    puzzle.level = 3
-    assert puzzle.directions == utils.validate_level(config.level_dirs[3])
+    ws = WordSearch(WORDS)
+    ws.level = 3
+    assert ws.directions == utils.validate_level(config.level_dirs[3])
 
 
 def test_set_secret_level():
-    puzzle = WordSearch(WORDS)
-    puzzle.secret_directions = 4  # type: ignore
-    assert puzzle.secret_directions == utils.validate_level(4)  # type: ignore
+    ws = WordSearch(WORDS)
+    ws.secret_directions = 4  # type: ignore
+    assert ws.secret_directions == utils.validate_level(4)  # type: ignore
 
 
 def test_bad_puzzle_level_value():
-    puzzle = WordSearch(WORDS)
+    ws = WordSearch(WORDS)
     with pytest.raises(ValueError):
-        puzzle.level = 757
+        ws.level = 757
 
 
 def test_bad_puzzle_level_type():
-    puzzle = WordSearch(WORDS)
+    ws = WordSearch(WORDS)
     with pytest.raises(TypeError):
-        puzzle.level = "A"  # type: ignore
+        ws.level = "A"  # type: ignore
 
 
 def test_garbage_puzzle_level_type():
-    puzzle = WordSearch(WORDS)
+    ws = WordSearch(WORDS)
     with pytest.raises(TypeError):
-        puzzle.level = 17.76  # type: ignore
+        ws.level = 17.76  # type: ignore
 
 
 def test_manual_level_control():
-    puzzle = WordSearch(WORDS)
+    ws = WordSearch(WORDS)
     tst_dirs = [Direction.E, Direction.SW, (-1, 0)]
-    puzzle.directions = tst_dirs  # type: ignore
-    assert puzzle.directions == utils.validate_level(tst_dirs)
+    ws.directions = tst_dirs  # type: ignore
+    assert ws.directions == utils.validate_level(tst_dirs)
 
 
 def test_set_puzzle_size():
-    puzzle = WordSearch(WORDS)
-    puzzle.size = 15
-    assert len(puzzle.puzzle) == 15
+    ws = WordSearch(WORDS)
+    ws.size = 15
+    assert len(ws.puzzle) == 15
 
 
 def test_bad_puzzle_size_value():
-    puzzle = WordSearch(WORDS)
+    ws = WordSearch(WORDS)
     with pytest.raises(ValueError):
-        puzzle.size = 1
+        ws.size = 1
 
 
 def test_bad_puzzle_size_type():
-    puzzle = WordSearch(WORDS)
+    ws = WordSearch(WORDS)
     with pytest.raises(TypeError):
-        puzzle.size = "A"  # type: ignore
+        ws.size = "A"  # type: ignore
 
 
 def test_puzzle_key():
-    puzzle = WordSearch(WORDS)
-    assert check_key(puzzle.key, puzzle.puzzle)
+    ws = WordSearch(WORDS)
+    assert check_key(ws.key, ws.puzzle)
 
 
 def test_export_pdf(tmp_path):
-    puzzle = WordSearch(WORDS)
+    ws = WordSearch(WORDS)
     tmp_path = pathlib.Path.joinpath(tmp_path, "test.pdf")
-    puzzle.save(tmp_path)
+    ws.save(tmp_path)
     assert tmp_path.exists()
 
 
 def test_export_csv(tmp_path):
-    puzzle = WordSearch(WORDS)
+    ws = WordSearch(WORDS)
     tmp_path = pathlib.Path.joinpath(tmp_path, "test.csv")
-    puzzle.save(tmp_path)
+    ws.save(tmp_path)
     assert tmp_path.exists()
 
 
 def test_invalid_save_path():
-    puzzle = WordSearch(WORDS)
+    ws = WordSearch(WORDS)
     with pytest.raises(OSError):
-        puzzle.save("~/some/random/dir/that/doesnt/exists")
+        ws.save("~/some/random/dir/that/doesnt/exists")
 
 
 def test_add_words():
-    puzzle = WordSearch(WORDS)
-    puzzle.add_words("test")
-    assert Word("test") in puzzle.words
+    ws = WordSearch(WORDS)
+    ws.add_words("test")
+    assert Word("test") in ws.words
 
 
 def test_add_regular_words_replacing_secret_word():
-    puzzle = WordSearch(WORDS)
-    puzzle.add_words("test", True)
-    puzzle.add_words("test")
-    assert Word("test") not in puzzle.secret_words and Word("test") in puzzle.words
+    ws = WordSearch(WORDS)
+    ws.add_words("test", True)
+    ws.add_words("test")
+    assert Word("test") not in ws.secret_words and Word("test") in ws.words
 
 
 def test_add_secret_words():
-    puzzle = WordSearch(WORDS)
-    puzzle.add_words("test", True)
-    assert Word("test") in puzzle.secret_words
+    ws = WordSearch(WORDS)
+    ws.add_words("test", True)
+    assert Word("test") in ws.secret_words
 
 
 def test_add_secret_words_replacing_regular_word():
-    puzzle = WordSearch(WORDS)
-    puzzle.add_words("test")
-    puzzle.add_words("test", True)
-    assert (
-        Word("test") not in puzzle.hidden_words and Word("test") in puzzle.secret_words
-    )
+    ws = WordSearch(WORDS)
+    ws.add_words("test")
+    ws.add_words("test", True)
+    assert Word("test") not in ws.hidden_words and Word("test") in ws.secret_words
 
 
 def test_remove_words():
-    puzzle = WordSearch(WORDS)
-    puzzle.add_words("test")
-    puzzle.remove_words("test")
-    assert Word("test") not in puzzle.words
+    ws = WordSearch(WORDS)
+    ws.add_words("test")
+    ws.remove_words("test")
+    assert Word("test") not in ws.words
 
 
 def test_remove_words_from_secret_words():
-    puzzle = WordSearch(WORDS)
-    puzzle.add_words("test", True)
-    puzzle.remove_words("test")
-    assert "test".upper() not in puzzle.words.union(puzzle.secret_words)
+    ws = WordSearch(WORDS)
+    ws.add_words("test", True)
+    ws.remove_words("test")
+    assert "test".upper() not in ws.words.union(ws.secret_words)
 
 
 def test_replace_words():
-    puzzle = WordSearch(WORDS)
-    puzzle.replace_words("set, of replaced, words")
-    assert len(puzzle.words) == 4
+    ws = WordSearch(WORDS)
+    ws.replace_words("set, of replaced, words")
+    assert len(ws.words) == 4
 
 
 def test_replace_secret_words():
-    puzzle = WordSearch(WORDS, secret_words="secret, blind, nope")
-    puzzle.replace_words("hidden", True)
-    assert len(puzzle.secret_words) == 1
+    ws = WordSearch(WORDS, secret_words="secret, blind, nope")
+    ws.replace_words("hidden", True)
+    assert len(ws.secret_words) == 1
 
 
 def test_reset_puzzle_size():
-    puzzle = WordSearch(WORDS)
-    prev_size = puzzle.size
-    puzzle.size = 25
-    puzzle.reset_size()
-    assert puzzle.size == prev_size
-
-
-def test_puzzle_solution():
-    # need to check for overlaps
-    puzzle = WordSearch(WORDS)
-    print(puzzle.solution)
-    puzzle_chars = set([char for line in puzzle.solution for char in line])
-    key_chars = set([char for word in puzzle.key for char in word])
-    key_chars.add("")
-    assert puzzle_chars == key_chars
+    ws = WordSearch(WORDS)
+    prev_size = ws.size
+    ws.size = 25
+    ws.reset_size()
+    assert ws.size == prev_size
 
 
 def test_puzzle_repr():
-    puzzle = WordSearch(WORDS)
-    assert eval(repr(puzzle)) == puzzle
+    ws = WordSearch(WORDS)
+    assert eval(repr(ws)) == ws
 
 
 def test_puzzle_equal():
-    puzzle1 = WordSearch(WORDS, size=10)
-    puzzle2 = WordSearch(WORDS, size=10)
-    assert puzzle1 == puzzle2
+    ws1 = WordSearch(WORDS, size=10)
+    ws2 = WordSearch(WORDS, size=10)
+    assert ws1 == ws2
 
 
 def test_puzzle_invalid_equality():
-    puzzle1 = WordSearch(WORDS, size=10)
-    puzzle2 = ["testing"]
-    assert puzzle1 != puzzle2
+    ws1 = WordSearch(WORDS, size=10)
+    ws2 = ["testing"]
+    assert ws1 != ws2
 
 
 def test_puzzle_non_equal():
-    puzzle1 = WordSearch(WORDS, size=10)
-    puzzle2 = WordSearch(WORDS, size=15)
-    assert puzzle1 != puzzle2
+    ws1 = WordSearch(WORDS, size=10)
+    ws2 = WordSearch(WORDS, size=15)
+    assert ws1 != ws2
 
 
 def test_puzzle_str():
-    puzzle = WordSearch(WORDS)
-    puzzle_str = utils.format_puzzle_for_show(puzzle)
-    assert str(puzzle) == puzzle_str
+    ws = WordSearch(WORDS)
+    puzzle_str = utils.format_puzzle_for_show(ws)
+    assert str(ws) == puzzle_str
 
 
 def test_puzzle_str_output(capsys):
-    puzzle = WordSearch(WORDS)
-    print(utils.format_puzzle_for_show(puzzle))
+    ws = WordSearch(WORDS)
+    print(utils.format_puzzle_for_show(ws))
     capture1 = capsys.readouterr()
-    print(puzzle)
+    print(ws)
     capture2 = capsys.readouterr()
     assert capture1.out == capture2.out
 
 
 def test_puzzle_show_output(capsys):
-    puzzle = WordSearch(WORDS)
-    print(utils.format_puzzle_for_show(puzzle))
+    ws = WordSearch(WORDS)
+    print(utils.format_puzzle_for_show(ws))
     capture1 = capsys.readouterr()
-    puzzle.show()
+    ws.show()
     capture2 = capsys.readouterr()
     assert capture1.out == capture2.out
 
@@ -261,18 +251,18 @@ def test_puzzle_show_str_output_for_empty_object(capsys):
 
 
 def test_puzzle_show_solution_output(capsys):
-    puzzle = WordSearch(WORDS)
-    print(utils.format_puzzle_for_show(puzzle, True))
+    ws = WordSearch(WORDS)
+    print(utils.format_puzzle_for_show(ws, True))
     capture1 = capsys.readouterr()
-    puzzle.show(True)
+    ws.show(True)
     capture2 = capsys.readouterr()
     assert capture1.out == capture2.out
 
 
 def test_json_output_property_for_puzzle():
     words = get_random_words(10)
-    p = WordSearch(words, level=3)
-    assert json.loads(p.json)["puzzle"] == p.puzzle
+    ws = WordSearch(words, level=3)
+    assert json.loads(ws.json)["puzzle"] == ws.puzzle
 
 
 def test_json_output_property_for_key():
@@ -290,61 +280,61 @@ def test_json_output_property_for_empty_object():
 
 
 def test_input_including_palindrome():
-    puzzle = WordSearch(WORDS + ", level")
-    assert len(puzzle.words) == 8
+    ws = WordSearch(WORDS + ", level")
+    assert len(ws.words) == 8
 
 
 def test_for_empty_spaces():
     for _ in range(100):
         words = get_random_words(10)
-        p = WordSearch(words, level=3)
-        flat = [item for sublist in p.puzzle for item in sublist]
-        assert p.size * p.size == len(flat)
+        ws = WordSearch(words, level=3)
+        flat = [item for sublist in ws.puzzle for item in sublist]
+        assert ws.size * ws.size == len(flat)
 
 
 def test_puzzle_with_secret_words():
-    puzzle = WordSearch(WORDS, secret_words=WORDS + ", dewlap")
-    assert len(puzzle.secret_words) == 1  # should all be ignored due to overlap
+    ws = WordSearch(WORDS, secret_words=WORDS + ", dewlap")
+    assert len(ws.secret_words) == 1  # should all be ignored due to overlap
 
 
 def test_clearing_secret_directions():
-    puzzle = WordSearch(WORDS, secret_level=1)
-    puzzle.secret_directions = set()
-    assert puzzle.secret_directions is None
+    ws = WordSearch(WORDS, secret_level=1)
+    ws.secret_directions = set()
+    assert ws.secret_directions is None
 
 
 def test_get_level():
-    puzzle = WordSearch(WORDS)
-    puzzle.level = 2
-    assert puzzle.level == level_dirs[2]  # type: ignore
+    ws = WordSearch(WORDS)
+    ws.level = 2
+    assert ws.level == level_dirs[2]  # type: ignore
 
 
 def test_add_words_with_resize():
-    puzzle = WordSearch(WORDS)
-    puzzle.size = 5
-    puzzle.add_words("test", reset_size=True)
-    assert puzzle.size != 5
+    ws = WordSearch(WORDS)
+    ws.size = 5
+    ws.add_words("test", reset_size=True)
+    assert ws.size != 5
 
 
 def test_add_secret_words_with_resize():
-    puzzle = WordSearch(WORDS)
-    puzzle.size = 5
-    puzzle.add_words("test", True, reset_size=True)
-    assert puzzle.size != 5
+    ws = WordSearch(WORDS)
+    ws.size = 5
+    ws.add_words("test", True, reset_size=True)
+    assert ws.size != 5
 
 
 def test_remove_words_with_resize():
-    puzzle = WordSearch(WORDS)
-    puzzle.size = 5
-    puzzle.remove_words("test", reset_size=True)
-    assert puzzle.size != 5
+    ws = WordSearch(WORDS)
+    ws.size = 5
+    ws.remove_words("test", reset_size=True)
+    assert ws.size != 5
 
 
 def test_replace_words_with_resize():
-    puzzle = WordSearch(WORDS)
-    puzzle.size = 5
-    puzzle.replace_words("set, of replaced, words", reset_size=True)
-    assert puzzle.size != 5
+    ws = WordSearch(WORDS)
+    ws.size = 5
+    ws.replace_words("set, of replaced, words", reset_size=True)
+    assert ws.size != 5
 
 
 def test_no_placed_words():
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 5db8b5d..1ac754c 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,4 +1,7 @@
 import subprocess
+from pathlib import Path
+
+from PIL import Image
 
 
 def test_entrypoint():
@@ -115,3 +118,27 @@ def test_random_secret_words_valid_input(capsys):
 def test_random_secret_words_invalid_input():
     result = subprocess.run("word-search -rx 500", shell=True)
     assert result.returncode == 2
+
+
+def test_preview_masks():
+    result = subprocess.run("word-search -pm", shell=True)
+    assert result.returncode == 0
+
+
+def test_mask():
+    result = subprocess.run("word-search -r 5 -s 21 -m Triangle", shell=True)
+    assert result.returncode == 0
+
+
+def test_invalid_mask():
+    result = subprocess.run("word-search -r 5 -s 21 -m Heptakaideka", shell=True)
+    assert result.returncode == 2
+
+
+def test_image_mask(tmp_path):
+    name = "test_image.jpg"
+    test_img = Image.new("L", (100, 100), (0))
+    img_path = Path.joinpath(tmp_path, name)
+    test_img.save(img_path, "JPEG")
+    result = subprocess.run(f"word-search -r 5 -im {img_path}", shell=True)
+    assert result.returncode == 0
diff --git a/tests/test_export.py b/tests/test_export.py
index 099df1c..0448eb5 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -12,6 +12,15 @@
 WORDS = "dog, cat, pig, horse, donkey, turtle, goat, sheep"
 
 
+def test_export_csv(tmp_path):
+    puzzle = WordSearch(WORDS)
+    path = Path.joinpath(tmp_path, "test.csv")
+    puzzle.save(path, solution=False)
+    with open(path, "r") as f:
+        data = f.read()
+    assert not re.findall("\nSOLUTION\n", data)
+
+
 def test_export_pdf_puzzles(tmp_path):
     """Export a bunch of puzzles as PDF and make sure they are all 1-page."""
     puzzles = []
@@ -32,20 +41,6 @@ def test_export_pdf_puzzles(tmp_path):
     assert pages == {1}
 
 
-def test_export_csv_puzzle_with_solution(tmp_path):
-    puzzle = WordSearch(WORDS)
-    path = Path.joinpath(tmp_path, f"{uuid.uuid4()}.csv")
-    puzzle.save(path, solution=True)
-    found = False
-    with open(path, "r") as fp:
-        lines = fp.readlines()
-        for row in lines:
-            if row.find("SOLUTION") == 0:
-                found = True
-                break
-    assert found
-
-
 def test_export_pdf_puzzle_with_solution(tmp_path):
     """Make sure a pdf puzzle exported with the solution is 2 pages."""
     puzzles = []
@@ -97,21 +92,3 @@ def test_export_csv_os_error():
     puzzle = WordSearch(WORDS)
     with pytest.raises(OSError):
         puzzle.save("/test.csv")
-
-
-def test_csv_export_without_solution(tmp_path):
-    puzzle = WordSearch(WORDS)
-    path = Path.joinpath(tmp_path, "test.csv")
-    puzzle.save(path, solution=False)
-    with open(path, "r") as f:
-        data = f.read()
-    assert not re.findall("\nSOLUTION\n", data)
-
-
-def test_csv_export_with_solution(tmp_path):
-    puzzle = WordSearch(WORDS)
-    path = Path.joinpath(tmp_path, "test.csv")
-    puzzle.save(path, solution=True)
-    with open(path, "r") as f:
-        data = f.read()
-    assert re.findall("\nSOLUTION\n", data)
diff --git a/tests/test_generate.py b/tests/test_generate.py
index 0dd313a..bd82541 100644
--- a/tests/test_generate.py
+++ b/tests/test_generate.py
@@ -1,102 +1,61 @@
 from word_search_generator import WordSearch
 from word_search_generator.generate import no_duped_words
-from word_search_generator.types import Position
 from word_search_generator.utils import get_random_words
+from word_search_generator.word import Direction, Word, Wordlist
+
+
+def setup_words():
+    BAT = Word("bat")
+    BAT.start_row = 0
+    BAT.start_column = 0
+    BAT.direction = Direction.SE
+    BAT.secret = False
+    PLACED_WORDS.add(BAT)
+
+    CAB = Word("cab")
+    CAB.start_row = 4
+    CAB.start_column = 2
+    CAB.direction = Direction.SE
+    CAB.secret = False
+    PLACED_WORDS.add(CAB)
+
+    RAT = Word("rat")
+    RAT.start_row = 0
+    RAT.start_column = 4
+    RAT.direction = Direction.S
+    RAT.secret = False
+    PLACED_WORDS.add(RAT)
+
+
+PLACED_WORDS: Wordlist = set()
+setup_words()
+PUZZLE = [
+    ["B", "*", "*", "*", "R"],
+    ["*", "A", "*", "*", "A"],
+    ["*", "*", "T", "*", "T"],
+    ["*", "*", "*", "*", "*"],
+    ["*", "*", "C", "A", "B"],
+]
 
 
 def test_dupe_at_position_1():
-    puzzle = WordSearch("bat cab rat", size=5)
-    puzzle._key = {
-        "BAT": {
-            "start": Position(0, 0),
-            "direction": "SE",
-            "secret": False,
-        },
-        "CAB": {
-            "start": Position(4, 2),
-            "direction": "SE",
-            "secret": False,
-        },
-        "RAT": {
-            "start": Position(0, 4),
-            "direction": "S",
-            "secret": False,
-        },
-    }
-    puzzle._solution = puzzle._puzzle = [
-        ["B", "", "", "", "R"],
-        ["", "A", "", "", "A"],
-        ["", "", "T", "", "T"],
-        ["", "", "", "", ""],
-        ["", "", "C", "A", "B"],
-    ]
-    check = no_duped_words(puzzle, "A", (3, 3))
+    check = no_duped_words(PUZZLE, PLACED_WORDS, "A", (3, 3))
     assert check is False
 
 
 def test_dupe_at_position_2():
-    puzzle = WordSearch("bat cab rat", size=5)
-    puzzle._key = {
-        "BAT": {
-            "start": Position(0, 0),
-            "direction": "SE",
-            "secret": False,
-        },
-        "CAB": {
-            "start": Position(4, 2),
-            "direction": "SE",
-            "secret": False,
-        },
-        "RAT": {
-            "start": Position(0, 4),
-            "direction": "S",
-            "secret": False,
-        },
-    }
-    puzzle._solution = puzzle._puzzle = [
-        ["B", "", "", "", "R"],
-        ["", "A", "", "", "A"],
-        ["", "", "T", "", "T"],
-        ["", "", "", "", ""],
-        ["", "", "C", "A", "B"],
-    ]
-    check = no_duped_words(puzzle, "A", (1, 3))
+    check = no_duped_words(PUZZLE, PLACED_WORDS, "A", (1, 3))
     assert check is False
 
 
 def test_no_dupe_at_position():
-    puzzle = WordSearch("bat cab rat", size=5)
-    puzzle._key = {
-        "BAT": {
-            "start": Position(0, 0),
-            "direction": "SE",
-            "secret": False,
-        },
-        "CAB": {
-            "start": Position(4, 2),
-            "direction": "SE",
-            "secret": False,
-        },
-        "RAT": {
-            "start": Position(0, 4),
-            "direction": "S",
-            "secret": False,
-        },
-    }
-    puzzle._solution = puzzle._puzzle = [
-        ["B", "", "", "", "R"],
-        ["", "A", "", "", "A"],
-        ["", "", "T", "", "T"],
-        ["", "", "", "", ""],
-        ["", "", "C", "A", "B"],
-    ]
-    check = no_duped_words(puzzle, "Z", (1, 3))
+    check = no_duped_words(PUZZLE, PLACED_WORDS, "Z", (1, 3))
     assert check is True
 
 
 def test_puzzle_size_less_than_shortest_word_length():
-    p = WordSearch("DONKEY", size=5)
-    assert p.size == 7
+    ws = WordSearch("DONKEY", size=5)
+    assert ws.size == 7
 
 
 def test_only_placed_words_in_key():
@@ -109,3 +68,15 @@ def test_too_many_supplied_words():
     w = get_random_words(100)
     p = WordSearch(w, size=5)
     assert len(p.words) != len(p.placed_words)
+
+
+def test_fit_all_words_with_plenty_of_space():
+    for _ in range(10):
+        p = WordSearch("cat dog pig cow mule duck")
+        assert len(p.placed_words) == 6
+
+
+def test_fit_all_words_with_plenty_of_space_and_secret_words():
+    for _ in range(10):
+        p = WordSearch("cat dog pig", secret_words="cow mule duck")
+        assert len(p.placed_words) == 6
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f30b936..f7f66c4 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,7 +1,7 @@
 import pytest
 
 from word_search_generator import WordSearch, utils
-from word_search_generator.types import Word
+from word_search_generator.word import Word
 
 
 def test_valid_cleanup_input_with_spaces():
@@ -29,7 +29,7 @@ def test_invalid_input_too_short():
 def test_stringify():
     inp = [["a"], ["b"], ["c"]]
     output = "a\nb\nc"
-    assert utils.stringify(inp) == output
+    assert utils.stringify(inp, ((0, 0), (1, 3))) == output
 
 
 def test_palindromes():
@@ -50,5 +50,19 @@ def test_invalid_level_direction_type():
 
 def test_answer_key_list():
     p = WordSearch("bat cab rat")
-    key_as_list = utils.get_answer_key_list(p.hidden_words.union(p.secret_words))
+    key_as_list = utils.get_answer_key_list(
+        p.hidden_words.union(p.secret_words), p.bounding_box
+    )
     assert len(key_as_list) == len(p.key) and key_as_list[0].startswith("BAT")
+
+
+def test_float_range():
+    assert len(list(utils.float_range(0.10))) == 1
+
+
+def test_float_range_invalid_args():
+    assert not list(utils.float_range(0.40, 0.10, 0.01))
+
+
+def test_float_range_negative():
+    assert len(list(utils.float_range(0.40, 0.30, -0.1))) == 2
diff --git a/tools/time_testing.py b/tools/time_testing.py
new file mode 100644
index 0000000..b814db3
--- /dev/null
+++ b/tools/time_testing.py
@@ -0,0 +1,23 @@
+import timeit
+
+setup = """\
+from word_search_generator import WordSearch
+from word_search_generator.mask.shapes import Star
+from word_search_generator.utils import get_random_words
+"""
+
+stmt = """\
+p=WordSearch(size=50)
+p.apply_mask(Star())
+p.add_words(get_random_words(100))"""
+
+result = timeit.timeit(stmt=stmt, setup=setup, number=10)
+print(result)
+
+"""
+RESULTS:
+
+V1 = 12.544383415000993
+V2 = 16.73762503299986
+V3 = 35.81765231499958
+"""