Skip to content

Units

A unit is anything that has pixels.
Bosses are units, minions are units, shield containers are units, etc.

Config

The config type for units is of the type UnitData.

The basic structure looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
    "properties": {
        // custom variables defined here
    },

    "behaviour":".fsm", // references a finite-state-machine in the same config file

    "baseForm":{
        // properties common to each form
    },

    "forms": [
        {
            // - each form specifies where its pixel source & anim files are located
            // - defines pixel types
            // - defines parts
        },
    ],

    "fsm":[
        "form0": {
            // the actions in the behaviour state machine
        },
    ],
}
Example json file of a simple 2-form boss
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
{
    // custom variables for this boss
    "properties": {
        "loopNum": { "type": "Int", "value": 0 }, // the starting value is already 0 by default, but it's specified here for clarity
    },

    "behaviour": ".fsm", // specify where to find the behaviour state machine definition (the leading "." indicates its inside the same json object)

    // properties defined in baseForm will be used for all forms unless overridden
    "baseForm": {
        "baseHp":18, // default hp for each pixel

        // when player is hit by a bullet, this handler is called
        "onPlayerHit":[
            { "action": "CallSubroutine", "target":"unit", "path": ".speech.sub", "params": { "message": "#octopus.boss.player_hit_bullet", "chance":0.1, }},
        ],
    },

    "forms": [
        {   
            // FORM 0 CONFIG

            "pxcSource": "octopus/pxc/boss/form0/source", // the location of the pixel source file for this form
            "pxcAnimDir": "octopus/pxc/boss/form0", // the directory where the anim files associated with the source file are located

            // list anim files to use (from the directory specified for pxcAnimDir)
            "anims": {
                "idle": { 
                    "time": 1.25, // how long each playthrough of the anim is
                    "easingType": "Linear", // how to ease through the anim frames
                },
            },

            // pixel properties
            "pixels": [
                {   
                    "range": "all", // apply these properties to all pixels
                    "sfxHit": "FleshHit",
                    "sfxDestroy": "FleshDestroy",
                    "sfxDisconnect": "FleshDisconnect",
                    "hitGlow":1.4,
                    "hitFlashTime":1,
                },
            ],

            // define parts
            "parts": {
                "core": {
                    "corePath": "misc/core/octopus/core0",
                    "size": 8, // a size of 8 means this part is an 8x8 square
                    "placements": [ 0 ],
                    "hp": 900,
                    "requires": [ "mid", "side" ], // this part cant be damaged until the required parts are destroyed
                    "onHit": [
                        { "action": "CallMethod", "target": "stage", "method": "ShakeCamera", "params": { "strength": 0.25, "time": 0.33, "easingType": "QuadOut" }},
                    ],
                },
                "mid": {
                    "corePath": "misc/core/octopus/smallGun",
                    "size": 4,
                    "placements": [ 73, 89 ],
                    "hp": 500,
                    "onDestroy": [
                        // spawn a powerup
                        { "action": "CallMethod", "target":"stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/powerup0Mid", "pos": "partPos", "angle":"vecToAngle((playerPos + playerVel * 0.5f) - partPos)",  }},
                    ],
                },
                "side": {
                    "corePath": "misc/core/octopus/smallGun",
                    "size": 3,
                    "placements": [ 64, 105 ], 
                    "hp": 500,
                    "onDestroy": [
                        { "action": "CallMethod", "target":"stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/powerup0Side", "pos": "partPos", "angle":"vecToAngle((playerPos + playerVel * 0.5f) - partPos)",  }},
                    ],
                }
            },

            // this handler is called each frame
            "onUpdate": [
                // continually re-align the rotation of the boss
                { "action": "CallMethod", "method": "AimTowards", "params": { "facingAngle":0, "rotPercent":0.125, }},
            ],

            // this handler is called when the form is destroyed
            "onDestroy": [
                { "action": "CallMethod", "target":"stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/powerup0Core", "pos": "corePos", "angle":"vecToAngle((playerPos + playerVel * 0.5f) - corePos)",  }},

                // controller rumble
                { "action": "CallMethod", "target": "player", "method": "Vibrate", "params": { "pos":"unitPos", "strength": 1, "time": 0.25, "maxDist": 150, "horizThreshold": 40 }},

                // camera shake
                { "action": "CallMethod", "target": "stage", "method": "ShakeCamera", "params": { "strength": 5, "time": 0.5, "easingType": "QuadOut" }},

                // slow time for a moment
                { "action": "CallMethod", "target": "stage", "method": "AddTimeScale", "params": { "scale": 0.5, "time": 0.5, "easingType": "CubicIn" }},
            ],

            // this handler is called when the player dies while this form is active
            "onPlayerDie":[
                // a chance to show a speech bubble
                { "action": "CallSubroutine", "target":"unit", "path": ".speech.sub", "params": { "message": "#octopus.boss.player_die_0", "chance":0.2, }},
            ],
        },
        { 
            // FORM 1 CONFIG

            "pxcSource": "octopus/pxc/boss/form1/source",
            "pxcAnimDir": "octopus/pxc/boss/form1",
            "spawnDelay":2.2, // time to wait before spawning this form
            "coreSpawnTime":1.75, // time to spawn the core
            "moveMode":"Target", // unit moves toward target position, instead of using velocity
            "targetPos":"unitSpawnPos + vec2(sin(stageTime * 0.5f), cos(stageTime * 0.5f)) * 2f", // unit will bob around a central point
            "posLerpSpeed":0.01, // how fast the unit lerps toward the target position

            "anims": {
                "spawn": { "time": 1, "easingType": "QuadIn", "next": "idle" },
                "idle": { "time": 1.25, "easingType": "Linear" },
            },
            "pixels": [
                {   
                    "range": "all", 
                    "sfxHit": "FleshHit",
                    "sfxDestroy": "FleshDestroy",
                    "sfxDisconnect": "FleshDisconnect",
                    "hitGlow":1.4,
                    "hitFlashTime":1,
                },
                { 
                    "range": "part('core')", 
                    "color": "color(1f, 0f, 0f)", // this property applies to pixels defined in the "core" part below
                },
                {  
                    "range": "part('mid')",  
                    "color": "color(1f, 0f, 0f)", // this property applies to pixels defined in "mid" parts below
                },
                {  "range": "part('side')", "color": "color(1f, 0f, 0f)", },
                {  "range": "part('bot')", "color": "color(1f, 0f, 0f)", },
                {
                    "range": "range(146,389)",
                    "sfxHit": "Thump",
                    "sfxDestroy": "MetalDestroy",
                    "sfxDisconnect": "MetalDisconnect",
                    "damagedColor": "color(0.175f, 1f, 0.35f) * 1.15f", // as the pixel loses hp, it changes toward this color
                    "hp": 40, // override the baseHp for these pixels
                    "hitGlow":1.2,
                    "hitFlashTime":0.5,
                }
            ],
            "parts": {
                "core": {
                    "corePath": "misc/core/octopus/core1",
                    "size": 8,
                    "placements": [ 0 ],
                    "hp": 1000,
                    "requires": [ "mid", "side", "bot" ],
                    "onHit": [
                        { "action": "CallMethod", "target": "stage", "method": "ShakeCamera", "params": { "strength": 0.25, "time": 0.33, "easingType": "QuadOut" }},
                    ],
                },
                "side": {
                    "corePath": "misc/core/octopus/smallGun",
                    "placements": [ 
                        { "size": 3, "start": 128 }, 
                        { "area": 9, "start": 137 }, // you can specify "area" instead of "size", for irregularly shaped parts
                    ],
                    "hp": 600,
                    "onDestroy": [ 
                        { "action": "CallMethod", "target":"stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/powerup1Side", "pos": "partPos", "angle":"vecToAngle((playerPos + playerVel * 0.5f) - partPos)",  }},
                    ],
                },
                "bot": {
                    "corePath": "misc/core/octopus/largeGun",
                    "size": 4,
                    "placements": [ 80, 112 ],
                    "hp": 600,
                    "onDestroy": [{ "action": "CallMethod", "target":"stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/powerup1Bot", "pos": "partPos", "angle":"vecToAngle((playerPos + playerVel * 0.5f) - partPos)",  }},],
                },
                "mid": {
                    "corePath": "misc/core/octopus/largeGun",
                    "size": 4,
                    "placements": [ 64, 96 ],
                    "hp": 600,
                    "onDestroy": [{ "action": "CallMethod", "target":"stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/powerup1Mid", "pos": "partPos", "angle":"vecToAngle((playerPos + playerVel * 0.5f) - partPos)",  }},],
                }
            },

            // this handler is called immediately when the unit is spawned
            "onSpawn": [
                { "action": "CallMethod", "target": "stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/effect/bgClouds.fgStart", }},
                { "action": "CallMethod", "target": "stage", "method": "SpawnPattern", "params": { "path": "octopus/pattern/effect/bgClouds.fgOngoing", }},
                { "action": "CallMethod", "target":"stage", "method": "SetGridColor", "params": { "color": "color(0f, 1f, 1f, 0.033f)", "time":1, "easingType":"QuadOut" }},
            ],

            // this handler is called when the pixels finish respawning
            "onFormRespawn": [
                { "action": "CallSubroutine", "target":"unit", "path": ".speech.sub", "params": { "message": "#octopus.boss.form_1", "chance":0.25, }},
            ],

            "onUpdate": [
                { "action": "CallMethod", "method": "Shake", "params": { "strength":"map(core.HpPercent, 1f, 0f, 0f, 0.175f, 'QuadIn')", "time":0, }},
                { "action": "CallMethod", "method": "SetScale", "params": { "scale": "vec2(1f + sin(stageTime * 1.8f) * 0.03f, 1f + sin(stageTime * 1.8f) * 0.03f)" }},
                { "action": "CallMethod", "method": "AimTowards", "params": { "facingAngle":0, "rotPercent":0.125, }},
            ],

            // this handler is called when the unit's pixel is hit by a bullet
            "onPixelHit": [
                { "action": "CallMethod", "target": "unit", "method": "Nudge", "params": { "vector":"bullet.FacingDirection * 0.15f", "time":0.25, "easingType":"QuadOut", }},
            ],

            "onPlayerDie":[
                { "action": "CallSubroutine", "target":"unit", "path": ".speech.sub", "params": { "message": "#octopus.boss.player_die_1", "chance":0.33, }},
            ],
        },
    ],

    // the state machine that handles the boss behaviour
    "fsm": {
        "inactive": [
            { "action": "Wait", } // wait indefinitely
        ],
        "form0": { // the name "form0" is important, when the first form of the unit finishes respawning, its behaviour state will be set to this
            // FORM 0
            "initial delay": [
                { "action": "Wait", "time":2, },
            ],
            "0": [
                { "action": "CallMethod", "method": "ChargePattern", "params": { "paths": [ "octopus/pattern/lazyBullets.1", "octopus/pattern/lazyBullets.2", "octopus/pattern/lazyBullets.3", "octopus/pattern/lazyBullets.4" ], "pathIndex":"loopNum % 4", "partType": "mid", "chargeTime": 0.75, "dir":"playerPos - partPos", }},
                { "action": "Wait", "time": 4, },
            ],
            "1": [
                { "action": "SetValue", "name": "loopNum", "value": "loopNum + 1" },
                { "action": "Goto", "state": "0" },
            ],
        },
        "form1": { // when the 2nd unit form finishes respawning, its behaviour state will be set to "form1"
            // FORM 1
            "initial delay 2": [
                { "action": "SetValue", "name": "loopNum", "value": 0 },

                { "action": "Wait", "time": 1 },
                { "action": "CallMethod", "method": "ChargePattern", "params": { "path": "octopus/pattern/curseMineOrb", "partType": "core", "chargeTime": 1, "loopNum":"loopNum", "dir": "(playerPos + playerVel * 0.75f) - partPos", }},
                { "action": "Wait", "time": 1 },
            ],
            "0": [
                { "action": "Condition", "condition": "loopNum > 0 && numShotPatterns % 2 == 0",
                    "true": [ 
                        { "action": "CallMethod", "method": "ChargePattern", "params": { "paths": [ "octopus/pattern/spread.spokes1", "octopus/pattern/spread.spokes2", "octopus/pattern/spread.spokes3" ], "pathIndex":"numShotPatterns % 3", "partType": "side", "chargeTime": 1, "dir":"(playerPos + playerVel * 0.25f) - partPos", }},
                        { "action": "Wait", "time": 3, },
                    ],
                    "false": [ 
                        { "action": "Repeat", "count": 2, "delay": 1.5, "inner": [
                            { "action": "CallMethod", "method": "ChargePattern", "params": { "path": "octopus/pattern/spread.1", "partType": "side", "chargeTime": 1, "dir":"(playerPos + playerVel * 0.25f) - partPos", }},
                        ]},
                        { "action": "Wait", "time": 2, },
                    ],},
            ],
            "1": [
                { "action": "SetValue", "name": "loopNum", "value": "loopNum + 1" },
                { "action": "Goto", "state": "0" },
            ],
        },
    },

    // subroutine to show a speech bubble and play a sound
    "speech.sub": {
        // the parameters we can pass in to the subroutine
        "parameters": {
            "this": { "type": "Unit" }, // this subroutine is intended to be run by Unit objects
            "message": { "type": "String" },
            "chance": { "type": "Float" },
        },

        "actions": [
            { "action": "Condition", "condition": "rand.Float(0f, 1f) < 0.5f",
                "true": [ 
                    { "action": "CallMethod", "target":"unit.GetPart('core')", "method": "SpawnBubble", "ignoreNullRef": true, "params": { "text": "${message}", "partType":"core", "lifetime": 4, }},
                    { "action": "CallMethod", "target":"stage", "method": "PlaySfx", "params": { "sfxType": "OctopusBossSpeech", "pos":"unitPos", }},
                ],},
        ]
    },
}

