ParaLily Update #4: Improving Level Design with Player Analytics and Heatmaps

ParaLily Update #4: Improving Level Design with Player Analytics and Heatmaps

In our last update we showed a bunch of Player Analytics that we collected from MAGFest including playtime, death variations, levels beaten, etc. Now we’d like to share how we collected, summarized, and utilized all that data to help improve ParaLily.

As a two man team we spend a lot of time not only designing and creating ParaLily, but also playing testing, ParaLily. Unfortunately all that testing really skews our perception of difficulty within the game. In order to combat this we have to let people test our game and see for ourselves where things are too hard or confusing. Fortunately conventions are great place to get feedback! We can watch players firsthand, and see where they get stuck or lost. Even better, we can make sure we don’t miss a beat, by keeping track of our players events in an audit log file.

Logging

The backbone of our analytic system relies heavily on Logging. Specifically Audit Logging, which is a method of recording chronological events. For an everyday example someone might log when they ate an apple. It’s important to note that we don’t care how many apples they ate, we just log each one as a separate entry like so:

ate apple
ate apple
ate apple

Now if we want to know where each apple was eaten, or what kind of specific apple it was, then we just add that to our log entry:

ate apple fuji kitchen
ate apple gala office
ate apple fuji bedroom

Looking at the logs we can see that three apples were eaten. Two of which were fuji, and were eaten at home. So from this bit of data we can assume that at home there are fuji apples and at work there are gala apples.

In ParaLily we apply this same concept where each line in our log file is simply an event that the Player executed. It ends up looking something like this:

