Experiments in Magic

Try it now, in browser!

A very brief playthrough of Experiments in Magic.

Made over 10 days for the Bigmode Game Jam 2025, this game is a silly sandbox game about crafting spells with unknown, but controllable, results and trying not to accidentally kill yourself. The game is 3D, first-person, and physics-based.

I came up with the original concept and wrote all of the code for the game. Additionally, I used modeled some of the 3D assets, including the architecture tower. The team also had two designers that helped with art, sound, level design, and the remaining 3D models.

Genre: Sandbox Simulation
Timeline: Created for Bigmode Game Jam 2025, which lasted from January 24, 2025 to February 3, 2025
Engine: Unity 6 URP
Platform: WebGL, Windows
Team size: 3
Responsibilities: Concept, Programming, Design, 3D Models
Itch Page: https://buildsgames.itch.io/experiments-in-magic (Play in browser!)
Source Code: https://github.com/WillyBMiles/ExperimentsInMagic

Highlights

  • You play as a wizard locked away in a remote tower where you are free to experiment with spells. Arrange glyphs within magical spellbooks to turn them into useful or dangerous spells.
  • An untrained Neural Net uses the arrangement of glyphs to determine what spell to cast.
  • A spell system with Shapes and Effects generate interesting and varied spells with a small number of actual spell parts.

Challenges

  • The biggest challenge was time and scope creep. Until around 3 hours before the only spell effect in the game was levitate, but it ended up with more than 10.
  • During a game jam there’s always a balance between polish and content. In this we decided to prioritize more heavily on polish over content. It seemed pointless to create a large number of spells that players would likely not see in the short time they played our game. Instead we decided to make the short time as fun as possible by making shaders and populating the tower with 3D models.
  • The original idea for the game involved much more direct combat on conflict. It soon became clear that the game would be more fun and interesting with the only drive being curiosity.

System spotlight: Neural Net

C#
using System;
using System.Collections.Generic;
using System.Linq;

public class NeuralNet
{
    readonly List<InputNode> inputs = new();
    readonly List<OutputNode> outputs = new();
    readonly List<List<Node>> layers = new();

    System.Random random;

    public void CreateNet(IReadOnlyList<InputNode> inputNodes, 
      IReadOnlyList<OutputNode> outputNodes, int hiddenLayers)
    {
        random = new();

        inputs.Clear();
        inputs.AddRange(inputNodes);
        outputs.Clear();
        outputs.AddRange(outputNodes);

        List<Node> lastLayer = inputs.Select(node => node as Node).ToList();

        //For each hidden layer
        for (int i =0; i < hiddenLayers; i++)
        {
            List<Node> listOfNodes = new();
            //make a number of nodes equal to the input
            inputs.ForEach(_ => { 
                Node node = new();
                //Set their weights randomly
                node.SetInputWeights(lastLayer, random);
                listOfNodes.Add(node);
                
            });
            //add that layer
            layers.Add(listOfNodes);
            lastLayer = listOfNodes;
        }
        //Outputs use the last layer
        //Outputs are NOT in the layer list
        outputs.ForEach(node => node.SetInputWeights(lastLayer, random));
    }

    public Dictionary<OutputNode, double> GenerateOutput()
    {
        Dictionary<Node, double> allWeights = new();
        foreach (var node in inputs)
        {
            allWeights[node] = node.GetValue();
        }
        foreach (var layer in layers)
        {
            foreach (var node in layer)
            {
                allWeights[node] = node.CalculateNode(allWeights);
            }
        }
        foreach (var output in outputs)
        {
            allWeights[output] = output.CalculateNode(allWeights);
        }

        return allWeights.Where(kvp => outputs.Contains(kvp.Key))
          .ToDictionary(kvp => kvp.Key as OutputNode, kvp => kvp.Value);
    }

}