Forms

Most of the properties for a unit can be defined for each form.
When one form is destroyed, the unit will respawn in their next form.

The config type for unit forms is of the type FormData. Each unit config should contain a list of forms:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
    "forms": [
        {
            // form 0
        },
        {
            // form 1
        },
    ],
}

Unit form info

Unit form json config

Base Form

Any properties defined for the baseForm of a unit apply to all of its forms, unless overridden (they're essentially the same thing as the #include functionality, which can be used instead).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
    "baseForm": {
        "baseHp":18,
        "onUpdate":[
            // actions
        ],
    },

    "forms": [
        {
            // form 0
            "baseHp":25,
        },
        {
            // form 1
            "onUpdate":[
                // different actions
            ],
        },
    ],
}

form 0 overrides the base pixel health of the base form while keeping its onUpdate actions, while the next form keeps the base form's baseHp but overrides its onUpdate actions.

Warning

Some properties won't show your changes until you completely reload the level - restarting isn't enough.

Press the G key to reload the level.

Pixels

Each form needs to specify the location of the pixel source file and the directory where its anim files are located.

Each unit form should have one source file, and one or more anim files.

1
2
3
4
5
6
7
{
    "forms":[
        "pxcSource":"octopus/pxc/boss/form0/source",
        "pxcAnimDir":"octopus/pxc/boss/form0",
        // ...
    ],
}