04d17h27m00s Game Start 0f4fbaf6-9e9a-4740-a01a-86369f74419e
04d17h27m24s 1-1 Level Start
04d17h27m48s 1-1 r1 81.6,7.7 Glyph
04d17h27m54s 1-1 r1 131.4,1.3 Glyph
04d17h28m00s 1-1 Level Win 35.92s
04d17h28m01s 1-2 Level Start
04d17h28m08s 1-2 r1 -4.8,-5.0 SXB
04d17h28m08s 1-2 r1 -0.3,-24.3 Death Fall
04d17h28m09s 1-2 Level Lose 7.45s
04d17h28m10s 1-2 Level Start
04d17h28m14s 1-2 r1 -25.8,-1.9 SB
04d17h28m31s 1-2 r2 16.6,-3.6 SF
04d17h28m35s 1-2 r1 16.9,-3.2 Glyph
04d17h28m37s 1-2 r1 16.9,-0.1 SB
04d17h28m42s 1-2 r2 33.9,0.5 SF
04d17h28m47s 1-2 r1 63.8,-2.1 SB
04d17h28m53s 1-2 r2 87.9,0.3 SF
04d17h28m54s 1-2 r1 89.9,-3.0 Death Thorn
04d17h28m54s 1-2 Level Lose 44.25s
04d17h28m56s 1-2 Level Start
04d17h29m02s 1-2 r1 -19.2,-0.3 SB
04d17h29m11s 1-2 r2 35.1,-1.1 SF
04d17h29m17s 1-2 r1 65.8,0.0 SB
04d17h29m22s 1-2 r2 71.6,-5.8 SF
04d17h29m23s 1-2 r1 71.6,-5.3 Glyph
04d17h29m25s 1-2 r1 75.9,-3.4 SB
04d17h29m27s 1-2 r2 82.4,-2.6 SF
04d17h29m31s 1-2 r1 93.6,-4.5 SB
04d17h29m32s 1-2 r2 98.2,-20.5 SF
04d17h29m33s 1-2 r1 98.9,-24.1 Death Fall
04d17h29m33s 1-2 Level Lose 37.52s
04d17h29m35s 1-2 Level Start
04d17h29m39s 1-2 r1 -23.6,-1.1 SB
04d17h29m49s 1-2 r2 41.3,-0.6 SF
04d17h30m00s 1-2 r1 101.8,-1.3 SB
04d17h30m06s 1-2 r2 130.2,1.1 SF
04d17h30m10s 1-2 Level Win 35.15s
04d17h30m11s 1-3 Level Start
04d17h30m19s 1-3 r1 19.8,1.8 Glyph
04d17h30m22s 1-3 r1 31.3,0.7 SB
04d17h30m31s 1-3 r2 61.2,-0.3 SB
04d17h30m42s 1-3 r3 90.8,-6.7 Glyph
04d17h30m43s 1-3 r3 94.6,-9.6 SF
04d17h30m47s 1-3 r2 110.6,-2.5 Glyph
04d17h30m52s 1-3 r2 146.5,-1.3 SB
04d17h30m54s 1-3 r3 151.2,-10.9 SF
04d17h30m55s 1-3 r2 152.6,-24.2 Death Fall
04d17h30m55s 1-3 Level Lose 43.77s
04d17h30m57s 1-3 Level Start
04d17h31m06s 1-3 r1 31.4,0.4 SB
04d17h31m13s 1-3 r2 59.8,0.0 SB
04d17h31m21s 1-3 r3 98.0,-12.4 SXF
04d17h31m21s 1-3 r3 98.1,-12.9 Death LavaFlow
04d17h31m21s 1-3 Level Lose 24.46s
04d17h31m23s 1-3 Level Start
04d17h31m32s 1-3 r1 31.3,-0.1 SB
04d17h31m41s 1-3 r2 59.4,-0.5 SB
04d17h31m49s 1-3 r3 95.2,-7.3 SF
04d17h32m02s 1-3 r2 154.0,-24.2 Death Fall
04d17h32m03s 1-3 Level Lose 40.07s
04d17h32m04s 1-3 Level Start
04d17h32m14s 1-3 r1 31.8,0.0 SB
04d17h32m19s 1-3 r2 59.1,-2.0 SF
04d17h32m20s 1-3 r1 59.2,-2.3 SB
04d17h32m22s 1-3 r2 64.4,-0.2 SF
04d17h32m24s 1-3 r1 67.0,-24.3 Death Fall
04d17h32m24s 1-3 Level Lose 20.14s
04d17h32m26s 1-3 Level Start
04d17h32m35s 1-3 r1 30.4,0.4 SB
04d17h32m42s 1-3 r2 60.6,-0.5 SB
04d17h32m49s 1-3 r3 91.5,-7.1 SF
04d17h33m00s 1-3 r2 145.0,-1.8 SF
04d17h33m01s 1-3 r1 146.7,-1.0 SB
04d17h33m02s 1-3 r2 148.8,-1.7 SF
04d17h33m05s 1-3 Level Win 39.18s
04d17h33m06s 1-4 Level Start
04d17h33m13s 1-4 r1 14.4,2.4 Glyph
04d17h33m17s 1-4 r1 34.2,5.0 SB
04d17h33m22s 1-4 r2 49.2,1.6 SB
04d17h33m24s 1-4 r3 49.2,2.0 SF
04d17h33m25s 1-4 r2 49.2,1.6 SF
04d17h33m26s 1-4 r1 49.2,0.4 SXB
04d17h33m31s 1-4 r1 71.5,-24.2 Death Fall
04d17h33m32s 1-4 Level Lose 25.30s
04d17h33m33s 1-4 Level Start
04d17h33m43s 1-4 r1 34.8,4.9 SB
04d17h33m46s 1-4 r2 48.3,1.8 SF
04d17h33m49s 1-4 r1 49.2,-1.2 SXB
04d17h33m50s 1-4 r1 49.2,-1.6 SB
04d17h33m51s 1-4 r2 49.2,-1.6 Glyph
04d17h33m54s 1-4 r2 51.7,-4.4 SF
04d17h33m56s 1-4 r1 55.9,-1.3 SB
04d17h34m00s 1-4 r2 51.5,3.7 SB
04d17h34m24s 1-4 r3 80.2,-5.8 SF
04d17h34m25s 1-4 r2 80.2,-7.0 SF
04d17h34m26s 1-4 r1 80.2,-12.5 SXB
04d17h34m26s 1-4 r1 80.2,-24.2 Death Fall
04d17h34m27s 1-4 Level Lose 53.39s
04d17h34m28s 1-4 Level Start
04d17h34m36s 1-4 r1 29.0,6.0 SB
04d17h34m40s 1-4 r2 53.1,3.5 SB
04d17h34m46s 1-4 r3 79.6,-9.2 SF
04d17h34m49s 1-4 r2 87.7,3.2 SB
04d17h34m51s 1-4 r3 84.3,3.3 Glyph
04d17h34m52s 1-4 r3 88.0,2.6 SF
04d17h34m59s 1-4 r2 134.0,2.6 SF
04d17h35m02s 1-4 Level Win 33.86s
04d17h35m03s 1-5 Level Start
04d17h35m11s 1-5 r1 -78.5,-1.2 Death Bunny
04d17h35m11s 1-5 Level Lose 7.72s
04d17h35m13s 1-5 Level Start
04d17h35m30s 1-5 r1 -60.4,2.3 SB
04d17h35m35s 1-5 r2 -43.6,2.1 SB
04d17h35m36s 1-5 r3 -38.5,-12.0 Death LavaFlow
04d17h35m37s 1-5 Level Lose 24.10s
04d17h35m38s 1-5 Level Start
04d17h35m48s 1-5 r1 -59.9,2.1 SB
04d17h35m52s 1-5 r2 -43.3,2.1 SF
04d17h35m53s 1-5 r1 -41.8,0.5 SB
04d17h35m55s 1-5 r2 -45.5,1.6 SF
04d17h35m56s 1-5 r1 -45.5,1.6 Glyph
04d17h35m56s 1-5 r1 -45.4,1.1 SB
04d17h35m57s 1-5 r2 -44.0,-5.8 SB
04d17h35m58s 1-5 r3 -42.8,-12.1 Death LavaFlow
04d17h35m58s 1-5 Level Lose 20.09s
04d17h36m00s 1-5 Level Start
04d17h36m05s 1-5 r1 -86.8,-3.0 Death Bunny
04d17h36m05s 1-5 Level Lose 5.64s
04d17h36m07s 1-5 Level Start
04d17h36m16s 1-5 r1 -60.7,2.1 SB
04d17h36m19s 1-5 r2 -43.9,2.1 SB
04d17h36m20s 1-5 r3 -42.0,1.4 SF
04d17h36m22s 1-5 r2 -34.1,3.5 SB
04d17h36m23s 1-5 r3 -31.2,3.6 SF
04d17h36m24s 1-5 r2 -29.4,0.6 SF
04d17h36m27s 1-5 r1 -14.2,-1.6 Glyph
04d17h36m28s 1-5 r1 -12.7,-1.6 SB
04d17h36m32s 1-5 r2 1.5,2.3 SF
04d17h36m33s 1-5 r1 6.0,-0.7 SB
04d17h36m34s 1-5 r2 7.1,-3.6 SB
04d17h36m35s 1-5 r3 7.5,-5.5 Death SkullSpider
04d17h36m35s 1-5 Level Lose 28.16s
04d17h36m37s 1-5 Level Start
04d17h36m46s 1-5 r1 -60.0,2.1 SB
04d17h36m49s 1-5 r2 -42.8,1.7 SB
04d17h36m50s 1-5 r3 -40.7,-1.5 SXF
04d17h36m50s 1-5 r3 -37.7,-12.1 Death LavaFlow
04d17h36m50s 1-5 Level Lose 13.77s
04d17h36m52s 1-5 Level Start
04d17h37m01s 1-5 r1 -59.1,1.7 SB
04d17h37m05s 1-5 r2 -34.9,3.4 SF
04d17h37m08s 1-5 r1 -14.7,-1.1 SB
04d17h37m12s 1-5 r2 1.1,2.3 SB
04d17h37m14s 1-5 r3 6.6,-5.6 Death SkullSpider
04d17h37m14s 1-5 Level Lose 22.10s
04d17h37m15s 1-5 Level Start
04d17h37m24s 1-5 r1 -59.8,1.7 SB
04d17h37m28s 1-5 r2 -36.3,2.7 SXF
04d17h37m29s 1-5 r2 -31.8,-1.3 SF
04d17h37m32s 1-5 r1 -14.0,-1.4 SB
04d17h37m36s 1-5 r2 8.1,-0.6 SB
04d17h37m37s 1-5 r3 9.7,-6.0 Death SkullSpider
04d17h37m37s 1-5 Level Lose 22.04s
04d17h37m39s 1-5 Level Start
04d17h37m48s 1-5 r1 -58.7,1.4 SB
04d17h37m52s 1-5 r2 -36.3,2.6 SXF
04d17h37m52s 1-5 r2 -32.8,-0.6 SF
04d17h37m56s 1-5 r1 -14.0,-1.1 SB
04d17h37m59s 1-5 r2 3.0,4.0 SF
04d17h38m00s 1-5 r1 4.1,4.2 SB
04d17h38m01s 1-5 r2 7.1,1.5 SB
04d17h38m02s 1-5 r3 9.7,-5.8 Death SkullSpider
04d17h38m02s 1-5 Level Lose 23.44s
04d17h38m04s 1-5 Level Start
04d17h38m13s 1-5 r1 -59.6,1.9 SB
04d17h38m17s 1-5 r2 -34.6,3.4 SF
04d17h38m20s 1-5 r1 -14.1,-1.0 SB
04d17h38m23s 1-5 r2 0.3,3.5 SB
04d17h38m24s 1-5 r3 1.8,4.0 SF
04d17h38m25s 1-5 r2 3.1,3.6 SB
04d17h38m26s 1-5 r3 7.3,-5.8 Death SkullSpider
04d17h38m27s 1-5 Level Lose 22.79s
04d17h38m28s 1-5 Level Start
04d17h38m37s 1-5 r1 -61.9,1.5 SB
04d17h38m41s 1-5 r2 -35.9,3.2 SXF
04d17h38m42s 1-5 r2 -30.9,-1.8 SF
04d17h38m45s 1-5 r1 -14.0,-1.1 SB
04d17h38m49s 1-5 r2 2.2,4.1 SB
04d17h38m53s 1-5 r3 17.6,-0.9 SF
04d17h38m55s 1-5 r2 22.8,0.0 SF
04d17h38m56s 1-5 r1 20.9,-24.3 Death Fall
04d17h38m57s 1-5 Level Lose 28.61s
04d17h38m58s 1-5 Level Start
04d17h39m07s 1-5 r1 -58.6,1.4 SB
04d17h39m12s 1-5 r2 -33.3,2.0 SF
04d17h39m15s 1-5 r1 -15.5,-1.4 SB
04d17h39m18s 1-5 r2 0.5,-0.3 SF
04d17h39m19s 1-5 r1 2.8,2.3 SB
04d17h39m21s 1-5 r2 6.0,-3.4 SB
04d17h39m22s 1-5 r3 4.8,-6.2 Death SkullSpider
04d17h39m22s 1-5 Level Lose 24.21s
04d17h39m24s 1-5 Level Start
04d17h39m33s 1-5 r1 -59.2,1.8 SB
04d17h39m37s 1-5 r2 -33.7,1.6 SF
04d17h39m41s 1-5 r1 -13.5,-1.1 SB
04d17h39m44s 1-5 r2 0.0,2.9 SF
04d17h39m45s 1-5 r1 1.0,3.8 SB
04d17h39m46s 1-5 r2 3.8,-5.2 SB
04d17h39m50s 1-5 r3 16.0,-1.6 SF
04d17h39m52s 1-5 r2 20.9,3.1 SB
04d17h39m55s 1-5 r3 30.1,2.9 SF
04d17h39m57s 1-5 r2 34.5,3.7 SB
04d17h39m59s 1-5 r3 40.3,3.7 SF
04d17h40m04s 1-5 r2 38.1,8.3 SB
04d17h40m06s 1-5 r3 38.2,-5.4 SF
04d17h40m07s 1-5 r2 38.8,-6.2 SB
04d17h40m09s 1-5 r3 38.5,-7.5 SF
04d17h40m10s 1-5 r2 37.8,-6.3 Glyph
04d17h40m11s 1-5 r2 37.7,-3.3 SF
04d17h40m12s 1-5 r1 37.8,-0.8 SB
04d17h40m16s 1-5 r2 56.0,3.2 SB
04d17h40m17s 1-5 r3 59.1,4.6 SF
04d17h40m19s 1-5 r2 62.1,1.1 SF
04d17h40m25s 1-5 r1 99.8,-24.3 Death Fall
04d17h40m25s 1-5 Level Lose 60.89s
04d17h40m26s 1-5 Level Start
04d17h40m35s 1-5 r1 -59.3,1.8 SB
04d17h40m40s 1-5 r2 -35.4,3.1 SF
04d17h40m43s 1-5 r1 -15.4,-1.2 SB
04d17h40m46s 1-5 r2 0.2,1.6 SB
04d17h40m49s 1-5 r3 4.0,-5.8 Death SkullSpider
04d17h40m49s 1-5 Level Lose 22.35s
04d17h40m50s 1-5 Level Start
04d17h40m59s 1-5 r1 -59.8,2.1 SB
04d17h41m03s 1-5 r2 -35.5,3.1 SF
04d17h41m07s 1-5 r1 -14.5,-1.0 SB
04d17h41m10s 1-5 r2 -2.1,2.9 SF
04d17h41m11s 1-5 r1 3.0,-2.0 SB
04d17h41m12s 1-5 r2 3.7,-14.2 SB
04d17h41m13s 1-5 r3 3.7,-14.2 Death LavaFlow
04d17h41m13s 1-5 Level Lose 23.19s
04d17h41m15s 1-5 Level Start
04d17h41m24s 1-5 r1 -59.4,1.8 SB
04d17h41m28s 1-5 r2 -33.3,-0.6 SF
04d17h41m32s 1-5 r1 -13.6,-1.2 SB
04d17h41m35s 1-5 r2 0.7,2.3 SB
04d17h41m40s 1-5 r3 18.0,-3.3 SF
04d17h41m42s 1-5 r2 20.5,3.4 SB
04d17h41m44s 1-5 r3 27.0,3.6 SF
04d17h41m50s 1-5 r2 56.4,1.8 SF
04d17h42m00s 1-5 Level Win 45.15s
04d17h42m02s 1-6 Level Start
04d17h42m14s 1-6 r1 34.6,-3.5 Death Bunny
04d17h42m14s 1-6 Level Lose 11.95s
04d17h42m16s 1-6 Level Start
04d17h42m29s 1-6 r1 49.8,-2.0 SB
04d17h42m31s 1-6 r2 46.9,1.6 Death Creep
04d17h42m31s 1-6 Level Lose 15.42s
04d17h42m33s 1-6 Level Start
04d17h42m44s 1-6 r1 35.5,2.8 SB
04d17h42m51s 1-6 r2 76.2,0.8 SB
04d17h42m55s 1-6 r3 98.4,1.3 SF
04d17h42m59s 1-6 r2 120.3,-8.4 SB
04d17h43m00s 1-6 r3 120.7,-10.0 SXF
04d17h43m00s 1-6 r3 120.9,-12.1 Death LavaFlow
04d17h43m01s 1-6 Level Lose 27.53s
04d17h43m02s 1-6 Level Start
04d17h43m14s 1-6 r1 35.5,2.8 SB
04d17h43m21s 1-6 r2 76.8,1.0 SB
04d17h43m25s 1-6 r3 96.4,0.9 SF
04d17h43m31s 1-6 r2 137.1,0.5 SF
04d17h43m33s 1-6 r1 152.8,0.5 SB
04d17h43m35s 1-6 r2 160.0,0.4 SB
04d17h43m38s 1-6 r3 175.8,0.2 SF
04d17h43m42s 1-6 r2 197.3,1.1 SB
04d17h43m44s 1-6 r3 211.2,-2.7 SF
04d17h43m46s 1-6 r2 220.6,1.2 SB
04d17h43m52s 1-6 r3 250.8,2.7 Death LavaPool
04d17h43m52s 1-6 Level Lose 49.95s
04d17h43m54s 1-6 Level Start
04d17h44m06s 1-6 r1 36.4,2.8 SB
04d17h44m12s 1-6 r2 76.2,1.1 SB
04d17h44m16s 1-6 r3 96.5,0.9 SF
04d17h44m22s 1-6 r2 135.2,2.5 SF
04d17h44m25s 1-6 r1 152.9,0.7 SB
04d17h44m27s 1-6 r2 159.1,1.0 SB
04d17h44m30s 1-6 r3 175.5,1.4 SF
04d17h44m33s 1-6 r2 197.8,1.1 SB
04d17h44m36s 1-6 r3 211.5,-2.5 SF
04d17h44m38s 1-6 r2 221.8,2.8 SB
04d17h44m41s 1-6 r3 237.5,-3.0 Death SkullSpider
04d17h44m42s 1-6 Level Lose 47.46s
04d17h44m43s 1-6 Level Start
04d17h44m55s 1-6 r1 36.8,2.8 SB
04d17h45m02s 1-6 r2 76.6,1.0 SB
04d17h45m05s 1-6 r3 96.6,1.5 SF
04d17h45m12s 1-6 r2 136.3,2.6 SF
04d17h45m15s 1-6 r1 153.6,0.7 SB
04d17h45m17s 1-6 r2 161.1,1.3 SB
04d17h45m20s 1-6 r3 175.3,1.6 SF
04d17h45m23s 1-6 r2 197.1,1.0 SB
04d17h45m26s 1-6 r3 212.5,-3.0 SF
04d17h45m27s 1-6 r2 218.5,0.9 SB
04d17h45m32s 1-6 r3 231.1,-2.1 Death Creep
04d17h45m32s 1-6 Level Lose 48.82s
04d17h45m34s 1-6 Level Start
04d17h45m46s 1-6 r1 37.1,2.7 SB
04d17h45m52s 1-6 r2 76.3,1.0 SB
04d17h45m55s 1-6 r3 98.1,1.7 SF
04d17h46m01s 1-6 r2 135.6,2.6 SF
04d17h46m04s 1-6 r1 151.5,0.1 SB
04d17h46m06s 1-6 r2 160.4,1.3 SB
04d17h46m08s 1-6 r3 172.9,2.8 SF
04d17h46m12s 1-6 r2 197.5,0.9 SB
04d17h46m15s 1-6 r3 210.6,-2.4 SF
04d17h46m17s 1-6 r2 221.9,2.0 SB
04d17h46m25s 1-6 r3 254.8,5.2 Glyph
04d17h46m31s 1-6 r3 291.6,-5.6 SF
04d17h46m35s 1-6 r2 316.3,-2.3 SF
04d17h46m38s 1-6 Level Win 63.54s
04d17h46m38s Game End 1177.96s 0f4fbaf6-9e9a-4740-a01a-86369f74419e