public class Node
{
    //Weight can be changed if certain nodes should have more effects on things,
    //Should be rather low because of weight propogation
    public double weightMult = 1.0; 
    public Dictionary<Node, double> inputWeights = new(); //none if Node is an inputNode
    public double CalculateNode(IReadOnlyDictionary<Node, double> allWeights)
    {
        double output = 0.0;
        foreach (var node in inputWeights)
        {
            output += allWeights[node.Key] * node.Value;
        }
        return Sigmoid(output);
    }

    public void SetInputWeights(List<Node> inputNodes, System.Random random)
    {
        foreach (var node in inputNodes)
        {
            inputWeights[node] = GenerateBaseWeight(node, random);
        }

        double sum = inputWeights.Select(kvp => Math.Abs(kvp.Value)).Sum();
        double scale = sum / 2.0;
        foreach (var node in inputWeights.Keys.ToArray())
        {
            inputWeights[node] /= scale;
        }
    }
    
    static double Sigmoid(double input)
    {
        //Sigmoid Centered around 0
        return (1.0 / (1.0 + Math.Pow(Math.E, -input)) - .5) * 2.0;
    }

    //Higher means more like a trained model, more predictable
    const double WEIGHT_POWER = 3.0; 
    public double GenerateBaseWeight(Node inputNode, System.Random random)
    {
        return Math.Pow((random.NextDouble() - .5) * 2.0, WEIGHT_POWER) * inputNode.weightMult;
    }
}

public abstract class InputNode : Node
{
    public abstract double GetValue();
}

public class OutputNode : Node
{
}

The classic way to implement a neural net is using vectors and matrices. I wanted to avoid this approach because it’s quite rigid and difficult to parse. I wanted be able to tinker with and understand the system rather than just trusting someone else’s implementation. This is especially because performance was not important to me, the net would only be run once when the player crafted the spell.

To determine what spell the player would cast for a given glyph arrangement, the game considers the presence, location, and arrangement of each glyph within a spell. These values are used as inputs into a neural net, with each effect and shape as an output node.

The most likely nodes, that is the ones with the largest weights, are then added to the spell. Additionally, some output nodes represent effect values, which is essentially just an integer from 1-5, each individual value also represented by an output node. The largest weight individual output node for each effect value is then assigned to that effect.

This system was put into place (as opposed to the more obvious system of simply running the output of one node through an activation function) because, due to the nature of an untrained neural net, the output values may not vary significantly. The specific weight setup may be such that a particular output value only vary between 0.6 and 0.7, for example. It’s much more consistent to compare multiple output nodes that represent distinct values and pick the largest one instead of relying on a single output value.

Each effect interprets its effect values differently. So, for example, if fireball was determined to be the first effect, and the first effect value had the value of 5, then the fireball would be very large and damaging. Whereas a fireball with effect value of 1 would create a small and low damage fireball.

So why a neural net? When concepting out the spell system, I had several goals in mind. The first is that the spell system, at least initially, should be unsolvable. This meant there had to be some level of randomness in the initial conditions of the spell system.

Secondly, the system should be repeatable. The player should be able to create the same spell multiple times without having extraordinary precision.

Finally, and relatedly, the system should be nonchaotic, meaning that when only slightly perturbing a crafted spell it should create a new spell that is only slightly different instead of entirely different. The player should be able to somewhat predict how their inputs affect the game.

A neural net with some careful initial conditions checked all of these boxes.

Each glyph represents an input into the neural net. You can place them by pressing Z, X, and C. A spell can have up to three of each type of glyph. This magic floating effect (and the glyph glitch) were both made by me in Shader Graph.


System Spotlight: Spell Calculation

C#
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System.Linq;

[DefaultExecutionOrder(0)]
public class SpellManager : MonoBehaviour
{
    // Singleton so it can be referenced by books
    static SpellManager instance;
    public static SpellManager Instance => instance;

    public static event System.Action<Spell> OnSpellCast;