Each form should also set some properties for its pixels.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
    "forms":[
        // ...

        "pixels": [
            {
                "range": "all", // these properties will apply to all pixels
                "hp":20,
                "sfxHit": "PixelHit",
                "sfxDestroy": "PixelDestroy",
                "sfxDisconnect": "PixelDisconnect",
            },
            {
                "range": "part('core')", // these properties will apply to pixels in the "core" part, and override previous properties
                "color": "#668855"
            },
            {
                "range": "range(616, 771)", // these pixels apply to a range of assigned numbers (generally a certain color), and override previous properties
                "hp":40,
                "sfxHit": "Thud3",
            },
            {
                "range": "range(72, 317)",
                "pixelType":"Invulnerable",
                "sfxHit": "MetalHit",
            },
        ],

        // ... 
    ],
}

To find the range of a certain type of pixel:

1) Load your animation file in the PxcEditor.

2) Decide which pixel type you want to get the range of - in this case, the purple colored pixels are going to be invulnerable.

3) Hold the C key and right click one of the pixels to select all other pixels of the same color.

4) Hold the Shift+C keys and right click one of the other purple pixels to add all pixels of that shade to the selection.

5) Press Middle Mouse Button or the = button on the far right of the toolbar to toggle showing pixel assignments.

6) The range (in this case 244, 1923) should now be copied to your clipboard. Paste it into the pixel config. If your range isn't a neat sequence, you probably didn't assign your pixels on a color-by-color basis.

Pixel Types

When specifying properties for pixels, you can choose between a few fundamental types.

Pixel Type Summary
Normal pixel has a certain amount of hp until it is destroyed
Explosive a small amount of damage will trigger this pixel to explode and destroy adjacent pixels
Invulnerable normal damage won't affect this pixel (though Explosive pixels will)

Parts