So what’s going on in all this mess? Just like in the apple scenario a space is our delimiter for each column. So our columns are roughly:

DATE/TIME LEVEL ROW PLAYER_POSITION EVENT

Date and time are great to have because it let’s us see just how long it took players to go from one place to another. Then in ParaLily we not only have levels like world 1 level 1, but also Universes or Rows as we call them, which are r1, r2, and r3. Logging the Players exact position gives us a lot of opportunities like generating heatmaps. Lastly, we log the event itself: Death, Shift Forward, Shift Back, or Glyph Collected.

We also have special scenarios like the game starting or ending, a level starting or ending. Timestamps for key things like when the game ends, in this case they beat it after 1177.96s. A Unique Identifier is generated per play-through, and helps us find specific logs if something crazy happens in game.

Then when the next player comes along we create a new log file. So each log file itself is a single play-through. Our folder by the end of a convention:

For developers looking to setup their own loggers it’s pretty straightforward. My biggest tip would be to make sure you are not re-opening the log file every time you write to it. Here’s the code we used for our Audit Logger:

using System;
using System.IO;
using System.Text;
using UnityEngine;

public static class Logger
{
	private static readonly string[] Row = new string[] { " r1 ", " r2 ", " r3 " };

	private static readonly string LogDir = Path.Combine(Application.persistentDataPath, "Logs/");
	private static readonly string FileName = "_ParaLily_audit.log";
	private static readonly string FileDateTimeFormat = "yyyy-MM-dd'T'HH'h'mm'm'ss's'";
	private static readonly string LogDateTimeFormat = "dd'd'HH'h'mm'm'ss's'";