    [HideInInspector]
    readonly List<Effect> effects = new();
    [HideInInspector]
    readonly List<Modifier> modifiers = new();
    [HideInInspector]
    readonly List<Shape> shapes = new();
    public Shape DefaultShape;
    readonly Dictionary<EffectValue, List<SpellValue>> effectValues = new();
    readonly List<SpellValue> extraSpellValues = new();

    readonly Dictionary<Effect, OutputNode> effectNodes = new();
    readonly Dictionary<Shape, OutputNode> shapeNodes = new();
    readonly Dictionary<Modifier, OutputNode> modifierNodes = new();

    public List<SpellPart> overrideSpellParts = new();

    [SerializeField]
    MiniObject miniObjectPrefab;
    public static MiniObject defaultMiniObjectPrefab;

    private void Awake()
    {
        instance = this;
    }

    void Start()
    {
        defaultMiniObjectPrefab = miniObjectPrefab;

        // Get every spell type
        var spells = Resources.LoadAll("Spells/");
        foreach (var spellObject in spells)
        {
            if (spellObject is not GameObject)
                continue;
            if ((spellObject as GameObject).TryGetComponent<Effect>(out Effect effect))
            {
                effects.Add(effect);
            }
            // Modifiers were scrapped as an idea of a third type of spell object
            // They would modify an entire shape
            if ((spellObject as GameObject).TryGetComponent<Modifier>(out Modifier modifier)) {
                modifiers.Add(modifier);
            }
            if ((spellObject as GameObject).TryGetComponent<Shape>(out Shape shape))
            {
                shapes.Add(shape);
            }
        }
        Debug.Log($"Found {effects.Count} effects, {modifiers.Count} modifiers, {shapes.Count} shapes");
        SetUpEffectValues();
        SetUpSpellValues();
        
    }

    public const int NUM_DIFFERENT_EFFECT_OUTPUTS = 3;


    void SetUpEffectValues()
    {
        foreach (var obj in System.Enum.GetValues(typeof(EffectValue)))
        {
            EffectValue effectValue = (EffectValue) obj;

            // Each spell type (shape or effect) gets its own set of effect values
            // These are determined by index, index 0 is the first effect or shape
            // and so on.
            effectValues[effectValue] = new();
            for (int i = 0; i < NUM_DIFFERENT_EFFECT_OUTPUTS; i++)
            {
                effectValues[effectValue]
                    .Add(new SpellValue(NUM_SPELL_VALUE_LEVELS));
            }
        }
        
    }

    public const int NUM_EXTRA_SPELL_VALUES = 5;
    public const int NUM_SPELL_VALUE_LEVELS = 5;
    void SetUpSpellValues()
    {
        for (int i =0; i< NUM_EXTRA_SPELL_VALUES; i++)
        {
            extraSpellValues.Add( new SpellValue(NUM_SPELL_VALUE_LEVELS));
        }
    }

    public List<OutputNode> GetOutputNodes()
    {
        List<OutputNode> nodes = new();
        foreach (Effect effect in effects)
        {
            //Create nodes based on the effect
            effectNodes[effect] = new OutputNode();
        }
        foreach (Shape shape in shapes)
        {
            //Create nodes based on the effect
            shapeNodes[shape] = new OutputNode();
        }
        foreach (Modifier modifier in modifiers)
        {
            //Create nodes based on the effect
            modifierNodes[modifier] = new OutputNode();
        }

        foreach (List<SpellValue> valueList in effectValues.Values)
        {
            foreach (SpellValue spellValue in valueList)
            {
                nodes.AddRange(spellValue.GenerateOutputNodes());
            }
        }


        foreach (SpellValue spellValue in extraSpellValues)
        {
            nodes.AddRange(spellValue.GenerateOutputNodes());
        }

        nodes.AddRange(effectNodes.Select(kvp => kvp.Value));
        nodes.AddRange(shapeNodes.Select(kvp => kvp.Value));
        nodes.AddRange(modifierNodes.Select(kvp => kvp.Value));

        return nodes;
    }