Each kind of part used in your enemy needs to be defined in the form's config.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
    "forms":[
        {
            "parts":{
                "core": {
                    "corePath": "misc/core/octopus/core0",
                    "size": 12,
                    "placements": [ 0 ],
                    "hp": 900,
                    "requires": [ "gun", ],
                },
                "gun": {
                    "corePath": "misc/core/octopus/smallGun",
                    "size": 7,
                    "placements": [ 73, 89 ],
                    "hp": 500,
                },
            }
        },
    ],
}

For more info check the Unit Parts page.

Animations

Importing

Animations should be declared in the unit form's anims property.

These anim json files should be located in the pxcAnimDir you specified for the unit form.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
    "forms": [
        {
            // ...
            "anims": {
                "spawn": { "time": 0.5, "easingType": "QuadIn", "next": "idle" },
                "idle": { "time": 1.25, "easingType": "Linear" },
                "idle_2": { "time": 1.5, "easingType": "SineInOut" },
            },
        },
    ],
}

Playback

Unit anims can be played with an Action.

Played anims will be added to the end of a queue, and played when earlier animations have been finished.

1
{ "action": "CallMethod", "target": "unit", "method": "PlayAnim", "params": { "name": "spawn", }},

There are a number of optional parameters as well:

1
public void PlayAnim(string name, bool reverse = false, bool forceChange = false, LoopMode loopMode = LoopMode.None, EasingType easingType = EasingType.None, bool playNext = false)
Parameter Summary
reverse whether anim should start from the end and play backwards
forceChange if true, end current animation immediately to play new one (may cause issues?!)
loopMode override the loop mode saved with the anim file
easingType override the anim's easing mode specified in the import settings
playNext if true, play new anim next instead of after all queued anims

Behaviour

The behaviour state machine should be specified in the behaviour unit property.

1
"behaviour": ".fsm",

The unit will begin in the first state, inactive.

When the first form of the unit finishes spawning, it will try to switch to a state called form0. In this case, form0 isn't itself a state, but a collection of states, and the first one will be chosen.

When the unit's second form finishes spawning, it will switch to the first state in form1.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
    "fsm": {
        "inactive": [
            { "action": "Wait"}
        ],
        "form0": {
            "state 0": [
                { "action": "Wait", "time": 1 },
            ],
            "state 1": [
                // attack
            ],
        },
        "form1": {
            "state 0": [
                { "action": "Wait", "time": 1.5 },
            ],
            "state 1": [
                // attack
            ],
        },

    },
}

Tip

Holding the H key will show the current state of a unit.

When finishing the last action in a state, the state machine will automatically move to the next state in the group.

When finishing the last state in a group of states, it will loop around to the first state in the group.

For example, when form1.state 1 finishes all its actions, playback will move back to form1.state 0.

Charging Patterns

When you want a unit to shoot a pattern, usually you'll charge it from their core or another part. This will cause the part to glow and look in the pattern direction as it leads up to firing.

1
{ "action": "CallMethod", "method": "ChargePattern", "params": { "path": "octopus/pattern/lazyBullets.1", "partType": "side", "chargeTime": 1, "dir":"playerPos - partPos", }},

Let's spread that out for clarity:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{ 
    "action": "CallMethod", 
    "method": "ChargePattern", 
    "params": { 
        "path": "octopus/pattern/lazyBullets.1", 
        "partType": "side", 
        "chargeTime": 1, 
        "dir":"playerPos - partPos", 
    }
},

It can be convenient to choose between multiple patterns in the same ChargePattern action.
Instead of using the path parameter, use the paths parameter to list the options, and pathIndex to choose which to use.

1
{ "action": "CallMethod", "method": "ChargePattern", "params": { "paths": [ "octopus/pattern/lazyBullets.1", "octopus/pattern/lazyBullets.2", "octopus/pattern/lazyBullets.3", "octopus/pattern/lazyBullets.4" ], "pathIndex":"numShotPatterns % 4", "partType": "side", "chargeTime": 1, "dir":"playerPos - partPos", }},

There are some optional ChargePattern parameters available:

Parameter Type Summary
path PatternData the path of the pattern to shoot
paths List of PatternData the path of the pattern to shoot
pathIndex int which index in paths to use
partType string which unit part(s) to fire from
partSelect PartSelectMode how to choose next part of partType
randomDegrees float change pattern angle by up to this much
mirrorMode MirrorMode used to create extra mirrored patterns
flip FlipPatternMode used to make patterns symmetrical across an axis
chargeTime float how long to charge before shooting
dir Vector2 func starting pattern direction
sizeMultiplier float scaling applied to every bullet in pattern
numVolleysAddition int adjust amount of volleys
numBulletsAddition int adjust amount of bullets in each volley
shootDelayModifier float scaling applied to shoot delay time
maxDist float don't shoot pattern if part is too far from player

Movement

Similar to how bullets work, units can choose to be moved by velocity or moved toward a target position.

Velocity

1
2
3
4
5
6
7
8
{
    "forms":[
        {
            "moveMode":"Velocity", // not necessary to specify since it's the default
            "accelVector":"normalize(playerPos - unitPos) * 0.5f", // move toward player
        }
    ],
}

Target

1
2
3
4
5
6
7
8
9
{
    "forms":[
        {
            "moveMode":"Target",
            "targetPos":"playerPos", // move toward player
            "posLerpSpeed":0.05, // unit moves 5% of the current distance to player each frame
        }
    ],
}

Info

The movement mode and the functions can be set with Actions such as SetMovementModeFunc, SetAccelVectorFunc, ClearAccelVectorFunc, etc.

Avoiding Other Units