	public static Guid Uid { get; set; }

	private static string auditLogPath;
	private static float gameStart;
	private static float playStart;
	private static StreamWriter logWriter;

	static Logger()
	{
		SetNewGame();
		if (!Directory.Exists(LogDir))
		{
			Directory.CreateDirectory(LogDir);
		}
	}

	public static void AuditGame()
	{
		SetNewGame();
		StringBuilder sb = new StringBuilder();
		sb.Append(DateTime.Now.ToString(LogDateTimeFormat)).Append(" Game Start ").Append(Uid.ToString());
		WriteLog(sb.ToString());
	}

	public static void AuditGame(string text)
	{
		StringBuilder sb = new StringBuilder();
		sb.Append(DateTime.Now.ToString(LogDateTimeFormat)).Append(" Game ").Append(text).Append(" ")
		  .Append(GameDurationInSecondsToString()).Append(" ").Append(Uid.ToString());
		WriteLog(sb.ToString());
	}

	public static void AuditLevelStart()
	{
		StringBuilder sb = new StringBuilder();
		playStart = Time.time;
		sb.Append(DateTime.Now.ToString(LogDateTimeFormat)).Append(" ").Append(GameManager.instance.CurrentSceneName).Append(" Level Start");
		WriteLog(sb.ToString());
	}