    // All outputs contains the normalized value of every node
    // from running the neural net on the glyph input
    // Number of spell parts is equal to the number unique glyphs used in the spell
    public Spell CalculateSpell(Dictionary<OutputNode, double> allOutputs, 
        int numberOfSpellParts)
    {
        Spell spell = new();

        //First calculate all effect values
        spell.spellValueHolder = new(extraSpellValues.Select(value => value.GetLevel(allOutputs)));
        Dictionary<EffectValue, SpellValueHolder> effectValueDict = new();
        foreach (var kvp in effectValues)
        {
            EffectValue ev = kvp.Key;
            List<SpellValue> spellValues = kvp.Value;

            SpellValueHolder holder = new(spellValues.Select(value => value.GetLevel(allOutputs)));
            effectValueDict[ev] = holder;
        }
        spell.effectValueHolder = new(effectValueDict);

        // Let's take all of the effects
        IEnumerable<Effect> topEffects = effects.OrderByDescending(e => allOutputs[effectNodes[e]]);


        // Take the top Output Nodes that correspond to effects, modifiers or shapes
        // These are ordered by the value of their output nodes
        IEnumerable<OutputNode> 
            currentOutputNodesList = 
            allOutputs
            .Where(kvp => 
                effectNodes.Values.Contains(kvp.Key) 
                || modifierNodes.Values.Contains(kvp.Key) 
                || shapeNodes.Values.Contains(kvp.Key))
            .OrderByDescending(kvp => kvp.Value)
            .Select(kvp => kvp.Key)
            .Take(numberOfSpellParts);
        

        // Always take the top effect, because a spell with no parts is boring
        List<SpellPart> finalSpellParts = new()
        {
            topEffects.First()
        };

        // Add nodes in order to highest to lowest
        // The extra conditions are just in case we don't have enough shapes or effects.
        // This shouldn't happen in the actual game.
        foreach (var node in currentOutputNodesList)
        {
            var part = GetFromNode(node);
            if (part == null)
                continue;
            if (finalSpellParts.Contains(part))
            {
                continue;
            }
            finalSpellParts.Add(part);
        }
        // Remove shapes from the end, shapes with no effect don't do anything
        while (finalSpellParts.Last() is Shape s && 
            finalSpellParts.Count(part => part is Shape) > 1)
        {
            finalSpellParts.Remove(s);
        }


#if UNITY_EDITOR
        // Useful for testing particular spell parts
        if (overrideSpellParts.Count > 0)
        {
            spell.spellParts.AddRange(overrideSpellParts);
            return spell;
        }
            
        
#endif
        // Let's just make sure that we only add the correct number of spell parts
        spell.spellParts.AddRange(finalSpellParts.Take(numberOfSpellParts));


        return spell;
        
        // Useful local function
        SpellPart GetFromNode(OutputNode outputNode)
        {
            if (effectNodes.ContainsValue(outputNode))
            {
                return effectNodes.First(kvp => kvp.Value == outputNode).Key;
            }
            if (shapeNodes.ContainsValue(outputNode))
            {
                return shapeNodes.First(kvp => kvp.Value == outputNode).Key;
            }
            if (modifierNodes.ContainsValue(outputNode))
            {
                return modifierNodes.First(kvp => kvp.Value == outputNode).Key;
            }
            return null;
        }
    }

    public void CastSpell(Spell spell, SpellBook book)
    {
        SpellDisplay.Instance.SpellCreated(spell.spellParts);
        spell.CastSpell(GenerateCastInformation(spell, book));
        OnSpellCast?.Invoke(spell);
    }

    CastInformation GenerateCastInformation(Spell spell, SpellBook book)
    {
        return new CastInformation()
        {
            book = book,
            caster = FindFirstObjectByType<PlayerMovement>().gameObject,
            casterView = Camera.main.transform,
            position = book.transform.position,
            rotation = Camera.main.transform.rotation,
            objectHit = book.gameObject
        };
    }
    

    public void StartCastCoroutine(Casting casting, CastInformation castInformation)
    {
        StartCoroutine(casting.Cast(castInformation));
    }

}