Generally you want to prevent units from overlapping eachother. You can define circular hitboxes that repel other units.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
    "forms": [
        {
            "repulsionCircles": [
                {  "offset":"vec2(0, 7f)", "radius":18, // strength is 1 by default},
                {  "offset":"vec2(-14.5f, -7f)", "radius":12, "strength":1.5, },
                {  "offset":"vec2(14.5f, -7f)", "radius":12, "strength":1.5, },
            ],

            // how strongly the unit's circles are repelled by other units
            "avoidUnitStrength":1,

            // how strongly the unit's circles repel other units' circles
            "repelUnitStrength":10,
        },
    ],
}

Tip

Hold the H key to see the repulsion circles of units.

Level Boundary

By default, a unit does not try to stay in the level bounds, and is removed when it completely leaves the bounds.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
    "disableOutOfBounds":false, // this unit does not disable when it goes out of bounds (default is true)

    "forms": [
        {
            // define new bounds for this unit
            "bounds":"rect(-stageWidth * 0.5f, -stageHeight * 0.5f, stageWidth, stageHeight * 0.75f)",

            // how strongly the unit adds force to stay within its bounds
            "stayInBoundsStrength":10,
        },
        {
            // this form only stays in bounds on the left and right sides
            "stayInBoundsStrengthLeft":10,
            "stayInBoundsStrengthRight":10,
            "stayInBoundsStrengthUp":0,
            "stayInBoundsStrengthDown":0,
        },
    ],
}

Tip

Hold the H key to see the bounds of units.

Facing

Target

1
2
3
4
5
6
7
8
9
{
    "forms":[
        {
            "facingMode":"Target",
            "targetFacingAngle":"vecToAngle(playerPos - unitPos)", // look at player
            "facingLerpSpeed":0.05, // rotate 5% of the way to target angle each frame
        }
    ],
}

AutoRotate

1
2
3
4
5
6
7
8
{
    "forms":[
        {
            "facingMode":"AutoRotate",
            "rotationSpeed":-100,
        }
    ],
}

Handlers

You may want to call Actions at certain points while a unit form is active.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
    "onSpawn":[
        // called immediately when unit is spawned
        { "action": "CallMethod", "target":"stage", "method": "ShakeCamera", "params": { "strength": 3, "time": 0.5, "easingType": "QuadOut" }},
    ],    

    "onFormRespawn":[ /* called when form has finished spawning pixels */ ],
    "onUpdate":[ /* called each frame this unit is active and not dormant or hidden, and on this form*/ ],
    "onDestroy":[ /* called when form is destroyed */ ],
    "onHidden":[ /* called when unit has become hidden */ ],
    "onPixelHit":[ /* called when a pixel of this form is hit by a bullet */ ],
    "onPixelHitByLaser":[ /* called pixel of this form is hit by a laser */ ],
    "onPixelDestroyed":[ /* called pixel of this form is destroyed */ ],
    "onPartHit":[ /* called when a part of this form is hit by a bullet */ ],
    "onPartHitByLaser":[ /* called when a part of this form is hit by a laser */ ],
    "onPartDestroyed":[ /* called when a part of this form is destroyed */ ],
    "onImplodeStart":[ /* called when this form starts imploding (core has taken lethal damage) */ ],
    "onImplodeUpdate":[ /* called while this form is imploding */ ],
    "onImplodeFinish":[ /* called when this form finishes imploding (and dies) */ ],
    "onPlayerHit":[ /* called when player takes damage but does not die */ ],
    "onPlayerDie":[ /* called when player takes lethal damage */ ],
    "onPlayerActivateStatus":[ /* called when player uses an item */ ],
    "onPlayerStatusLevelChanged":[ /* called when a status effect changes level */ ],
    "onDormantStart":[ /* called when unit becomes dormant (don't simulate) */ ],
    "onDormantEnd":[ /* called when unit is no longer dormant */ ],
    "onDormantUpdate":[ /* called while unit is dormant, according to "dormantUpdateInterval" */ ],
},

Unit Destruction

Anchors

Individual pixels can set the isAnchor property to true. When non-anchor pixels or parts are disconnected from all anchor pixels, they will be destroyed.

Cores

Generally, units should declare a part with the name "core", though this isn't absolutely required.

The core part of a unit will automatically set all its pixels as anchors.

Note

If a unit has no core (and no anchor pixels), it will handle destruction differently: whenever pixels become separated, the smaller of the separate pieces will be destroyed.

Imploding

"Imploding" refers to the animation when a unit has received lethal damage to its core, but before it is destroyed.

In order for your unit to display this behaviour, it must define a few handlers in its form config:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
"baseForm": {
    // the time the imploding effect lasts
    "implodeTime":1.5,

    // called once, when the unit core takes lethal damage
    "onImplodeStart": [
        // play sounds, show speech bubbles, initialize variables
    ],

    // called each frame between onImplodeStart and onImplodeFinish
    "onImplodeUpdate": [
        // make the unit wiggle around, destroy random pixels, etc
    ],

    // called once, after implodeTime has elapsed, and right before the unit is destroyed
    "onImplodeFinish": [
        // play sounds, spawn explosion effects, destroy nearby bullets, etc
    ],
}

Tip

You can manually call the Implode action on a unit to destroy it at any time, without it needing to take lethal damage to the core.

1
{ "action": "CallMethod", "target":"unit", "method": "Implode", },

Custom Variables

Custom variables can be defined in the properties structure.

1
2
3
4
5
6
7
8
9
{
    "properties": {
        "loopNum": { "type": "Int", },
        "pixel": { "type": "PixelData", },
        "revengeCounter": { "type": "Int", },
        "shootTimer": { "type": "Float", "value":6, },
        "hasShot": { "type": "Bool", },
    },
}

These values can be used in any script func by the unit.

1
2
3
{ "action": "Repeat", "count": "revengeCounter", "inner": [
    // ...
]},

To modify them, use the SetValue method:

1
{ "action": "SetValue", "name": "revengeCounter", "value": "revengeCounter + 1" },

Warning

When specifying the starting value of custom properties, you can't use a scriptfunc.

