In an ideal software engineering world, what should happen is that you define your requirements before doing any design work. This has a problem: What if you don't really know your requirements? I kind of have some ideas on what spells I want to have and how they should work with each other, but it's very complicated and nebulous. I just don't know.
So, I'm moving into the design phase where I'm trying to create a generalized spell system architecture based off of a lot of question marks, intuition, and guess work. I'm taking inspiration from the collectible card game, "Magic: The Gathering" since I feel it comes closest to having the kind of deep spell system I enjoy playing. In MtG, you have various types of spells, ranging from summons, enchantments, instants, counter spells, sorceries, etc. MtG is somewhat of a turn based spell game, but mine is going to be real time. And my game allows you to research upgrades for your spells. And some spells will have variable power. The bottom line is that it's going to be a rats nest of complexity.
I suspect that some design and pre-planning would be beneficial, but too much may be a waste of time and effort. Initially, my approach was to try to imagine every spell in my game and every possible variable it would want to know, categorize my spells into different groups, generate classes for each spell group, inherit from a generic spell object, and spells would be be based off of spell templates which inherit from the spell category it would belong to (with research modifiers). I'm a bit lazy (actually, very lazy!), so looking ahead, I think I can expect to have 100+ spell templates, maybe 200+. Do I really want to create a new class for every spell?! That would mean that I'd have to generate 200+ classes, and each time a spell changes, I'd have to recompile. I've got a tab delimited text file which contains some preliminary spell info, but I'm contemplating switching over to an XML file which loads spell templates into a spell database. Again, if you could follow all of that, it's a rat's nest of complexity.
When it comes down to it, I really don't know the details of what I want and how I want it. Chances are, it'll change anyways. Looking back at how I handled the terrain, I kind of followed a different software engineering approach: Build the simplest part you know. This defines the core of what you need. Extend it a bit, refactor as necessary, add a bit more, test, tweak, and slowly grow it. If there are problems with the approach, it'll become obvious when you've built it -- That is when you take a closer look at what you're doing. Once you've built a concrete case, it's easier to abstract and generalize off of the data you're actually using rather than trying to abstract off of guess work and run the risk of creating stuff you don't actually need.
Let's get more specific.
For the terrain, I was starting from nothing. I had a general idea on what I wanted to do:
Create a large terrain mesh with elevation information pulled from a height map file of some sort.
Initially, I started with the most simple task: "Can I render a triangle onto the screen?"
Once I got a triangle, the next task becomes: "Can I render a huge grid of triangles onto the screen?"
This grid of triangles became my "Terrain" class.
Now, I've got a large, flat mesh of triangles. It's very unimpressive terrain at this point. What's next? "Can I read in a height map file and give each triangle a vertical offset based on the RGB value of the height map?"
Eventually, I got that as well and I now had rolling hills.
Initially, I was working with a very small height map with the dimensions of about 3x3. I increased it to 9x9, then 16x16, and 64x64 just to see what it would look like. On a whim, I tried 1024x1024. This resulted in my first huge problem. I was trying to render my terrain in a single draw call and there's a limit to the number of triangles a single draw call can render at once (about 2 million). If each pixel on the height map corresponds to a tile, and each tile has four verticies (one for each corner), I'd surpass the draw limit and crash. Great! A real problem rather than an imaginary preoptimization to sweat over!
I did some research online to see how other people rendered large terrains. I found some planet rendering demos and was inspired to learn more. Apparently, the common technique is to break a large terrain mesh down into smaller blocks and to stitch those blocks together to create a seamless large mesh. So, I thought I'd give that a try.
Now, I realized that my original terrain class was not going to be able to support such a drastic change to my terrain architecture. Refactoring time! Rather than modifying my existing, working terrain class, I decided to create a new class called "Terrain2". I'd use the other terrain class as a reference source and borrow code, requirements and design from it for my new and improved terrain class.
My new terrain class was pretty much just a manager for a two dimensional array of terrain blocks.
Each block would be mostly independent of every other terrain block. I read in one of the planet rendering demos that someone else had found that the optimal size for a block was 16x16, so I decided to go with that (in hind sight, I probably could have gone much larger, like 128x128).
Now, keeping in mind the "Start simple, get complicated later" principle, I decided to see if I could render just one terrain block with my terrain manager. I eventually got it, and then went on to create many more terrain blocks. I was using a lot more draw calls and rendering a few more triangles than perfectly necessary, but the performance was fine and that's the point where you say "Good enough!".
I then realized that the terrain blocks way out in the distance didn't need to have a super high level of resolution. The triangles end up being no more than a few pixels on the screen anyways, so why waste a bunch of resources rendering a high res version if you don't need it? So, the next question becomes: "Can you render a lower resolution terrain block for the distant blocks?"
Eventually, I solved that problem and was able to render fewer triangles. But this introduced a new, unforeseen problem! I was reducing the number of triangles by a factor of two by skipping every other vertex. It works fine, but if a lower resolution terrain block is adjacent to a higher level terrain block, then the loss of vertex data introduces unsightly gaps between the seams. What's the fix for that?!
I did some research and asked for help from smarter people than I. The recommended solution is to "skirt" your lower resolution blocks with any adjacent higher resolution blocks. Initially, I tried to enforce a simple rule to reduce the amount of work I had to do: The resolution of a terrain block can differ by no more than one level to any of its adjacent blocks! I coded up this rule and play tested it and started seeing some strange artifacts. In some cases, it didn't matter how far my camera was from a terrain block, it just wouldn't change to a lower resolution! What ended up happening is that my new rule was creating deadlocks (to borrow from the multi-threading terminology). Each terrain block was dependent on its adjacent terrain blocks before it could change resolution, and in some cases, large chains of dependencies were forming. If one of those locking blocks changed, the whole chain could change. But if that locking block was dependent on a neighbor in its cascading chain, it would never unlock. What a mess. I could try to resolve the problem by occassionally jiggling a random block resolution every now and then and see if that caused a cascade, but that seemed a bit unreliable and complex. Another possibility is to write some code to detect whether this deadlock condition was created. But that's also quite possibly messy. It's time to take a step back and re-evaluate the original rule I had in place. Did I really need that rule? I created it so that I could keep things simple and avoid doing a bit of extra work. As someone in the chat aptly told me, "Doing things manually isn't always a bad thing!". It's probably less work to do the work manually & tediously rather than trying to find some clever way to do it the hard way. So, I ditched the arbitrary rule and manually figured out the vertex positions for each resolution of terrain for each adjacent terrain block.
A very neat thing happened here. When I was doing everything manually, I started noticing a pattern emerge. I had about 500 lines of manual code at this point, but after I started seeing the pattern, I was able to reduce it down to about 20 lines of code. I immediately felt a bit stupid for having spent time writing 500 lines of code, only to delete it and replace it with 20 lines of better code, but then I reflected on that for a moment: If I hadn't written those 500 lines of code, I would have never seen the pattern in the first place such that I'd get those 20 lines of code. It was a necessary step! This lead me to another interesting insight: It's okay to be stupid and tedious and do things manually. It will probably lead to that obvious but genius solution.
So, with that principle in mind, I've got this ridiculously complicated spell system in mind and I barely have a clue on how I want to go about architecting it. It's time to get concrete and manual and just start stupidly creating spells of various types. Once I've got a few various types of spells, I can spend time abstracting and generalizing. I don't have to manually do all 200+ spells, just 5-10, before I refactor and rearchitect. This seems like a lot of wasteful work at first glance ("measure twice, cut once!", right?), but by doing it this way, I know exactly what data I'm working with and what data each spell will need. Rather than resorting to a lot of guess work and unnecessary code, I've got something tangible to work with. I'm trepidatiously excited to see how this approach will work out. I'll report my findings in a few weeks and let you know how it works out.
I am currently in sort of the same boat regarding spells in my own game. If it helps any, here is how I am tackling it.
First, all objects in my game are component based. This means that I necessarily need to break a particular spell down into its smallest behaviors. Is it a projectile? If so, what kind of payload does it deliver? Questions of that nature. As an example, I currently have a basic fireball spell for testing purposes. The fireball spell breaks down to these components:
1) A particle system to show a cool sparkly flame trail
2) A projectile controller, which functions to move the fireball scene node through an arc and terminate at the end
3) A payload that triggers on fireball termination to spawn another particle effect for an explosion
4) Another payload that triggers on fireball termination to deal a specified amount of fire damage to all targets in a radius around the projectile terminus
By breaking it down like that, I come up with a set of reusable components. The projectile component can be re-used for any projectile spell. Swap out the explosion payload and the fire damage radius payload for an ice effect and an ice damage payload, change the color ramp of the fireball particle effect to shades of blue-white, add another payload to deliver a Chill effect, and now you have an ice bolt spell. Remove the projectile component and add an immediate cast component, and now you have an ice blast centered on the caster. Add another payload to spawn smaller versions of the icebolt radiating out in all directions, and now you have an ice blast that shoots a ring of ice at encircling foes. And so on, and so on.
By composing effects of pieces this way, I don't have to worry about large, ponderous object inheritance hierarchies. I just collect together the specialized bits that make a spell function the way I want it to, and let its behavior emerge from the combination. As ideas occur to me, I add other payloads. Maybe one payload will summon a specified monster. Another might deal healing to everything in a radius. Attach this to the ice bolt instead of the ice damage payload, and now you have a ranged, projectile-based bolt of healing. There would be a payload for just about everything you can think of, and complex behaviors and synergies could arise from using different payload components in combination. Add a payload that inflicts Fire Vulnerability to a fireball, and suddenly you have a really ouchie fireball. Add an Immolation payload to it, which spawns a Burn effect on the target, and now you have a roasting enemy that will soon be dead.
Hope that gives you something to chew on, and good luck.