	public static void AuditLevel(string text)
	{
		StringBuilder sb = new StringBuilder();
		sb.Append(DateTime.Now.ToString(LogDateTimeFormat)).Append(" ").Append(GameManager.instance.CurrentSceneName).Append(" Level ").Append(text).Append(" ").Append(PlayDurationInSecondsToString());
		WriteLog(sb.ToString());
	}

	public static void AuditFull(string text)
	{
		StringBuilder sb = new StringBuilder();
		sb.Append(DateTime.Now.ToString(LogDateTimeFormat)).Append(" ").Append(GameManager.instance.CurrentSceneName).Append(Row[RowManager.instance.CurrentParallel])
		  .Append(PlayerController.instance.transform.position.ToStringXY()).Append(" ").Append(text);
		WriteLog(sb.ToString());
	}

	private static void WriteLog(string text)
	{
		logWriter.WriteLine(text);
		logWriter.Flush();
	}

	public static float GameDurationInSeconds()
	{
		return Time.time - gameStart;
	}

	public static float PlayDurationInSeconds()
	{
		return Time.time - playStart;
	}

	private static string GameDurationInSecondsToString()
	{
		return GameDurationInSeconds().ToString("0.00s");
	}

	private static string PlayDurationInSecondsToString()
	{
		return PlayDurationInSeconds().ToString("0.00s");
	}

	private static void SetNewGame()
	{
		Uid = Guid.NewGuid();
		gameStart = Time.time;
		auditLogPath = Path.Combine(LogDir, DateTime.Now.ToString(FileDateTimeFormat) + FileName);
		Dispose();
		logWriter = new StreamWriter(auditLogPath);
		logWriter.AutoFlush = false;
	}

	public static void Dispose()
	{
		logWriter?.Dispose();
	}
}

Summarizing with Shell Scripting

So what do we do with all of this data? There’s hundreds of lines per file and hundreds of files. It would take forever to sift through and tally all this up by hand. Fear not! Scripting is here to save us from all that misery. To get all the important bits of data we want, we created a shell script to do all the heavy lifting, and generate a clean summarized output of all that player data. After it runs it leaves us with a folder with all of these files:

The files each look similar to this:

Game Start 109
Game End 7
Game Timeout 59
Game Reset 27
Level Start 1707
Level Win 191
Level Lose 1427
SB 2893
SF 1996
SXB 322
SXF 360
Glyph 576
Death 1427
Death Thorn 373
Death Fall 790
Death SkullSpider 57
Death LavaPool 24
Death LavaFlow 116
Death Creep 12
Death Bunny 16
Death Quicksand 39

*Data collected from Cecil Con

And here’s the Shell Script we developed to do all the summarizing:

#!/bin/bash
function date_time_format {
  date "+%Y-%m-%d"
}

SUMMARY_DIR="summary_$(date_time_format)"

LEVELS=( "1-1" "1-2" "1-3" "1-4" "1-5" "1-6" "1-7" "1-8" )
ROWS=( "r1" "r2" "r3" )
DAYS=( "14d" ) # Days the event ran 
HOURS=( "10h" "11h" "12h" "13h" "14h" "15h" "16h" "17h" )
#HOURS=( "00h" "01h" "02h" "03h" "04h" "05h" "06h" "07h" "08h" "09h" "10h" "11h" "12h" "13h" "14h" "15h" "16h" "17h" "18h" "19h" "20h" "21h" "22h" "23h" )

KEYWORDS_BY_LEVEL_BY_ROW=( "SB" "SF" "SXB" "SXF" "Glyph" "Death" )
KEYWORDS_BY_LEVEL=( "Level Start" "Level Win" "Level Lose" "Level Reset" "Level Timeout" "SB" "SF" "SXB" "SXF" "Glyph" "Death" "Death Thorn" "Death Fall" "Death SkullSpider" "Death LavaPool" "Death LavaFlow" "Death Creep" "Death Bunny" "Death Quicksand" )
KEYWORDS_BY_ROW=( "SB" "SF" "SXB" "SXF" "Glyph" "Death" )
KEYWORDS_OVERALL=( "Game Start" "Game End" "Game Timeout" "Game Reset" "Level Start" "Level Win" "Level Lose" "SB" "SF" "SXB" "SXF" "Glyph" "Death" "Death Thorn" "Death Fall" "Death SkullSpider" "Death LavaPool" "Death LavaFlow" "Death Creep" "Death Bunny" "Death Quicksand" )
KEYWORDS_BY_COORDINATE=( "SB" "SF" "SXB" "SXF" "Glyph" "Death" )
KEYWORDS_BY_HOUR=( "Game Start" "Game End" "Game Timeout" "Game Reset" "Level Start" "Level Win" "Level Lose" "SB" "SF" "SXB" "SXF" "Glyph" "Death" "Death Thorn" "Death Fall" "Death SkullSpider" "Death LavaPool" "Death LavaFlow" "Death Creep" "Death Bunny" "Death Quicksand" )

cd '.\Logs'
mkdir $SUMMARY_DIR

echo "Building Analytic Summary to $SUMMARY_DIR..."