For instance:

1
"revengeCounter": { "type": "Int", "value":"rand.Int(0, 99)" },
Is not allowed, but using a simple value like 6 is:
1
"revengeCounter": { "type": "Int", "value":6 },

Texture

Unit forms have a texture property, which itself has a single shader property.

It draws a scrolling wavy texture on the pixels.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
    "baseForm":{
        "texture": {
            "shader": {
                // the texture to draw (path relative to your campaign folder)
                "texturePath":"pxc_textures/blurredClouds",

                // how strong the effect is
                "intensity": "rand.Float(0.9, 1.1)",

                // i can't remember what all this stuff does, you just gotta play with it
                "speedX": "rand.Float(0.8, 1.2)",
                "speedY": "rand.Float(0.15, 0.25)",
                "timeFactor": "rand.Float(0.08, 0.12)",
                "timeFactor2": "rand.Float(0.45, 0.55)",    
                "timeFactor3": "rand.Float(0.65, 0.75)",
                "freqX": "rand.Float(9, 11)",
                "freqY": "rand.Float(7, 9)",
                "depthX": "rand.Float(0.075, 0.125)",
                "depthY": "rand.Float(0.125, 0.175)",
            }
        },
    },
}

The unit on the left has no "shader", while the unit on the right has a subtle cloudy coloring.

Speech Bubbles

Unit's SpawnBubble creates a speech bubble coming out of a part.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{ "action": "CallMethod", "method": "SpawnBubble", 
    "params": { 
        "text": "Hihihi", 
        "partType":"core", 
        "lifetime": 1,
        "fillColor": "color(0.2f, 0.1f, fastSin(stageTime * PI * 2f) * 0.2f + 0.5f, 0.8f)", 
        "borderColor": "color(0.75f, 0.75f, fastSin((stageTime + 0.5f) * PI * 2f) * 0.05f + 0.95f, 0.66f)", 
        "borderWidth": "fastSin(stageTime * PI * 3f) * 2f + 4f",
        "textColor":"lerp(color(1f, 1f, 1f), color(0.75f, 0.75f, 1f), 0.5f + fastSin(stageTime * PI * 12f) * 0.5f)",
        "font":"Quantico",
        "fontSize":33,
    }
},

Tip

In order to print the value of a property or function in text (instead of just displaying the name), format the string with ${ } surrounding whatever should be evaluated.

1
"text": "I'm thinking of a number!! It's ${rand.Int(1, 100)}!!!", 

Fonts

Here is an image showing the different fonts available.

String Files

Instead of including speech lines directly in the unit configs, you can list them in a separate string file. This allows for more variety and opens up the possibility of localizing into other languages.

Here is text.json, the strings file for the example custom campaign:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
    "en": { // english
        "example": {
            "title": "Example Stage",
            "description": "This is an example stage",

            "boss":{
                "welcome": [
                    "Let's fight", 
                    { "value": "I'm just an example boss!", "weight": 0.8 }, 
                    { "value": "I will kill you", "weight": 0.3 },
                ],

                "death": [
                    { "value": "You killed me!", "weight": 1.5 }, 
                    { "value": "Aghhhh....", "weight": 0.66 },
                ],
            },
        },
    }
}

Your plugin.json file needs to list this file under the strings property, and it's possible to include more than one.

1
2
3
4
5
6
7
{
    "title": "Example Plugin",
    "description": "Custom campaign example",

    // ...
    "strings": [ "text" ],
}

Now we can use those strings in any of our scripts.

The text #example.boss.welcome will be replaced with a random selection defined for that string, with Let's fight having the greatest chance with a default weight of 1.0, I'm just an example boss! happening slightly less often, and I will kill you being even more rare.

Script Parameters

These parameters can be used inside any scriptfunc by a Unit.

Debugging

debugVector: draws a 2d vector from the unit's position
debugText: displays a string below the unit's position (use ${NAME} for properties)

Examples

Phobia movement

The Phobia boss script is at redux/hunter/unit/boss.json.

Form 0 alternates between moving toward the center of the stage, and running from the player.

In properties, the unit declares the Vector2 property targetPos to represent where to move, and the boolean variable isGoingToCenter to keep track of the state:

6
7
"targetPos": { "type": "Vector2", },
"isGoingToCenter": { "type": "Bool", "value": false },
targetPos defaults to Vector2(0, 0), the center of the stage, and we'll keep that inital value during Form 0.

In the definition of the first form, the accelVector property controls the unit's acceleration:

294
"accelVector":"normalize(isGoingToCenter ? (targetPos - unitPos) : (unitPos - playerPos)) * map(dist(isGoingToCenter ? (targetPos - unitPos) : (unitPos - playerPos)), 0f, 80f, (isGoingToCenter ? 16f : 3f), (isGoingToCenter ? 30f : 15f), 'SineOut') * select(diffInt, 0.8f, 1f, 1.2f)",

normalize(isGoingToCenter ? (targetPos - unitPos) : (unitPos - playerPos))

In the above section, (targetPos - unitPos) represents the vector from the boss to (0, 0), and (unitPos - playerPos) represents the vector from the player to the boss.

We choose which vector to use depending on whether isGoingToCenter is true or false, using a ternary operator.

Then we normalize the vector, which keeps the same direction but sets the length to 1. The normalized vector is multiplied by this next section, which determines the speed based on distance to the target:

map(dist(isGoingToCenter ? (targetPos - unitPos) : (unitPos - playerPos)), 0f, 80f, (isGoingToCenter ? 16f : 3f), (isGoingToCenter ? 30f : 15f), 'SineOut')

dist(isGoingToCenter ? (targetPos - unitPos) : (unitPos - playerPos)) finds the distance to the target, depending on our current state.