public class Spell
{
    public List<SpellPart> spellParts = new();
    public SpellValueHolder spellValueHolder;
    public EffectValueHolder effectValueHolder;

    public void CastSpell(CastInformation castInformation)
    {
        castInformation.spellValueHolder = spellValueHolder.Duplicate();
        castInformation.effectValueHolder = effectValueHolder.Duplicate();

        Shape firstshape = null;
        List<Casting> castings = new();
        Casting currentCasting = new();
        castings.Add(currentCasting);

        currentCasting.index = 0;
        
        // Organize spells into "castings"
        // Each casting contains one shape and some number of effects
        // Because the first part is always an effect,
        //      the first casting also contains all effects before the first shape
        foreach (var part in spellParts)
        {
            if (part is Shape shape)
            {
                if (firstshape == null)
                {
                    firstshape = shape;
                }
                else
                {
                    currentCasting = new Casting()
                    {
                        shape = shape,
                        index = currentCasting.index + 1
                    };
                }

                currentCasting.shape = shape;
            }
            else
            {
                currentCasting.parts.Add(part);
            }
        }
        
        foreach (Casting c in castings)
        {
            c.allCastings = castings;
        }

        SpellManager.Instance.StartCastCoroutine(castings.First(), castInformation);

    }


}

public class Casting
{
    public Shape shape;
    public List<SpellPart> parts = new();
    public int index;
    public IReadOnlyList<Casting> allCastings;

    readonly List<Modifier> instantiatedModifiers = new();
    public IReadOnlyList<Modifier> InstantiatedModifiers => instantiatedModifiers;
    Shape instantiatedShape;

    public IEnumerator Cast(CastInformation castInformation)
    {
        castInformation.casting = this;
        foreach (var part in parts)
        {
            if (part is Modifier modifierPrefab)
            {
                Modifier m = 
                    GameObject.Instantiate(modifierPrefab);
                bool shouldContinue = false;
                m.Create(castInformation, () => shouldContinue = true);
                yield return new WaitUntil(() => shouldContinue);

                instantiatedModifiers.Add(m);
            }
        }


        if (shape == null)
        {
            shape = SpellManager.Instance.DefaultShape;
        }
        instantiatedShape = GameObject.Instantiate(shape, castInformation.position, castInformation.rotation);
        instantiatedShape.Create(castInformation);
    }

}


public struct CastInformation {
    public SpellBook book;
    public GameObject caster;
    public Transform casterView;
    public Vector3 position;
    public Quaternion rotation;
    public GameObject objectHit;
    public EffectValueHolder effectValueHolder;
    public SpellValueHolder spellValueHolder;
    public Casting casting;
}

See line 145 to see the start of the CalculateSpell function that does most of the work for deciding spells. There are references to the scrapped modifier system that would change the spells in unique ways.

Spells are made from several spell parts. The system is designed so that the definition of a spell contains only its spell parts (in order) and a list of effect values. Effect values, as described above, are just integers between 1 and 5 that help define things like size and power of effects.

There are two types of spell parts. Effects affect objects in the world, by levitating them, shrinking or growing them, setting them on fire and so forth. Shapes define how a spell targets objects. Example shapes include launching a projectile, targeting the book, targeting the player, or targeting everything in the area. All spell parts are also prefabs, this makes organization and grouping easy, and the performance hit of spawning multiple game objects is negligible in a project like this.

After running the input glyphs values through the neural net, that data is stored in a spell book. The player can then pick up the book and cast the spell within. The outputs of the neural net decide which effects and shapes are used in the spells.

So now all spell parts are organized by the value of their output from highest to lowest. The top spell parts are taken, with the number of spell parts equal to the number of unique glyphs that were used in the spell. The top effect is always added because a spell with no effects is boring.

By default, spells always target the book. Once a shape is added, each effect is assigned to a shape which chain into each other. A “projectile” shape then a “nearby” shape will look like a launched explosion.

Example of a typical short playthrough.