#--Totals by Level by Row Summary--
TOTALS_BY_LEVEL_BY_ROW_FILE="$SUMMARY_DIR\\totals_by_level_by_row.txt"
echo "Generating File $TOTALS_BY_LEVEL_BY_ROW_FILE..."
for (( i=0; i<${#LEVELS[@]}; i++ ))
do
  for (( k=0; k<${#ROWS[@]}; k++ ))
  do
    for (( j=0; j<${#KEYWORDS_BY_LEVEL_BY_ROW[@]}; j++ ))
    do
      COUNT[$j]=$(grep "${LEVELS[$i]}.*${ROWS[$k]}.*${KEYWORDS_BY_LEVEL_BY_ROW[$j]}" *audit* | wc -l )
      echo "${LEVELS[$i]} ${ROWS[$k]} ${KEYWORDS_BY_LEVEL_BY_ROW[$j]} ${COUNT[$j]}" | tee -a $TOTALS_BY_LEVEL_BY_ROW_FILE
    done
  done
done

#--Totals by Row Summary--
TOTALS_BY_ROW_FILE="$SUMMARY_DIR\\totals_by_row.txt"
echo "Generating File $TOTALS_BY_ROW_FILE..."
for (( i=0; i<${#ROWS[@]}; i++ ))
do
  for (( k=0; k<${#KEYWORDS_BY_ROW[@]}; k++ ))
  do
    COUNT[$k]=$(grep "${ROWS[$i]}.*${KEYWORDS_BY_ROW[$k]}" *audit* | wc -l )
    echo "${ROWS[$i]} ${KEYWORDS_BY_ROW[$k]} ${COUNT[$k]}" | tee -a $TOTALS_BY_ROW_FILE
  done
done

#--Totals by Level Summary--
TOTALS_BY_LEVEL_FILE="$SUMMARY_DIR\\totals_by_level.txt"
echo "Generating File $TOTALS_BY_LEVEL_FILE..."
for (( i=0; i<${#LEVELS[@]}; i++ ))
do
  for (( k=0; k<${#KEYWORDS_BY_LEVEL[@]}; k++ ))
  do
    COUNT[$k]=$(grep "${LEVELS[$i]}.*${KEYWORDS_BY_LEVEL[$k]}" *audit* | wc -l )
    echo "${LEVELS[$i]} ${KEYWORDS_BY_LEVEL[$k]} ${COUNT[$k]}" | tee -a $TOTALS_BY_LEVEL_FILE
  done
done

#--Totals Overall Summary--
TOTALS_FILE="$SUMMARY_DIR\\totals_overall.txt"
echo "Generating File $TOTALS_FILE..."
for (( i=0; i<${#KEYWORDS_OVERALL[@]}; i++ ))
do
  COUNT[$i]=$(grep "${KEYWORDS_OVERALL[$i]}" *audit* | wc -l )
  echo "${KEYWORDS_OVERALL[$i]} ${COUNT[$i]}" | tee -a $TOTALS_FILE
done

#--Coordinate Summary--
echo "Generating Coordinate Files..."
for (( i=0; i<${#LEVELS[@]}; i++ ))
do
  coordinate_file="$SUMMARY_DIR\\${LEVELS[$i]}_coordinates.txt"
  for (( k=0; k<${#KEYWORDS_BY_COORDINATE[@]}; k++ ))
  do
    for (( j=0; j<${#ROWS[@]}; j++ ))
    do
      grep "${LEVELS[$i]} ${ROWS[j]}.*${KEYWORDS_BY_COORDINATE[$k]}" *audit* | awk '{print $5,$3,$4;}' >> "$coordinate_file"
    done
  done
  echo "Generated $coordinate_file."
done

#--Hourly Totals Summary--
HOURLY_FILE="$SUMMARY_DIR\\totals_by_day_by_hour.txt"
echo "Generating File $HOURLY_FILE..."
for (( i=0; i<${#DAYS[@]}; i++))
do
  for (( k=0; k<${#HOURS[@]}; k++ ))
  do
    for (( j=0; j<${#KEYWORDS_BY_HOUR[@]}; j++ ))
    do
      count=$(grep "${DAYS[$i]}${HOURS[$k]}.*${KEYWORDS_BY_HOUR[$j]}" *audit* | wc -l)
      echo "${DAYS[$i]}${HOURS[$k]} ${KEYWORDS_BY_HOUR[$j]} $count" | tee -a "$HOURLY_FILE"
    done
    # Game time by Day by Hour
    hourly_game_time=$(grep "${DAYS[$i]}${HOURS[$k]}.*Game End\|${DAYS[$i]}${HOURS[$k]}.*Game Timeout\|${DAYS[$i]}${HOURS[$k]}.*Game Reset" *audit* | sed 's/s//g' | awk '{sum += $4} END {print sum}')
    echo "${DAYS[$i]}${HOURS[$k]} Playtime $hourly_game_time" | tee -a "$HOURLY_FILE"
  done
done

#--Playtime Summary--
PLAYTIME_FILE="$SUMMARY_DIR\\playtime.txt"
echo "Generating File $PLAYTIME_FILE..."
for (( i=0; i<${#LEVELS[@]}; i++ ))
do
  # Playtime by Level
  plays=$(grep "${LEVELS[$i]}.*Level Start" *audit* | wc -l)
  playtime=$(grep "${LEVELS[$i]}.*Level" *audit* | sed 's/s//g' | awk '{sum += $5} END {print sum}')
  average_playtime=$(awk -v x="$playtime" -v y="$plays" 'BEGIN {print x / y}')
  echo "${LEVELS[$i]} Total Plays $plays" | tee -a $PLAYTIME_FILE
  echo "${LEVELS[$i]} Total Playtime $playtime" | tee -a $PLAYTIME_FILE
  echo "${LEVELS[$i]} Average Playtime $average_playtime" | tee -a $PLAYTIME_FILE
done
total_games=$(grep "Game Start" *audit* | wc -l)
total_game_time=$(grep "Game End\|Game Timeout\|Game Reset" *audit* | sed 's/s//g' | awk '{sum += $4} END {print sum}')
average_game_time=$(awk -v x="$total_game_time" -v y="$total_games" 'BEGIN {print x / y}')
echo "Total Games $total_games" | tee -a $PLAYTIME_FILE
echo "Total Game Time $total_game_time" | tee -a $PLAYTIME_FILE
echo "Average Game Time $average_game_time" | tee -a $PLAYTIME_FILE

mv $SUMMARY_DIR $USERPROFILE/Desktop
echo "Analytic Summary Completed."

sleep 10

The script running in action with data from MAGFest:

Take a minute or two to figure out what’s going on. Essentially we are looping through various arrays that contain the keywords found in our logs files, and use grep along with wc (word count) to tally up our totals. Then we output the totals to corresponding text files. Using this method we can tally up just about anything that we can key off of. So when we said that Logging is the backbone of our analytics, it really is! Without properly formatted log files this summary script would have been much harder to develop.

Utilizing the Analytics in Unity

Out of all the data we collected and summarized, what we find to be the most useful is the coordinate data. Meaning where the Player did the thing. While the totals and times are great for a quick glance as to how players are performing; the coordinate data is what gives us that extra bit of detail as to what exactly needs to be changed. Our Shell Script generates a coordinate file per level, and they look like this:

SB r1 29.4,6.9
SB r1 28.0,5.5
SB r1 19.2,2.0
SB r1 98.8,-18.2
SB r1 28.1,4.3
SB r1 27.5,6.3
SB r1 28.3,6.7
SB r1 28.3,6.7
SB r1 49.3,-4.3
SB r1 69.5,-4.8
SB r1 29.2,6.9
SB r1 33.9,5.3
SB r1 49.0,-4.3
SB r1 49.0,-1.9
SB r1 55.1,-1.3
SB r1 28.8,3.5
SB r1 37.4,5.5
SB r1 30.0,4.6
SB r1 24.8,3.4
SB r1 24.5,3.3
SF r2 50.7,3.3
SF r2 52.6,-2.9
SF r2 132.9,2.3
SF r2 53.0,0.0
SF r2 51.4,-4.3
SF r2 55.0,-3.6
SF r2 48.2,1.8
SF r2 49.6,1.6
SF r2 49.6,-4.0
SF r2 135.0,0.8
SXB r1 95.2,-9.2
SXB r2 55.5,-4.0
SXB r2 54.8,-2.7
SXB r2 85.4,-1.8
SXB r2 48.5,1.7
SXB r2 49.9,-1.4
SXB r2 60.2,-6.1
SXB r2 47.8,1.8
SXB r2 47.8,1.8
SXB r2 47.8,1.8
SXB r2 47.8,1.8
SXF r2 27.0,-4.6
SXF r2 26.5,-1.3
Glyph r1 12.4,1.6
Glyph r1 12.6,2.1
Glyph r1 12.6,2.5
Glyph r1 12.2,1.6
Glyph r1 12.5,2.0
Glyph r1 12.3,1.6
Glyph r1 12.4,1.0
Glyph r1 12.6,2.4
Glyph r1 12.7,2.6
Glyph r1 12.5,2.2
Glyph r1 12.4,2.4
Glyph r1 12.7,2.5
Glyph r1 12.4,2.3
Glyph r1 12.5,2.1
Glyph r1 12.6,2.3
Glyph r1 12.3,2.0
Glyph r1 12.4,2.4
Glyph r1 12.5,2.1
Glyph r1 12.3,2.0
Glyph r1 12.5,2.7
Glyph r1 12.8,2.1
Glyph r1 12.4,2.2
Glyph r1 12.4,2.2
Glyph r1 12.4,1.9
Glyph r1 12.3,1.8
Glyph r1 12.5,2.3
Glyph r1 12.8,2.6
Glyph r1 12.4,2.1
Glyph r1 12.3,2.2
Glyph r2 26.9,-4.0
Glyph r2 50.1,-1.8
Glyph r2 49.4,-4.0
Glyph r2 49.0,-4.0
Glyph r2 49.1,-3.8
Glyph r2 26.5,-4.0
Glyph r2 49.1,-3.8
Glyph r2 49.2,-1.2
Glyph r2 49.6,-1.4
Glyph r2 49.6,-2.3
Glyph r2 48.7,-4.0
Glyph r2 49.9,-2.2
Glyph r2 48.9,-1.9
Glyph r2 49.3,-2.2
Glyph r2 49.6,-1.4
Glyph r2 49.5,-1.7
Glyph r2 49.7,-1.3
Glyph r2 26.1,-4.6
Glyph r2 49.4,-4.0
Glyph r2 49.4,-4.0
Glyph r2 49.6,-1.0
Glyph r2 50.2,-3.4
Glyph r2 48.7,-4.0
Glyph r2 49.8,-4.0
Death r1 12.5,-2.2
Death r1 5.6,-2.4
Death r1 42.5,-24.2
Death r1 97.0,-24.1
Death r1 15.8,-2.4
Death r1 2.6,-1.9
Death r1 73.9,-24.0
Death r1 20.4,-1.4
Death r1 1.9,-2.0
Death r1 12.5,-2.2
Death r1 4.5,-2.5
Death r1 90.2,-23.9
Death r1 7.4,-2.3
Death r1 62.7,-24.2
Death r1 19.8,-1.9
Death r1 20.4,-1.4
Death r1 2.6,-1.9
Death r1 11.0,-2.3
Death r1 14.7,-2.0
Death r1 20.4,-1.4
Death r1 15.8,-2.4
Death r1 100.8,-23.9
Death r1 12.5,-2.2
Death r1 15.2,-2.0
Death r1 12.5,-2.2
Death r1 61.2,-24.1
Death r1 5.8,-2.3
Death r1 127.5,-24.2
Death r1 12.5,-2.2
Death r1 2.5,-1.9
Death r1 20.4,-1.4
Death r1 20.4,-1.4
Death r1 20.4,-1.4
Death r1 23.3,-1.0
Death r1 155.9,-24.2
Death r1 20.4,-1.4
Death r1 21.4,-1.0
Death r1 7.4,-2.3
Death r1 7.4,-2.3
Death r1 13.6,-2.4
Death r1 141.1,-24.1
Death r1 20.4,-1.4
Death r1 20.4,-1.4
Death r1 12.5,-2.2
Death r1 12.5,-2.2
Death r1 2.6,-1.9
Death r1 7.4,-2.3
Death r1 47.7,-24.2
Death r1 12.5,-2.2
Death r1 2.6,-1.9
Death r1 22.6,-1.1
Death r1 45.5,-24.0
Death r1 20.4,-1.4

Then we bring all these coordinate files into Unity under a Resources -> Editor folder

Next we use our custom made “Heatmap” tool, that’s actually more of a Density Scatterplot, that will plot each piece of data for the corresponding level, parenting it to the appropriate row, and giving it the correct icon for the event.

In this Screenshot you can see how it’s definitely a Scatterplot and we call it a Density Scatterplot because we reduced the transparency for each icon. So they get brighter when there’s more stacked. This helps us detect the events of many as opposed to a stray event from a single person or two.

Notice this is plotting all the events for all our different Universes, which can get confusing quick. Luckily, our Script is setup to create the icons in a parenting structure that easily allows us to toggle the events we want to look at on/off.

Now we are just showing Deaths that occurred in the first universe aka r1. In our Hierarchy it looks like this:

It’s great reassurance when we come across an odd or challenging section of a level, and can toggle on the heatmap to see how other players have been performing in the same spot.

To make all this work and have the right icons go the right points, we developed two C# Scripts. The first is used to store the icon, row, and coordinate for each plot point, and the second is an EditorWindow that can load/unload the heatmap on the fly whether we’re in the editor or the game view:

using System;
using UnityEngine;

namespace Heatmap
{
	public class HeatmapData
	{
		public HeatmapIcon Icon { get; private set; }
		public int Row { get; private set; }
		public Vector2 Coordinate { get; private set; }

		public HeatmapData(string data)
		{
			string[] fields = data.Split(' ');
			Icon = (HeatmapIcon) Enum.Parse(typeof(HeatmapIcon), "Heatmap_" + fields[0]);
			Row = int.Parse(fields[1].Remove(0, 1));
			string[] coordinates = fields[2].Split(',');
			Coordinate = new Vector2(float.Parse(coordinates[0]), float.Parse(coordinates[1]));
		}
	}
}
using EditorExtension;
using System;
using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;

namespace Heatmap
{
	public enum HeatmapIcon { Heatmap_Death, Heatmap_SF, Heatmap_SB, Heatmap_SXF, Heatmap_SXB, Heatmap_Glyph };

	public class HeatmapManager : EditorWindow
	{
		private const string LoadDir = "Tools/Load Heatmap %h";
		private const string UnloadDir = "Tools/Unload Heatmap %#h";
		private const string FileLocation = "Editor/Coordinates/TooManyGames/";
		private const string FileLabel = "_coordinates";

		private const string RowName = "Row_";
		private const string HeatmapRowName = "Heatmap_Row_";
		private const int RowCount = 3;
		private const int DirectoryGroup = 101;

		private static Transform[][] heatmapRows = new Transform[RowCount][];

		[MenuItem(LoadDir, false, 101)]
		public static void LoadHeatmap()
		{
			Debug.Log("Loading Heatmap...");
			// Load all the icons from Resources folder
			int iconCount = Enum.GetNames(typeof(HeatmapIcon)).Length;
			GameObject[] icons = new GameObject[iconCount];

			for (int i = 0; i < icons.Length; i++)
			{
				icons[i] = (GameObject) Resources.Load("Heatmap Prefabs/" + ((HeatmapIcon) i).ToString(), typeof(GameObject));
			}

			string currentSceneName = SceneManager.GetActiveScene().name;
			Debug.Log(FileLocation + currentSceneName);
			HeatmapData[] heatmapData = LoadHeatmapData(((TextAsset) Resources.Load(FileLocation + currentSceneName + FileLabel, typeof(TextAsset))).text.TrimEnd().Split(new string[] { "\n" }, StringSplitOptions.None));

			CreateHeatmapRowObjects(iconCount);

			for (int i = 0; i < heatmapData.Length; i++)
			{
				GameObject icon = Instantiate(icons[(int) heatmapData[i].Icon], heatmapRows[heatmapData[i].Row - 1][(int) heatmapData[i].Icon + 1]);
				icon.transform.position = heatmapData[i].Coordinate;
			}
		}

		[MenuItem(UnloadDir, false, DirectoryGroup + 1)]
		public static void UnloadHeatmap()
		{
			Debug.Log("Unloading Heatmap...");
			Transform rowManager = GetRowManager();
			for (int i = 1; i <= RowCount; i++)
			{
				Transform row = rowManager.Find(RowName + i);
				if (row != null)
				{
					DestroyImmediate(row.Find(HeatmapRowName + i).gameObject);
				}
			}
		}

		private static HeatmapData[] LoadHeatmapData(string[] lines)
		{
			HeatmapData[] heatmapData = new HeatmapData[lines.Length];
			for (int i = 0; i < lines.Length; i++)
			{
				heatmapData[i] = new HeatmapData(lines[i]);
			}
			return heatmapData;
		}

		private static void CreateHeatmapRowObjects(int length)
		{
			Transform rowManager = GetRowManager();
			// +1 for the Heatmap_Row_i parent
			length += 1;
			for (int row = 0; row < heatmapRows.Length; row++)
			{
				Transform rowTrans = rowManager.Find(RowName + (row + 1));
				if (rowTrans != null)
				{
					CreateHeatmapIconObjects(length, row, rowTrans);
				}
			}
		}

		private static void CreateHeatmapIconObjects(int length, int row, Transform rowTrans)
		{
			heatmapRows[row] = new Transform[length];
			for (int k = 0; k < heatmapRows[row].Length; k++)
			{
				if (k == 0)
				{
					heatmapRows[row][k] = new GameObject(HeatmapRowName + (row + 1)).transform;
					heatmapRows[row][k].parent = rowTrans;
				}
				else
				{
					heatmapRows[row][k] = new GameObject(((HeatmapIcon) k - 1).ToString()).transform;
					heatmapRows[row][k].parent = heatmapRows[row][0];
				}
			}
		}

		private static Transform GetRowManager()
		{
			return GameObject.Find("RowManager_" + SceneManager.GetActiveScene().name).transform;
		}
	}
}

This second script handles the bulk of the logic. Parsing the text files, creating the plot points, loading the correct icons, and then scattering the plot points across the level in an organized hierarchical fashion.

A lot of design, testing, and effort goes into making sure each level in ParaLily is an exciting, rewarding, and fair experience. With this method of logging everything/anything, summarizing it up, and bringing it all back in Unity; we can ensure our levels meet our high quality standards!


Thank you,
ParaLily Dev Team
Nate & Jeff
 
 
P.S. We skimmed over a lot of the technical setup and coding portions, so if you have any questions or comments feel free to email us info@ParaLilyGame.com and we'll get back to you when we can!