The map function checks how far the distance is from 0f to 80f, and returns the corresponding value in the range from (isGoingToCenter ? 16f : 3f) to (isGoingToCenter ? 30f : 15f). For example, if the distance is 80 or higher, it will return the max side of the range: 30 if isGoingToCenter is true, and 15 otherwise.

For the final part of accelVector, the speed is adjusted based on the difficulty:

select(diffInt, 0.8f, 1f, 1.2f)

The select function checks the first parameter (in this case the difficulty represented as either 0/1/2 for Easy/Normal/Nightmare). It uses the first parameter to decide which of the other parameters to use, for example if diffInt is 2, the returned value will be 1.2f.

In essence, the long accelVector function can be thought of as: "accelVector":"direction * speed * difficulty modifier"

Also in the the first form, these properties control the unit's rotation:

295
296
297
"facingMode":"Target",
"targetFacingAngle":"vecToAngle(isGoingToCenter ? (targetPos - unitPos) : (unitPos - playerPos))",
"facingLerpSpeed":"map(distSqr(targetPos - unitPos), 0f, 100f * 100f, 0.00175f, 0.0066f, 'QuadIn') * select(diffInt, 1f, 1f, 1.5f)",

To determine targetFacingAngle, we use vecToAngle to convert the intended direction into degrees.

For facingLerpSpeed, we again use a map function to lerp a value based on distance (though in this case we use distSqr as it's more performant). The boss rotates faster when they are farther away from the center of the stage.

We also need to change the value of isGoingToCenter:

391
392
393
"onUpdate":[
    { "action": "Condition", "condition": "isGoingToCenter && distSqr(targetPos - unitPos) < select(diffInt, 2f, 2f, 25f)",
        "true": [{ "action": "SetValue", "name": "isGoingToCenter", "value": false },],},

Every frame Form 0 runs the onUpdate method. The above Condition checks if isGoingToCenter is true, and if the boss is close enough to the center of the stage - if so, isGoingToCenter is set to false.

467
468
469
470
"onOutOfBoundsLeft":[ { "action": "SetValue", "name": "isGoingToCenter", "value": true }, ],
"onOutOfBoundsRight":[ { "action": "SetValue", "name": "isGoingToCenter", "value": true }, ],
"onOutOfBoundsDown":[ { "action": "SetValue", "name": "isGoingToCenter", "value": true }, ],
"onOutOfBoundsUp":[ { "action": "SetValue", "name": "isGoingToCenter", "value": true }, ],

These callbacks set the value of isGoingToCenter to true whenever the boss hits the outer bounds of the stage.


Form 1 simply moves and rotates toward the player, but it rotates slower when it's near the player, and moves faster when it's facing the player or far from the player.

504
505
506
507
"accelVector":"normalize(playerPos - unitPos) * map(distSqr(playerPos - unitPos), 0f, 100f * 100f, map(dot(unitFacingDir, normalize(playerPos - unitPos)), -1f, 1f, 0f, 1f, 'QuadInOut'), map(dot(unitFacingDir, normalize(playerPos - unitPos)), -1f, 1f, 15f, 75f, 'QuadInOut'), 'Linear') * (0.4f + (fastSin(unitTime * 1f) * 0.33f)) * select(diffInt, 0.8f, 1f, 1.2f)",
"facingMode":"Target",
"targetFacingAngle":"vecToAngle(playerPos - unitPos)",
"facingLerpSpeed":"map(distSqr(playerPos - unitPos), 0f, 75f * 75f, 0f, 0.015f, 'QuadIn')",

The long accelVector property for Form 1 involves nested map functions, so it looks more confusing than it is.

map(distSqr(playerPos - unitPos), 0f, 100f * 100f, map(dot(unitFacingDir, normalize(playerPos - unitPos)), -1f, 1f, 0f, 1f, 'QuadInOut'), map(dot(unitFacingDir, normalize(playerPos - unitPos)), -1f, 1f, 15f, 75f, 'QuadInOut'), 'Linear')

This can be represented as map(distance squared, 0, 100*100, 0-1 based on facing the player, 15-75 based on facing the player)

The dot function takes two vectors, and returns a value from -1 to 1 depending on how much the vectors point in the same direction. If the vectors are facing in opposite directions, it returns -1, and if they are facing in the exact same direction, it returns 1.

map(dot(unitFacingDir, normalize(playerPos - unitPos)), -1f, 1f, 0f, 1f, 'QuadInOut')

The above map simply checks how closely the boss is facing the player, and re-maps the lower end of the -1 to 1 range to 0 instead.

(0.4f + (fastSin(unitTime * 1f) * 0.33f))

The above section simply uses fastSin (a more performant, less precise version of sin) to multiply by (0.4 - 0.33) to (0.4 + 0.33), depending on time.


Form 2 has the most complex movement behaviour. The boss repeats this same pattern:
1. aim toward the player without moving for 6 seconds
2. move forward until hitting the side of the stage or until a certain amount of time elapses
3. wait for a bit without moving or rotating

These variables are defined in properties to control the movement of Form 2:

 9
10
11
12
"form2IsRecovering": { "type": "Bool", "value": false },
"form2IsAiming": { "type": "Bool", "value": true },
"form2IsMoving": { "type": "Bool", "value": false },
"form2Timer": { "type": "Float", "value": 0 },

In the onUpdate callback for Form 2, we check multiple Conditions based on the value of the properties.

 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
"onUpdate":[
    { "action": "Condition", "condition": "form2IsRecovering",
        "true": [ 
            { "action": "SetValue", "name": "form2Timer", "value": "form2Timer + dt", },
            { "action": "Condition", "condition": "form2Timer > map(unitPixelPercent, 1f, 0.33f, 10f, 8f, 'QuadIn')",
                "true": [ 
                    { "action": "CallSubroutine", "target":"unit", "path": ".form2StartAiming.sub", }, 
                ],},
        ],
        "false": [ 
            { "action": "Condition", "condition": "form2IsAiming",
                "true": [ 
                    { "action": "SetValue", "name": "form2Timer", "value": "form2Timer + dt", },
                    { "action": "Condition", "condition": "form2Timer > 6f",
                        "true": [ 
                            // charge
                            { "action": "CallSubroutine", "target":"unit", "path": ".speech.sub", "params": { "message": "#hunter.charge_2", "chance":"attempts == 0 ? 1f : map(attempts % 5, 0, 4, 0.1f, 0.45f)", }},
                            { "action": "SetValue", "name": "form2IsAiming", "value": false },
                            { "action": "SetValue", "name": "form2IsMoving", "value": true },
                            { "action": "SetValue", "name": "form2Timer", "value": 0 },
                            { "action": "CallMethod", "method": "SetFacingLerpSpeedFunc", "params": { "func": "0"}},
                            { "action": "CallMethod", "method": "SetAccelVectorFunc", "params": { "func": "unitFacingDir * 300f * mapReturn(form2Timer, 0f, map(unitDestroyedPartCount, 0, 10, 7.5f, 4.5f) * select(diffInt, 1.25f, 1f, 0.85f), 0f, 1f, 'Linear') * select(diffInt, 0.4f, 1f, 1.2f)"}},

                            { "action": "SetValue", "name": "form2NumTimesMoved", "value": "form2NumTimesMoved + 1" },
                        ],},
                ],},
        ], },

    { "action": "Condition", "condition": "form2IsMoving",
        "true": [ 
            { "action": "SetValue", "name": "form2Timer", "value": "form2Timer + dt", },
            { "action": "Condition", "condition": "form2Timer > map(unitDestroyedPartCount, 0, 10, 7.5f, 4.5f) * select(diffInt, 1.25f, 1f, 0.85f)",
                "true": [ { "action": "CallSubroutine", "target":"unit", "path": ".form2HitEdge.sub", }, ],},
        ],},

If form2IsRecovering is true, we wait a certain amount of time before calling the subroutine .form2StartAiming.sub

If form2IsAiming is true, we wait 6 seconds before setting form2IsMoving to true, using SetFacingLerpSpeedFunc to set rotation speed to 0, and using SetAccelVectorFunc to change the boss' speed.

If form2IsMoving is true, we increment form2Timer and call the subroutine .form2HitEdge.sub if enough time has elapsed. We also call that subroutine if the boss hits the edge of the stage.

1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
"form2StartAiming.sub": {
    "actions": [
        { "action": "CallSubroutine", "target":"unit", "path": ".speech.sub", "params": { "message": "#hunter.aim_2", "chance":"attempts == 0 ? 0.1f : map((attempts + 2) % 7, 0, 6, 0.05f, 0.4f)", }},
        { "action": "SetValue", "name": "form2IsRecovering", "value": false },
        { "action": "SetValue", "name": "form2IsAiming", "value": true },
        { "action": "SetValue", "name": "form2IsMoving", "value": false },
        { "action": "SetValue", "name": "form2Timer", "value": 0 },
        { "action": "CallMethod", "method": "SetAccelVectorFunc", "params": { "func": "vec2(0,0)"}},
        { "action": "CallMethod", "method": "SetFacingLerpSpeedFunc", "params": { "func": "map(distSqr(playerPos - unitPos), 0f, 100f * 100f, 0.0075f, 0.045f, 'QuadIn') * select(diffInt, 0.7f, 0.95f, 1f) * map(form2Timer, 0f, 2f, 0f, 1f, 'QuadIn')"}},

        { "action": "Condition", "condition": "distSqr(playerPos - unitPos) < 100f * 100f && !stage.DoesBulletExist('redux/hunter/pattern/draggingVine.chainBullet') && numShotPatterns % 5 != 4",
            "true": [
                { "action": "CallSubroutine", "target":"unit", "path": ".attackSpeech.sub", "params": { "message": "#hunter.attack_1", "chanceIncrease":0 }},
                { "action": "CallMethod", "method": "ChargePattern", "params": { "path": "redux/hunter/pattern/draggingVine.1", "partType": "core", "chargeTime":"0.5f * select(diffInt, 1.2f, 1f, 0.8f)", "dir":"playerPos - partPos", }},
                { "action": "Wait", "time": "3f * select(diffInt, 1.2f, 1f, 0.8f)" },
            ],},

        { "action": "Condition", "condition": "form2NumTimesMoved % 4 == 3 || form2NumTimesMoved == 9 || form2NumTimesMoved == 21",
            "true": [ { "action": "CallMethod", "method": "SetTargetFacingAngleFunc", "params": { "func": "vecToAngle(vec2(0f, 0f) - unitPos) + (fastSin(stageTime * 2f) * 10f)"}},],
            "false": [ { "action": "CallMethod", "method": "SetTargetFacingAngleFunc", "params": { "func": "vecToAngle(playerPos - unitPos)"}},],},
    ]
},

"form2HitEdge.sub": {
    "actions": [
        { "action": "CallMethod", "method": "SetAccelVectorFunc", "params": { "func": "vec2(0,0)"}},
        { "action": "SetValue", "name": "form2IsRecovering", "value": true },
        { "action": "SetValue", "name": "form2IsAiming", "value": false },
        { "action": "SetValue", "name": "form2IsMoving", "value": false },
        { "action": "SetValue", "name": "form2Timer", "value": 0 },
    ]
},

In form2StartAiming.sub, we use SetAccelVectorFunc to set the acceleration to vec2(0, 0) so the boss doesn't move, and use SetFacingLerpSpeedFunc so that the boss will aim at the player.

Likewise, in form2HitEdge.sub we also set the acceleration to 0 so the boss stops moving, and we set the flag form2IsRecovering to true.